mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-15 07:12:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c450e830de | ||
|
|
d88fe8dec2 | ||
|
|
dc641bf1f4 | ||
|
|
73b18a28ed | ||
|
|
f84be8df72 |
@@ -1,3 +1,2 @@
|
||||
avoid-breaking-exported-api = true
|
||||
allow-unwrap-in-tests = true
|
||||
cognitive-complexity-threshold = 25
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:2": {
|
||||
"version": "26.13.1",
|
||||
"version": "26.13.0",
|
||||
"pnpmVersion": "12.18.0"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "1.100.0",
|
||||
"version": "1.99.1",
|
||||
"profile": "default"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v1.2.2": {
|
||||
"github/gh-aw-actions/setup@v1.0.1": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v1.2.2",
|
||||
"sha": "53d0749e19424a918acd82b4d140264ce48f2f14"
|
||||
"version": "v1.0.1",
|
||||
"sha": "221ba4f4319de6b8e809d1a15528b3ae6a818aa5"
|
||||
}
|
||||
},
|
||||
"containers": {
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -373,7 +373,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -479,7 +479,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6.1.1
|
||||
name: Install pnpm
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-aw extension
|
||||
uses: github/gh-aw-actions/setup-cli@862164f9ffbcce2e24e5397da938e3b19f839dce # v1.2.3
|
||||
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
|
||||
with:
|
||||
version: v1.2.2
|
||||
version: v1.0.1
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
- name: Install frontend dependencies
|
||||
env:
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Install Rust Stable
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: dtolnay/rust-toolchain@1.100.0
|
||||
uses: dtolnay/rust-toolchain@1.99.1
|
||||
|
||||
- name: Rust Cache
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
run: |
|
||||
# Ensure cross target is installed for the pinned toolchain; fallback without explicit toolchain if needed
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.100.0 || rustup target add ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }} --toolchain 1.99.1 || rustup target add ${{ matrix.target }}
|
||||
rustup target list --installed
|
||||
echo "Rust target ${{ matrix.target }} installed."
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
+101
-280
File diff suppressed because one or more lines are too long
@@ -33,7 +33,6 @@ max-daily-ai-credits: 5000
|
||||
permissions:
|
||||
issues: read
|
||||
pull-requests: read
|
||||
copilot-requests: none
|
||||
|
||||
pre-agent-steps:
|
||||
- name: Make Copilot quota exhaustion non-blocking
|
||||
|
||||
Generated
+107
-288
File diff suppressed because one or more lines are too long
@@ -30,7 +30,6 @@ permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
copilot-requests: none
|
||||
steps:
|
||||
- name: Load review rules from the base revision
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
if: github.event.action != 'closed'
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
- name: Validate source and publish the approved snapshot
|
||||
id: source
|
||||
env:
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -439,7 +439,7 @@ jobs:
|
||||
- name: Install Rust Stable
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
@@ -461,7 +461,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm install and check
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
@@ -565,7 +565,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -593,7 +593,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -661,7 +661,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
+101
-280
File diff suppressed because one or more lines are too long
@@ -28,7 +28,6 @@ permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
copilot-requests: none
|
||||
max-ai-credits: 60
|
||||
max-turns: 12
|
||||
max-daily-ai-credits: 500
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: '1.100.0'
|
||||
toolchain: '1.99.1'
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.13.1'
|
||||
node-version: '26.13.0'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
nodejs 26.13.1
|
||||
nodejs 26.13.0
|
||||
pnpm 12.18.0
|
||||
rust 1.100.0
|
||||
rust 1.99.1
|
||||
|
||||
@@ -68,8 +68,6 @@ the ownership evidence the AI-slop review evaluates (see
|
||||
- For code review and verification of review fixes, follow [REVIEW.md](REVIEW.md).
|
||||
Review is read-only unless changes are authorized; findings do not expand
|
||||
that authorization to unrelated fixes, commits, pushes, or GitHub updates.
|
||||
- For macOS frontend performance or WebView memory investigations, follow
|
||||
[scripts/perf/AGENTS.md](scripts/perf/AGENTS.md).
|
||||
- When handing work between agents or review rounds, use the
|
||||
[AI context sharing guidance](REVIEW.md#ai-context-sharing).
|
||||
- Read the relevant sections and linked module rules, expanding only when the
|
||||
|
||||
Generated
+35
-35
@@ -159,9 +159,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.5"
|
||||
version = "1.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5"
|
||||
checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -1123,6 +1123,7 @@ dependencies = [
|
||||
"rfd 0.18.0",
|
||||
"runas",
|
||||
"rustls",
|
||||
"scopeguard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml_ng",
|
||||
@@ -1328,7 +1329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2043,7 +2044,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2302,7 +2303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2818,7 +2819,7 @@ dependencies = [
|
||||
"gobject-sys 0.21.5",
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3372,7 +3373,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4252,7 +4253,7 @@ dependencies = [
|
||||
"png",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4419,7 +4420,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4896,7 +4897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5576,7 +5577,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6129,7 +6130,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6188,7 +6189,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6808,7 +6809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7072,9 +7073,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.40.3"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a86bac2f7d06e6384984118ad727f5694110f0ec44d924d05c8fdec3d030bee6"
|
||||
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
@@ -7519,15 +7520,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.9.0"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c278945f30912c8c32728265c66d16ca1b139509c4874d00579f5c8c5276605a"
|
||||
checksum = "2c57e3451553f95a1db3bfed6a358a77ce5080b6399a242c6eadac113f97abd2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 0.8.22",
|
||||
@@ -7544,8 +7544,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-mihomo"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?rev=e49bbedcc67d60da2c10e81bc6433b0f8e8fa232#e49bbedcc67d60da2c10e81bc6433b0f8e8fa232"
|
||||
version = "0.6.0"
|
||||
source = "git+https://github.com/clash-verge-rev/tauri-plugin-mihomo?rev=7a03d16f4d39b1bb885b7babe66b9e153e34b615#7a03d16f4d39b1bb885b7babe66b9e153e34b615"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"clashmap",
|
||||
@@ -7569,9 +7569,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.8.1"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a226bfb8189ec4ab2c1936e66330ad06c44d9f6c54a674338208491dee177f"
|
||||
checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
@@ -7589,9 +7589,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.7.3"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54592c6718b42ab198bf11bda6a428a3553ca2f2f8e506df56f6a063ec328331"
|
||||
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
@@ -7867,7 +7867,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8533,7 +8533,7 @@ dependencies = [
|
||||
"png",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8642,7 +8642,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9088,9 +9088,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.12"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7265c9301c16a8711c992c2d8da90ee77ef54b1ed51482d6dfeb00dc756b1297"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -9184,7 +9184,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9205,7 +9205,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
@@ -9780,7 +9780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10126,9 +10126,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.5.1"
|
||||
version = "9.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3476fd497ac8af5b1ec7b51acab7bef380f7e8dd8e0487595bafd839417a302"
|
||||
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bzip2",
|
||||
|
||||
@@ -132,7 +132,6 @@ large_const_arrays = "warn"
|
||||
large_futures = "deny"
|
||||
large_stack_arrays = "warn"
|
||||
large_types_passed_by_value = "deny"
|
||||
map_unwrap_or = "deny"
|
||||
missing_const_for_fn = "deny"
|
||||
mutex_atomic = "deny"
|
||||
mutex_integer = "deny"
|
||||
|
||||
+1
-8
@@ -29,25 +29,17 @@
|
||||
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
|
||||
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
||||
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
|
||||
- 修复窗口较窄时,首页「当前节点」卡片中过长的节点名把延迟标签挤出卡片
|
||||
- 修复断开链式代理时清空已选节点,导致再次连接需要重新选择的问题
|
||||
- 修复「连接」页表格排序时表头换行、标题位置跳动的问题
|
||||
- 修复「TUN 设置」未手动保存时,界面显示的默认 MTU 1500 未生效的问题
|
||||
- 修复连接详情较窄时操作按钮偏离右侧
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
- 修复 macOS TUN 模式下原有 DNS 被清除、关闭后 DNS 未恢复及联网后设置未生效
|
||||
- 修复 macOS 断网时误报代理已回退直连,恢复联网后自动重试系统代理
|
||||
- 修复 macOS 切换主题后标题栏保留原有深浅色
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增连接页面按网络协议(TCP/UDP)筛选连接
|
||||
- 新增从连接详情快捷设置域名、进程或源/目标 IP 规则,支持父域及子域匹配与修改已有规则
|
||||
|
||||
</details>
|
||||
|
||||
@@ -56,6 +48,7 @@
|
||||
|
||||
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
|
||||
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
|
||||
- 优化连接和规则页面的资源占用,减少连接列表更新及排序时的卡顿
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
|
||||
@@ -206,10 +206,10 @@ impl Logger {
|
||||
.map(|s| (*s).to_string())
|
||||
.or_else(|| info.payload().downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Unknown panic payload".to_string());
|
||||
let location = info.location().map_or_else(
|
||||
|| "Unknown location".to_string(),
|
||||
|loc| format!("{}:{}", loc.file(), loc.line()),
|
||||
);
|
||||
let location = info
|
||||
.location()
|
||||
.map(|loc| format!("{}:{}", loc.file(), loc.line()))
|
||||
.unwrap_or_else(|| "Unknown location".to_string());
|
||||
logging!(error, Type::System, "Panic occurred at {}: {}", location, payload);
|
||||
Self::global().flush_logs();
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
||||
@@ -13,13 +13,14 @@ pub(super) async fn check_chatgpt(client: &Client) -> UnlockItem {
|
||||
|
||||
let web_status = get_text(client, "https://api.openai.com/compliance/cookie_requirements")
|
||||
.await
|
||||
.map_or("Failed", |body| {
|
||||
.map(|body| {
|
||||
if body.to_ascii_lowercase().contains("unsupported_country") {
|
||||
"Unsupported Country/Region"
|
||||
} else {
|
||||
"Yes"
|
||||
}
|
||||
});
|
||||
})
|
||||
.unwrap_or("Failed");
|
||||
|
||||
UnlockItem::checked(CHATGPT_WEB_NAME, web_status, region)
|
||||
}
|
||||
|
||||
@@ -157,10 +157,11 @@ async fn is_unavailable(client: &Client) -> bool {
|
||||
.get("https://disneyplus.com")
|
||||
.send()
|
||||
.await
|
||||
.map_or(true, |response| {
|
||||
.map(|response| {
|
||||
let url = response.url().as_str();
|
||||
url.contains("preview") || url.contains("unavailable")
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn fallback_region(client: &Client) -> Option<UnlockItem> {
|
||||
|
||||
@@ -114,10 +114,11 @@ async fn check_netflix_cdn(client: &Client) -> UnlockItem {
|
||||
}
|
||||
|
||||
match response.json::<FastResponse>().await {
|
||||
Ok(data) => data.targets.first().map_or_else(
|
||||
|| item("Unknown"),
|
||||
|target| UnlockItem::checked_region(NETFLIX_NAME, "Yes", &target.location.country),
|
||||
),
|
||||
Ok(data) => data
|
||||
.targets
|
||||
.first()
|
||||
.map(|target| UnlockItem::checked_region(NETFLIX_NAME, "Yes", &target.location.country))
|
||||
.unwrap_or_else(|| item("Unknown")),
|
||||
Err(e) => {
|
||||
logging!(error, Type::Network, "解析 Fast.com API 响应失败: {e}");
|
||||
item("Failed (解析错误)")
|
||||
|
||||
@@ -12,7 +12,7 @@ parking_lot = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
# sysinfo 0.38.2 conflicts with dark-light
|
||||
# see https://github.com/GuillaumeGomez/sysinfo/issues/1623
|
||||
sysinfo = { version = "0.40.3", default-features = false, features = ["network", "system"] }
|
||||
sysinfo = { version = "0.40.1", default-features = false, features = ["network", "system"] }
|
||||
|
||||
[target.'cfg(not(windows))'.dependencies]
|
||||
libc = "0.2.191"
|
||||
|
||||
@@ -118,7 +118,8 @@ fn is_binary_admin() -> bool {
|
||||
#[cfg(windows)]
|
||||
Token::with_current_process()
|
||||
.and_then(|token| token.privilege_level())
|
||||
.is_ok_and(|level| level != PrivilegeLevel::NotPrivileged)
|
||||
.map(|level| level != PrivilegeLevel::NotPrivileged)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"duplicates",
|
||||
"cycles"
|
||||
],
|
||||
"ignoreBinaries": ["rustc"],
|
||||
"ignoreBinaries": ["rustc", "cc"],
|
||||
"ignoreDependencies": ["lint-staged"],
|
||||
"ignoreIssues": {
|
||||
"src/types/generated/**": ["exports", "types"],
|
||||
|
||||
+14
-7
@@ -36,7 +36,11 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"knip:check": "knip",
|
||||
"knip:report": "knip --no-exit-code"
|
||||
"knip:report": "knip --no-exit-code",
|
||||
"perf:build": "node scripts/perf/build.mjs",
|
||||
"perf:run": "node scripts/perf/run.mjs",
|
||||
"perf:compare": "node scripts/perf/compare.mjs",
|
||||
"perf:test": "node --test scripts/perf/report.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/collision": "^0.5.0",
|
||||
@@ -54,8 +58,8 @@
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "2.8.4",
|
||||
"@tauri-apps/plugin-fs": "2.7.1",
|
||||
"@tauri-apps/plugin-http": "2.9.0",
|
||||
"@tauri-apps/plugin-opener": "2.7.3",
|
||||
"@tauri-apps/plugin-http": "2.8.3",
|
||||
"@tauri-apps/plugin-opener": "2.7.2",
|
||||
"@tauri-apps/plugin-updater": "2.14.1",
|
||||
"ahooks": "^3.10.0",
|
||||
"cidr-block": "^2.3.0",
|
||||
@@ -63,7 +67,7 @@
|
||||
"dayjs": "1.11.23",
|
||||
"foxact": "^0.3.12",
|
||||
"foxts": "^5.9.1",
|
||||
"i18next": "^27.0.0",
|
||||
"i18next": "^26.4.2",
|
||||
"js-yaml": "^5.4.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"meta-json-schema": "^1.19.31",
|
||||
@@ -75,7 +79,7 @@
|
||||
"react-dom": "19.3.1",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "18.0.1",
|
||||
"react-i18next": "17.1.13",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -98,6 +102,7 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@wdio/tauri-service": "1.5.1",
|
||||
"adm-zip": "^0.6.1",
|
||||
"axios": "^1.20.0",
|
||||
"cli-color": "^2.0.4",
|
||||
@@ -116,7 +121,7 @@
|
||||
"https-proxy-agent": "^9.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"knip": "6.45.1",
|
||||
"knip": "6.44.2",
|
||||
"lint-staged": "^17.5.1",
|
||||
"sass": "^1.104.1",
|
||||
"tar": "^7.5.22",
|
||||
@@ -126,7 +131,9 @@
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.1"
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "10.2.0",
|
||||
"ws": "8.22.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
|
||||
Generated
+1774
-46
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -50,7 +50,12 @@
|
||||
},
|
||||
{
|
||||
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
|
||||
"matchPackageNames": ["@tauri-apps/**", "tauri", "tauri-*"],
|
||||
"matchPackageNames": [
|
||||
"@tauri-apps/**",
|
||||
"@wdio/tauri-service",
|
||||
"tauri",
|
||||
"tauri-*"
|
||||
],
|
||||
"groupName": "tauri",
|
||||
"addLabels": ["tauri"]
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.100.0"
|
||||
channel = "1.99.1"
|
||||
components = ["rustfmt", "clippy"]
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Product Measurement Adapter Guidelines
|
||||
|
||||
[GUIDE.md](GUIDE.md) defines the product adapter boundary and explicit tool invocation.
|
||||
Keep real component imports, initialization and the isolated native entry here,
|
||||
using product frontend/native build dependencies. Do not copy components into the
|
||||
tool. Preserve perf build gates; ordinary builds must not require the external tool.
|
||||
|
||||
The adapter must not load or modify daily configuration or control the daily app,
|
||||
Core, Service, TUN, DNS or system proxy. Preserve loopback isolation, owned-process
|
||||
PID/start attribution, nonactivation, visibility/occlusion and source continuity.
|
||||
Do not weaken gates or substitute Chrome/Vite for native WebView evidence. Label
|
||||
JavaScript adapters separately from native plugin/IPC paths. Expected startup errors
|
||||
must use the owned app's normal nonzero exit, never panic through Tauri setup.
|
||||
|
||||
Review adapter source changes, including initialization and native/build configuration,
|
||||
for changed measurement meaning. Matching interfaces alone do not prove equivalence;
|
||||
an adapter review cannot waive other acceptance conditions.
|
||||
|
||||
For implementation changes, run scoped ESLint, both perf TypeScript projects
|
||||
(`tsconfig.json` and `tsconfig.node.json`), formatting and relevant production checks,
|
||||
including Rust checks for native changes. Run scoped Rust Clippy before freezing
|
||||
the optimized artifact. Check ordinary-build exclusion and application-entry prerequisites.
|
||||
|
||||
Follow the external AGENTS.md's
|
||||
[measurement-validation requirements](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/AGENTS.md)
|
||||
and README's
|
||||
[validation order](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#validate-before-freezing-a-build).
|
||||
These govern affected native coverage, comparison, failure cleanup and evidence reuse.
|
||||
+132
-27
@@ -1,35 +1,140 @@
|
||||
# Product measurement adapter
|
||||
# macOS Traffic Performance Harness
|
||||
|
||||
This directory owns the isolated native entry, real Traffic/Logs/Connections
|
||||
imports, initialization, JavaScript socket adapter, replay receiver and minimal DOM
|
||||
layer. These evolve with product APIs and use the product's React/Tauri/Wry
|
||||
versions. Keep the explicit perf build gates; ordinary builds need no external tool.
|
||||
This guide is the operating and maintenance contract for humans and coding agents.
|
||||
The only scenario is the production home traffic graph in a dedicated, optimized
|
||||
Tauri WKWebView. It measures foreground updates, native hide, then restoration.
|
||||
It does not initialize the complete homepage or run Mihomo, Service or networking.
|
||||
|
||||
The external [clash-verge-rev-harness operating guide](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md)
|
||||
owns running, sampling, reports, comparison and owned cleanup. Follow its
|
||||
[first setup and installation instructions](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#build-and-run),
|
||||
reusing an existing checkout. For subsequent calls, pass both absolute roots:
|
||||
## Build And Run
|
||||
|
||||
Use this repository's pnpm, Rust and Xcode Command Line Tools on macOS. No global
|
||||
WebDriver installation is needed. Keep each binary beside its generated manifest.
|
||||
|
||||
```sh
|
||||
project=/absolute/path/to/clash-verge-rev
|
||||
harness=/absolute/path/to/clash-verge-rev-harness
|
||||
node "$harness/build.mjs" --project "$project" --output target/perf/build
|
||||
caffeinate -di node "$harness/run.mjs" --project "$project" --binary target/perf/build/clash-verge --output target/perf/smoke --repeat 1 --seconds 5 --warmup 2
|
||||
node "$harness/compare.mjs" "$project/target/perf/smoke" "$project/target/perf/smoke"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm perf:build target/perf/baseline-build
|
||||
caffeinate -di pnpm perf:run -- --binary target/perf/baseline-build/clash-verge --output target/perf/baseline --repeat 3 --seconds 20 --warmup 10
|
||||
pnpm perf:compare -- target/perf/baseline target/perf/baseline
|
||||
pnpm perf:test
|
||||
```
|
||||
|
||||
JavaScript socket replacement does not measure native plugin IPC. DOM mutation/text
|
||||
hashes show view-input delivery, not painting or cache ownership. Adapter changes
|
||||
require reviewing measurement meaning under the external validation requirements.
|
||||
`perf:build [directory]` uses release optimization, Vite `--mode perf` and the
|
||||
explicit Cargo `perf-harness` feature. It preserves the binary and records HEAD,
|
||||
dirty state, compiled source, lockfile, frontend, build-settings and binary hashes.
|
||||
Source changes during compilation reject the manifest. Each run also hashes the
|
||||
external collector and runner. Ordinary builds exclude the driver and measurement
|
||||
entry; debug assertions are not the gate. Do not edit compiled inputs during builds.
|
||||
|
||||
For virtual display mode, this adapter places only the hidden measurement window
|
||||
on the exact owned NSScreen, checks complete visibleFrame containment, mode/Space
|
||||
flags and nonactivation, and exits on drift. The tool owns helper lifecycle and topology
|
||||
restoration; see its
|
||||
[virtual display guide](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#optional-virtual-display).
|
||||
Output directories must be new and Git ignored; use `target/perf/`. JSON/CSV keep
|
||||
raw process samples, observations, phase markers, attribution and cleanup evidence.
|
||||
`summary.json` and `report.md` provide distributions by independent run, phase and
|
||||
process role. Failed runs exit nonzero and retain their evidence. The explicit
|
||||
`traffic-webview-v3` schema rejects older reports instead of silently migrating them.
|
||||
|
||||
`isolation.json` owns the product configuration and process watch scope; keep it
|
||||
aligned with product data directories and executable names. The tool owns
|
||||
[command isolation snapshots](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#command-isolation-snapshots).
|
||||
The adapter must not load daily configuration or control Core, Service, TUN, DNS
|
||||
or system proxy.
|
||||
## Isolation And Visibility
|
||||
|
||||
The dedicated measurement main bypasses normal initialization and shutdown. It
|
||||
does not read or write daily configuration, subscriptions, proxy, TUN or Service.
|
||||
It uses a distinct app identity and incognito WebView. Both automatic services
|
||||
bind loopback and are owned by the current run; never kill processes by name.
|
||||
|
||||
The measurement window stays above ordinary windows and covers part of the screen,
|
||||
without activation or mouse capture: clicks pass through to the application behind it.
|
||||
Native topmost and click-through states are verified, while native focus and application
|
||||
activation must remain false. WebDriver uses the embedded WebView directly; DOM focus
|
||||
is recorded but not required. Daily windows are unchanged; the topmost window exits
|
||||
with its owned measurement instance. You may keep using another application.
|
||||
The console must be unlocked. Lock state is checked before startup and recorded
|
||||
at phase boundaries; unknown state fails and observed drift rejects comparison.
|
||||
Boundary checks do not provide continuous lock monitoring or unlock the computer.
|
||||
|
||||
No focus does not mean no visibility: shown stages require `document.hidden=false`,
|
||||
fresh Worker data, advancing Canvas draw timestamps and changed Canvas pixels.
|
||||
Topmost placement avoids ordinary-window occlusion; system overlays or another Space
|
||||
can still pause WebKit. Such a run fails; do not weaken Canvas assertions to make it
|
||||
pass. `caffeinate -di` lasts only for the command and cannot prevent deliberate lock.
|
||||
Hidden stages still require continuous input and Worker data.
|
||||
|
||||
## Fixed Workload
|
||||
|
||||
The native window is 1000 x 700 logical pixels. Verify driver dimensions against
|
||||
DPR and record the actual CSS viewport; titlebar space is excluded. The graph is
|
||||
920 x 360 with a ten-minute range, default bezier style and real data retention.
|
||||
The measurement preload sets blur pause false without changing production defaults.
|
||||
|
||||
Deterministic loopback WebSocket replay enters the public traffic append boundary,
|
||||
then the production Worker and Canvas. It does not cover Mihomo or network traffic.
|
||||
There is no history prefill. Absolute 200 ms deadlines sustain 5 Hz without cumulative
|
||||
timer drift or catch-up bursts; missing a complete period fails the source.
|
||||
Warmup is at least the requested duration with a five-second readiness allowance.
|
||||
Fixed input anchors reserve two seconds for each native transition; measured stages
|
||||
retain their full duration. Readiness uses observable state and deadlines, not sleep.
|
||||
Before the first show, wait for actual replay input and Worker data so the graph's
|
||||
visibility listeners are installed; then require fresh Canvas output after showing.
|
||||
Total replay, including allowances, must remain below ten minutes for raw history.
|
||||
Two-input endpoint variation is 400 ms; larger differences reject comparison.
|
||||
|
||||
Worker snapshots are asynchronous. Count/latest-value checks verify deterministic
|
||||
upload/download values at the retained point count, with a joint period of 60 and
|
||||
received-minus-retained count below 60. This detects net missing/duplicate points,
|
||||
not every historical element or exactly cancelling losses and duplicates.
|
||||
Source age must be below 2500 ms, Worker age below 3500 ms and visible draw age below
|
||||
2500 ms. Future timestamps, lost sequence, process changes and failed restore fail.
|
||||
|
||||
## Metrics And Comparison
|
||||
|
||||
The native command obtains the actual WKWebView process ID using the private
|
||||
`_webProcessIdentifier` selector, with availability checking. External collection
|
||||
verifies the canonical system WebContent executable and process start identity.
|
||||
It handles PID reuse and exits; unsupported ownership fails closed. Main and WebView
|
||||
remain separate. Mihomo is not started; shared GPU/network attribution is unavailable.
|
||||
|
||||
CPU is cumulative process time converted from Mach ticks; interval percentages use
|
||||
adjacent valid samples, with one occupied core equal to 100%. The first difference
|
||||
is unavailable. Memory is RSS bytes, not physical footprint or exclusive memory.
|
||||
Reports include CPU mean/P50/P95/peak/seconds, RSS median/P95/peak/delta, sample count
|
||||
and time coverage. Memory slope requires at least 60 seconds of continuous samples.
|
||||
RSS growth is not proof of a leak; process statistics are not function profiles.
|
||||
The embedded driver adds a 20 Hz macOS runloop timer and observation overhead.
|
||||
|
||||
For A/B, build the candidate separately and alternate independent instances:
|
||||
|
||||
```sh
|
||||
pnpm perf:build target/perf/candidate-build
|
||||
for i in 1 2 3; do
|
||||
caffeinate -di pnpm perf:run -- --binary target/perf/baseline-build/clash-verge --repeat 1 --output target/perf/ab-baseline/$i
|
||||
caffeinate -di pnpm perf:run -- --binary target/perf/candidate-build/clash-verge --repeat 1 --output target/perf/ab-candidate/$i
|
||||
done
|
||||
pnpm perf:compare -- target/perf/ab-baseline target/perf/ab-candidate
|
||||
pnpm perf:run -- --binary target/perf/candidate-build/clash-verge --repeat 1 --seconds 5 --warmup 2 --fail-phase hidden --output target/perf/failed
|
||||
```
|
||||
|
||||
Comparison accepts summary files, run directories or groups of run directories.
|
||||
Build mode, toolchain, platform, host, power, WebKit, viewport and workload must match.
|
||||
Invalid and incomparable reports exit 2. A/A remains inconclusive. A/B compares
|
||||
independent-run summaries, not adjacent time samples. An optional third numeric
|
||||
argument is a percentage floor justified by baseline noise; there is no universal
|
||||
pass threshold. Fewer than three runs per group, overlapping ranges or differences
|
||||
within either group's range remain inconclusive. Background load limits inference.
|
||||
|
||||
## Automation Delivery Boundary
|
||||
|
||||
When the operator is a coding agent, the final change keeps only the
|
||||
performance-optimization code. Harness additions introduced during the
|
||||
work — measurement entries, configs, scripts, reports, manifests — must
|
||||
stay untracked by Git and out of every commit; keep them on ignored
|
||||
paths or remove them before delivery. Commits remain minimal and
|
||||
necessary for the optimization itself. Track or commit harness files
|
||||
only when a human explicitly requests it at the end.
|
||||
|
||||
## Maintenance And Acceptance
|
||||
|
||||
Keep measurement code behind the explicit build gates. Reuse the existing driver,
|
||||
Worker, graph, attribution and cleanup; do not add mock IPC or replace real WebView
|
||||
acceptance with Chrome/Vite. Preserve source/binary/report hashes and every failed run.
|
||||
After changes, run scoped ESLint, both perf TypeScript projects, `pnpm perf:test`,
|
||||
format checks and relevant production checks. Verify a real three-phase smoke,
|
||||
three independent runs, A/A, invalid/incomparable reports and injected-failure cleanup.
|
||||
Verify ordinary builds exclude measurement code and daily instance state is intact.
|
||||
Changes to contracts require a schema bump and new reports. Use bounded WebKit CPU
|
||||
or Allocation/Heap captures for deeper diagnosis; do not infer hotspots from counters.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { subscribe, scenario } from './replay'
|
||||
|
||||
// Only this perf entry replaces socket transport; the normal application never imports it.
|
||||
export function installAdapter() {
|
||||
const connect = async (kind: string) => {
|
||||
const data = document.documentElement.dataset
|
||||
data.perfSubscriptions = String(Number(data.perfSubscriptions || 0) + 1)
|
||||
const callbacks = new Set<
|
||||
(message: { type: 'Text'; data: string }) => void
|
||||
>()
|
||||
const unsubscribe = subscribe((frame) => {
|
||||
const messages =
|
||||
kind === 'logs'
|
||||
? (frame.payload as ILogItem[])
|
||||
: [
|
||||
kind === 'traffic'
|
||||
? { up: frame.sequence * 1000, down: frame.sequence * 5000 }
|
||||
: frame.payload,
|
||||
]
|
||||
for (const payload of messages) {
|
||||
const message = { type: 'Text' as const, data: JSON.stringify(payload) }
|
||||
callbacks.forEach((callback) => {
|
||||
callback(message)
|
||||
data.perfDelivered = String(Number(data.perfDelivered || 0) + 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
return {
|
||||
addListener: (
|
||||
callback: (message: { type: 'Text'; data: string }) => void,
|
||||
) => {
|
||||
callbacks.add(callback)
|
||||
return () => callbacks.delete(callback)
|
||||
},
|
||||
close: async () => {
|
||||
unsubscribe()
|
||||
callbacks.clear()
|
||||
data.perfSubscriptions = String(Number(data.perfSubscriptions) - 1)
|
||||
},
|
||||
} as unknown as MihomoWebSocket
|
||||
}
|
||||
MihomoWebSocket.connect_logs = async () => connect('logs')
|
||||
MihomoWebSocket.connect_connections = async () => connect('connections')
|
||||
MihomoWebSocket.connect_traffic = async () => connect('traffic')
|
||||
if (scenario === 'logs')
|
||||
localStorage.setItem(
|
||||
'clash-log',
|
||||
JSON.stringify({
|
||||
enable: true,
|
||||
logLevel: 'INFO',
|
||||
logFilter: 'all',
|
||||
logOrder: 'desc',
|
||||
}),
|
||||
)
|
||||
}
|
||||
+21
-227
@@ -1,136 +1,5 @@
|
||||
use objc2::{class, msg_send, runtime::AnyObject, sel};
|
||||
use objc2_foundation::{NSPoint, NSRect, ns_string};
|
||||
use tauri::{Manager as _, WebviewWindow};
|
||||
|
||||
#[link(name = "CoreGraphics", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CGDisplayCopyDisplayMode(display: u32) -> *mut std::ffi::c_void;
|
||||
fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64;
|
||||
fn CGDisplayModeRelease(mode: *mut std::ffi::c_void);
|
||||
fn CGMainDisplayID() -> u32;
|
||||
fn CGDisplayIsInMirrorSet(display: u32) -> u32;
|
||||
fn CGDisplayModeGetWidth(mode: *mut std::ffi::c_void) -> usize;
|
||||
fn CGDisplayModeGetHeight(mode: *mut std::ffi::c_void) -> usize;
|
||||
fn CGDisplayModeGetPixelWidth(mode: *mut std::ffi::c_void) -> usize;
|
||||
fn CGDisplayModeGetPixelHeight(mode: *mut std::ffi::c_void) -> usize;
|
||||
}
|
||||
|
||||
fn expected_display() -> Result<Option<u32>, String> {
|
||||
match std::env::var("PERF_DISPLAY_MODE").as_deref() {
|
||||
Ok("physical") | Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Ok("virtual") => {
|
||||
let id = std::env::var("PERF_DISPLAY_ID")
|
||||
.map_err(|e| e.to_string())?
|
||||
.parse::<u32>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if id == 0 {
|
||||
return Err("invalid owned display ID".into());
|
||||
}
|
||||
Ok(Some(id))
|
||||
}
|
||||
_ => Err("unknown measurement display mode".into()),
|
||||
}
|
||||
}
|
||||
const fn bounds(rect: NSRect) -> [f64; 4] {
|
||||
[rect.origin.x, rect.origin.y, rect.size.width, rect.size.height]
|
||||
}
|
||||
fn contained(frame: NSRect, screen: NSRect) -> bool {
|
||||
frame.origin.x >= screen.origin.x
|
||||
&& frame.origin.y >= screen.origin.y
|
||||
&& frame.origin.x + frame.size.width <= screen.origin.x + screen.size.width
|
||||
&& frame.origin.y + frame.size.height <= screen.origin.y + screen.size.height
|
||||
}
|
||||
unsafe fn screen_id(screen: *mut AnyObject) -> u32 {
|
||||
unsafe {
|
||||
if screen.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let dictionary: *mut AnyObject = msg_send![screen, deviceDescription];
|
||||
let value: *mut AnyObject = msg_send![dictionary, objectForKey: ns_string!("NSScreenNumber")];
|
||||
msg_send![value, unsignedIntValue]
|
||||
}
|
||||
}
|
||||
unsafe fn display_state(native: &AnyObject) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(wanted) = expected_display()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
unsafe {
|
||||
let screen: *mut AnyObject = msg_send![native, screen];
|
||||
let id = screen_id(screen);
|
||||
if id == 0 || id != wanted || id == CGMainDisplayID() || CGDisplayIsInMirrorSet(id) != 0 {
|
||||
return Err("owned virtual screen unavailable".into());
|
||||
}
|
||||
let frame: NSRect = msg_send![screen, frame];
|
||||
let visible: NSRect = msg_send![screen, visibleFrame];
|
||||
let window_frame: NSRect = msg_send![native, frame];
|
||||
let scale: f64 = msg_send![screen, backingScaleFactor];
|
||||
let behavior: usize = msg_send![native, collectionBehavior];
|
||||
let mode = CGDisplayCopyDisplayMode(id);
|
||||
if mode.is_null() {
|
||||
return Err("owned display mode unavailable".into());
|
||||
}
|
||||
let rate = CGDisplayModeGetRefreshRate(mode);
|
||||
let logical = [CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)];
|
||||
let pixels = [CGDisplayModeGetPixelWidth(mode), CGDisplayModeGetPixelHeight(mode)];
|
||||
CGDisplayModeRelease(mode);
|
||||
if logical != [1280, 900]
|
||||
|| pixels != [2560, 1800]
|
||||
|| scale != 2.0
|
||||
|| (rate - 60.0).abs() > 0.01
|
||||
|| frame.size.width != 1280.0
|
||||
|| frame.size.height != 900.0
|
||||
|| !contained(window_frame, visible)
|
||||
|| behavior & 1 == 0
|
||||
|| behavior & 2 != 0
|
||||
{
|
||||
return Err("owned virtual screen geometry/mode changed".into());
|
||||
}
|
||||
Ok(Some(
|
||||
serde_json::json!({"id": id, "frame": bounds(frame), "visibleFrame": bounds(visible), "windowFrame": bounds(window_frame), "scale": scale, "refreshHz": rate, "collectionBehavior": behavior, "contained": true, "main": false, "mirrored": false, "logical": logical, "pixels": pixels}),
|
||||
))
|
||||
}
|
||||
}
|
||||
unsafe fn target_screen(wanted: u32) -> Result<*mut AnyObject, String> {
|
||||
unsafe {
|
||||
let screens: *mut AnyObject = msg_send![class!(NSScreen), screens];
|
||||
let count: usize = msg_send![screens, count];
|
||||
let mut target = std::ptr::null_mut::<AnyObject>();
|
||||
for index in 0..count {
|
||||
let screen: *mut AnyObject = msg_send![screens, objectAtIndex: index];
|
||||
if screen_id(screen) == wanted {
|
||||
target = screen;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if target.is_null() || wanted == CGMainDisplayID() {
|
||||
return Err("exact owned virtual display missing".into());
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
unsafe fn place_virtual(window: &WebviewWindow) -> Result<(), String> {
|
||||
unsafe {
|
||||
let Some(wanted) = expected_display()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let target = target_screen(wanted)?;
|
||||
let native = &*(window.ns_window().map_err(|e| e.to_string())? as *const AnyObject);
|
||||
let visible: NSRect = msg_send![target, visibleFrame];
|
||||
let frame: NSRect = msg_send![native, frame];
|
||||
if frame.size.width > visible.size.width || frame.size.height > visible.size.height {
|
||||
return Err("owned window does not fit virtual visibleFrame".into());
|
||||
}
|
||||
let behavior: usize = msg_send![native, collectionBehavior];
|
||||
let _: () = msg_send![native, setCollectionBehavior: (behavior & !2usize) | 1usize];
|
||||
let origin = NSPoint::new(
|
||||
visible.origin.x + (visible.size.width - frame.size.width) / 2.0,
|
||||
visible.origin.y + (visible.size.height - frame.size.height) / 2.0,
|
||||
);
|
||||
let _: () = msg_send![native, setFrameOrigin: origin];
|
||||
display_state(native)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
#[tauri::command]
|
||||
async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json::Value, String> {
|
||||
@@ -138,28 +7,16 @@ async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json:
|
||||
return Err("unknown measurement action".into());
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let fail_app = window.app_handle().clone();
|
||||
window
|
||||
.with_webview(move |view| unsafe {
|
||||
let native = &*(view.ns_window() as *const AnyObject);
|
||||
let sender = std::ptr::null::<AnyObject>();
|
||||
let display = match display_state(native) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
let _: () = msg_send![native, orderOut: sender];
|
||||
let _ = tx.send(Err(error));
|
||||
fail_app.exit(2);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if action == "show" {
|
||||
let _: () = msg_send![native, orderBack: sender];
|
||||
} else if action == "hide" {
|
||||
let _: () = msg_send![native, orderOut: sender];
|
||||
}
|
||||
let visible: bool = msg_send![native, isVisible];
|
||||
let occlusion: usize = msg_send![native, occlusionState];
|
||||
let active_space: bool = msg_send![native, isOnActiveSpace];
|
||||
let focused: bool = msg_send![native, isKeyWindow];
|
||||
let click_through: bool = msg_send![native, ignoresMouseEvents];
|
||||
let app: *const AnyObject = msg_send![class!(NSApplication), sharedApplication];
|
||||
@@ -171,41 +28,21 @@ async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json:
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let _ = tx.send(Ok((
|
||||
pid,
|
||||
visible,
|
||||
focused,
|
||||
active,
|
||||
click_through,
|
||||
occlusion & 2 == 0,
|
||||
active_space,
|
||||
display,
|
||||
)));
|
||||
let _ = tx.send((pid, visible, focused, active, click_through));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (pid, visible, focused, active, click_through, occluded, active_space, display) = rx
|
||||
let (pid, visible, focused, active, click_through) = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|e| e.to_string())??;
|
||||
.map_err(|e| e.to_string())?;
|
||||
if pid <= 0 {
|
||||
return Err("WKWebView process ownership unavailable".into());
|
||||
}
|
||||
let top = window.is_always_on_top().map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"main": std::process::id(), "webview": pid,
|
||||
"visible": visible, "nativeFocused": focused, "appActive": active,
|
||||
"virtualScreen": display, "expectedDisplayID": expected_display()?,
|
||||
"top": top, "occluded": occluded, "activeSpace": active_space, "clickThrough": click_through,
|
||||
"top": top, "clickThrough": click_through,
|
||||
"token": std::env::var("PERF_TOKEN").map_err(|e| e.to_string())?}))
|
||||
}
|
||||
#[tauri::command]
|
||||
const fn get_clash_logs() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_verge_config() -> serde_json::Value {
|
||||
serde_json::json!({"language": "en", "theme_mode": "light"})
|
||||
}
|
||||
|
||||
pub fn run() -> std::process::ExitCode {
|
||||
#[cfg(feature = "clippy")]
|
||||
let context = tauri::test::mock_context(tauri::test::noop_assets());
|
||||
@@ -213,66 +50,23 @@ pub fn run() -> std::process::ExitCode {
|
||||
let context = tauri::generate_context!();
|
||||
let result = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_wdio_webdriver::init())
|
||||
.invoke_handler(tauri::generate_handler![perf_state, get_clash_logs, get_verge_config])
|
||||
.invoke_handler(tauri::generate_handler![perf_state])
|
||||
.setup(|app| {
|
||||
let startup = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
app.handle()
|
||||
.set_activation_policy(tauri::ActivationPolicy::Prohibited)?;
|
||||
let scenario = std::env::var("PERF_SCENARIO").unwrap_or_else(|_| "traffic".into());
|
||||
let layer = std::env::var("PERF_LAYER").unwrap_or_else(|_| "component".into());
|
||||
if !matches!(scenario.as_str(), "traffic" | "logs" | "connections")
|
||||
|| !matches!(layer.as_str(), "page" | "component" | "dom")
|
||||
{
|
||||
return Err("invalid perf scene".into());
|
||||
}
|
||||
if let Some(wanted) = expected_display().map_err(std::io::Error::other)? {
|
||||
unsafe {
|
||||
target_screen(wanted).map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
let port: u16 = std::env::var("PERF_REPLAY_PORT")?.parse()?;
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"perf",
|
||||
tauri::WebviewUrl::App(format!("index.html?port={port}&scenario={scenario}&layer={layer}").into()),
|
||||
)
|
||||
.title("Clash Verge - performance replay")
|
||||
.inner_size(1000., 700.)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.focusable(false)
|
||||
.always_on_top(true)
|
||||
.incognito(true)
|
||||
.build()?;
|
||||
window.set_ignore_cursor_events(true)?;
|
||||
unsafe {
|
||||
place_virtual(&window).map_err(std::io::Error::other)?;
|
||||
}
|
||||
if expected_display().map_err(std::io::Error::other)?.is_some() {
|
||||
let handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Moved(_) | tauri::WindowEvent::ScaleFactorChanged { .. }
|
||||
) && let Some(window) = handle.get_webview_window("perf")
|
||||
{
|
||||
let valid = window
|
||||
.ns_window()
|
||||
.ok()
|
||||
.is_some_and(|native| unsafe { display_state(&*(native as *const AnyObject)).is_ok() });
|
||||
if !valid {
|
||||
let _ = window.hide();
|
||||
handle.exit(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = startup {
|
||||
eprintln!("Measurement startup failed: {error}");
|
||||
app.handle().exit(1);
|
||||
}
|
||||
app.handle().set_activation_policy(tauri::ActivationPolicy::Accessory)?;
|
||||
let port: u16 = std::env::var("PERF_REPLAY_PORT")?.parse()?;
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"perf",
|
||||
tauri::WebviewUrl::App(format!("index.html?port={port}").into()),
|
||||
)
|
||||
.title("Clash Verge - performance replay")
|
||||
.inner_size(1000., 700.)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.always_on_top(true)
|
||||
.incognito(true)
|
||||
.build()?
|
||||
.set_ignore_cursor_events(true)?;
|
||||
Ok(())
|
||||
})
|
||||
.build(context);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const output = resolve(process.argv[2] ?? 'target/perf/build')
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
const command = (cmd, args) =>
|
||||
execFileSync(cmd, args, { encoding: 'utf8' }).trim()
|
||||
const source = () =>
|
||||
command('git', ['ls-files', '-co', '--exclude-standard'])
|
||||
.split('\n')
|
||||
.filter(existsSync)
|
||||
.filter(
|
||||
(path) =>
|
||||
!path.startsWith('scripts/perf/') ||
|
||||
/\.(rs|tsx|html|mts|json)$/.test(path),
|
||||
)
|
||||
.sort()
|
||||
.map((path) => `${path}\0${hash(readFileSync(path))}`)
|
||||
.join('\n')
|
||||
mkdirSync(output, { recursive: true })
|
||||
const before = hash(source())
|
||||
const buildSettingsSha256 = hash(
|
||||
['Cargo.toml', '.cargo/config.toml']
|
||||
.filter(existsSync)
|
||||
.map((path) => `${path}\0${hash(readFileSync(path))}`)
|
||||
.join('\n'),
|
||||
)
|
||||
execFileSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vite',
|
||||
'build',
|
||||
'--mode',
|
||||
'perf',
|
||||
'--config',
|
||||
'scripts/perf/vite.mts',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
const mode = 'release + perf-harness (build settings hash recorded)'
|
||||
const buildCommand =
|
||||
'TAURI_CONFIG=<scripts/perf/tauri.json> cargo build -p clash-verge --release --features perf-harness --locked --target-dir target/perf/cargo'
|
||||
execFileSync(
|
||||
'cargo',
|
||||
[
|
||||
'build',
|
||||
'-p',
|
||||
'clash-verge',
|
||||
'--release',
|
||||
'--features',
|
||||
'perf-harness',
|
||||
'--locked',
|
||||
'--target-dir',
|
||||
'target/perf/cargo',
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
TAURI_CONFIG: readFileSync('scripts/perf/tauri.json', 'utf8'),
|
||||
},
|
||||
},
|
||||
)
|
||||
if (before !== hash(source()))
|
||||
throw new Error('Source changed during build; refuse manifest')
|
||||
const binary = `${output}/clash-verge`
|
||||
copyFileSync('target/perf/cargo/release/clash-verge', binary)
|
||||
const files = command('find', ['target/perf/dist', '-type', 'f'])
|
||||
.split('\n')
|
||||
.sort()
|
||||
writeFileSync(
|
||||
`${binary}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
head: command('git', ['rev-parse', 'HEAD']),
|
||||
dirty: command('git', ['status', '--porcelain']),
|
||||
sourceSha256: before,
|
||||
binarySha256: hash(readFileSync(binary)),
|
||||
frontendSha256: hash(
|
||||
files.map((path) => `${path}\0${hash(readFileSync(path))}`).join('\n'),
|
||||
),
|
||||
mode,
|
||||
buildSettingsSha256,
|
||||
buildEnvironment: Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) =>
|
||||
/^(RUSTFLAGS|CARGO_ENCODED_RUSTFLAGS|CARGO_PROFILE_|RUSTC_WRAPPER)/.test(
|
||||
key,
|
||||
),
|
||||
),
|
||||
),
|
||||
buildCommand,
|
||||
binary,
|
||||
toolchain: `${command('rustc', ['--version'])}; node ${process.version}; pnpm ${command('pnpm', ['--version'])}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
console.log(`Measurement binary and manifest: ${binary}`)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { compare } from './report.mjs'
|
||||
const args = process.argv.slice(2).filter((v) => v !== '--')
|
||||
if (args.length < 2)
|
||||
throw new Error(
|
||||
'Usage: pnpm perf:compare -- baseline/summary.json candidate/summary.json [noise-threshold-percent]',
|
||||
)
|
||||
const threshold = args[2] === undefined ? null : Number(args[2])
|
||||
if (threshold !== null && (!Number.isFinite(threshold) || threshold < 0))
|
||||
throw new Error(
|
||||
'threshold must be a nonnegative percentage justified by baseline noise',
|
||||
)
|
||||
function load(path) {
|
||||
if (statSync(path).isFile()) return JSON.parse(readFileSync(path))
|
||||
if (existsSync(join(path, 'summary.json')))
|
||||
return load(join(path, 'summary.json'))
|
||||
const reports = readdirSync(path)
|
||||
.sort()
|
||||
.map((name) => load(join(path, name)))
|
||||
const first = reports[0]
|
||||
if (
|
||||
!first ||
|
||||
reports.some(
|
||||
(r) =>
|
||||
r.build?.binarySha256 !== first.build?.binarySha256 ||
|
||||
['invalid', 'incomparable'].includes(compare(first, r).verdict),
|
||||
)
|
||||
)
|
||||
throw new Error('group contains mixed builds, contracts, or invalid runs')
|
||||
return { ...first, runs: reports.flatMap((r) => r.runs) }
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = compare(...args.slice(0, 2).map(load), threshold)
|
||||
} catch (error) {
|
||||
result = { verdict: 'invalid', reason: String(error) }
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
if (['invalid', 'incomparable'].includes(result.verdict)) process.exitCode = 2
|
||||
+49
-26
@@ -1,29 +1,52 @@
|
||||
import { layer, scenario, startReplay } from './replay'
|
||||
import { ThemeProvider, createTheme } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
if (scenario === 'traffic') {
|
||||
await import('./traffic')
|
||||
} else {
|
||||
const root = document.getElementById('root')!
|
||||
document.body.style.cssText = 'margin:0;font:14px -apple-system,sans-serif'
|
||||
root.style.cssText = 'width:1000px;height:700px;overflow:hidden'
|
||||
new MutationObserver(() => {
|
||||
document.documentElement.dataset.perfDraw = String(Date.now())
|
||||
}).observe(root, { childList: true, characterData: true, subtree: true })
|
||||
window.addEventListener('error', (event) => {
|
||||
document.documentElement.dataset.perfError = event.message
|
||||
})
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
document.documentElement.dataset.perfError = String(event.reason)
|
||||
})
|
||||
if (layer === 'dom') {
|
||||
const { mount } = await import('./minimal')
|
||||
mount(root)
|
||||
} else {
|
||||
if (layer === 'page') {
|
||||
const { installAdapter } = await import('./adapter')
|
||||
installAdapter()
|
||||
import { EnhancedCanvasTrafficGraph } from '../../src/components/home/enhanced-canvas-traffic-graph'
|
||||
import { useTrafficMonitorEnhanced } from '../../src/hooks/use-traffic-monitor'
|
||||
import { initializeLanguage } from '../../src/services/i18n'
|
||||
import { setPreloadConfig } from '../../src/services/preload'
|
||||
|
||||
setPreloadConfig({
|
||||
pause_render_traffic_stats_on_blur: false,
|
||||
} as IVergeConfig)
|
||||
await initializeLanguage('en')
|
||||
export function Replay() {
|
||||
const {
|
||||
graphData: { appendData },
|
||||
} = useTrafficMonitorEnhanced({ subscribe: false })
|
||||
useEffect(() => {
|
||||
let received = 0,
|
||||
previous = 0,
|
||||
gaps = 0
|
||||
const socket = new WebSocket(
|
||||
`ws://127.0.0.1:${new URLSearchParams(location.search).get('port')}`,
|
||||
)
|
||||
socket.onmessage = ({ data }) => {
|
||||
const point = JSON.parse(data)
|
||||
appendData(point)
|
||||
if (previous && point.sequence !== previous + 1) gaps++
|
||||
previous = point.sequence
|
||||
document.documentElement.dataset.perfReceived = String(++received)
|
||||
document.documentElement.dataset.perfGaps = String(gaps)
|
||||
document.documentElement.dataset.perfSourceTime = String(point.timestamp)
|
||||
}
|
||||
await import('./scenes')
|
||||
}
|
||||
startReplay()
|
||||
return () => socket.close()
|
||||
}, [appendData])
|
||||
return (
|
||||
<main
|
||||
aria-label="Home traffic graph replay"
|
||||
style={{ width: 920, height: 360, margin: 32 }}
|
||||
>
|
||||
<EnhancedCanvasTrafficGraph />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<Replay />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>,
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"configRoots": [
|
||||
"Library/Application Support/io.github.clash-verge-rev.clash-verge-rev",
|
||||
"Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev"
|
||||
],
|
||||
"configDirectories": ["profiles"],
|
||||
"configExtensions": [".yaml", ".yml", ".json", ".js"],
|
||||
"processNames": {
|
||||
"app": "clash-verge",
|
||||
"core": "verge-mihomo",
|
||||
"service": "clash-verge-service"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { scenario, subscribe } from './replay'
|
||||
|
||||
export function mount(root: HTMLElement) {
|
||||
const rows = Array.from({ length: 20 }, () => {
|
||||
const row = document.createElement('div')
|
||||
row.style.cssText = 'height:32px;white-space:nowrap;overflow:hidden'
|
||||
root.appendChild(row)
|
||||
return row
|
||||
})
|
||||
subscribe(({ payload }) => {
|
||||
const texts =
|
||||
scenario === 'logs'
|
||||
? (payload as ILogItem[]).map((log) => log.payload)
|
||||
: (payload as IConnections).connections.map(
|
||||
(connection) => connection.metadata.host,
|
||||
)
|
||||
rows.forEach((row, index) => {
|
||||
row.textContent = texts[index % texts.length] ?? ''
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface Frame {
|
||||
sequence: number
|
||||
timestamp: number
|
||||
payload: ILogItem[] | IConnections
|
||||
}
|
||||
export const params = new URLSearchParams(location.search)
|
||||
export const scenario = params.get('scenario') ?? 'traffic'
|
||||
export const layer = params.get('layer') ?? 'component'
|
||||
const listeners = new Set<(frame: Frame) => void>()
|
||||
export const subscribe = (listener: (frame: Frame) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
export function startReplay() {
|
||||
const data = document.documentElement.dataset
|
||||
let previous = 0
|
||||
data.perfReceived = '0'
|
||||
data.perfGaps = '0'
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${params.get('port')}`)
|
||||
socket.onmessage = ({ data: raw }) => {
|
||||
const frame = JSON.parse(raw) as Frame
|
||||
if (frame.sequence !== previous + 1)
|
||||
data.perfGaps = String(Number(data.perfGaps) + 1)
|
||||
previous = frame.sequence
|
||||
data.perfReceived = String(previous)
|
||||
data.perfSourceTime = String(frame.timestamp)
|
||||
for (const listener of listeners) listener(frame)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
data.perfError = 'replay socket failed'
|
||||
}
|
||||
socket.onclose = () => {
|
||||
data.perfError = 'replay socket closed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
export const SCHEMA = 'traffic-webview-v3'
|
||||
export const phases = ['foreground', 'hidden', 'restored']
|
||||
export const roles = ['webview', 'main']
|
||||
export const quantile = (values, p) => {
|
||||
const v = values.filter(Number.isFinite).sort((a, b) => a - b)
|
||||
if (!v.length) return null
|
||||
const index = (v.length - 1) * p
|
||||
return (
|
||||
v[Math.floor(index)] +
|
||||
(v[Math.ceil(index)] - v[Math.floor(index)]) * (index % 1)
|
||||
)
|
||||
}
|
||||
export function delta(previous, next) {
|
||||
if (
|
||||
!previous ||
|
||||
previous.pid !== next.pid ||
|
||||
previous.start !== next.start ||
|
||||
![previous.time, next.time, previous.cpu, next.cpu].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
next.time <= previous.time ||
|
||||
next.cpu < previous.cpu
|
||||
)
|
||||
return null
|
||||
return ((next.cpu - previous.cpu) / (next.time - previous.time)) * 100000
|
||||
}
|
||||
export function summarize(rows, seconds) {
|
||||
const output = {}
|
||||
for (const phase of phases)
|
||||
for (const role of roles) {
|
||||
const selected = rows.filter((r) => r.phase === phase && r.role === role)
|
||||
const samples = selected.map((r, i) => ({
|
||||
...r,
|
||||
percent: delta(selected[i - 1], r),
|
||||
cpuDelta: i ? r.cpu - selected[i - 1].cpu : null,
|
||||
}))
|
||||
const valid = samples.filter((r) => Number.isFinite(r.percent))
|
||||
const memory = samples.map((r) => r.rss)
|
||||
const span =
|
||||
samples.length > 1 ? samples.at(-1).time - samples[0].time : 0
|
||||
const continuous =
|
||||
samples.length > 1 &&
|
||||
samples.every(
|
||||
(r) =>
|
||||
[r.time, r.cpu, r.rss].every(Number.isFinite) &&
|
||||
Number.isInteger(r.pid) &&
|
||||
r.pid > 0 &&
|
||||
/^\d+$/.test(r.start) &&
|
||||
r.time >= 0 &&
|
||||
r.cpu >= 0 &&
|
||||
r.rss > 0,
|
||||
) &&
|
||||
samples.every(
|
||||
(r, i) =>
|
||||
!i ||
|
||||
(r.time > samples[i - 1].time &&
|
||||
r.time - samples[i - 1].time < 2500 &&
|
||||
r.cpu >= samples[i - 1].cpu &&
|
||||
r.pid === samples[i - 1].pid &&
|
||||
r.start === samples[i - 1].start),
|
||||
)
|
||||
output[`${phase}/${role}`] = {
|
||||
count: samples.length,
|
||||
cpuCount: valid.length,
|
||||
missing: Math.max(0, Math.floor(seconds) - samples.length),
|
||||
coverage: Math.min(1, samples.length / seconds),
|
||||
continuous,
|
||||
observedSeconds: span / 1000,
|
||||
intervalCoverage: Math.min(1, span / ((seconds - 1) * 1000)),
|
||||
cpuMean: null,
|
||||
cpuP50: quantile(
|
||||
valid.map((r) => r.percent),
|
||||
0.5,
|
||||
),
|
||||
cpuP95: quantile(
|
||||
valid.map((r) => r.percent),
|
||||
0.95,
|
||||
),
|
||||
cpuPeak: quantile(
|
||||
valid.map((r) => r.percent),
|
||||
1,
|
||||
),
|
||||
cpuSeconds: valid.length
|
||||
? valid.reduce((sum, r) => sum + r.cpuDelta, 0)
|
||||
: null,
|
||||
rssP50: quantile(memory, 0.5),
|
||||
rssP95: quantile(memory, 0.95),
|
||||
rssPeak: quantile(memory, 1),
|
||||
rssDelta: continuous ? samples.at(-1).rss - samples[0].rss : null,
|
||||
rssSlopeBytesPerSecond:
|
||||
span >= 60000 && continuous
|
||||
? (samples.at(-1).rss - samples[0].rss) / (span / 1000)
|
||||
: null,
|
||||
}
|
||||
output[`${phase}/${role}`].cpuMean =
|
||||
valid.length && span > 0
|
||||
? (output[`${phase}/${role}`].cpuSeconds / span) * 100000
|
||||
: null
|
||||
}
|
||||
return output
|
||||
}
|
||||
export function replayValues(sequence) {
|
||||
return {
|
||||
up: 100000 + (sequence % 20) * 10000,
|
||||
down: 500000 + (sequence % 30) * 20000,
|
||||
}
|
||||
}
|
||||
export const validVisibility = (s) =>
|
||||
typeof s.hidden === 'boolean' &&
|
||||
(s.phase === 'hidden'
|
||||
? s.visible === false && s.hidden
|
||||
: phases.includes(s.phase) && s.visible === true && !s.hidden)
|
||||
export const validObservation = (s) =>
|
||||
validWorkload(s) &&
|
||||
validVisibility(s) &&
|
||||
s.nativeFocused === false &&
|
||||
s.appActive === false &&
|
||||
s.top === true &&
|
||||
s.clickThrough === true &&
|
||||
[s.now, s.source, s.worker, s.draw].every(
|
||||
(v) => Number.isFinite(v) && v > 0,
|
||||
) &&
|
||||
s.now >= s.source &&
|
||||
s.now - s.source < 2500 &&
|
||||
s.now >= s.worker &&
|
||||
s.now - s.worker < 3500 &&
|
||||
s.now >= s.draw &&
|
||||
(s.phase === 'hidden' || s.now - s.draw < 2500)
|
||||
export function validWorkload(s) {
|
||||
if (!s) return false
|
||||
const expected = replayValues(s.points)
|
||||
return (
|
||||
Number.isInteger(s.received) &&
|
||||
Number.isInteger(s.points) &&
|
||||
s.points > 0 &&
|
||||
s.received >= s.points &&
|
||||
s.received - s.points < 60 &&
|
||||
s.lastUp === expected.up &&
|
||||
s.lastDown === expected.down &&
|
||||
s.gaps === 0
|
||||
)
|
||||
}
|
||||
export function compare(a, b, threshold = null) {
|
||||
const stats = new Map()
|
||||
const text = (value) => typeof value === 'string' && value.length > 0
|
||||
const object = (value) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
const expectedMarks = [
|
||||
'ready',
|
||||
...phases.flatMap((phase) => [phase, `${phase}:end`]),
|
||||
]
|
||||
const valid = (x) =>
|
||||
x?.schema === SCHEMA &&
|
||||
text(x?.harnessSha256) &&
|
||||
[
|
||||
'head',
|
||||
'sourceSha256',
|
||||
'binarySha256',
|
||||
'frontendSha256',
|
||||
'buildSettingsSha256',
|
||||
'mode',
|
||||
'buildCommand',
|
||||
'binary',
|
||||
'toolchain',
|
||||
].every((key) => text(x.build?.[key])) &&
|
||||
typeof x.build.dirty === 'string' &&
|
||||
object(x.build.buildEnvironment) &&
|
||||
[
|
||||
'method',
|
||||
'mode',
|
||||
'toolchain',
|
||||
'buildSettingsSha256',
|
||||
'platform',
|
||||
'machine',
|
||||
'arch',
|
||||
'host',
|
||||
'hardware',
|
||||
'power',
|
||||
'powerSource',
|
||||
'webkit',
|
||||
'replay',
|
||||
'automation',
|
||||
].every((key) => text(x.contract?.[key])) &&
|
||||
object(x.contract.buildEnvironment) &&
|
||||
x.build.buildSettingsSha256 === x.contract.buildSettingsSha256 &&
|
||||
[
|
||||
'warmup',
|
||||
'intervalMs',
|
||||
'transitionSeconds',
|
||||
'readinessAllowanceSeconds',
|
||||
].every((key) => Number.isFinite(x.contract[key]) && x.contract[key] > 0) &&
|
||||
x.contract.scenario === 'traffic' &&
|
||||
typeof x.contract.consoleLocked === 'boolean' &&
|
||||
x.contract.windowPolicy === 'nonactivating-top-click-through-v1' &&
|
||||
x.contract.replayMs === 200 &&
|
||||
x.contract.rangeMinutes === 10 &&
|
||||
['window', 'viewport'].every(
|
||||
(key) =>
|
||||
Array.isArray(x.contract[key]) &&
|
||||
x.contract[key].length === (key === 'window' ? 2 : 3) &&
|
||||
x.contract[key].every((v) => Number.isFinite(v) && v > 0),
|
||||
) &&
|
||||
Number.isFinite(x.contract?.seconds) &&
|
||||
x.contract.seconds >= 5 &&
|
||||
Array.isArray(x.contract.phaseTargets) &&
|
||||
x.contract.phaseTargets.length === phases.length &&
|
||||
x.contract.phaseTargets.every((n) => Number.isInteger(n) && n > 0) &&
|
||||
Array.isArray(x.runs) &&
|
||||
x.runs.length &&
|
||||
x.runs.every((r) => {
|
||||
if (
|
||||
!r?.ok ||
|
||||
!Array.isArray(r.samples) ||
|
||||
!r.samples.every(
|
||||
(s) => s && roles.includes(s.role) && phases.includes(s.phase),
|
||||
) ||
|
||||
!Array.isArray(r.marks) ||
|
||||
!r.marks.every((m) => m && typeof m.name === 'string')
|
||||
)
|
||||
return false
|
||||
stats.set(r, summarize(r.samples, x.contract.seconds))
|
||||
return (
|
||||
r.cleanup?.mainExited &&
|
||||
r.cleanup?.driverClosed &&
|
||||
r.cleanup?.webviewExited &&
|
||||
Array.isArray(r.consoleStates) &&
|
||||
r.consoleStates.length === expectedMarks.length &&
|
||||
r.consoleStates.every(
|
||||
(s, i) =>
|
||||
s &&
|
||||
s.name === (i ? expectedMarks[i] : 'initial') &&
|
||||
typeof s.locked === 'boolean' &&
|
||||
Number.isFinite(s.time) &&
|
||||
s.time > 0 &&
|
||||
(!i || s.time >= r.consoleStates[i - 1].time),
|
||||
) &&
|
||||
Number.isInteger(r.replay?.sent) &&
|
||||
r.replay.sent > 0 &&
|
||||
Number.isFinite(r.replay.maxLateMs) &&
|
||||
r.replay.maxLateMs >= 0 &&
|
||||
r.replay.maxLateMs < x.contract.replayMs &&
|
||||
!r.replay.error &&
|
||||
r.actualWarmupMs >= x.contract.warmup * 1000 &&
|
||||
Array.isArray(r.observations) &&
|
||||
r.observations.length > 0 &&
|
||||
r.observations.every(
|
||||
(s, i) =>
|
||||
validObservation(s) && (!i || s.now > r.observations[i - 1].now),
|
||||
) &&
|
||||
r.marks.length === expectedMarks.length &&
|
||||
r.marks.every(
|
||||
(m, i) =>
|
||||
m.name === expectedMarks[i] &&
|
||||
Number.isFinite(m.time) &&
|
||||
m.time > 0 &&
|
||||
(!i ||
|
||||
(validObservation({ ...m, phase: m.name.split(':')[0] }) &&
|
||||
/^[a-f0-9]{64}$/.test(m.pixelsSha256))) &&
|
||||
(!phases.includes(m.name) ||
|
||||
(m.target === x.contract.phaseTargets[phases.indexOf(m.name)] &&
|
||||
m.received === m.target)) &&
|
||||
(!i || m.time > r.marks[i - 1].time),
|
||||
) &&
|
||||
roles.every((role) =>
|
||||
r.samples
|
||||
.filter((s) => s.role === role)
|
||||
.every(
|
||||
(s, i, rows) =>
|
||||
!i ||
|
||||
(s.pid === rows[i - 1].pid &&
|
||||
s.start === rows[i - 1].start &&
|
||||
s.time > rows[i - 1].time &&
|
||||
s.cpu >= rows[i - 1].cpu &&
|
||||
phases.indexOf(s.phase) >= phases.indexOf(rows[i - 1].phase)),
|
||||
),
|
||||
) &&
|
||||
validStats(stats.get(r)) &&
|
||||
phases.every((phase) => {
|
||||
const start = r.marks.find((m) => m.name === phase)
|
||||
const end = r.marks.find((m) => m.name === `${phase}:end`)
|
||||
const observed = r.observations.filter((s) => s.phase === phase)
|
||||
return (
|
||||
start &&
|
||||
end &&
|
||||
observed.length > 0 &&
|
||||
[
|
||||
start.deadlineIndex,
|
||||
end.deadlineIndex,
|
||||
start.senderSent,
|
||||
end.senderSent,
|
||||
].every((n) => Number.isInteger(n) && n >= 0) &&
|
||||
end.deadlineIndex > start.deadlineIndex &&
|
||||
end.senderSent >= start.senderSent &&
|
||||
end.senderSent <= r.replay.sent &&
|
||||
start.senderSent >= start.received &&
|
||||
end.senderSent >= end.received &&
|
||||
Math.abs(
|
||||
end.received -
|
||||
start.received -
|
||||
(end.deadlineIndex - start.deadlineIndex),
|
||||
) <= 2 &&
|
||||
observed.every(
|
||||
(s) =>
|
||||
s.now >= start.now &&
|
||||
s.now <= end.now &&
|
||||
s.received >= start.received &&
|
||||
s.received <= end.received,
|
||||
) &&
|
||||
end.worker > start.worker &&
|
||||
(phase === 'hidden' ||
|
||||
(end.draw > start.draw &&
|
||||
end.pixelsSha256 !== start.pixelsSha256))
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
if (![a, b].every(valid))
|
||||
return { verdict: 'invalid', reason: 'failed, malformed or missing runs' }
|
||||
if (
|
||||
[a, b].some((x) =>
|
||||
x.runs.some((r) =>
|
||||
r.consoleStates.some((s) => s.locked !== x.contract.consoleLocked),
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
verdict: 'incomparable',
|
||||
reason: 'console lock state changed at observed boundaries',
|
||||
}
|
||||
if (
|
||||
!isDeepStrictEqual(a.contract, b.contract) ||
|
||||
['mode', 'buildCommand', 'toolchain', 'buildEnvironment'].some(
|
||||
(key) => !isDeepStrictEqual(a.build[key], b.build[key]),
|
||||
) ||
|
||||
a.harnessSha256 !== b.harnessSha256
|
||||
)
|
||||
return {
|
||||
verdict: 'incomparable',
|
||||
reason: 'harness, platform, build mode, workload or environment differ',
|
||||
}
|
||||
for (const phase of phases) {
|
||||
for (const name of [phase, `${phase}:end`]) {
|
||||
const sizes = [a, b].map((x) =>
|
||||
x.runs.map((r) => r.marks.find((m) => m.name === name)?.received),
|
||||
)
|
||||
if (!sizes.flat().every(Number.isFinite))
|
||||
return { verdict: 'invalid', reason: 'missing observed workload size' }
|
||||
if (
|
||||
Math.abs(quantile(sizes[0], 0.5) - quantile(sizes[1], 0.5)) > 2 ||
|
||||
sizes.some((s) => Math.max(...s) - Math.min(...s) > 2)
|
||||
)
|
||||
return {
|
||||
verdict: 'incomparable',
|
||||
reason: `${name}: input size varies by more than two replay points`,
|
||||
}
|
||||
}
|
||||
}
|
||||
const same = a.build.binarySha256 === b.build.binarySha256
|
||||
const enough = a.runs.length >= 3 && b.runs.length >= 3
|
||||
const metrics = {}
|
||||
for (const phase of phases)
|
||||
for (const role of roles)
|
||||
for (const metric of ['cpuMean', 'rssP50']) {
|
||||
const key = `${phase}/${role}`
|
||||
const av = a.runs.map((r) => stats.get(r)[key][metric])
|
||||
const bv = b.runs.map((r) => stats.get(r)[key][metric])
|
||||
const before = quantile(av, 0.5),
|
||||
after = quantile(bv, 0.5)
|
||||
const noise = Math.max(...av) - Math.min(...av)
|
||||
const candidateRange = Math.max(...bv) - Math.min(...bv)
|
||||
const overlap =
|
||||
Math.max(...av) >= Math.min(...bv) &&
|
||||
Math.max(...bv) >= Math.min(...av)
|
||||
metrics[`${key}/${metric}`] = {
|
||||
before,
|
||||
after,
|
||||
delta: after - before,
|
||||
baselineRange: noise,
|
||||
candidateRange,
|
||||
verdict:
|
||||
same ||
|
||||
!enough ||
|
||||
threshold === null ||
|
||||
!av.every(Number.isFinite) ||
|
||||
!bv.every(Number.isFinite) ||
|
||||
overlap ||
|
||||
Math.abs(after - before) <=
|
||||
Math.max(
|
||||
noise,
|
||||
candidateRange,
|
||||
(Math.abs(before) * threshold) / 100,
|
||||
)
|
||||
? 'inconclusive'
|
||||
: after < before
|
||||
? 'lower'
|
||||
: 'higher',
|
||||
}
|
||||
}
|
||||
return {
|
||||
verdict: 'inconclusive',
|
||||
kind: same ? 'A/A' : 'A/B',
|
||||
reason: same
|
||||
? 'same binary; natural variation only'
|
||||
: 'inspect independent-run metrics; no universal pass threshold',
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
export function markdown(report) {
|
||||
const lines = [
|
||||
'# Home traffic graph - replay measurement',
|
||||
'',
|
||||
`Success: ${report.runs.filter((r) => r.ok).length}/${report.runs.length}`,
|
||||
'',
|
||||
'CPU: one fully occupied core = 100%. Memory: process resident size bytes, not exclusive memory. Null = unavailable; first CPU observation has no delta. Slope requires 60 seconds of continuous observations.',
|
||||
'',
|
||||
'| Run / stage / role | CPU mean / P50 / P95 / peak (%) | CPU s | RSS P50 / P95 / peak / delta (bytes) | Samples / coverage |',
|
||||
'|---|---|---|---|---|',
|
||||
]
|
||||
for (const [i, run] of report.runs.entries()) {
|
||||
if (run.error) lines.push(`\nRun ${i + 1} failed: ${run.error}\n`)
|
||||
for (const [key, s] of Object.entries(run.stats))
|
||||
lines.push(
|
||||
`| ${i + 1}/${key} | ${[s.cpuMean, s.cpuP50, s.cpuP95, s.cpuPeak].map((v) => v?.toFixed(2) ?? 'unavailable').join(' / ')} | ${s.cpuSeconds?.toFixed(3) ?? 'unavailable'} | ${[s.rssP50, s.rssP95, s.rssPeak, s.rssDelta].map((v) => v ?? 'unavailable').join(' / ')} | ${s.count} / ${(s.coverage * 100).toFixed(1)}% |`,
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'Scope: loopback replay → public traffic append boundary → production Worker → homepage Canvas component. Mihomo and its network/subscription chain are not running. Shared GPU/network processes are unavailable and excluded. Process statistics do not identify function hotspots, component CPU or retained objects; memory growth is not proof of a leak.',
|
||||
'',
|
||||
'Next measurement: use WebKit CPU sampling for the highest-CPU foreground/restored phase; use Allocation/Heap captures for repeatable RSS growth. React Compiler is an untested local A/B candidate only.',
|
||||
)
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
export const validStats = (stats) =>
|
||||
Object.values(stats).every(
|
||||
(s) =>
|
||||
s.continuous &&
|
||||
s.coverage >= 0.8 &&
|
||||
s.intervalCoverage >= 0.8 &&
|
||||
s.cpuCount >= 3,
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
compare,
|
||||
delta,
|
||||
phases,
|
||||
roles,
|
||||
quantile,
|
||||
summarize,
|
||||
validWorkload,
|
||||
replayValues,
|
||||
SCHEMA,
|
||||
validVisibility,
|
||||
} from './report.mjs'
|
||||
const latest = (sequence) => {
|
||||
const { up, down } = replayValues(sequence)
|
||||
return { lastUp: up, lastDown: down }
|
||||
}
|
||||
const observation = (phase, now) => ({
|
||||
phase,
|
||||
now,
|
||||
source: now - 100,
|
||||
worker: now - 100,
|
||||
draw: now - 100,
|
||||
visible: phase !== 'hidden',
|
||||
hidden: phase === 'hidden',
|
||||
focused: false,
|
||||
nativeFocused: false,
|
||||
appActive: false,
|
||||
top: true,
|
||||
clickThrough: true,
|
||||
})
|
||||
const rows = phases.flatMap((phase, phaseIndex) =>
|
||||
roles.flatMap((role) =>
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
phase,
|
||||
role,
|
||||
pid: role === 'main' ? 1 : 2,
|
||||
start: '1',
|
||||
time: (phaseIndex * 5 + i) * 1000,
|
||||
cpu: (phaseIndex * 5 + i) / 10,
|
||||
rss: 100 + i,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const run = {
|
||||
ok: true,
|
||||
samples: rows,
|
||||
cleanup: { mainExited: true, driverClosed: true, webviewExited: true },
|
||||
replay: { sent: 130, maxLateMs: 10 },
|
||||
actualWarmupMs: 3000,
|
||||
observations: phases.map((phase, i) => ({
|
||||
...observation(phase, 2000 + i * 10000),
|
||||
received: 35 + i * 35,
|
||||
points: 34 + i * 35,
|
||||
gaps: 0,
|
||||
...latest(34 + i * 35),
|
||||
})),
|
||||
marks: [
|
||||
{ name: 'ready', time: 1 },
|
||||
...phases.flatMap((name, i) => [
|
||||
{
|
||||
name,
|
||||
target: 35 + i * 35,
|
||||
deadlineIndex: 35 + i * 35,
|
||||
senderSent: 35 + i * 35,
|
||||
received: 35 + i * 35,
|
||||
points: 34 + i * 35,
|
||||
...latest(34 + i * 35),
|
||||
gaps: 0,
|
||||
time: 1000 + i * 10000,
|
||||
...observation(name, 1000 + i * 10000),
|
||||
pixelsSha256: 'a'.repeat(64),
|
||||
},
|
||||
{
|
||||
name: `${name}:end`,
|
||||
deadlineIndex: 60 + i * 35,
|
||||
senderSent: 60 + i * 35,
|
||||
received: 60 + i * 35,
|
||||
points: 59 + i * 35,
|
||||
...latest(59 + i * 35),
|
||||
gaps: 0,
|
||||
time: 6000 + i * 10000,
|
||||
...observation(name, 6000 + i * 10000),
|
||||
pixelsSha256: 'b'.repeat(64),
|
||||
},
|
||||
]),
|
||||
],
|
||||
}
|
||||
run.consoleStates = run.marks.map((m, i) => ({
|
||||
name: i ? m.name : 'initial',
|
||||
time: m.time,
|
||||
locked: false,
|
||||
}))
|
||||
const baseline = {
|
||||
schema: SCHEMA,
|
||||
harnessSha256: 'fixture-harness',
|
||||
build: {
|
||||
head: 'fixture-head',
|
||||
dirty: '',
|
||||
sourceSha256: 'fixture-source',
|
||||
binarySha256: 'same',
|
||||
frontendSha256: 'fixture-assets',
|
||||
buildSettingsSha256: 'fixture-settings',
|
||||
mode: 'release',
|
||||
buildCommand: 'fixture-build',
|
||||
binary: '/fixture/app',
|
||||
toolchain: 'fixture-tools',
|
||||
buildEnvironment: {},
|
||||
},
|
||||
contract: {
|
||||
scenario: 'traffic',
|
||||
replayMs: 200,
|
||||
consoleLocked: false,
|
||||
windowPolicy: 'nonactivating-top-click-through-v1',
|
||||
seconds: 5,
|
||||
warmup: 2,
|
||||
phaseTargets: [35, 70, 105],
|
||||
transitionSeconds: 2,
|
||||
readinessAllowanceSeconds: 5,
|
||||
intervalMs: 1000,
|
||||
rangeMinutes: 10,
|
||||
window: [1000, 700],
|
||||
viewport: [1000, 668, 2],
|
||||
method: 'fixture-method',
|
||||
mode: 'release',
|
||||
toolchain: 'fixture-tools',
|
||||
buildSettingsSha256: 'fixture-settings',
|
||||
platform: 'macOS',
|
||||
machine: 'fixture-model',
|
||||
arch: 'arm64',
|
||||
host: 'fixture-host',
|
||||
hardware: 'fixture-hardware',
|
||||
power: 'fixture-power',
|
||||
powerSource: 'AC',
|
||||
webkit: 'fixture-webkit',
|
||||
replay: 'fixture-5Hz',
|
||||
automation: 'fixture-wdio',
|
||||
buildEnvironment: {},
|
||||
},
|
||||
runs: [run, run, run],
|
||||
}
|
||||
test('quantiles, CPU units, first observation and PID reuse', () => {
|
||||
assert.equal(quantile([0, 10, 20], 0.95), 19)
|
||||
assert.equal(delta(undefined, rows[0]), null)
|
||||
assert.equal(delta(rows[0], rows[1]), 10)
|
||||
assert.equal(delta(rows[0], { ...rows[1], start: '2' }), null)
|
||||
assert.equal(delta(rows[0], { ...rows[1], time: NaN }), null)
|
||||
assert.equal(summarize(rows, 5)['hidden/main'].cpuMean, 10)
|
||||
assert.equal(summarize([], 5)['hidden/main'].cpuSeconds, null)
|
||||
})
|
||||
test('report schema: A/A, failed, incomparable and corrupt samples', () => {
|
||||
assert.equal(compare(baseline, baseline).kind, 'A/A')
|
||||
assert.equal(
|
||||
compare(baseline, { ...baseline, schema: 'old' }).verdict,
|
||||
'invalid',
|
||||
)
|
||||
for (const change of [
|
||||
{ appActive: true },
|
||||
{ nativeFocused: true },
|
||||
{ top: false },
|
||||
{ top: undefined },
|
||||
{ clickThrough: false },
|
||||
{ clickThrough: undefined },
|
||||
{ hidden: true },
|
||||
{ worker: 3000 },
|
||||
{ now: 6000 },
|
||||
{ draw: 3000 },
|
||||
{ worker: null },
|
||||
]) {
|
||||
const invalid = structuredClone(baseline)
|
||||
Object.assign(invalid.runs[0].observations[0], change)
|
||||
assert.equal(compare(baseline, invalid).verdict, 'invalid')
|
||||
}
|
||||
for (const key of ['draw', 'pixelsSha256']) {
|
||||
const stalled = structuredClone(baseline)
|
||||
stalled.runs[0].marks[2][key] = stalled.runs[0].marks[1][key]
|
||||
assert.equal(compare(baseline, stalled).verdict, 'invalid')
|
||||
}
|
||||
const missingPhase = structuredClone(baseline)
|
||||
missingPhase.runs[0].observations.pop()
|
||||
assert.equal(compare(baseline, missingPhase).verdict, 'invalid')
|
||||
const consoleDrift = structuredClone(baseline)
|
||||
consoleDrift.runs[0].consoleStates[3].locked = true
|
||||
assert.equal(compare(baseline, consoleDrift).verdict, 'incomparable')
|
||||
consoleDrift.runs[0].consoleStates[3].locked = null
|
||||
assert.equal(compare(baseline, consoleDrift).verdict, 'invalid')
|
||||
assert.equal(
|
||||
validVisibility({ phase: 'restored', visible: true, hidden: true }),
|
||||
false,
|
||||
)
|
||||
const missedPeriod = structuredClone(baseline)
|
||||
missedPeriod.runs[0].replay.maxLateMs = 200
|
||||
assert.equal(compare(baseline, missedPeriod).verdict, 'invalid')
|
||||
const delayed = {
|
||||
received: 100,
|
||||
points: 97,
|
||||
gaps: 0,
|
||||
lastUp: 270000,
|
||||
lastDown: 640000,
|
||||
}
|
||||
assert.equal(validWorkload(delayed), true)
|
||||
assert.equal(validWorkload(null), false)
|
||||
assert.equal(validWorkload({ ...delayed, points: 96 }), false)
|
||||
assert.equal(validWorkload({ ...delayed, points: 98 }), false)
|
||||
assert.equal(validWorkload({ ...delayed, received: 157 }), false)
|
||||
assert.equal(validWorkload({ ...delayed, lastDown: 660000 }), false)
|
||||
const missedAnchor = structuredClone(baseline)
|
||||
missedAnchor.runs[0].marks[1].received++
|
||||
assert.equal(compare(baseline, missedAnchor).verdict, 'invalid')
|
||||
const droppedInputs = structuredClone(baseline)
|
||||
const end = droppedInputs.runs[0].marks[2]
|
||||
end.received -= 10
|
||||
end.points -= 10
|
||||
Object.assign(end, latest(end.points))
|
||||
assert.equal(compare(droppedInputs, droppedInputs).verdict, 'invalid')
|
||||
const workerLag = structuredClone(baseline)
|
||||
workerLag.runs[0].observations[0].points -= 3
|
||||
assert.equal(compare(baseline, workerLag).verdict, 'invalid')
|
||||
const inputDrift = structuredClone(baseline)
|
||||
inputDrift.runs = baseline.runs.map((r) => structuredClone(r))
|
||||
inputDrift.runs[0].marks[2].received += 3
|
||||
inputDrift.runs[0].marks[2].points += 3
|
||||
inputDrift.runs[0].marks[2].deadlineIndex += 3
|
||||
inputDrift.runs[0].marks[2].senderSent += 3
|
||||
Object.assign(
|
||||
inputDrift.runs[0].marks[2],
|
||||
latest(inputDrift.runs[0].marks[2].points),
|
||||
)
|
||||
assert.equal(compare(baseline, inputDrift).verdict, 'incomparable')
|
||||
const snapshotPhase = structuredClone(baseline)
|
||||
snapshotPhase.runs[0].marks[2].points++
|
||||
Object.assign(
|
||||
snapshotPhase.runs[0].marks[2],
|
||||
latest(snapshotPhase.runs[0].marks[2].points),
|
||||
)
|
||||
assert.equal(compare(baseline, snapshotPhase).kind, 'A/A')
|
||||
const changedSettings = structuredClone(baseline)
|
||||
changedSettings.build.buildSettingsSha256 =
|
||||
changedSettings.contract.buildSettingsSha256 = 'changed-settings'
|
||||
assert.equal(compare(baseline, changedSettings).verdict, 'incomparable')
|
||||
assert.equal(
|
||||
compare(baseline, { ...baseline, harnessSha256: 'changed' }).verdict,
|
||||
'incomparable',
|
||||
)
|
||||
assert.equal(
|
||||
compare(baseline, {
|
||||
...baseline,
|
||||
contract: { ...baseline.contract, seconds: 6 },
|
||||
}).verdict,
|
||||
'incomparable',
|
||||
)
|
||||
assert.equal(
|
||||
compare(baseline, { ...baseline, runs: [{ ...run, ok: false }] }).verdict,
|
||||
'invalid',
|
||||
)
|
||||
const corrupt = structuredClone(baseline)
|
||||
corrupt.runs[0].samples[1].time = 0
|
||||
assert.equal(compare(baseline, corrupt).verdict, 'invalid')
|
||||
const shifted = structuredClone(baseline)
|
||||
shifted.runs[0].samples
|
||||
.filter((s) => s.phase === 'hidden')
|
||||
.forEach((s) => {
|
||||
s.time -= 5000
|
||||
})
|
||||
assert.equal(compare(baseline, shifted).verdict, 'invalid')
|
||||
const badMarks = structuredClone(baseline)
|
||||
badMarks.runs[0].marks.forEach((m) => {
|
||||
m.time = -1
|
||||
})
|
||||
assert.equal(compare(baseline, badMarks).verdict, 'invalid')
|
||||
const clustered = structuredClone(baseline)
|
||||
clustered.runs[0].samples.forEach((s) => {
|
||||
s.time /= 1000
|
||||
})
|
||||
assert.equal(compare(baseline, clustered).verdict, 'invalid')
|
||||
for (const [section, key] of [
|
||||
['contract', 'platform'],
|
||||
['build', 'sourceSha256'],
|
||||
]) {
|
||||
const missing = structuredClone(baseline)
|
||||
delete missing[section][key]
|
||||
assert.equal(compare(missing, missing).verdict, 'invalid')
|
||||
}
|
||||
assert.equal(
|
||||
compare(baseline, {
|
||||
...baseline,
|
||||
contract: { ...baseline.contract, platform: 'other-macOS' },
|
||||
}).verdict,
|
||||
'incomparable',
|
||||
)
|
||||
for (const malformed of [
|
||||
null,
|
||||
{},
|
||||
{ runs: [null] },
|
||||
{ ...baseline, runs: [{ ...run, samples: null }] },
|
||||
])
|
||||
assert.equal(compare(baseline, malformed).verdict, 'invalid')
|
||||
const noisy = structuredClone(baseline)
|
||||
noisy.runs = baseline.runs.map((r) => structuredClone(r))
|
||||
noisy.build.binarySha256 = 'different'
|
||||
noisy.runs.forEach((r, i) =>
|
||||
r.samples.forEach((s) => {
|
||||
s.cpu *= [0.1, 0.2, 10][i]
|
||||
}),
|
||||
)
|
||||
assert.equal(
|
||||
compare(baseline, noisy, 5).metrics['foreground/webview/cpuMean'].verdict,
|
||||
'inconclusive',
|
||||
)
|
||||
})
|
||||
test('macOS collector agrees with process.cpuUsage on a busy owned process', {
|
||||
skip: process.platform !== 'darwin',
|
||||
}, () => {
|
||||
mkdirSync('target/perf', { recursive: true })
|
||||
execFileSync('cc', [
|
||||
'-Wall',
|
||||
'-Wextra',
|
||||
'-Werror',
|
||||
'scripts/perf/sample.c',
|
||||
'-o',
|
||||
'target/perf/test-sample',
|
||||
])
|
||||
const sample = () =>
|
||||
Number(
|
||||
execFileSync('target/perf/test-sample', [String(process.pid)], {
|
||||
encoding: 'utf8',
|
||||
}).split(' ')[2],
|
||||
)
|
||||
const first = sample(),
|
||||
cpu = process.cpuUsage(),
|
||||
start = performance.now()
|
||||
while (performance.now() - start < 500) Math.sqrt(Math.random())
|
||||
const used = process.cpuUsage(cpu),
|
||||
measured = sample() - first
|
||||
assert.ok(Math.abs(measured - (used.user + used.system) / 1e6) < 0.05)
|
||||
})
|
||||
@@ -0,0 +1,674 @@
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
realpathSync,
|
||||
} from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { createTauriCapabilities } from '@wdio/tauri-service'
|
||||
import { remote } from 'webdriverio'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
import {
|
||||
delta,
|
||||
summarize,
|
||||
markdown,
|
||||
phases,
|
||||
roles,
|
||||
validStats,
|
||||
validWorkload,
|
||||
replayValues,
|
||||
SCHEMA,
|
||||
validObservation,
|
||||
validVisibility,
|
||||
} from './report.mjs'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2).filter((v) => v !== '--'),
|
||||
options: {
|
||||
output: { type: 'string', default: `target/perf/runs/${Date.now()}` },
|
||||
binary: { type: 'string', default: 'target/perf/build/clash-verge' },
|
||||
repeat: { type: 'string', default: '3' },
|
||||
seconds: { type: 'string', default: '20' },
|
||||
warmup: { type: 'string', default: '10' },
|
||||
'fail-phase': { type: 'string' },
|
||||
},
|
||||
})
|
||||
const seconds = Number(values.seconds),
|
||||
warmup = Number(values.warmup),
|
||||
repeat = Number(values.repeat)
|
||||
const replayMs = 200
|
||||
if (process.platform !== 'darwin') throw new Error('macOS only')
|
||||
function readConsole() {
|
||||
const consoleState = JSON.parse(
|
||||
execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], {
|
||||
input: execFileSync('/usr/sbin/ioreg', ['-n', 'Root', '-d1', '-a']),
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
)
|
||||
const locked = consoleState.IOConsoleLocked
|
||||
if (typeof locked !== 'boolean')
|
||||
throw new Error('Environment unavailable: unknown console lock state')
|
||||
return locked
|
||||
}
|
||||
function checkConsole() {
|
||||
const locked = readConsole()
|
||||
if (locked)
|
||||
throw new Error(
|
||||
'Environment unavailable: an unlocked graphical session is required; window focus is not required',
|
||||
)
|
||||
return locked
|
||||
}
|
||||
const consoleLocked = checkConsole()
|
||||
if (
|
||||
![seconds, warmup, repeat].every(Number.isFinite) ||
|
||||
seconds < 5 ||
|
||||
warmup < 2 ||
|
||||
repeat < 1 ||
|
||||
!Number.isInteger(repeat)
|
||||
)
|
||||
throw new Error('Require seconds >= 5, warmup >= 2, integer repeat >= 1')
|
||||
const phaseTargets = phases.map(
|
||||
(_, i) =>
|
||||
Math.ceil(((warmup + 5) * 1000) / replayMs) +
|
||||
i * Math.ceil(((seconds + 2) * 1000) / replayMs),
|
||||
)
|
||||
if ((phaseTargets[2] * replayMs) / 1000 + seconds + 5 >= 600)
|
||||
throw new Error(
|
||||
'Total replay including transition allowance must be < 10 minutes',
|
||||
)
|
||||
const binary = realpathSync(values.binary),
|
||||
output = resolve(values.output)
|
||||
const webcontentBinary = realpathSync(
|
||||
'/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent',
|
||||
)
|
||||
const build = JSON.parse(readFileSync(`${binary}.json`))
|
||||
const hash = (data) => createHash('sha256').update(data).digest('hex')
|
||||
if (hash(readFileSync(binary)) !== build.binarySha256)
|
||||
throw new Error('Binary does not match build manifest')
|
||||
try {
|
||||
execFileSync('git', ['check-ignore', '-q', '--no-index', output])
|
||||
} catch {
|
||||
throw new Error('Output directory must be Git ignored')
|
||||
}
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
mkdirSync(output)
|
||||
execFileSync('cc', ['-O2', 'scripts/perf/sample.c', '-o', `${output}/sample`])
|
||||
const command = (name, args) =>
|
||||
execFileSync(name, args, { encoding: 'utf8' }).trim()
|
||||
const report = {
|
||||
schema: SCHEMA,
|
||||
build,
|
||||
contract: {
|
||||
scenario: 'traffic',
|
||||
replayMs,
|
||||
consoleLocked,
|
||||
method: 'mach-rusage-v0-seconds/rss-bytes-v1',
|
||||
mode: build.mode,
|
||||
buildSettingsSha256: build.buildSettingsSha256,
|
||||
buildEnvironment: build.buildEnvironment,
|
||||
toolchain: build.toolchain,
|
||||
platform: command('sw_vers', []),
|
||||
machine: command('sysctl', ['-n', 'hw.model']),
|
||||
arch: process.arch,
|
||||
host: hash(command('scutil', ['--get', 'LocalHostName'])),
|
||||
hardware: command('sysctl', ['hw.ncpu', 'hw.memsize']),
|
||||
power: command('pmset', ['-g', 'custom']),
|
||||
powerSource: command('pmset', ['-g', 'batt']).split('\n')[0],
|
||||
webkit: command('defaults', [
|
||||
'read',
|
||||
'/System/Library/Frameworks/WebKit.framework/Resources/Info',
|
||||
'CFBundleVersion',
|
||||
]),
|
||||
seconds,
|
||||
warmup,
|
||||
phaseTargets,
|
||||
transitionSeconds: 2,
|
||||
readinessAllowanceSeconds: 5,
|
||||
intervalMs: 1000,
|
||||
window: [1000, 700],
|
||||
windowPolicy: 'nonactivating-top-click-through-v1',
|
||||
rangeMinutes: 10,
|
||||
replay:
|
||||
'deterministic-v1, 5Hz, no prefill, append boundary, default bezier, blur pause false',
|
||||
automation:
|
||||
'wdio plugin 1.3.0; 20Hz macOS runloop pump plus one WebDriver observation/second',
|
||||
},
|
||||
runs: [],
|
||||
}
|
||||
report.units = {
|
||||
time: 'monotonic milliseconds',
|
||||
cpu: 'cumulative CPU seconds',
|
||||
percent: 'one CPU core = 100%',
|
||||
rss: 'resident size bytes',
|
||||
start: 'Mach process start ticks',
|
||||
}
|
||||
report.monotonicOriginEpochMs = performance.timeOrigin
|
||||
report.harnessSha256 = hash(
|
||||
['run.mjs', 'report.mjs', 'sample.c']
|
||||
.map((p) => readFileSync(`scripts/perf/${p}`))
|
||||
.join('\n'),
|
||||
)
|
||||
let interrupted = false
|
||||
const abort = new AbortController()
|
||||
const pause = (ms) => sleep(ms, undefined, { signal: abort.signal })
|
||||
for (const signal of ['SIGINT', 'SIGTERM'])
|
||||
process.on(signal, () => {
|
||||
interrupted = true
|
||||
abort.abort()
|
||||
})
|
||||
const assert = (value, reason) => {
|
||||
if (!value) throw new Error(reason)
|
||||
}
|
||||
const drawn = (s) => s.now >= s.draw && s.now - s.draw < 2500
|
||||
const marker = (s) => ({
|
||||
...s,
|
||||
pixelsSha256: hash(s.pixels),
|
||||
pixels: undefined,
|
||||
ua: undefined,
|
||||
token: undefined,
|
||||
})
|
||||
async function freePort() {
|
||||
const server = createServer()
|
||||
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
||||
const port = server.address().port
|
||||
await new Promise((done) => server.close(done))
|
||||
return port
|
||||
}
|
||||
for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
|
||||
const initialLocked = checkConsole()
|
||||
const token = randomUUID(),
|
||||
port = await freePort()
|
||||
const run = {
|
||||
token,
|
||||
ok: false,
|
||||
samples: [],
|
||||
observations: [],
|
||||
consoleStates: [
|
||||
{ name: 'initial', locked: initialLocked, time: Date.now() },
|
||||
],
|
||||
marks: [],
|
||||
stats: {},
|
||||
cleanup: {},
|
||||
replay: { sent: 0, maxLateMs: 0 },
|
||||
}
|
||||
report.runs.push(run)
|
||||
const replay = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
||||
await new Promise((done) => replay.on('listening', done))
|
||||
let sequence = 0,
|
||||
replayStarted,
|
||||
timer
|
||||
replay.on('connection', () => {
|
||||
if (replayStarted !== undefined) {
|
||||
run.replay.error = 'unexpected replay reconnection'
|
||||
clearTimeout(timer)
|
||||
return
|
||||
}
|
||||
replayStarted = performance.now()
|
||||
timer = setTimeout(send, replayMs)
|
||||
})
|
||||
const deadlineIndex = () =>
|
||||
Math.floor((performance.now() - replayStarted) / replayMs)
|
||||
function send() {
|
||||
const deadline = replayStarted + (sequence + 1) * replayMs
|
||||
const late = performance.now() - deadline
|
||||
if (late < 0) {
|
||||
timer = setTimeout(send, Math.ceil(-late))
|
||||
return
|
||||
}
|
||||
run.replay.maxLateMs = Math.max(run.replay.maxLateMs, late)
|
||||
if (late >= replayMs) {
|
||||
run.replay.error = `replay source missed a complete ${replayMs} ms period`
|
||||
return
|
||||
}
|
||||
sequence++
|
||||
run.replay.sent = sequence
|
||||
const timestamp = Date.now()
|
||||
const point = JSON.stringify({
|
||||
sequence,
|
||||
timestamp,
|
||||
...replayValues(sequence),
|
||||
})
|
||||
for (const socket of replay.clients)
|
||||
if (socket.readyState === 1) socket.send(point)
|
||||
timer = setTimeout(
|
||||
send,
|
||||
Math.max(
|
||||
0,
|
||||
replayStarted + (sequence + 1) * replayMs - performance.now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
const log = openSync(`${output}/${iteration + 1}.log`, 'w')
|
||||
const child = spawn(binary, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
PERF_TOKEN: token,
|
||||
PERF_REPLAY_PORT: String(replay.address().port),
|
||||
TAURI_WEBDRIVER_PORT: String(port),
|
||||
},
|
||||
stdio: ['ignore', log, log],
|
||||
})
|
||||
closeSync(log)
|
||||
let browser, childError, previous, mainStart
|
||||
const identities = {}
|
||||
child.on('error', (error) => {
|
||||
childError = error
|
||||
})
|
||||
const exited = new Promise((done) => child.once('exit', done))
|
||||
const alive = () =>
|
||||
!childError && child.exitCode === null && child.signalCode === null
|
||||
const collect = (pids) =>
|
||||
command(`${output}/sample`, pids.map(String))
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [pid, start, cpu, rss, ...path] = line.split(' ')
|
||||
return {
|
||||
pid: Number(pid),
|
||||
start,
|
||||
cpu: Number(cpu),
|
||||
rss: Number(rss),
|
||||
path: path.join(' '),
|
||||
time: performance.now(),
|
||||
}
|
||||
})
|
||||
const identify = (role, row) => {
|
||||
assert(
|
||||
row.path === (role === 'main' ? binary : webcontentBinary),
|
||||
`unexpected ${role} process executable: ${row.path}`,
|
||||
)
|
||||
assert(
|
||||
BigInt(row.start) >= BigInt(mainStart),
|
||||
'process predates owned application',
|
||||
)
|
||||
const identity = `${row.pid}/${row.start}`
|
||||
assert(
|
||||
!identities[role] || identities[role] === identity,
|
||||
'process identity changed during scenario',
|
||||
)
|
||||
identities[role] = identity
|
||||
}
|
||||
const state = async (action = 'state', pixels = false) =>
|
||||
browser.execute(
|
||||
async (action, pixels) => {
|
||||
const native = await window.__TAURI_INTERNALS__.invoke('perf_state', {
|
||||
action,
|
||||
})
|
||||
const canvas = document.querySelector('canvas')
|
||||
return {
|
||||
...native,
|
||||
now: Date.now(),
|
||||
hidden: document.hidden,
|
||||
focused: document.hasFocus(),
|
||||
received: Number(document.documentElement.dataset.perfReceived),
|
||||
source: Number(document.documentElement.dataset.perfSourceTime),
|
||||
gaps: Number(document.documentElement.dataset.perfGaps),
|
||||
worker: Number(document.documentElement.dataset.perfWorker),
|
||||
points: Number(document.documentElement.dataset.perfPoints),
|
||||
lastUp: Number(document.documentElement.dataset.perfLastUp),
|
||||
lastDown: Number(document.documentElement.dataset.perfLastDown),
|
||||
draw: Number(canvas?.dataset.perfDraw),
|
||||
pixels: pixels ? canvas?.toDataURL() : null,
|
||||
ua: navigator.userAgent,
|
||||
}
|
||||
},
|
||||
action,
|
||||
pixels,
|
||||
)
|
||||
const checked = async (action, pixels) => {
|
||||
assert(!interrupted, 'interrupted')
|
||||
assert(!run.replay.error, run.replay.error)
|
||||
assert(
|
||||
alive(),
|
||||
`owned app exited: ${childError ?? child.exitCode ?? child.signalCode}`,
|
||||
)
|
||||
const value = await state(action, pixels)
|
||||
assert(!run.replay.error, run.replay.error)
|
||||
run.lastObservation = { ...value, pixels: undefined, ua: undefined }
|
||||
assert(
|
||||
value.token === token && value.main === child.pid,
|
||||
'instance identity mismatch',
|
||||
)
|
||||
if (!identities.webview) {
|
||||
run.initialAttribution = collect([value.webview])[0]
|
||||
identify('webview', run.initialAttribution)
|
||||
}
|
||||
assert(
|
||||
value.nativeFocused === false && value.appActive === false,
|
||||
'measurement window unexpectedly activated',
|
||||
)
|
||||
assert(
|
||||
value.top === true && value.clickThrough === true,
|
||||
'measurement window must be natively topmost and click-through',
|
||||
)
|
||||
return value
|
||||
}
|
||||
try {
|
||||
mainStart = collect([child.pid])[0].start
|
||||
const deadline = Date.now() + 30000
|
||||
while (true) {
|
||||
assert(
|
||||
Date.now() < deadline && !interrupted && alive(),
|
||||
'embedded WebDriver readiness deadline or app exit',
|
||||
)
|
||||
try {
|
||||
if (
|
||||
(
|
||||
await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
).ok
|
||||
)
|
||||
break
|
||||
} catch {}
|
||||
await pause(100)
|
||||
}
|
||||
const listener = command('lsof', [
|
||||
'-nP',
|
||||
'-a',
|
||||
'-p',
|
||||
String(child.pid),
|
||||
`-iTCP:${port}`,
|
||||
'-sTCP:LISTEN',
|
||||
])
|
||||
assert(
|
||||
listener.includes(`127.0.0.1:${port}`) && !listener.includes(`*:${port}`),
|
||||
'driver must belong to this app and bind loopback',
|
||||
)
|
||||
const capabilities = createTauriCapabilities(binary)
|
||||
delete capabilities['wdio:tauriServiceOptions']
|
||||
browser = await remote({
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
capabilities,
|
||||
logLevel: 'error',
|
||||
connectionRetryCount: 0,
|
||||
connectionRetryTimeout: 5000,
|
||||
})
|
||||
const handles = await browser.getWindowHandles()
|
||||
assert(handles.length === 1, 'expected exactly one measurement WebView')
|
||||
await browser.switchToWindow(handles[0])
|
||||
// Replay's parent effect starts the socket after the graph installs its listeners.
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const s = await checked()
|
||||
return (
|
||||
validWorkload(s) &&
|
||||
s.received >= 3 &&
|
||||
s.now >= s.worker &&
|
||||
s.now - s.worker < 2500
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: 15000,
|
||||
interval: 100,
|
||||
timeoutMsg: 'traffic effects/Worker not ready before show',
|
||||
},
|
||||
)
|
||||
run.initialData = run.lastObservation
|
||||
await checked('show')
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const s = await checked()
|
||||
return validObservation({ phase: 'foreground', ...s })
|
||||
},
|
||||
{
|
||||
timeout: 15000,
|
||||
interval: 250,
|
||||
timeoutMsg: 'traffic data path/page not ready',
|
||||
},
|
||||
)
|
||||
run.userAgent = (await checked()).ua
|
||||
run.backgroundLoad = command('sysctl', ['-n', 'vm.loadavg'])
|
||||
const viewport = await browser.execute(() => [
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
devicePixelRatio,
|
||||
])
|
||||
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
|
||||
const driverScale = run.viewport.driver.width / 1000
|
||||
assert(
|
||||
viewport[0] === 1000 &&
|
||||
viewport[1] > 0 &&
|
||||
viewport[1] <= 700 &&
|
||||
(driverScale === 1 || driverScale === viewport[2]) &&
|
||||
run.viewport.driver.height / driverScale === 700,
|
||||
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
|
||||
)
|
||||
assert(
|
||||
!report.contract.viewport ||
|
||||
JSON.stringify(report.contract.viewport) === JSON.stringify(viewport),
|
||||
'viewport changed between runs',
|
||||
)
|
||||
report.contract.viewport = viewport
|
||||
run.marks.push({ name: 'ready', time: Date.now() })
|
||||
const readyAt = performance.now()
|
||||
assert(
|
||||
phaseTargets[0] - (await checked()).received >=
|
||||
(warmup * 1000) / replayMs,
|
||||
'readiness exceeded fixed replay allowance',
|
||||
)
|
||||
for (const phase of phases) {
|
||||
run.consoleStates.push({
|
||||
name: phase,
|
||||
locked: readConsole(),
|
||||
time: Date.now(),
|
||||
})
|
||||
const operation = performance.now()
|
||||
let s = await checked(phase === 'hidden' ? 'hide' : 'show', true)
|
||||
assert(
|
||||
s.visible === (phase !== 'hidden'),
|
||||
`${phase}: native visibility mismatch`,
|
||||
)
|
||||
const beforeDraw = s.draw
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
s = await checked('state', true)
|
||||
return (
|
||||
validVisibility({ phase, ...s }) &&
|
||||
(phase === 'hidden' || (s.draw > beforeDraw && drawn(s)))
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
interval: 100,
|
||||
timeoutMsg: `${phase}: window/Canvas readiness deadline`,
|
||||
},
|
||||
)
|
||||
const operationMs = performance.now() - operation
|
||||
const target = phaseTargets[phases.indexOf(phase)]
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
s = await checked('state', true)
|
||||
assert(
|
||||
s.received <= target,
|
||||
`${phase}: missed replay anchor ${target}`,
|
||||
)
|
||||
return s.received === target
|
||||
},
|
||||
{
|
||||
timeout: Math.max(1000, (target - s.received) * replayMs + 1000),
|
||||
interval: 50,
|
||||
timeoutMsg: `${phase}: replay anchor deadline`,
|
||||
},
|
||||
)
|
||||
assert(
|
||||
validObservation({ phase, ...s }),
|
||||
`${phase}: invalid traffic observation`,
|
||||
)
|
||||
const first = s,
|
||||
start = performance.now(),
|
||||
firstIndex = deadlineIndex(),
|
||||
phaseEpoch = Date.now()
|
||||
if (phase === 'foreground') {
|
||||
run.actualWarmupMs = start - readyAt
|
||||
assert(
|
||||
run.actualWarmupMs >= warmup * 1000,
|
||||
'insufficient actual warmup',
|
||||
)
|
||||
}
|
||||
run.marks.push({
|
||||
name: phase,
|
||||
time: phaseEpoch,
|
||||
operationMs,
|
||||
transitionMs: start - operation,
|
||||
target,
|
||||
...marker(s),
|
||||
deadlineIndex: firstIndex,
|
||||
senderSent: run.replay.sent,
|
||||
})
|
||||
previous = {}
|
||||
if (values['fail-phase'] === phase)
|
||||
throw new Error(`injected failure at ${phase}`)
|
||||
while (performance.now() - start < seconds * 1000) {
|
||||
const tick = performance.now()
|
||||
s = await checked()
|
||||
run.observations.push({ phase, ...run.lastObservation })
|
||||
assert(
|
||||
validObservation({ phase, ...s }),
|
||||
`${phase}: invalid visibility, workload or freshness`,
|
||||
)
|
||||
assert(
|
||||
performance.now() - replayStarted < 600000,
|
||||
'replay exceeded raw history window',
|
||||
)
|
||||
const samples = collect(roles.map((role) => s[role]))
|
||||
run.attribution = {
|
||||
main: s.main,
|
||||
webview: s.webview,
|
||||
observed: samples,
|
||||
}
|
||||
for (let i = 0; i < roles.length; i++) {
|
||||
const row = { ...samples[i], phase, role: roles[i] }
|
||||
identify(row.role, row)
|
||||
row.percent = delta(previous[row.role], row)
|
||||
row.cpuDelta =
|
||||
row.percent === null ? null : row.cpu - previous[row.role].cpu
|
||||
run.samples.push(row)
|
||||
previous[row.role] = row
|
||||
}
|
||||
await pause(Math.max(0, 1000 - (performance.now() - tick)))
|
||||
}
|
||||
s = await checked('state', true)
|
||||
const endIndex = deadlineIndex()
|
||||
const expected = endIndex - firstIndex
|
||||
assert(
|
||||
validObservation({ phase, ...s }) &&
|
||||
s.worker > first.worker &&
|
||||
Math.abs(s.received - first.received - expected) <= 2,
|
||||
`${phase}: unstable or lost workload`,
|
||||
)
|
||||
if (phase !== 'hidden')
|
||||
assert(
|
||||
s.draw > first.draw && s.pixels !== first.pixels,
|
||||
`${phase}: Canvas did not render fresh data`,
|
||||
)
|
||||
run.marks.push({
|
||||
name: `${phase}:end`,
|
||||
time: s.now,
|
||||
...marker(s),
|
||||
deadlineIndex: endIndex,
|
||||
senderSent: run.replay.sent,
|
||||
})
|
||||
run.consoleStates.push({
|
||||
name: `${phase}:end`,
|
||||
locked: readConsole(),
|
||||
time: Date.now(),
|
||||
})
|
||||
}
|
||||
run.stats = summarize(run.samples, seconds)
|
||||
assert(
|
||||
validStats(run.stats),
|
||||
'insufficient coverage or process discontinuity',
|
||||
)
|
||||
run.ok = true
|
||||
} catch (error) {
|
||||
run.error = String(error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await browser?.deleteSession().catch(() => {})
|
||||
try {
|
||||
if (alive() && collect([child.pid])[0].start === mainStart) {
|
||||
child.kill('SIGTERM')
|
||||
await Promise.race([exited, sleep(5000)])
|
||||
if (alive() && collect([child.pid])[0].start === mainStart) {
|
||||
child.kill('SIGKILL')
|
||||
await Promise.race([exited, sleep(2000)])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
run.cleanup.error = String(error)
|
||||
}
|
||||
clearTimeout(timer)
|
||||
for (const socket of replay.clients) socket.terminate()
|
||||
await new Promise((done) => replay.close(done))
|
||||
run.cleanup.mainExited =
|
||||
child.exitCode !== null || child.signalCode !== null
|
||||
if (identities.webview) {
|
||||
const [pid, start] = identities.webview.split('/')
|
||||
const remains = () => {
|
||||
try {
|
||||
return collect([pid])[0].start === start
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + 5000
|
||||
while (remains() && Date.now() < deadline) await sleep(100)
|
||||
run.cleanup.webviewExited = !remains()
|
||||
}
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
run.cleanup.driverClosed = false
|
||||
} catch {
|
||||
run.cleanup.driverClosed = true
|
||||
}
|
||||
if (
|
||||
!run.cleanup.mainExited ||
|
||||
!run.cleanup.driverClosed ||
|
||||
run.cleanup.webviewExited === false
|
||||
) {
|
||||
run.ok = false
|
||||
run.error = `${run.error ?? ''} cleanup failed`
|
||||
process.exitCode = 1
|
||||
}
|
||||
run.stats = summarize(run.samples, seconds)
|
||||
writeFileSync(
|
||||
`${output}/${iteration + 1}.json`,
|
||||
JSON.stringify(run, null, 2),
|
||||
)
|
||||
const columns = [
|
||||
'phase',
|
||||
'role',
|
||||
'time',
|
||||
'pid',
|
||||
'start',
|
||||
'cpu',
|
||||
'percent',
|
||||
'rss',
|
||||
]
|
||||
writeFileSync(
|
||||
`${output}/${iteration + 1}.csv`,
|
||||
[
|
||||
columns.join(','),
|
||||
...run.samples.map((r) =>
|
||||
columns.map((c) => r[c] ?? 'unavailable').join(','),
|
||||
),
|
||||
].join('\n'),
|
||||
)
|
||||
writeFileSync(`${output}/summary.json`, JSON.stringify(report, null, 2))
|
||||
writeFileSync(`${output}/report.md`, markdown(report))
|
||||
}
|
||||
console.log(`Run ${iteration + 1}: ${run.ok ? 'passed' : run.error}`)
|
||||
}
|
||||
console.log(`Reports: ${output}`)
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <libproc.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/resource.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
mach_timebase_info_data_t timebase;
|
||||
if (mach_timebase_info(&timebase) != KERN_SUCCESS) return 1;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
int pid = atoi(argv[i]);
|
||||
struct rusage_info_v0 r;
|
||||
char path[PROC_PIDPATHINFO_MAXSIZE];
|
||||
if (pid <= 0 || proc_pid_rusage(pid, RUSAGE_INFO_V0, (rusage_info_t *)&r)) return 1;
|
||||
if (proc_pidpath(pid, path, sizeof(path)) <= 0) return 1;
|
||||
printf("%d %llu %.9f %llu %s\n", pid, r.ri_proc_start_abstime,
|
||||
((double)r.ri_user_time + r.ri_system_time) * timebase.numer / timebase.denom / 1e9, r.ri_resident_size, path);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ThemeProvider, createTheme } from '@mui/material'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import { VirtualList } from '../../src/components/base/virtual-list'
|
||||
import { ConnectionRowItem } from '../../src/components/connection/connection-row-item'
|
||||
import { useConnectionRowViews } from '../../src/components/connection/connection-row-view'
|
||||
import LogItem from '../../src/components/log/log-item'
|
||||
import { initializeLanguage } from '../../src/services/i18n'
|
||||
import { setPreloadConfig } from '../../src/services/preload'
|
||||
|
||||
import { layer, scenario, subscribe } from './replay'
|
||||
import '../../src/assets/styles/page.scss'
|
||||
|
||||
const searchState = {
|
||||
text: '',
|
||||
matchCase: false,
|
||||
matchWholeWord: false,
|
||||
useRegularExpression: false,
|
||||
}
|
||||
export function Components() {
|
||||
const [logs, setLogs] = useState<ILogItem[]>([])
|
||||
const [connections, setConnections] = useState<IConnectionsItem[]>([])
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(({ payload }) => {
|
||||
if (scenario === 'logs')
|
||||
setLogs((previous) =>
|
||||
previous.concat(payload as ILogItem[]).slice(-1000),
|
||||
)
|
||||
else setConnections((payload as IConnections).connections)
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const rows = useConnectionRowViews(connections)
|
||||
return (
|
||||
<VirtualList
|
||||
count={scenario === 'logs' ? logs.length : rows.length}
|
||||
estimateSize={scenario === 'logs' ? 50 : 56}
|
||||
style={{ height: 640, margin: 24 }}
|
||||
renderItem={(index) =>
|
||||
scenario === 'logs' ? (
|
||||
<LogItem
|
||||
value={logs[logs.length - 1 - index]}
|
||||
searchState={searchState}
|
||||
/>
|
||||
) : (
|
||||
<ConnectionRowItem
|
||||
row={rows[index]}
|
||||
closed={false}
|
||||
onShowDetail={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
dayjs.extend(relativeTime)
|
||||
const root = document.getElementById('root')!
|
||||
setPreloadConfig({
|
||||
language: 'en',
|
||||
theme_mode: 'light',
|
||||
pause_render_traffic_stats_on_blur: false,
|
||||
} as IVergeConfig)
|
||||
await initializeLanguage('en')
|
||||
const Scene =
|
||||
layer === 'component'
|
||||
? Components
|
||||
: scenario === 'logs'
|
||||
? (await import('../../src/pages/logs')).default
|
||||
: (await import('../../src/pages/connections')).default
|
||||
createRoot(root).render(
|
||||
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<Scene />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>,
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ThemeProvider, createTheme } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import { EnhancedCanvasTrafficGraph } from '../../src/components/home/enhanced-canvas-traffic-graph'
|
||||
import { useTrafficMonitorEnhanced } from '../../src/hooks/use-traffic-monitor'
|
||||
import { initializeLanguage } from '../../src/services/i18n'
|
||||
import { setPreloadConfig } from '../../src/services/preload'
|
||||
|
||||
setPreloadConfig({
|
||||
pause_render_traffic_stats_on_blur: false,
|
||||
} as IVergeConfig)
|
||||
await initializeLanguage('en')
|
||||
export function Replay() {
|
||||
const {
|
||||
graphData: { appendData },
|
||||
} = useTrafficMonitorEnhanced({ subscribe: false })
|
||||
useEffect(() => {
|
||||
let received = 0,
|
||||
previous = 0,
|
||||
gaps = 0
|
||||
const socket = new WebSocket(
|
||||
`ws://127.0.0.1:${new URLSearchParams(location.search).get('port')}`,
|
||||
)
|
||||
socket.onmessage = ({ data }) => {
|
||||
const point = JSON.parse(data)
|
||||
appendData(point)
|
||||
if (previous && point.sequence !== previous + 1) gaps++
|
||||
previous = point.sequence
|
||||
document.documentElement.dataset.perfReceived = String(++received)
|
||||
document.documentElement.dataset.perfGaps = String(gaps)
|
||||
document.documentElement.dataset.perfSourceTime = String(point.timestamp)
|
||||
}
|
||||
return () => socket.close()
|
||||
}, [appendData])
|
||||
return (
|
||||
<main
|
||||
aria-label="Home traffic graph replay"
|
||||
style={{ width: 920, height: 360, margin: 32 }}
|
||||
>
|
||||
<EnhancedCanvasTrafficGraph />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<Replay />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["../../src", "./*.tsx", "./*.ts"]
|
||||
"include": ["../../src", "./*.tsx"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ repository = "https://github.com/clash-verge-rev/clash-verge-rev.git"
|
||||
default-run = "clash-verge"
|
||||
build = "build.rs"
|
||||
edition = "2024"
|
||||
rust-version = "1.100"
|
||||
rust-version = "1.99"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
@@ -72,12 +72,12 @@ sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch
|
||||
] }
|
||||
network-interface = { version = "2.0.5", features = ["serde"] }
|
||||
tauri-plugin-shell = "2.6.0"
|
||||
tauri-plugin-opener = "2.7.3"
|
||||
tauri-plugin-opener = "2.7.2"
|
||||
tauri-plugin-dialog = "2.8.4"
|
||||
tauri-plugin-fs = "2.7.1"
|
||||
tauri-plugin-deep-link = "2.6.3"
|
||||
tauri-plugin-window-state = "2.5.0"
|
||||
zip = "9.5.1"
|
||||
zip = "9.4.2"
|
||||
flate2 = "1.1.11"
|
||||
reqwest_dav = "0.3.3"
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
@@ -86,20 +86,21 @@ sha2 = "0.11.1"
|
||||
getrandom = "0.4.3"
|
||||
futures = "0.3.34"
|
||||
gethostname = "1.1.0"
|
||||
tauri-plugin-notification = "2.8.1"
|
||||
scopeguard = "1.2.0"
|
||||
tauri-plugin-notification = "2.8.0"
|
||||
backon = { version = "1.6.0", features = ["tokio-sleep"] }
|
||||
tauri-plugin-http = "2.9.0"
|
||||
tauri-plugin-http = "2.8.3"
|
||||
tracing = { workspace = true }
|
||||
tauri-plugin-devtools = { version = "2.2.3", optional = true }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "e49bbedcc67d60da2c10e81bc6433b0f8e8fa232" }
|
||||
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", rev = "7a03d16f4d39b1bb885b7babe66b9e153e34b615" }
|
||||
async-trait = "0.1.92"
|
||||
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
|
||||
"client",
|
||||
] }
|
||||
arc-swap = "1.9.5"
|
||||
arc-swap = "1.9.4"
|
||||
tokio-rustls = "0.26.6"
|
||||
rustls = { version = "0.23.46", features = ["ring"] }
|
||||
webpki-roots = "1.0.12"
|
||||
webpki-roots = "1.0.11"
|
||||
dark-light = "3.0.0"
|
||||
bytes = "1.12.1"
|
||||
tokio-util = { version = "0.7.20", features = ["time"] }
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"core:window:allow-set-theme",
|
||||
"core:window:allow-set-background-color",
|
||||
"core:webview:allow-set-webview-background-color",
|
||||
"notification:default",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// bundles (PR #8131), so this generator must stay an example, never a bin.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let output = args.next().map_or_else(
|
||||
|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"),
|
||||
std::path::PathBuf::from,
|
||||
);
|
||||
let output = args
|
||||
.next()
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts"));
|
||||
if args.next().is_some() {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into());
|
||||
}
|
||||
|
||||
@@ -18,11 +18,9 @@ use crate::{
|
||||
};
|
||||
use clash_verge_draft::{Draft, SharedDraft};
|
||||
use clash_verge_logging::{Type, logging, logging_error};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
mem::DropGuard,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -402,9 +400,9 @@ pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult<Vali
|
||||
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
let _switching_profile = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
|
||||
});
|
||||
}
|
||||
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let profiles = IProfiles {
|
||||
|
||||
@@ -83,7 +83,8 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap<String, GroupScope
|
||||
.get("exclude-type")
|
||||
.and_then(|types| types.as_str())
|
||||
.filter(|types| !types.is_empty())
|
||||
.map_or_default(|types| types.split('|').map(str::to_owned).collect());
|
||||
.map(|types| types.split('|').map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
Some((
|
||||
name,
|
||||
GroupScope {
|
||||
|
||||
@@ -191,7 +191,7 @@ impl Config {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error_msg = config_result.err().map_or_default(|err| err.to_string());
|
||||
let error_msg = config_result.err().map(|err| err.to_string()).unwrap_or_default();
|
||||
logging!(warn, Type::Config, "生成配置文件失败,使用默认配置: {error_msg}");
|
||||
CoreManager::global()
|
||||
.use_default_config(NoticeStatus::ConfigValidateError, "")
|
||||
|
||||
@@ -371,10 +371,9 @@ impl PrfItem {
|
||||
|
||||
let uid = help::get_uid("R").into();
|
||||
let file = format!("{uid}.yaml").into();
|
||||
let name = name.map_or_else(
|
||||
|| filename.map_or_else(|| "Remote File".into(), |s| s.into()),
|
||||
|s| s.to_owned(),
|
||||
);
|
||||
let name = name
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or_else(|| filename.map(|s| s.into()).unwrap_or_else(|| "Remote File".into()));
|
||||
let data = resp.text();
|
||||
|
||||
let data = data.trim_start_matches('\u{feff}');
|
||||
|
||||
@@ -70,8 +70,6 @@ pub mod files {
|
||||
pub mod tun {
|
||||
pub const DEFAULT_STACK: &str = "mips";
|
||||
|
||||
pub const DEFAULT_MTU: u16 = 1500;
|
||||
|
||||
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
||||
|
||||
/// Saved dialog fields; `enable` belongs to the verge switch.
|
||||
|
||||
@@ -279,7 +279,8 @@ async fn cleanup_auto_backups() -> Result<()> {
|
||||
.and_then(|meta| meta.modified())
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |dur| dur.as_secs());
|
||||
.map(|dur| dur.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
files.push((path, modified));
|
||||
}
|
||||
|
||||
@@ -539,16 +539,18 @@ fn bind_socket(claim: &BindClaim) -> io::Result<Socket> {
|
||||
}
|
||||
|
||||
fn is_bind_conflict(error: &io::Error) -> bool {
|
||||
if error.kind() == io::ErrorKind::AddrInUse {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Win32::Networking::WinSock::WSAEACCES;
|
||||
|
||||
error.kind() == io::ErrorKind::AddrInUse || error.raw_os_error() == Some(WSAEACCES)
|
||||
error.raw_os_error() == Some(WSAEACCES)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
error.kind() == io::ErrorKind::AddrInUse
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -14,10 +14,10 @@ use anyhow::{Result, anyhow};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use clash_verge_service_ipc::StageRuntimeOutcome;
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
mem::DropGuard,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
@@ -191,9 +191,9 @@ impl CoreManager {
|
||||
logging!(debug, Type::Core, "Configuration update is already running");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
|
||||
if !force && !self.should_update_config() {
|
||||
logging!(debug, Type::Core, "Skipping config update due to debounce");
|
||||
@@ -314,9 +314,9 @@ impl CoreManager {
|
||||
logging!(debug, Type::Core, "Configuration update is already running");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
|
||||
let runtime = Config::runtime().await;
|
||||
let transaction = DraftTransaction::begin(vec![&runtime])?;
|
||||
|
||||
@@ -6,8 +6,9 @@ use crate::core::proxy_control::{self, SysproxyFailure};
|
||||
use crate::core::service::{SERVICE_MANAGER, ServiceStatus};
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::{mem::DropGuard, path::Path};
|
||||
use std::path::Path;
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin;
|
||||
|
||||
@@ -355,9 +356,9 @@ impl CoreManager {
|
||||
if !self.try_start_config_update() {
|
||||
anyhow::bail!("configuration update is already running");
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
self.start_core_during_config_update().await
|
||||
}
|
||||
|
||||
@@ -382,9 +383,9 @@ impl CoreManager {
|
||||
service_rejection,
|
||||
));
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
// Lock order is config then lifecycle. disable_tun_and_persist commits the shared Verge
|
||||
// draft without claiming it, so without this it can commit another transaction's staged patch.
|
||||
let config_write = Config::lock_config_write().await;
|
||||
@@ -462,9 +463,9 @@ impl CoreManager {
|
||||
if !self.try_start_config_update() {
|
||||
anyhow::bail!("configuration update is already running");
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
// Lock order is config then lifecycle, for the unclaimed draft write below.
|
||||
let _config_write = Config::lock_config_write().await;
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
@@ -794,9 +795,9 @@ impl CoreManager {
|
||||
if !self.try_start_config_update() {
|
||||
anyhow::bail!("configuration update is already running");
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
self.restart_core_during_config_update().await
|
||||
}
|
||||
|
||||
@@ -965,9 +966,9 @@ impl CoreManager {
|
||||
if !self.try_start_config_update() {
|
||||
return HandoffOutcome::NotReady;
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
});
|
||||
}
|
||||
|
||||
// Lock order is config then lifecycle.
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
|
||||
@@ -331,7 +331,9 @@ fn build_provider_records(
|
||||
providers: Option<ProxyProviders>,
|
||||
records: &mut BTreeMap<String, ProxyNodeView>,
|
||||
) -> (Vec<ProxyProviderView>, ProviderCandidates) {
|
||||
let providers = providers.map_or_default(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>());
|
||||
let providers = providers
|
||||
.map(|providers| providers.providers.into_iter().collect::<BTreeMap<_, _>>())
|
||||
.unwrap_or_default();
|
||||
let mut views = Vec::new();
|
||||
let mut candidates = BTreeMap::new();
|
||||
|
||||
|
||||
@@ -126,7 +126,8 @@ fn allocate_provider_destination(destination: &str, url: &str, reserved: &mut Ha
|
||||
let path = Path::new(destination);
|
||||
let extension = path
|
||||
.extension()
|
||||
.map_or_default(|ext| format!(".{}", ext.to_string_lossy()));
|
||||
.map(|ext| format!(".{}", ext.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut suffix = String::new();
|
||||
let mut sequence = 0;
|
||||
loop {
|
||||
|
||||
@@ -1470,7 +1470,10 @@ async fn create_sync_temp(target: &Path) -> Result<(PathBuf, tokio::fs::File)> {
|
||||
.await
|
||||
.with_context(|| format!("failed to create provider cache directory {}", parent.display()))?;
|
||||
}
|
||||
let name = target.file_name().map_or_default(|name| name.to_string_lossy());
|
||||
let name = target
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_default();
|
||||
use crate::utils::retry::{RetryError, RetryPolicy, retry};
|
||||
retry(
|
||||
RetryPolicy::fixed(std::num::NonZeroUsize::MIN.saturating_add(7), std::time::Duration::ZERO),
|
||||
|
||||
@@ -7,9 +7,9 @@ use crate::{
|
||||
use anyhow::Result;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use parking_lot::RwLock;
|
||||
use scopeguard::defer;
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
mem::DropGuard,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -541,9 +541,9 @@ impl Sysopt {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let _reset_sysproxy = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
let mut proxy_state = self.proxy_state_lock.lock().await;
|
||||
let _guard_operation = self.guard_operation_lock.lock().await;
|
||||
let drained = self.stop_proxy_guard_locked().await;
|
||||
|
||||
@@ -210,7 +210,8 @@ impl Tray {
|
||||
.latest_arc()
|
||||
.0
|
||||
.get("mode")
|
||||
.map_or("rule", |val| val.as_str().unwrap_or("rule"))
|
||||
.map(|val| val.as_str().unwrap_or("rule"))
|
||||
.unwrap_or("rule")
|
||||
.to_owned()
|
||||
};
|
||||
let profiles_config = Config::profiles().await;
|
||||
@@ -413,23 +414,26 @@ impl Tray {
|
||||
}
|
||||
|
||||
fn create_hotkeys(hotkeys: &Option<Vec<String>>) -> HashMap<&str, &str> {
|
||||
hotkeys.as_ref().map_or_default(|h| {
|
||||
h.iter()
|
||||
.filter_map(|item| {
|
||||
let mut parts = item.split(',');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(func), Some(key)) => {
|
||||
// 托盘菜单的 `accelerator` 在 Linux/Windows 下不支持小键盘按键,Windows 下也不支持 Win 键(前端上报为 `CMD`)
|
||||
let uppercase_key = key.to_uppercase();
|
||||
let unsupported =
|
||||
uppercase_key.contains("NUMPAD") || (cfg!(windows) && uppercase_key.contains("CMD"));
|
||||
if unsupported { None } else { Some((func, key)) }
|
||||
hotkeys
|
||||
.as_ref()
|
||||
.map(|h| {
|
||||
h.iter()
|
||||
.filter_map(|item| {
|
||||
let mut parts = item.split(',');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(func), Some(key)) => {
|
||||
// 托盘菜单的 `accelerator` 在 Linux/Windows 下不支持小键盘按键,Windows 下也不支持 Win 键(前端上报为 `CMD`)
|
||||
let uppercase_key = key.to_uppercase();
|
||||
let unsupported =
|
||||
uppercase_key.contains("NUMPAD") || (cfg!(windows) && uppercase_key.contains("CMD"));
|
||||
if unsupported { None } else { Some((func, key)) }
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect::<HashMap<&str, &str>>()
|
||||
})
|
||||
})
|
||||
.collect::<HashMap<&str, &str>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn create_profile_menu_item(
|
||||
@@ -489,14 +493,12 @@ fn create_subcreate_proxy_menu_item(
|
||||
.proxies
|
||||
.get(proxy_str)
|
||||
.and_then(|h| h.history.last())
|
||||
.map_or_else(
|
||||
|| "-ms".into(),
|
||||
|h| match h.delay {
|
||||
0 => "-ms".into(),
|
||||
delay if delay >= 10000 => "-ms".into(),
|
||||
_ => format!("{}ms", h.delay),
|
||||
},
|
||||
);
|
||||
.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);
|
||||
|
||||
@@ -609,7 +611,7 @@ async fn create_tray_menu(
|
||||
config
|
||||
.get("proxy-groups")
|
||||
.and_then(|groups| groups.as_sequence())
|
||||
.map_or_default(|groups| {
|
||||
.map(|groups| {
|
||||
groups
|
||||
.iter()
|
||||
.filter_map(|group| group.get("name"))
|
||||
@@ -618,6 +620,7 @@ async fn create_tray_menu(
|
||||
.map(|(index, name)| (name.into(), index))
|
||||
.collect::<HashMap<String, usize>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
(proxy_nodes_data, runtime_proxy_groups_order)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use scopeguard::defer;
|
||||
use serde::Serialize;
|
||||
use smartstring::alias::String;
|
||||
use std::{
|
||||
fmt,
|
||||
hash::{DefaultHasher, Hash as _, Hasher as _},
|
||||
mem::DropGuard,
|
||||
sync::LazyLock,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
@@ -421,9 +421,9 @@ impl CoreConfigValidator {
|
||||
logging!(info, Type::Validate, "验证已在进行中,跳过新的验证请求");
|
||||
return Ok(ValidationOutcome::Busy);
|
||||
}
|
||||
let _validation = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
self.finish();
|
||||
});
|
||||
}
|
||||
logging!(info, Type::Validate, "生成临时配置文件用于验证");
|
||||
|
||||
// The test run reads the app dir, which never sees updates made by a service core.
|
||||
@@ -449,7 +449,8 @@ fn has_ext<P: AsRef<std::path::Path>>(path: P, ext: &str) -> bool {
|
||||
path.as_ref()
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(ext))
|
||||
.map(|s| s.eq_ignore_ascii_case(ext))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn contains_any_keyword<'a>(buf: &'a [u8], keywords: &'a [&str]) -> bool {
|
||||
|
||||
@@ -459,7 +459,7 @@ fn gui_tun_keys(clash_config: &Mapping) -> Vec<Value> {
|
||||
keys.extend(
|
||||
constants::tun::GUI_KEYS
|
||||
.iter()
|
||||
.filter(|key| tun.get(**key).is_some_and(|value| !value.is_null()))
|
||||
.filter(|key| tun.contains_key(**key))
|
||||
.map(|key| Value::from(*key)),
|
||||
);
|
||||
}
|
||||
@@ -531,16 +531,6 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
fn ensure_tun_default_mtu(mut config: Mapping) -> Mapping {
|
||||
if let Some(Value::Mapping(tun)) = config.get_mut("tun")
|
||||
&& matches!(tun.get("mtu"), None | Some(Value::Null))
|
||||
{
|
||||
tun.insert(Value::from("mtu"), Value::from(constants::tun::DEFAULT_MTU));
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
||||
fn merge_default_config(
|
||||
mut config: Mapping,
|
||||
@@ -558,9 +548,7 @@ fn merge_default_config(
|
||||
});
|
||||
let patch_tun = value.as_mapping().cloned().unwrap_or_else(Mapping::new);
|
||||
for (key, value) in patch_tun.into_iter() {
|
||||
if !value.is_null() {
|
||||
tun.insert(key, value);
|
||||
}
|
||||
tun.insert(key, value);
|
||||
}
|
||||
config.insert("tun".into(), tun.into());
|
||||
} else {
|
||||
@@ -646,7 +634,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||
let proxy_names = config
|
||||
.get("proxies")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map_or_default(|seq| {
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|item| match item {
|
||||
Value::Mapping(map) => map
|
||||
@@ -657,12 +645,13 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||
_ => None,
|
||||
})
|
||||
.collect::<HashSet<String>>()
|
||||
});
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let group_names = config
|
||||
.get("proxy-groups")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map_or_default(|seq| {
|
||||
.map(|seq| {
|
||||
seq.iter()
|
||||
.filter_map(|item| {
|
||||
item.as_mapping()
|
||||
@@ -671,17 +660,19 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||
.map(std::convert::Into::into)
|
||||
})
|
||||
.collect::<HashSet<String>>()
|
||||
});
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let provider_names = config
|
||||
.get("proxy-providers")
|
||||
.and_then(Value::as_mapping)
|
||||
.map_or_default(|map| {
|
||||
.map(|map| {
|
||||
map.keys()
|
||||
.filter_map(Value::as_str)
|
||||
.map(std::convert::Into::into)
|
||||
.collect::<HashSet<String>>()
|
||||
});
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut allowed_names = proxy_names;
|
||||
allowed_names.extend(group_names);
|
||||
@@ -725,13 +716,15 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||
let is_fake_ip = dns
|
||||
.get("enhanced-mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_none_or(|m| m == "fake-ip");
|
||||
.map(|m| m == "fake-ip")
|
||||
.unwrap_or(true);
|
||||
|
||||
// Hand-edited YAML may leave the key present but empty.
|
||||
let range6_missing = dns
|
||||
.get("fake-ip-range6")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_none_or(|s| s.trim().is_empty());
|
||||
.map(|s| s.trim().is_empty())
|
||||
.unwrap_or(true);
|
||||
|
||||
if ipv6_enabled && is_fake_ip && range6_missing {
|
||||
dns.insert(Value::from("fake-ip-range6"), Value::from("2001:2::0/64"));
|
||||
@@ -754,7 +747,8 @@ fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
|
||||
config
|
||||
.get_mut(key)
|
||||
.and_then(Value::as_mapping_mut)
|
||||
.map_or_default(std::mem::take)
|
||||
.map(std::mem::take)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, Mapping) {
|
||||
@@ -890,7 +884,6 @@ pub async fn enhance(
|
||||
|
||||
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
|
||||
let config = authoritative.enforce(config);
|
||||
let config = ensure_tun_default_mtu(config);
|
||||
let config = ensure_lan_bind_address(config);
|
||||
|
||||
let config = cleanup_proxy_groups(config);
|
||||
|
||||
@@ -20,10 +20,14 @@ fn collect_proxy_names(seq: &Sequence) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn is_selector_group(group_map: &Mapping) -> bool {
|
||||
group_map.get("type").and_then(Value::as_str).is_some_and(|value| {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value == "select" || value == "selector"
|
||||
})
|
||||
group_map
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value == "select" || value == "selector"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn use_seq(seq: SeqMap, mut config: Mapping, field: &str) -> Mapping {
|
||||
@@ -151,6 +155,7 @@ mod tests {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn test_delete_proxy_and_references() {
|
||||
let config_str = r#"
|
||||
@@ -228,6 +233,7 @@ proxy-groups:
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn test_add_new_proxies_to_first_selector_group() {
|
||||
let config_str = r#"
|
||||
@@ -297,6 +303,7 @@ proxy-groups:
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[allow(clippy::expect_used)]
|
||||
fn test_preserve_non_sequence_proxy_groups() {
|
||||
let config_str = r#"
|
||||
|
||||
@@ -235,7 +235,8 @@ pub async fn import_local_backup(source: String) -> Result<String> {
|
||||
let ext = source_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map_or_default(|ext| ext.to_ascii_lowercase());
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
if ext != "zip" {
|
||||
return Err(anyhow!("Only .zip backup files are supported"));
|
||||
}
|
||||
@@ -313,7 +314,8 @@ pub async fn list_local_backup() -> Result<Vec<LocalBackupFile>> {
|
||||
};
|
||||
let last_modified = metadata
|
||||
.modified()
|
||||
.map_or_default(|time| chrono::DateTime::<Utc>::from(time).to_rfc3339());
|
||||
.map(|time| chrono::DateTime::<Utc>::from(time).to_rfc3339())
|
||||
.unwrap_or_default();
|
||||
backups.push(LocalBackupFile {
|
||||
filename: file_name.into(),
|
||||
path: path.to_string_lossy().into(),
|
||||
|
||||
@@ -487,8 +487,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn package_urls_pin_the_resolved_version() {
|
||||
let release = package_url(false, "v1.19.30").unwrap();
|
||||
let alpha = package_url(true, "alpha-c0e43eb").unwrap();
|
||||
let release = package_url(false, "v1.19.30").unwrap_or_default();
|
||||
let alpha = package_url(true, "alpha-c0e43eb").unwrap_or_default();
|
||||
assert!(release.contains("/releases/download/v1.19.30/"), "{release}");
|
||||
assert!(
|
||||
release.ends_with("-v1.19.30.gz") || release.ends_with("-v1.19.30.zip"),
|
||||
|
||||
@@ -44,7 +44,7 @@ fn ensure_icon_cache_target(icon_cache_dir: &Path, file_name: &str) -> CmdResult
|
||||
}
|
||||
|
||||
fn normalized_text_prefix(content: &[u8]) -> std::string::String {
|
||||
let content = content.trim_prefix(&[0xEF, 0xBB, 0xBF]);
|
||||
let content = content.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(content);
|
||||
let start = content
|
||||
.iter()
|
||||
.position(|byte| !byte.is_ascii_whitespace())
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_draft::DraftTransaction;
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use std::mem::DropGuard;
|
||||
use scopeguard::{ScopeGuard, defer, guard};
|
||||
|
||||
pub async fn probe_listener(request: ListenerProbe) -> Result<ListenerProbeOutcome> {
|
||||
AsyncHandler::spawn_blocking(move || probe_listener_sync(&request))
|
||||
@@ -35,9 +35,9 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
if !manager.try_start_config_update() {
|
||||
bail!("configuration update is already running");
|
||||
}
|
||||
let _config_update = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
manager.finish_config_update();
|
||||
});
|
||||
}
|
||||
|
||||
// Read first because transaction rollback discards all claimed drafts.
|
||||
let current = current_runtime_mapping().await?;
|
||||
@@ -52,13 +52,13 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
// Hide PAC while the staged ports are not yet serving traffic.
|
||||
manager.core_starting();
|
||||
// Recompute PAC state after every exit path.
|
||||
let _core_start = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
manager.core_start_settled();
|
||||
});
|
||||
}
|
||||
// A user-picked port ends the startup fallback, but only once the save lands: until then the
|
||||
// Core still serves the borrowed port, so every failing exit must put it back or the system
|
||||
// proxy and PAC point at a dead port. Declared after the PAC guard so it restores first.
|
||||
let borrowed_port = DropGuard::new(MixedPort::session_fallback(), restore_borrowed_port);
|
||||
let borrowed_port = guard(MixedPort::session_fallback(), restore_borrowed_port);
|
||||
MixedPort::clear_session_fallback();
|
||||
Config::generate()
|
||||
.await
|
||||
@@ -125,7 +125,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result<SaveProxyPo
|
||||
|
||||
transaction.commit();
|
||||
// The save landed, so the port the app had borrowed is now irrelevant.
|
||||
let _ = DropGuard::dismiss(borrowed_port);
|
||||
let _ = ScopeGuard::into_inner(borrowed_port);
|
||||
announce(Refresh::Clash);
|
||||
announce(Refresh::Verge);
|
||||
logging!(info, Type::Config, "Proxy port configuration applied and persisted");
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
use crate::core::notify::NoticeStatus;
|
||||
|
||||
use std::{
|
||||
mem::DropGuard,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use scopeguard::defer;
|
||||
|
||||
use crate::{
|
||||
config::{Config, IVerge},
|
||||
@@ -63,9 +61,9 @@ pub async fn reconcile_tun_availability() {
|
||||
|
||||
// The flag coalesces newly spawned tasks while the config lock serializes writers.
|
||||
DISABLING_TUN.store(true, Ordering::Release);
|
||||
let _disabling_tun = DropGuard::new((), |()| {
|
||||
defer! {
|
||||
DISABLING_TUN.store(false, Ordering::Release);
|
||||
});
|
||||
}
|
||||
|
||||
logging!(
|
||||
info,
|
||||
|
||||
@@ -15,7 +15,7 @@ fn main() -> ExitCode {
|
||||
|
||||
#[cfg(not(all(feature = "perf-harness", target_os = "macos")))]
|
||||
fn main() -> ExitCode {
|
||||
let default_parallelism = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
let worker_limit = std::cmp::min(default_parallelism, 4);
|
||||
let blocking_limit = 2 * worker_limit;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ pub async fn connect_traffic_stream() -> Result<MihomoWsEventStream<TrafficSpeed
|
||||
.ws_traffic({
|
||||
let message_tx = message_tx.clone();
|
||||
move |message| {
|
||||
if let Some(event) = parse_traffic_event(message.as_bytes()) {
|
||||
if let Some(event) = parse_traffic_event(&message) {
|
||||
try_send_internal_event(&message_tx, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ use crate::{config::with_encryption, process::AsyncHandler};
|
||||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use clash_verge_logging::{Type, logging};
|
||||
use nanoid::nanoid;
|
||||
use scopeguard::{ScopeGuard, guard};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use serde_yaml_ng::{Mapping, Value};
|
||||
use std::{
|
||||
mem::DropGuard,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
@@ -168,7 +168,7 @@ pub async fn save_yaml_str(path: &Path, yaml_str: &str) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
let temporary = DropGuard::new(temporary, |path| {
|
||||
let temporary = guard(temporary, |path| {
|
||||
let _ = std::fs::remove_file(path);
|
||||
});
|
||||
let mut file = tokio::fs::File::from_std(file);
|
||||
@@ -195,7 +195,7 @@ pub async fn save_yaml_str(path: &Path, yaml_str: &str) -> Result<()> {
|
||||
|
||||
std::fs::rename(temporary.as_path(), path)
|
||||
.with_context(|| format!("failed to replace file \"{}\"", path.display()))?;
|
||||
let _ = DropGuard::dismiss(temporary);
|
||||
let _ = ScopeGuard::into_inner(temporary);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -234,15 +234,15 @@ impl WindowManager {
|
||||
}
|
||||
|
||||
pub fn is_main_window_visible(window: Option<&WebviewWindow<Wry>>) -> bool {
|
||||
window.is_some_and(|w| w.is_visible().unwrap_or(false))
|
||||
window.map(|w| w.is_visible().unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_main_window_focused(window: Option<&WebviewWindow<Wry>>) -> bool {
|
||||
window.is_some_and(|w| w.is_focused().unwrap_or(false))
|
||||
window.map(|w| w.is_focused().unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_main_window_minimized(window: Option<&WebviewWindow<Wry>>) -> bool {
|
||||
window.is_some_and(|w| w.is_minimized().unwrap_or(false))
|
||||
window.map(|w| w.is_minimized().unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Keep new windows hidden until the frontend overlay renders, avoiding a theme flash.
|
||||
|
||||
@@ -5,24 +5,10 @@ import { useCallback, useImperativeHandle, useState, type Ref } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { closeConnection } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
import {
|
||||
getConnectionRuleTargets,
|
||||
type ConnectionRuleKind,
|
||||
} from './connection-rule'
|
||||
import {
|
||||
ConnectionRuleDialog,
|
||||
type ConnectionRuleTarget,
|
||||
} from './connection-rule-dialog'
|
||||
|
||||
export interface ConnectionDetailRef {
|
||||
open: (
|
||||
detail: IConnectionsItem,
|
||||
closed: boolean,
|
||||
preferredKind?: ConnectionRuleKind,
|
||||
) => void
|
||||
open: (detail: IConnectionsItem, closed: boolean) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
@@ -30,12 +16,7 @@ export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<IConnectionsItem | null>(null)
|
||||
const [closed, setClosed] = useState(false)
|
||||
const [preferredKind, setPreferredKind] =
|
||||
useState<ConnectionRuleKind>('domain')
|
||||
const theme = useTheme()
|
||||
const [ruleTarget, setRuleTarget] = useState<ConnectionRuleTarget | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
@@ -44,90 +25,51 @@ export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: (
|
||||
detail: IConnectionsItem,
|
||||
closed: boolean,
|
||||
preferredKind = 'domain',
|
||||
) => {
|
||||
open: (detail: IConnectionsItem, closed: boolean) => {
|
||||
if (open) return
|
||||
setOpen(true)
|
||||
setDetail(detail)
|
||||
setClosed(closed)
|
||||
setPreferredKind(preferredKind)
|
||||
},
|
||||
close: onClose,
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'.MuiSnackbarContent-root': {
|
||||
width: '520px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
boxSizing: 'border-box',
|
||||
maxHeight: '480px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
'.MuiSnackbarContent-message': { width: '100%', minWidth: 0 },
|
||||
}}
|
||||
message={
|
||||
detail ? (
|
||||
<InnerConnectionDetail
|
||||
data={detail}
|
||||
closed={closed}
|
||||
preferredKind={preferredKind}
|
||||
onClose={onClose}
|
||||
onAddRule={(target) => {
|
||||
setRuleTarget(target)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{ruleTarget && (
|
||||
<ConnectionRuleDialog
|
||||
target={ruleTarget}
|
||||
onClose={() => setRuleTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'.MuiSnackbarContent-root': {
|
||||
maxWidth: '520px',
|
||||
maxHeight: '480px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}}
|
||||
message={
|
||||
detail ? (
|
||||
<InnerConnectionDetail
|
||||
data={detail}
|
||||
closed={closed}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
data: IConnectionsItem
|
||||
closed: boolean
|
||||
preferredKind: ConnectionRuleKind
|
||||
onClose?: () => void
|
||||
onAddRule: (target: ConnectionRuleTarget) => void
|
||||
}
|
||||
|
||||
const InnerConnectionDetail = ({
|
||||
data,
|
||||
closed,
|
||||
preferredKind,
|
||||
onClose,
|
||||
onAddRule,
|
||||
}: InnerProps) => {
|
||||
const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { current } = useProfiles()
|
||||
const { metadata, rulePayload } = data
|
||||
const targets = getConnectionRuleTargets(metadata)
|
||||
const canAddRule = Boolean(
|
||||
current?.uid &&
|
||||
current.option?.rules &&
|
||||
(targets.domain ||
|
||||
targets.process ||
|
||||
targets.source ||
|
||||
targets.destination),
|
||||
)
|
||||
const theme = useTheme()
|
||||
const chains = [...data.chains].reverse().join(' / ')
|
||||
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule
|
||||
@@ -163,7 +105,7 @@ const InnerConnectionDetail = ({
|
||||
{ label: t('connections.components.fields.rule'), value: rule },
|
||||
{
|
||||
label: t('connections.components.fields.process'),
|
||||
value: `${metadata.process ?? ''}${metadata.processPath ? `(${metadata.processPath})` : ''}`,
|
||||
value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ''}`,
|
||||
},
|
||||
{
|
||||
label: t('connections.components.fields.time'),
|
||||
@@ -190,14 +132,7 @@ const InnerConnectionDetail = ({
|
||||
const onDelete = useLockFn(async () => closeConnection(data.id))
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
userSelect: 'text',
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ userSelect: 'text', color: theme.palette.text.secondary }}>
|
||||
{information.map((each) => (
|
||||
<div key={each.label}>
|
||||
<b>{each.label}</b>
|
||||
@@ -212,32 +147,8 @@ const InnerConnectionDetail = ({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!canAddRule}
|
||||
onClick={() => {
|
||||
if (!current?.uid || !current.option?.rules) return
|
||||
onAddRule({
|
||||
metadata: { ...metadata },
|
||||
profileUid: current.uid,
|
||||
profileName: current.name || current.uid,
|
||||
rulesUid: current.option.rules,
|
||||
preferredKind,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('connections.ruleDialog.entry')}
|
||||
</Button>
|
||||
{!closed && (
|
||||
{!closed && (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
title={t('connections.components.actions.closeConnection')}
|
||||
@@ -248,18 +159,6 @@ const InnerConnectionDetail = ({
|
||||
>
|
||||
{t('connections.components.actions.closeConnection')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{!canAddRule && (
|
||||
<Box sx={{ mt: 1, typography: 'caption' }}>
|
||||
{t(
|
||||
targets.domain ||
|
||||
targets.process ||
|
||||
targets.source ||
|
||||
targets.destination
|
||||
? 'connections.ruleDialog.noProfile'
|
||||
: 'connections.ruleDialog.noMetadata',
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -8,7 +8,7 @@ let timerId: number | null = null
|
||||
const listeners = new Set<RelativeTimeListener>()
|
||||
|
||||
const startTimer = () => {
|
||||
if (timerId !== null) return
|
||||
if (timerId !== null || document.hidden || listeners.size === 0) return
|
||||
|
||||
currentTime = Date.now()
|
||||
timerId = window.setInterval(() => {
|
||||
@@ -18,19 +18,34 @@ const startTimer = () => {
|
||||
}
|
||||
|
||||
const stopTimer = () => {
|
||||
if (listeners.size > 0 || timerId === null) return
|
||||
if (timerId === null) return
|
||||
|
||||
window.clearInterval(timerId)
|
||||
timerId = null
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopTimer()
|
||||
} else {
|
||||
startTimer()
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
|
||||
const subscribeRelativeTime = (listener: RelativeTimeListener) => {
|
||||
listeners.add(listener)
|
||||
if (listeners.size === 1) {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
startTimer()
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
stopTimer()
|
||||
if (listeners.size === 0) {
|
||||
stopTimer()
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ import { closeConnection } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { RelativeTime } from './connection-relative-time'
|
||||
import type { ConnectionRowView } from './connection-row-view'
|
||||
import type { ConnectionRuleKind } from './connection-rule'
|
||||
|
||||
interface Props {
|
||||
row: ConnectionRowView
|
||||
closed: boolean
|
||||
onShowDetail: (id: string, preferredKind?: ConnectionRuleKind) => void
|
||||
onShowDetail: (id: string) => void
|
||||
}
|
||||
|
||||
const tagStyle = {
|
||||
@@ -87,17 +86,7 @@ export const ConnectionRowItem = memo(
|
||||
<div style={tagsStyle}>
|
||||
<span style={tagStyle}>{row.network}</span>
|
||||
<span style={tagStyle}>{row.type}</span>
|
||||
{row.process && (
|
||||
<span
|
||||
style={tagStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onShowDetail(row.id, 'process')
|
||||
}}
|
||||
>
|
||||
{row.process}
|
||||
</span>
|
||||
)}
|
||||
{row.process && <span style={tagStyle}>{row.process}</span>}
|
||||
{row.chains && <span style={tagStyle}>{row.chains}</span>}
|
||||
<span style={tagStyle}>
|
||||
<RelativeTime start={row.time} />
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
MenuItem,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
useClashConfigData,
|
||||
useProxiesData,
|
||||
} from '@/providers/app-data-context'
|
||||
import { getProfiles, readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
import {
|
||||
getConnectionRuleTargets,
|
||||
getConnectionRules,
|
||||
isConnectionRuleDomain,
|
||||
findConnectionRule,
|
||||
saveConnectionRule,
|
||||
type ConnectionRuleKind,
|
||||
} from './connection-rule'
|
||||
|
||||
export interface ConnectionRuleTarget {
|
||||
metadata: IConnectionsItem['metadata']
|
||||
profileUid: string
|
||||
profileName: string
|
||||
rulesUid: string
|
||||
preferredKind: ConnectionRuleKind
|
||||
}
|
||||
|
||||
function getInitialKind(target: ConnectionRuleTarget) {
|
||||
const targets = getConnectionRuleTargets(target.metadata)
|
||||
return targets[target.preferredKind]
|
||||
? target.preferredKind
|
||||
: ((['domain', 'process', 'source', 'destination'] as const).find(
|
||||
(kind) => targets[kind],
|
||||
) ?? 'domain')
|
||||
}
|
||||
|
||||
export function ConnectionRuleDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: ConnectionRuleTarget
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { proxyView } = useProxiesData()
|
||||
const { clashConfig } = useClashConfigData()
|
||||
const targets = getConnectionRuleTargets(target.metadata)
|
||||
const [kind, setKind] = useState<ConnectionRuleKind>(() =>
|
||||
getInitialKind(target),
|
||||
)
|
||||
const [policy, setPolicy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<'DOMAIN' | 'DOMAIN-SUFFIX'>('DOMAIN')
|
||||
const [domainValue, setDomainValue] = useState(targets.domain?.value ?? '')
|
||||
const [rules, setRules] = useState<string[] | null>(null)
|
||||
const [loadAttempt, setLoadAttempt] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const selected = targets[kind]
|
||||
const editingDomain = kind === 'domain' && scope === 'DOMAIN-SUFFIX'
|
||||
const invalidDomain =
|
||||
editingDomain &&
|
||||
!isConnectionRuleDomain(domainValue, targets.domain?.value ?? '')
|
||||
const condition = selected
|
||||
? {
|
||||
type: kind === 'domain' ? scope : selected.type,
|
||||
value: editingDomain ? domainValue.trim() : selected.value,
|
||||
}
|
||||
: undefined
|
||||
const existing =
|
||||
rules && condition && !invalidDomain
|
||||
? findConnectionRule(rules, condition)
|
||||
: undefined
|
||||
const modifying = Boolean(existing)
|
||||
const groups = proxyView
|
||||
? proxyView.groups.length || !proxyView.global
|
||||
? proxyView.groups
|
||||
: [proxyView.global]
|
||||
: []
|
||||
const policies = [
|
||||
...new Set(['DIRECT', 'REJECT', ...groups.map((group) => group.name)]),
|
||||
]
|
||||
|
||||
const kindLabels = {
|
||||
domain: t('connections.ruleDialog.domain'),
|
||||
process: t('connections.components.fields.process'),
|
||||
source: t('connections.ruleDialog.sourceIp'),
|
||||
destination: t('connections.ruleDialog.destinationIp'),
|
||||
}
|
||||
|
||||
const unavailablePolicy = Boolean(policy && !policies.includes(policy))
|
||||
const blocked = loading || saving || rules === null
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
readProfileFile(target.rulesUid)
|
||||
.then((content) => {
|
||||
const loadedRules = getConnectionRules(content)
|
||||
const ruleTargets = getConnectionRuleTargets(target.metadata)
|
||||
const initialKind = getInitialKind(target)
|
||||
const selectedTarget = ruleTargets[initialKind]
|
||||
const initial = selectedTarget
|
||||
? (findConnectionRule(loadedRules, selectedTarget) ??
|
||||
(initialKind === 'domain'
|
||||
? findConnectionRule(loadedRules, {
|
||||
...selectedTarget,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined))
|
||||
: undefined
|
||||
if (cancelled) return
|
||||
setRules(loadedRules)
|
||||
setPolicy(initial?.policy ?? null)
|
||||
setScope(initial?.type === 'DOMAIN-SUFFIX' ? 'DOMAIN-SUFFIX' : 'DOMAIN')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [target, loadAttempt])
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
if (
|
||||
blocked ||
|
||||
!condition ||
|
||||
invalidDomain ||
|
||||
!policy ||
|
||||
!policies.includes(policy)
|
||||
)
|
||||
return
|
||||
if (/[\r\n,]/u.test(condition.value) || /[\r\n,]/u.test(policy)) {
|
||||
setError(t('connections.ruleDialog.unsupportedValue'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await mutate(
|
||||
async () => {
|
||||
const checkProfile = async () => {
|
||||
const profiles = await getProfiles()
|
||||
const current = profiles.items?.find(
|
||||
(item) => item.uid === profiles.current,
|
||||
)
|
||||
if (
|
||||
profiles.current !== target.profileUid ||
|
||||
current?.option?.rules !== target.rulesUid
|
||||
) {
|
||||
throw new Error(t('connections.ruleDialog.profileChanged'))
|
||||
}
|
||||
}
|
||||
await checkProfile()
|
||||
const content = await readProfileFile(target.rulesUid)
|
||||
const updated = saveConnectionRule(content, condition, policy)
|
||||
await checkProfile()
|
||||
return saveProfileFile(target.rulesUid, updated)
|
||||
},
|
||||
{ id: `save-profile-file:${target.rulesUid}`, errorNotice: false },
|
||||
)
|
||||
if (!result.ok || !result.value) {
|
||||
setError(t('connections.ruleDialog.applyFailed'))
|
||||
return
|
||||
}
|
||||
showNotice.success('connections.ruleDialog.saved')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>
|
||||
{t(
|
||||
modifying
|
||||
? 'connections.ruleDialog.modifyTitle'
|
||||
: 'connections.ruleDialog.title',
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('connections.ruleDialog.matchBy')}
|
||||
value={kind}
|
||||
disabled={blocked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as ConnectionRuleKind
|
||||
const nextTarget = targets[next]
|
||||
const rule =
|
||||
rules && nextTarget
|
||||
? (findConnectionRule(rules, nextTarget) ??
|
||||
(next === 'domain'
|
||||
? findConnectionRule(rules, {
|
||||
...nextTarget,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined))
|
||||
: undefined
|
||||
setDomainValue(targets.domain?.value ?? '')
|
||||
setKind(next)
|
||||
setPolicy(rule?.policy ?? null)
|
||||
setScope(
|
||||
rule?.type === 'DOMAIN-SUFFIX' ? 'DOMAIN-SUFFIX' : 'DOMAIN',
|
||||
)
|
||||
}}
|
||||
>
|
||||
{(['domain', 'process', 'source', 'destination'] as const).map(
|
||||
(option) => (
|
||||
<MenuItem
|
||||
key={option}
|
||||
value={option}
|
||||
disabled={!targets[option]}
|
||||
>
|
||||
{kindLabels[option]}
|
||||
{!targets[option] &&
|
||||
` · ${t('connections.ruleDialog.unavailable')}`}
|
||||
</MenuItem>
|
||||
),
|
||||
)}
|
||||
</TextField>
|
||||
{kind === 'process' ? (
|
||||
<>
|
||||
{target.metadata.process?.trim() && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={kindLabels.process}
|
||||
value={target.metadata.process}
|
||||
slotProps={{
|
||||
input: { readOnly: true },
|
||||
htmlInput: { title: target.metadata.process },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected?.type === 'PROCESS-PATH' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('connections.ruleDialog.fullPath')}
|
||||
value={selected.value}
|
||||
slotProps={{
|
||||
input: { readOnly: true },
|
||||
htmlInput: { title: selected.value },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={
|
||||
editingDomain
|
||||
? t('connections.ruleDialog.matchingDomain')
|
||||
: kindLabels[kind]
|
||||
}
|
||||
value={editingDomain ? domainValue : (selected?.value ?? '')}
|
||||
disabled={editingDomain && blocked}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setDomainValue(value)
|
||||
const match =
|
||||
rules &&
|
||||
isConnectionRuleDomain(value, targets.domain?.value ?? '')
|
||||
? findConnectionRule(rules, {
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
value: value.trim(),
|
||||
})
|
||||
: undefined
|
||||
if (match) setPolicy(match.policy)
|
||||
}}
|
||||
error={invalidDomain}
|
||||
helperText={
|
||||
editingDomain ? (
|
||||
<>
|
||||
{t('connections.ruleDialog.connectionHost', {
|
||||
host: targets.domain?.value,
|
||||
})}
|
||||
{invalidDomain && (
|
||||
<>
|
||||
<br />
|
||||
{t('connections.ruleDialog.invalidDomain')}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
slotProps={{
|
||||
input: { readOnly: !editingDomain },
|
||||
htmlInput: {
|
||||
title: editingDomain ? domainValue : selected?.value,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{kind === 'domain' && (
|
||||
<RadioGroup
|
||||
row
|
||||
aria-label={t('connections.ruleDialog.domainScope')}
|
||||
value={scope}
|
||||
onChange={(_, value) => {
|
||||
const nextScope = value as 'DOMAIN' | 'DOMAIN-SUFFIX'
|
||||
setScope(nextScope)
|
||||
setDomainValue(targets.domain?.value ?? '')
|
||||
const match =
|
||||
rules && targets.domain
|
||||
? findConnectionRule(rules, {
|
||||
...targets.domain,
|
||||
type: nextScope,
|
||||
})
|
||||
: undefined
|
||||
setPolicy(match?.policy ?? null)
|
||||
}}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="DOMAIN"
|
||||
control={<Radio size="small" />}
|
||||
disabled={blocked}
|
||||
label={t('connections.ruleDialog.exactDomain')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="DOMAIN-SUFFIX"
|
||||
control={<Radio size="small" />}
|
||||
disabled={blocked}
|
||||
label={t('connections.ruleDialog.allSubdomains')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
{(kind === 'source' || kind === 'destination') && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{t(
|
||||
kind === 'source'
|
||||
? 'connections.ruleDialog.sourceHint'
|
||||
: 'connections.ruleDialog.destinationHint',
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
{kind === 'process' && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{selected?.type === 'PROCESS-PATH'
|
||||
? t('connections.ruleDialog.processPathHint')
|
||||
: t('connections.ruleDialog.processNameHint')}
|
||||
</Typography>
|
||||
)}
|
||||
<Autocomplete
|
||||
size="small"
|
||||
options={
|
||||
unavailablePolicy && policy ? [policy, ...policies] : policies
|
||||
}
|
||||
getOptionLabel={(value) =>
|
||||
value === 'DIRECT'
|
||||
? t('proxies.components.enums.policies.DIRECT')
|
||||
: value === 'REJECT'
|
||||
? t('proxies.components.enums.policies.REJECT')
|
||||
: value
|
||||
}
|
||||
getOptionDisabled={(value) => !policies.includes(value)}
|
||||
value={policy}
|
||||
onChange={(_, value) => setPolicy(value)}
|
||||
disabled={blocked}
|
||||
loading={loading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t('rules.modals.editor.form.labels.proxyPolicy')}
|
||||
margin="normal"
|
||||
error={unavailablePolicy}
|
||||
helperText={
|
||||
unavailablePolicy
|
||||
? t('connections.ruleDialog.policyUnavailable')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{clashConfig && clashConfig.mode !== 'rule' && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t('connections.ruleDialog.ruleModeHint')}
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="error">
|
||||
{error}
|
||||
{rules === null && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setLoadAttempt((attempt) => attempt + 1)
|
||||
}}
|
||||
>
|
||||
{t('shared.actions.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 2, overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{t(
|
||||
modifying
|
||||
? 'connections.ruleDialog.editDestination'
|
||||
: 'connections.ruleDialog.destination',
|
||||
{
|
||||
name: target.profileName,
|
||||
},
|
||||
)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{t('connections.ruleDialog.newConnections')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={saving} onClick={onClose}>
|
||||
{t('shared.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
loading={saving}
|
||||
disabled={
|
||||
blocked ||
|
||||
!condition ||
|
||||
invalidDomain ||
|
||||
!policy ||
|
||||
!policies.includes(policy)
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t(
|
||||
modifying
|
||||
? 'connections.ruleDialog.saveAndApply'
|
||||
: 'connections.ruleDialog.addAndApply',
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { dump, load } from 'js-yaml'
|
||||
import isFQDN from 'validator/es/lib/isFQDN'
|
||||
import isIP from 'validator/es/lib/isIP'
|
||||
|
||||
export type ConnectionRuleKind = 'domain' | 'process' | 'source' | 'destination'
|
||||
|
||||
function getAddressRule(address: string | undefined, source: boolean) {
|
||||
const ip = address?.trim()
|
||||
if (!ip || !isIP(ip)) return undefined
|
||||
const ipv4 = isIP(ip, 4)
|
||||
return {
|
||||
type: source ? 'SRC-IP-CIDR' : ipv4 ? 'IP-CIDR' : 'IP-CIDR6',
|
||||
value: `${ip}/${ipv4 ? 32 : 128}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectionRuleTargets(
|
||||
metadata: Pick<
|
||||
IConnectionsItem['metadata'],
|
||||
| 'host'
|
||||
| 'process'
|
||||
| 'processPath'
|
||||
| 'sourceIP'
|
||||
| 'destinationIP'
|
||||
| 'remoteDestination'
|
||||
>,
|
||||
) {
|
||||
const host = metadata.host?.trim() ?? ''
|
||||
const path = metadata.processPath
|
||||
const name = metadata.process
|
||||
const domain =
|
||||
host && !isIP(host) && isFQDN(host, { require_tld: false })
|
||||
? { type: 'DOMAIN', value: host }
|
||||
: undefined
|
||||
const process = path?.trim()
|
||||
? { type: 'PROCESS-PATH', value: path }
|
||||
: name?.trim()
|
||||
? { type: 'PROCESS-NAME', value: name }
|
||||
: undefined
|
||||
const source = getAddressRule(metadata.sourceIP, true)
|
||||
const destination =
|
||||
getAddressRule(metadata.destinationIP, false) ??
|
||||
getAddressRule(metadata.remoteDestination, false)
|
||||
return { domain, process, source, destination }
|
||||
}
|
||||
|
||||
export function isConnectionRuleDomain(value: string, host: string) {
|
||||
const domain = value.trim()
|
||||
if (!domain || isIP(domain) || !isFQDN(domain, { require_tld: false }))
|
||||
return false
|
||||
const normalized = domain.toLowerCase()
|
||||
const current = host.toLowerCase()
|
||||
return (
|
||||
normalized === current ||
|
||||
(domain.split('.').length >= 2 && current.endsWith(`.${normalized}`))
|
||||
)
|
||||
}
|
||||
|
||||
function parseRuleExtension(content: string) {
|
||||
const parsed: unknown = load(content)
|
||||
if (
|
||||
parsed !== undefined &&
|
||||
parsed !== null &&
|
||||
(typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.getPrototypeOf(parsed) !== Object.prototype)
|
||||
) {
|
||||
throw new Error('The rule extension must be a YAML mapping.')
|
||||
}
|
||||
const config = (parsed ?? {}) as Record<string, unknown>
|
||||
for (const key of ['prepend', 'append', 'delete']) {
|
||||
const sequence = config[key]
|
||||
if (
|
||||
sequence != null &&
|
||||
(!Array.isArray(sequence) ||
|
||||
sequence.some((item) => typeof item !== 'string'))
|
||||
) {
|
||||
throw new Error('The rule extension contains an invalid rule list.')
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
interface RuleCondition {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function matchesConnectionRule(raw: string, target: RuleCondition) {
|
||||
const parts = raw.split(',').map((part) => part.trim())
|
||||
const hasNoResolve =
|
||||
(target.type === 'IP-CIDR' || target.type === 'IP-CIDR6') &&
|
||||
parts.length === 4 &&
|
||||
parts[3] === 'no-resolve'
|
||||
if (parts.length !== 3 && !hasNoResolve) return false
|
||||
if (target.type === 'DOMAIN' || target.type === 'DOMAIN-SUFFIX') {
|
||||
return (
|
||||
parts[0] === target.type &&
|
||||
parts[1].toLowerCase() === target.value.toLowerCase()
|
||||
)
|
||||
}
|
||||
return parts[0] === target.type && parts[1] === target.value
|
||||
}
|
||||
|
||||
export function getConnectionRules(content: string): string[] {
|
||||
const config = parseRuleExtension(content)
|
||||
return [
|
||||
...((config.prepend ?? []) as string[]),
|
||||
...((config.append ?? []) as string[]),
|
||||
]
|
||||
}
|
||||
|
||||
export function findConnectionRule(rules: string[], target: RuleCondition) {
|
||||
for (const raw of rules) {
|
||||
if (matchesConnectionRule(raw, target)) {
|
||||
const [type, , policy] = raw.split(',').map((part) => part.trim())
|
||||
return { type, policy }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function saveConnectionRule(
|
||||
content: string,
|
||||
target: RuleCondition,
|
||||
policy: string,
|
||||
): string {
|
||||
const config = parseRuleExtension(content)
|
||||
const rule = `${target.type},${target.value},${policy}`
|
||||
let replaced = false
|
||||
for (const key of ['prepend', 'append']) {
|
||||
if (!Array.isArray(config[key])) continue
|
||||
config[key] = (config[key] as string[]).flatMap((raw) => {
|
||||
if (!matchesConnectionRule(raw, target)) return [raw]
|
||||
if (replaced) return []
|
||||
replaced = true
|
||||
return [raw.split(',').length === 4 ? `${rule},no-resolve` : rule]
|
||||
})
|
||||
}
|
||||
if (!replaced) {
|
||||
config.prepend = [rule, ...((config.prepend ?? []) as string[])]
|
||||
}
|
||||
return dump(config, { forceQuotes: true })
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getConnectionStartTime,
|
||||
getConnectionTypeLabel,
|
||||
} from './connection-row-view'
|
||||
import type { ConnectionRuleKind } from './connection-rule'
|
||||
|
||||
const ROW_HEIGHT = 40
|
||||
const RESIZE_HANDLE_WIDTH = 6
|
||||
@@ -224,6 +223,16 @@ const compareConnectionCellValue = (
|
||||
right: IConnectionsItem,
|
||||
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot,
|
||||
) => {
|
||||
switch (field) {
|
||||
case 'download':
|
||||
return (left.download ?? 0) - (right.download ?? 0)
|
||||
case 'upload':
|
||||
return (left.upload ?? 0) - (right.upload ?? 0)
|
||||
case 'dlSpeed':
|
||||
return (left.curDownload ?? 0) - (right.curDownload ?? 0)
|
||||
case 'ulSpeed':
|
||||
return (left.curUpload ?? 0) - (right.curUpload ?? 0)
|
||||
}
|
||||
const leftValue = getConnectionCellValue(field, getSnapshot(left))
|
||||
const rightValue = getConnectionCellValue(field, getSnapshot(right))
|
||||
|
||||
@@ -248,7 +257,7 @@ const renderCell = (
|
||||
interface RowComponentProps {
|
||||
row: IConnectionsItem
|
||||
columns: DisplayColumn[]
|
||||
onShowDetail: (id: string, preferredKind?: ConnectionRuleKind) => void
|
||||
onShowDetail: (id: string) => void
|
||||
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot
|
||||
borderColor: string
|
||||
virtualTop: number
|
||||
@@ -264,22 +273,7 @@ const RowComponent = memo(
|
||||
virtualTop,
|
||||
}: RowComponentProps) {
|
||||
const handleClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const field =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('[data-connection-field]')
|
||||
?.dataset.connectionField
|
||||
: undefined
|
||||
const preferredKind =
|
||||
field === 'process'
|
||||
? 'process'
|
||||
: field === 'source'
|
||||
? 'source'
|
||||
: field === 'remoteDestination'
|
||||
? 'destination'
|
||||
: 'domain'
|
||||
onShowDetail(row.id, preferredKind)
|
||||
},
|
||||
() => onShowDetail(row.id),
|
||||
[onShowDetail, row.id],
|
||||
)
|
||||
const snapshot = getSnapshot(row)
|
||||
@@ -301,7 +295,6 @@ const RowComponent = memo(
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.field}
|
||||
data-connection-field={column.field}
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
flex: `0 0 ${column.size}px`,
|
||||
@@ -335,7 +328,7 @@ const RowComponent = memo(
|
||||
|
||||
interface Props {
|
||||
connections: IConnectionsItem[]
|
||||
onShowDetail: (id: string, preferredKind?: ConnectionRuleKind) => void
|
||||
onShowDetail: (id: string) => void
|
||||
columnManagerOpen: boolean
|
||||
onCloseColumnManager: () => void
|
||||
}
|
||||
@@ -350,8 +343,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
const onShowDetailRef = useRef(rawOnShowDetail)
|
||||
onShowDetailRef.current = rawOnShowDetail
|
||||
const onShowDetail = useCallback(
|
||||
(id: string, preferredKind?: ConnectionRuleKind) =>
|
||||
onShowDetailRef.current(id, preferredKind),
|
||||
(id: string) => onShowDetailRef.current(id),
|
||||
[],
|
||||
)
|
||||
const { t } = useTranslation()
|
||||
@@ -407,7 +399,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: 'download',
|
||||
headerName: t('shared.labels.downloaded'),
|
||||
width: 96,
|
||||
width: 76,
|
||||
minWidth: 60,
|
||||
align: 'right',
|
||||
cell: (_, snapshot) => snapshot.downloadText,
|
||||
@@ -415,7 +407,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: 'upload',
|
||||
headerName: t('shared.labels.uploaded'),
|
||||
width: 96,
|
||||
width: 76,
|
||||
minWidth: 60,
|
||||
align: 'right',
|
||||
cell: (_, snapshot) => snapshot.uploadText,
|
||||
@@ -423,7 +415,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: 'dlSpeed',
|
||||
headerName: t('connections.components.fields.dlSpeed'),
|
||||
width: 96,
|
||||
width: 76,
|
||||
minWidth: 60,
|
||||
align: 'right',
|
||||
cell: (_, snapshot) => snapshot.downloadSpeedText,
|
||||
@@ -431,7 +423,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
{
|
||||
field: 'ulSpeed',
|
||||
headerName: t('connections.components.fields.ulSpeed'),
|
||||
width: 96,
|
||||
width: 76,
|
||||
minWidth: 60,
|
||||
align: 'right',
|
||||
cell: (_, snapshot) => snapshot.uploadSpeedText,
|
||||
@@ -509,6 +501,8 @@ export const ConnectionTable = (props: Props) => {
|
||||
const [sorting, setSorting] = useState<SortingState | null>(null)
|
||||
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null)
|
||||
useEffect(() => () => resizeCleanupRef.current?.(), [])
|
||||
const rowSnapshotCacheRef = useRef(new Map<string, TableRowSnapshot>())
|
||||
const getRowSnapshot = useCallback((row: IConnectionsItem) => {
|
||||
const cache = rowSnapshotCacheRef.current
|
||||
@@ -682,6 +676,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
minWidth: number,
|
||||
maxWidth: number | undefined,
|
||||
) => {
|
||||
resizeCleanupRef.current?.()
|
||||
const handleMove = (clientX: number) => {
|
||||
const nextWidth = Math.max(
|
||||
minWidth,
|
||||
@@ -704,8 +699,10 @@ export const ConnectionTable = (props: Props) => {
|
||||
window.removeEventListener('touchmove', handleTouchMove)
|
||||
window.removeEventListener('touchend', cleanup)
|
||||
window.removeEventListener('touchcancel', cleanup)
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
|
||||
resizeCleanupRef.current = cleanup
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', cleanup)
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: true })
|
||||
@@ -816,7 +813,6 @@ export const ConnectionTable = (props: Props) => {
|
||||
onClick={() => toggleSorting(column.field)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent:
|
||||
@@ -828,33 +824,15 @@ export const ConnectionTable = (props: Props) => {
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
textAlign: column.align === 'right' ? 'right' : 'left',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{column.headerName}
|
||||
</span>
|
||||
{sorting?.id === column.field ? (
|
||||
<span style={{ flexShrink: 0 }}>
|
||||
{sorting.desc ? '▼' : '▲'}
|
||||
</span>
|
||||
) : column.align === 'right' ? (
|
||||
// Reserve the indicator's space so right-aligned labels
|
||||
// don't shift when sorting is toggled.
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ flexShrink: 0, visibility: 'hidden' }}
|
||||
>
|
||||
▲
|
||||
</span>
|
||||
) : null}
|
||||
{column.headerName}
|
||||
{sorting?.id === column.field
|
||||
? sorting.desc
|
||||
? '▼'
|
||||
: '▲'
|
||||
: null}
|
||||
</button>
|
||||
<div
|
||||
onMouseDown={(event) =>
|
||||
|
||||
@@ -1008,13 +1008,8 @@ export const CurrentProxyCard = () => {
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
noWrap
|
||||
title={currentProxy?.name}
|
||||
sx={{ fontWeight: 'medium' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body1" sx={{ fontWeight: 'medium' }}>
|
||||
{currentProxy?.name ??
|
||||
t('home.components.currentProxy.labels.noActiveNode')}
|
||||
</Typography>
|
||||
@@ -1068,7 +1063,6 @@ export const CurrentProxyCard = () => {
|
||||
size="small"
|
||||
label={delayManager.formatDelay(currentDelay)}
|
||||
color={convertDelayColor(currentDelay)}
|
||||
sx={{ flexShrink: 0, ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1106,9 +1106,10 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
|
||||
{tooltipData.visible && (
|
||||
<Box
|
||||
style={{ left: tooltipData.x + 8, top: tooltipData.y - 8 }}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: tooltipData.x + 8,
|
||||
top: tooltipData.y - 8,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
|
||||
@@ -463,12 +463,15 @@ export const ProxyChain = ({
|
||||
|
||||
localStorage.removeItem('proxy-chain-group')
|
||||
localStorage.removeItem('proxy-chain-exit-node')
|
||||
localStorage.removeItem('proxy-chain-items')
|
||||
|
||||
await mutate(() => closeAllConnections(), {
|
||||
id: 'close-all-connections',
|
||||
errorNotice: false,
|
||||
})
|
||||
await refreshProxy()
|
||||
|
||||
onUpdateChain([])
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from proxy chain:', error)
|
||||
alert(t('proxies.page.chain.disconnectFailed'))
|
||||
@@ -547,6 +550,7 @@ export const ProxyChain = ({
|
||||
mode,
|
||||
proxyView,
|
||||
selectedGroup,
|
||||
onUpdateChain,
|
||||
recordSelection,
|
||||
])
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ const COLOR = [
|
||||
]
|
||||
|
||||
interface Props {
|
||||
value: Rule & { lineNo: number }
|
||||
value: Rule
|
||||
lineNo: number
|
||||
}
|
||||
|
||||
const parseColor = (text: string) => {
|
||||
@@ -31,7 +32,7 @@ const parseColor = (text: string) => {
|
||||
}
|
||||
|
||||
const RuleItem = (props: Props) => {
|
||||
const { value } = props
|
||||
const { value, lineNo } = props
|
||||
|
||||
return (
|
||||
<Item sx={{ borderBottom: '1px solid var(--divider-color)' }}>
|
||||
@@ -40,7 +41,7 @@ const RuleItem = (props: Props) => {
|
||||
variant="body2"
|
||||
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: 'center' }}
|
||||
>
|
||||
{value.lineNo}
|
||||
{lineNo}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ userSelect: 'text' }}>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
autoDetectInterface: true,
|
||||
dnsHijack: ['any:53'],
|
||||
strictRoute: false,
|
||||
mtu: 1500 as number | undefined,
|
||||
mtu: 1500,
|
||||
dnsMode: '',
|
||||
})
|
||||
|
||||
@@ -374,15 +374,14 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
sx={{ width: 250 }}
|
||||
value={values.mtu ?? ''}
|
||||
value={values.mtu}
|
||||
placeholder="1500"
|
||||
onChange={(e) => {
|
||||
const next = Number.parseInt(e.target.value, 10)
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
mtu: Number.isNaN(next) ? undefined : next,
|
||||
mtu: parseInt(e.target.value),
|
||||
}))
|
||||
}}
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
|
||||
@@ -150,25 +150,49 @@ const mergeConnectionSnapshot = (
|
||||
const nextConnections = payload.connections ?? []
|
||||
const previousActive = previous.activeConnections ?? []
|
||||
const previousClosed = previous.closedConnections ?? []
|
||||
const previousActiveById = new Map<string, IConnectionsItem>()
|
||||
const sameOrder =
|
||||
nextConnections.length === previousActive.length &&
|
||||
nextConnections.every(
|
||||
(connection, i) => connection.id === previousActive[i].id,
|
||||
)
|
||||
let previousActiveById: Map<string, IConnectionsItem> | undefined
|
||||
|
||||
for (let i = 0; i < previousActive.length; i++) {
|
||||
const previousConnection = previousActive[i]
|
||||
previousActiveById.set(previousConnection.id, previousConnection)
|
||||
if (!sameOrder) {
|
||||
previousActiveById = new Map()
|
||||
for (let i = 0; i < previousActive.length; i++) {
|
||||
const previousConnection = previousActive[i]
|
||||
previousActiveById.set(previousConnection.id, previousConnection)
|
||||
}
|
||||
}
|
||||
|
||||
const activeConnections: IConnectionsItem[] = []
|
||||
let activeConnections = sameOrder ? previousActive : []
|
||||
for (let i = 0; i < nextConnections.length; i++) {
|
||||
const connection = nextConnections[i]
|
||||
const previousConnection = previousActiveById.get(connection.id)
|
||||
if (previousConnection) previousActiveById.delete(connection.id)
|
||||
activeConnections.push(normalizeConnection(connection, previousConnection))
|
||||
const previousConnection = sameOrder
|
||||
? previousActive[i]
|
||||
: previousActiveById?.get(connection.id)
|
||||
previousActiveById?.delete(connection.id)
|
||||
const normalized = normalizeConnection(connection, previousConnection)
|
||||
if (activeConnections[i] === normalized) continue
|
||||
if (activeConnections === previousActive) {
|
||||
activeConnections = previousActive.slice()
|
||||
}
|
||||
activeConnections[i] = normalized
|
||||
}
|
||||
|
||||
if (previousActiveById.size === 0) {
|
||||
const uploadTotal = payload.uploadTotal ?? 0
|
||||
const downloadTotal = payload.downloadTotal ?? 0
|
||||
if (!previousActiveById?.size) {
|
||||
if (
|
||||
activeConnections === previousActive &&
|
||||
uploadTotal === previous.uploadTotal &&
|
||||
downloadTotal === previous.downloadTotal
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
return {
|
||||
uploadTotal: payload.uploadTotal ?? 0,
|
||||
downloadTotal: payload.downloadTotal ?? 0,
|
||||
uploadTotal,
|
||||
downloadTotal,
|
||||
activeConnections,
|
||||
closedConnections: previousClosed,
|
||||
}
|
||||
@@ -198,8 +222,8 @@ const mergeConnectionSnapshot = (
|
||||
}
|
||||
|
||||
return {
|
||||
uploadTotal: payload.uploadTotal ?? 0,
|
||||
downloadTotal: payload.downloadTotal ?? 0,
|
||||
uploadTotal,
|
||||
downloadTotal,
|
||||
activeConnections,
|
||||
closedConnections,
|
||||
}
|
||||
@@ -227,7 +251,9 @@ const flushPendingMessage = () => {
|
||||
|
||||
lastFlushAt = Date.now()
|
||||
|
||||
connectionData = mergeConnectionSnapshot(payload, connectionData)
|
||||
const nextData = mergeConnectionSnapshot(payload, connectionData)
|
||||
if (nextData === connectionData) return
|
||||
connectionData = nextData
|
||||
notifyConnectionListeners()
|
||||
}
|
||||
|
||||
@@ -363,8 +389,6 @@ const handleSummaryText = (messageData: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (connectionSummary.activeConnectionCount === (payload.count ?? 0)) return
|
||||
|
||||
connectionSummary = mergeConnectionSummary(payload)
|
||||
notifySummaryListeners()
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "الأعمدة",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Spalten",
|
||||
"dragHandle": "Ziehpunkt"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Columns",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Columnas",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "ستونها",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Kolom",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user