Compare commits

..
Author SHA1 Message Date
Tunglies d83d78d8d7 fix(linux): keep Service upgrade notices until the frontend takes them
Silent start creates no window, so emitted notices were dropped while the
upgrade marker was already acknowledged. Keep both upgrade notices in backend
state, replay the state warning on subscription, and acknowledge the marker
only after every notice has been taken.

Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies 0b67405bad fix(linux): report an unrestored Service state regardless of readiness
Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies aeffb55820 feat(linux): show when an upgrade could not restore the Service state
Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies 136385c028 fix(linux): report a Service state restore failure after reinstall
Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies e25b8afec3 fix(linux): keep runtime-only Service enablement across upgrades
Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies 90bc660630 docs(linux): note the uninstaller wrapper beside the real binary
Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies 5740f216fb fix(linux): only flag an rpm upgrade the Service may not survive
Refs #8515
2026-11-10 07:53:00 +08:00
Tunglies ba6fc606ca docs(changelog): cover Windows and Linux Service reinstall on upgrade 2026-11-10 07:52:59 +08:00
Tunglies c75ad61aa3 fix(linux): keep the Service through released rpm %preun via uninstaller wrapper
Refs #8515
2026-11-10 07:52:59 +08:00
Tunglies 9048a752e2 feat(windows): reinstall an existing Service during NSIS upgrades
Refs #8553
2026-11-10 07:52:59 +08:00
Tunglies 9618dcd4a4 revert(linux): drop journal-based recovery of a removed Service
The journal cannot tell an upgrade's prerm removal from an explicit
uninstall, nor restore the previous enablement, so it must not trigger
an automatic reinstall. Upgrades from packages whose prerm removes the
Service are left unrecovered.

Refs #8515
2026-11-10 07:52:59 +08:00
Tunglies 2ecefeddf3 fix(linux): bind deb Service recovery to the removing transaction
Reinstall a Service removed by a previous prerm only when the old prerm
uninstalls unconditionally, this dpkg run completed a stop job for it,
and its group comes from that same invocation's stdout journal entries.
Hand the evidence to the postinst through a single-use file tied to the
old version and cleared by every preinst.

Refs #8515
2026-11-10 07:52:59 +08:00
Tunglies e3ad1cd319 feat(linux): preserve Service state across upgrades and surface failures
Reinstall an existing Service while keeping its enabled, runtime-masked
and active state. Detect, from the journal, a running Service removed by
a previous deb prerm and reinstall it with its recorded group. Package
script output never reaches the app, so leave a /run marker that makes
the app offer a reinstall once when the Service is not ready.

Hold the core lifecycle until the package manager exits, even after the
startup install times out, so no core start overlaps the Service
reinstall; restart the app once a late install succeeds.

Refs #8515
2026-11-10 07:52:59 +08:00
Tunglies 1b45614056 feat(linux): reinstall an existing Service when upgrading the package
The deb postinst and rpm %post now reinstall the Service in place when an
upgrade finds its systemd unit, reusing the unit's group so package-manager
runs without a sudo/pkexec user keep the same IPC access. First installs and
disabled, masked, or group-less units are left alone; failures warn and exit
0 because neither dpkg nor rpm can restore the previous Service from a failed
postinst.

The in-app updater stops a Service-run core before installing on Linux and
restarts it if the install fails, so the post-update restart does not fail on
the session lost when the Service restarts.

Refs #8515
2026-11-10 07:52:59 +08:00
152 changed files with 5520 additions and 2387 deletions
-1
View File
@@ -1,3 +1,2 @@
avoid-breaking-exported-api = true
allow-unwrap-in-tests = true
cognitive-complexity-threshold = 25
+2 -2
View File
@@ -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": {},
+3 -3
View File
@@ -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": {
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+2 -23
View File
@@ -31,27 +31,6 @@ 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
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install Node.js
uses: actions/setup-node@v7
with:
node-version: '26.13.1'
cache: 'pnpm'
- name: Install frontend dependencies
env:
HUSKY: 0
run: pnpm install --frozen-lockfile
version: v1.0.1
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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."
+1 -1
View File
@@ -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
File diff suppressed because one or more lines are too long
-1
View File
@@ -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
+107 -288
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -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
+1 -1
View File
@@ -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'
+11 -11
View File
@@ -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
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
+2 -2
View File
@@ -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
-113
View File
@@ -1,113 +0,0 @@
name: Sync Mihomo Changelog
on:
workflow_dispatch:
inputs:
dry_run:
description: Preview the Changelog diff without creating a PR
type: boolean
default: true
schedule:
# UTC+8 Monday 11:30
- cron: '30 3 * * 1'
permissions:
contents: write
pull-requests: write
issues: write
concurrency:
group: sync-mihomo-changelog
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: '26.13.1'
- name: Sync Mihomo version
id: sync
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
run: |
args=()
if [[ "$DRY_RUN" == 'true' ]]; then args+=(--dry-run); fi
node scripts/sync-mihomo-version.mjs "${args[@]}"
- name: Create labeled PR and enqueue
if: steps.sync.outputs.changed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
uses: actions/github-script@v9
env:
MIHOMO_VERSION: ${{ steps.sync.outputs.version }}
with:
script: |
const { readFileSync } = await import('node:fs');
const { execFileSync } = await import('node:child_process');
const { owner, repo } = context.repo;
const base = context.payload.repository.default_branch;
const version = process.env.MIHOMO_VERSION;
const appVersion = JSON.parse(readFileSync('package.json', 'utf8')).version;
const branch = `chore/sync-mihomo-${appVersion}-${version}`;
const title = `chore: sync Mihomo changelog to ${version}`;
const pulls = await github.rest.pulls.list({
owner, repo, base, head: `${owner}:${branch}`, state: 'all',
});
let pr = pulls.data[0];
if (pr && (pr.state !== 'open' || pr.user.login !== 'github-actions[bot]')) {
throw new Error(`Existing candidate needs maintainer attention: ${pr.html_url}`);
}
if (!pr) {
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha });
await github.graphql(`mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`, {
input: {
branch: { repositoryNameWithOwner: `${owner}/${repo}`, branchName: branch },
expectedHeadOid: sha,
message: { headline: title },
fileChanges: { additions: [{
path: 'Changelog.md',
contents: readFileSync('Changelog.md').toString('base64'),
}] },
},
});
({ data: pr } = await github.rest.pulls.create({
owner, repo, base, head: branch, title,
body: `## Problem\n\nChangelog.md needs to record the latest stable Mihomo version.\n\n` +
`## Approach\n\nRead the same upstream version.txt used by the core download script.\n\n` +
`## Scope\n\nOnly the Mihomo version line in Changelog.md changes to ${version}.`,
}));
}
for (let attempt = 0; attempt < 10; attempt++) {
({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: pr.number }));
if (pr.mergeable !== null) break;
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (!pr.mergeable) throw new Error(`Sync PR is not mergeable: ${pr.html_url}`);
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number,
});
if (files.length !== 1 || files[0].filename !== 'Changelog.md') {
throw new Error('The sync PR must change only Changelog.md.');
}
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: ['dependencies', 'upstream'],
});
const result = await github.graphql(`query($id: ID!) {
node(id: $id) { ... on PullRequest { mergeQueueEntry { id } } }
}`, { id: pr.node_id });
if (!result.node.mergeQueueEntry) {
await github.graphql(`mutation($input: EnqueuePullRequestInput!) {
enqueuePullRequest(input: $input) { mergeQueueEntry { id } }
}`, { input: { pullRequestId: pr.node_id, expectedHeadOid: pr.head.sha } });
}
await core.summary.addRaw(`Queued Mihomo sync PR: ${pr.html_url}\n`).write();
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -1,3 +1,3 @@
nodejs 26.13.1
nodejs 26.13.0
pnpm 12.18.0
rust 1.100.0
rust 1.99.1
-2
View File
@@ -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
+29 -85
View File
@@ -159,9 +159,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.5"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5"
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
dependencies = [
"rustversion",
]
@@ -1120,9 +1120,10 @@ dependencies = [
"percent-encoding",
"reqwest 0.13.5",
"reqwest_dav",
"rfd 0.18.0",
"rfd",
"runas",
"rustls",
"scopeguard",
"serde",
"serde_json",
"serde_yaml_ng",
@@ -2403,9 +2404,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.11"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
@@ -3240,9 +3241,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.5"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
@@ -4069,9 +4070,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.25.0"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1d73c59a56771343712b22ea5615092c612c16ba291c0979e3105dfeec4890"
checksum = "490750560fa7851fbbf5d3ab1a8750f09350ed361ed8aed85b1e1e40587812ee"
dependencies = [
"sha2 0.11.1",
]
@@ -4537,7 +4538,6 @@ dependencies = [
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
"objc2-uniform-type-identifiers",
]
[[package]]
@@ -4759,16 +4759,6 @@ dependencies = [
"objc2-user-notifications",
]
[[package]]
name = "objc2-uniform-type-identifiers"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
name = "objc2-user-notifications"
version = "0.3.2"
@@ -5312,12 +5302,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
[[package]]
name = "polonius-the-crab"
version = "0.5.0"
@@ -5950,31 +5934,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rfd"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4b9d4857b10297ffb0f64cae711e983a7aeaee561848d56d429d3858bd8ed93"
dependencies = [
"block2",
"dispatch2",
"js-sys",
"libc",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-uniform-type-identifiers",
"percent-encoding",
"pollster",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -7072,9 +7031,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",
@@ -7468,7 +7427,7 @@ checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5"
dependencies = [
"log",
"raw-window-handle",
"rfd 0.17.2",
"rfd",
"serde",
"serde_json",
"tauri",
@@ -7519,15 +7478,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 +7502,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 +7527,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 +7547,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",
@@ -8820,9 +8778,9 @@ dependencies = [
[[package]]
name = "warp"
version = "0.4.4"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92be475cc42b6b3913eeac77f0cbc0dd8f6a1ad6400c0722dbe36ba0c3ee799"
checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea"
dependencies = [
"bytes",
"futures-channel",
@@ -9088,9 +9046,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",
]
@@ -10074,20 +10032,6 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
@@ -10126,9 +10070,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.5.1"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3476fd497ac8af5b1ec7b51acab7bef380f7e8dd8e0487595bafd839417a302"
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
dependencies = [
"aes",
"bzip2",
@@ -10156,9 +10100,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.8"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
[[package]]
name = "zmij"
-1
View File
@@ -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"
+4 -11
View File
@@ -13,8 +13,6 @@
first (Windows/macOS/Linux) when the change is platform-specific;
merge sub-symptoms as 主题:症状、症状; full-width punctuation,
「」 for UI labels, backticks for config keys and commands
- omit the redundant suffix 「的问题」; end with the concrete symptom
or outcome instead
- platform entries sit under their platform heading (bold, emoji
prefixed, ordered Windows/macOS/Linux, combined for pairs like
macOS/Linux) at the end of their section;
@@ -23,8 +21,6 @@
GitHub, so they never reach users. -->
## v2.6.0
- **Mihomo 内核升级至 v1.19.34**
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
@@ -32,24 +28,17 @@
- 修复订阅响应过大导致内存占用过高,限制单次下载为 4 MiB
- 修复全局模式默认停在 `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)筛选连接
</details>
@@ -63,4 +52,8 @@
- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址
**🖥️/🐧 Windows/Linux**
- 优化 Windows/Linux 升级安装时自动重装已安装的服务并保留启停设置,重装失败时提示
</details>
+4 -4
View File
@@ -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]
+1 -1
View File
@@ -14,7 +14,7 @@
"duplicates",
"cycles"
],
"ignoreBinaries": ["diff", "rustc"],
"ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"],
+14 -7
View File
@@ -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.1",
"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.1"
},
"lint-staged": {
"*.{ts,tsx,js,mjs}": [
+1860 -127
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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
View File
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.100.0"
channel = "1.99.1"
components = ["rustfmt", "clippy"]
+1 -1
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'
const reviewer = 'renovate-minor-review.lock.yml'
const agents = ['Copilot', 'copilot-swe-agent', 'copilot-swe-agent[bot]']
const agents = ['copilot-swe-agent', 'copilot-swe-agent[bot]']
const checks = {
'.github/workflows/frontend-check.yml': [
'Run Prettier check',
-28
View File
@@ -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
View File
@@ -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.
-57
View File
@@ -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
View File
@@ -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);
+107
View File
@@ -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}`)
+41
View File
@@ -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
View File
@@ -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>,
)
-13
View File
@@ -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"
}
}
-21
View File
@@ -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] ?? ''
})
})
}
-37
View File
@@ -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'
}
}
+443
View File
@@ -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,
)
+340
View File
@@ -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)
})
+674
View File
@@ -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}`)
+20
View File
@@ -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;
}
-81
View File
@@ -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>,
)
-52
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["../../src", "./*.tsx", "./*.ts"]
"include": ["../../src", "./*.tsx"]
}
+23
View File
@@ -550,9 +550,16 @@ const SERVICE_BINARIES = [
'clash-verge-service-uninstall',
]
// Linux packages install the uninstaller under /usr/lib behind a wrapper sidecar; see
// src-tauri/packages/linux/service-uninstall.sh and the files entries in tauri.linux.conf.json.
const LINUX_UNINSTALLER_FILE = 'clash-verge-service-uninstall-bin'
function serviceFileInfo(name) {
const ext = platform === 'win32' ? '.exe' : ''
const suffix = platform === 'linux' ? `-${SIDECAR_HOST}` : ''
if (platform === 'linux' && name === 'clash-verge-service-uninstall') {
return { sourceFile: name, targetFile: LINUX_UNINSTALLER_FILE }
}
return {
sourceFile: `${name}${ext}`,
targetFile: `${name}${suffix}${ext}`,
@@ -573,6 +580,22 @@ async function findExtractedFile(dir, fileName) {
}
async function resolveServiceBundle() {
await fetchServiceBundle()
if (platform === 'linux') {
const wrapper = path.join(
SERVICE_DIR,
`clash-verge-service-uninstall-${SIDECAR_HOST}`,
)
await fsp.copyFile(
path.join(cwd, 'src-tauri', 'packages', 'linux', 'service-uninstall.sh'),
wrapper,
)
await fsp.chmod(wrapper, 0o755)
await updateHashCache(wrapper)
}
}
async function fetchServiceBundle() {
const files = SERVICE_BINARIES.map((name) => {
const info = serviceFileInfo(name)
return {
-88
View File
@@ -1,88 +0,0 @@
import { spawnSync } from 'node:child_process'
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
const { values } = parseArgs({
options: {
'dry-run': { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
version: { type: 'string' },
file: { type: 'string', default: 'Changelog.md' },
},
})
async function main() {
let version = values.version
if (!version) {
const response = await fetch(
'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt',
{ signal: AbortSignal.timeout(30_000) },
)
if (!response.ok)
throw new Error(`Failed to fetch Mihomo version: ${response.status}`)
version = await response.text()
}
version = version.trim().replace(/^v?/, 'v')
if (!/^v\d+\.\d+\.\d+$/.test(version))
throw new Error(`Invalid stable Mihomo version: ${version}`)
const content = readFileSync(values.file, 'utf8')
const title = /^## .+$/m.exec(content)
if (!title) throw new Error('Could not find changelog title.')
const start = title.index + title[0].length
const end = content.indexOf('\n## ', start)
const section = content.slice(start, end === -1 ? undefined : end)
const line =
/^(\s*-\s*(?:\*\*)?\s*Mihomo(?:\(Meta\))?\s*内核升级至\s*)v?\d+(?:\.\d+)+(?:[-+][^\s*]+)?((?:\*\*)?[\t ]*)$/m
const nextSection = line.test(section)
? section.replace(line, `$1${version}$2`)
: `\n\n- **Mihomo 内核升级至 ${version}**${section.replace(/^\n*/, '\n\n')}`
const nextContent =
content.slice(0, start) +
nextSection +
(end === -1 ? '' : content.slice(end))
const changed = content !== nextContent
if (process.env.GITHUB_OUTPUT)
appendFileSync(
process.env.GITHUB_OUTPUT,
`version=${version}\nchanged=${changed}\n`,
)
if (!changed) {
console.log(`Mihomo version in changelog is already ${version}`)
return
}
const diff = spawnSync(
'diff',
[
'-u',
'--label',
`a/${values.file}`,
'--label',
`b/${values.file}`,
values.file,
'-',
],
{ input: nextContent, encoding: 'utf8' },
)
if (diff.error) throw diff.error
if (diff.status !== 1)
throw new Error(diff.stderr || 'Could not preview changelog diff.')
process.stdout.write(diff.stdout)
if (process.env.GITHUB_STEP_SUMMARY)
appendFileSync(
process.env.GITHUB_STEP_SUMMARY,
`### Mihomo ${version}\n\n\`\`\`diff\n${diff.stdout}\`\`\`\n`,
)
if (values.check) {
process.exitCode = 1
return
}
if (!values['dry-run']) writeFileSync(values.file, nextContent)
}
main().catch((error) => {
console.error('sync-mihomo-version failed:', error.message)
process.exitCode = 1
})
+14 -13
View File
@@ -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"
@@ -56,15 +56,15 @@ serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
socket2 = { workspace = true }
warp = { version = "0.4.4", features = ["server"] }
warp = { version = "0.4.3", features = ["server"] }
open = "5.4.5"
rfd = { version = "0.18.0", default-features = false, features = ["common-controls-v6", "xdg-portal"] }
rfd = { version = "0.17.2", default-features = false, features = ["common-controls-v6"] }
dirs = "7.0.0"
dunce = "1.0.5"
nanoid = "0.5"
chrono = { workspace = true }
boa_engine = "0.22.0"
http-body-util = "0.1.5"
http-body-util = "0.1.4"
percent-encoding = "2.3.2"
reqwest = { workspace = true }
sysproxy = { git = "https://github.com/clash-verge-rev/sysproxy-rs.git", branch = "main", features = [
@@ -72,13 +72,13 @@ 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"
flate2 = "1.1.11"
zip = "9.3.0"
flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.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.3"
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"] }
@@ -153,7 +154,7 @@ tauri-plugin-updater = "2.14.1"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
warp = { version = "0.4.4", features = ["test"] }
warp = { version = "0.4.3", features = ["test"] }
[lints]
workspace = true
-2
View File
@@ -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",
+4 -4
View File
@@ -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());
}
@@ -3,8 +3,101 @@ set -e
chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
# The /usr/bin uninstaller is the service-uninstall.sh wrapper; this is the real binary it runs.
chmod +x /usr/lib/clash-verge-rev/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
SERVICE_UNIT="clash-verge-service.service"
SERVICE_UNIT_FILE="/etc/systemd/system/$SERVICE_UNIT"
# Read by the app, which offers a reinstall when the package could not keep the Service.
UPGRADE_MARKER="/run/clash-verge-service-upgrade"
# Read by /usr/bin/clash-verge-service-uninstall to keep the Service through the previous %preun.
TRANSACTION_RECORD="/run/clash-verge-service-rpm-upgrade"
# Set to the previous state when restoring it fails; the app shows it even if the Service is ready.
UNRESTORED_STATE=""
write_upgrade_marker() {
(
umask 022
{
printf 'gid=%s\nid=%s\n' "$1" "$(date +%s%N)"
if [ -n "$UNRESTORED_STATE" ]; then
printf 'restore=%s\n' "$UNRESTORED_STATE"
fi
} >"$UPGRADE_MARKER"
)
}
# Scriptlets run as children of the process running the rpm transaction; its start time
# keeps a stale record from matching a later process that reuses the PID.
record_transaction() {
local start
start="$(sed 's/.*) //' "/proc/$PPID/stat" | cut -d' ' -f20)"
[ -n "$start" ] || return 1
(umask 022 && printf '%s %s\n' "$PPID" "$start" >"$TRANSACTION_RECORD")
}
existing_service_gid() {
local group
group="$(sed -n 's/^Group=//p' "$SERVICE_UNIT_FILE" | head -n 1)"
if [ -n "$group" ]; then
getent group "$group" | cut -d: -f3
fi
return 0
}
# Failures only warn: dpkg and rpm cannot restore the previous Service from a failed postinst,
# and a failing script would leave the package unconfigured or make the app report a failed update.
# The installer always enables and starts the unit, so the previous enablement and activity are restored.
reinstall_existing_service() {
local gid="$1" enabled active=no
enabled="$(systemctl is-enabled "$SERVICE_UNIT" 2>/dev/null)"
if systemctl is-active --quiet "$SERVICE_UNIT"; then
active=yes
fi
local status=0 restored=yes
if [ "$enabled" = "masked-runtime" ]; then
systemctl unmask --runtime --quiet "$SERVICE_UNIT" || restored=no
fi
if ! CLASH_VERGE_SERVICE_GID="$gid" /usr/bin/clash-verge-service-install --install-service; then
echo "Warning: reinstalling Clash Verge Service failed; Clash Verge will offer to reinstall it." >&2
status=1
fi
case "$enabled" in
disabled) systemctl disable --quiet "$SERVICE_UNIT" || restored=no ;;
enabled-runtime) { systemctl disable --quiet "$SERVICE_UNIT" && systemctl enable --runtime --quiet "$SERVICE_UNIT"; } || restored=no ;;
masked-runtime) systemctl mask --runtime --quiet "$SERVICE_UNIT" || restored=no ;;
esac
if [ "$active" = "no" ]; then
systemctl stop "$SERVICE_UNIT" || restored=no
fi
if [ "$restored" = "no" ]; then
UNRESTORED_STATE="${enabled:-unknown},$([ "$active" = yes ] && echo active || echo inactive)"
echo "Warning: restoring the previous Clash Verge Service state ($UNRESTORED_STATE) failed; check it with systemctl." >&2
status=1
fi
return "$status"
}
# Upgrades keep an existing Service current; first installs and explicit removals never add one.
# A symlinked unit is masked or managed by the administrator and is left alone.
# The previous %preun runs after this script; the record keeps it from removing the Service.
# Without a record that %preun removes the Service, so the marker makes the app offer a reinstall.
if [ "$1" -ge 2 ] && [ -d /run/systemd/system ] && [ -f "$SERVICE_UNIT_FILE" ] && [ ! -L "$SERVICE_UNIT_FILE" ]; then
recorded=yes
record_transaction || recorded=no
gid="$(existing_service_gid)"
if [ -z "$gid" ]; then
echo "Warning: the Clash Verge Service group could not be resolved; Clash Verge will offer to reinstall the Service." >&2
write_upgrade_marker ""
elif reinstall_existing_service "$gid" && [ "$recorded" = yes ]; then
rm -f "$UPGRADE_MARKER"
else
write_upgrade_marker "$gid"
fi
fi
# The enforcement file exists in both Enforcing and Permissive mode.
if [ ! -e /sys/fs/selinux/enforce ]; then
exit 0
+79
View File
@@ -1,8 +1,87 @@
#!/bin/bash
chmod +x /usr/bin/clash-verge-service-install
chmod +x /usr/bin/clash-verge-service-uninstall
# The /usr/bin uninstaller is the service-uninstall.sh wrapper; this is the real binary it runs.
chmod +x /usr/lib/clash-verge-rev/clash-verge-service-uninstall
chmod +x /usr/bin/clash-verge-service
SERVICE_UNIT="clash-verge-service.service"
SERVICE_UNIT_FILE="/etc/systemd/system/$SERVICE_UNIT"
# Read by the app, which offers a reinstall when the package could not keep the Service.
UPGRADE_MARKER="/run/clash-verge-service-upgrade"
# Set to the previous state when restoring it fails; the app shows it even if the Service is ready.
UNRESTORED_STATE=""
write_upgrade_marker() {
(
umask 022
{
printf 'gid=%s\nid=%s\n' "$1" "$(date +%s%N)"
if [ -n "$UNRESTORED_STATE" ]; then
printf 'restore=%s\n' "$UNRESTORED_STATE"
fi
} >"$UPGRADE_MARKER"
)
}
existing_service_gid() {
local group
group="$(sed -n 's/^Group=//p' "$SERVICE_UNIT_FILE" | head -n 1)"
if [ -n "$group" ]; then
getent group "$group" | cut -d: -f3
fi
return 0
}
# Failures only warn: dpkg and rpm cannot restore the previous Service from a failed postinst,
# and a failing script would leave the package unconfigured or make the app report a failed update.
# The installer always enables and starts the unit, so the previous enablement and activity are restored.
reinstall_existing_service() {
local gid="$1" enabled active=no
enabled="$(systemctl is-enabled "$SERVICE_UNIT" 2>/dev/null)"
if systemctl is-active --quiet "$SERVICE_UNIT"; then
active=yes
fi
local status=0 restored=yes
if [ "$enabled" = "masked-runtime" ]; then
systemctl unmask --runtime --quiet "$SERVICE_UNIT" || restored=no
fi
if ! CLASH_VERGE_SERVICE_GID="$gid" /usr/bin/clash-verge-service-install --install-service; then
echo "Warning: reinstalling Clash Verge Service failed; Clash Verge will offer to reinstall it." >&2
status=1
fi
case "$enabled" in
disabled) systemctl disable --quiet "$SERVICE_UNIT" || restored=no ;;
enabled-runtime) { systemctl disable --quiet "$SERVICE_UNIT" && systemctl enable --runtime --quiet "$SERVICE_UNIT"; } || restored=no ;;
masked-runtime) systemctl mask --runtime --quiet "$SERVICE_UNIT" || restored=no ;;
esac
if [ "$active" = "no" ]; then
systemctl stop "$SERVICE_UNIT" || restored=no
fi
if [ "$restored" = "no" ]; then
UNRESTORED_STATE="${enabled:-unknown},$([ "$active" = yes ] && echo active || echo inactive)"
echo "Warning: restoring the previous Clash Verge Service state ($UNRESTORED_STATE) failed; check it with systemctl." >&2
status=1
fi
return "$status"
}
# Upgrades keep an existing Service current; first installs and explicit removals never add one.
# A symlinked unit is masked or managed by the administrator and is left alone.
if [ "$1" = "configure" ] && [ -n "$2" ] && [ -d /run/systemd/system ] &&
[ -f "$SERVICE_UNIT_FILE" ] && [ ! -L "$SERVICE_UNIT_FILE" ]; then
gid="$(existing_service_gid)"
if [ -z "$gid" ]; then
echo "Warning: the Clash Verge Service group could not be resolved; Clash Verge will offer to reinstall the Service." >&2
write_upgrade_marker ""
elif reinstall_existing_service "$gid"; then
rm -f "$UPGRADE_MARKER"
else
write_upgrade_marker "$gid"
fi
fi
. /etc/os-release
if [ "$ID" = "deepin" ]; then
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Installed as /usr/bin/clash-verge-service-uninstall. During an rpm upgrade the new %post
# reinstalls an existing Service before the previous package's %preun runs, and released
# %preun scripts uninstall it unconditionally through this path. Skip only that call: one
# made by a scriptlet of the transaction that %post recorded.
RECORD=/run/clash-verge-service-rpm-upgrade
UNINSTALLER=/usr/lib/clash-verge-rev/clash-verge-service-uninstall
# Fields after the parenthesised command name, which may itself contain spaces.
stat_field() {
sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f"$2"
}
if [ -f "$RECORD" ] && [ ! -L "$RECORD" ] && read -r txn_pid txn_start <"$RECORD" && [ -n "$txn_start" ]; then
pid=$PPID
depth=0
while [ "$depth" -lt 4 ] && [ -n "$pid" ] && [ "$pid" -gt 1 ]; do
if [ "$pid" = "$txn_pid" ] && [ "$(stat_field "$pid" 20)" = "$txn_start" ]; then
rm -f "$RECORD"
echo "Keeping the Clash Verge Service through this upgrade."
exit 0
fi
pid="$(stat_field "$pid" 2)"
depth=$((depth + 1))
done
fi
exec "$UNINSTALLER" "$@"
+69 -1
View File
@@ -77,6 +77,9 @@ Var VC_REDIST_URL
Var VC_REDIST_EXE
Var VC_RUNTIME_READY
Var VC_RUNTIME_NEEDED
Var ServiceExisted
Var ServiceStartType
Var ServiceWasRunning
Name "${PRODUCTNAME}"
BrandingText "${COPYRIGHT}"
@@ -466,6 +469,34 @@ FunctionEnd
{{#each language_files}}
!include "{{this}}"
{{/each}}
!ifdef LANG_ENGLISH
LangString serviceReinstallFailed ${LANG_ENGLISH} "The ${PRODUCTNAME} Service could not be reinstalled (code $0). Open ${PRODUCTNAME} and reinstall the Service to restore Service mode."
!endif
!ifdef LANG_SIMPCHINESE
LangString serviceReinstallFailed ${LANG_SIMPCHINESE} "${PRODUCTNAME} 服务重装失败(代码 $0)。请打开 ${PRODUCTNAME} 重新安装服务以恢复服务模式。"
!endif
!ifdef LANG_RUSSIAN
LangString serviceReinstallFailed ${LANG_RUSSIAN} "Не удалось переустановить службу ${PRODUCTNAME} (код $0). Откройте ${PRODUCTNAME} и переустановите службу, чтобы восстановить режим службы."
!endif
!macro RecordVergeService
StrCpy $ServiceExisted 0
StrCpy $ServiceStartType ""
StrCpy $ServiceWasRunning 1
SimpleSC::ExistsService "clash_verge_service"
Pop $0
${If} $0 == 0
StrCpy $ServiceExisted 1
SimpleSC::GetServiceStartType "clash_verge_service"
Pop $0
Pop $1
${IfThen} $0 == 0 ${|} StrCpy $ServiceStartType $1 ${|}
SimpleSC::ServiceIsRunning "clash_verge_service"
Pop $0
Pop $1
${IfThen} $0 == 0 ${|} StrCpy $ServiceWasRunning $1 ${|}
${EndIf}
!macroend
Function .onInit
${GetOptions} $CMDLINE "/P" $PassiveMode
@@ -483,6 +514,9 @@ Function .onInit
StrCpy $UpdateMode 1
${EndIf}
; Recorded before the reinstall page can run the previous uninstaller, which removes the Service.
!insertmacro RecordVergeService
!if "${DISPLAYLANGUAGESELECTOR}" == "true"
; Auto-update forwards the app's UI language as `/LANG=<NSIS-lang-id>` so
; the installer uses it directly and skips the interactive language
@@ -663,6 +697,36 @@ FunctionEnd
${EndIf}
!macroend
; Reinstalls the Service with the bundled cores, then restores the recorded start type and stopped state.
!macro ReinstallVergeService
StrCpy $R0 '"$INSTDIR\resources\clash-verge-service-install.exe" --install-service'
!ifdef MIHOMO_SHA256
StrCpy $R0 '$R0 --install-core "$INSTDIR\verge-mihomo.exe" --sha256 "${MIHOMO_SHA256}"'
!endif
!ifdef MIHOMO_ALPHA_SHA256
StrCpy $R0 '$R0 --install-core "$INSTDIR\verge-mihomo-alpha.exe" --sha256 "${MIHOMO_ALPHA_SHA256}"'
!endif
DetailPrint "Reinstalling ${PRODUCTNAME} Service..."
nsExec::ExecToLog $R0
Pop $0
${If} $0 == 0
${AndIf} $ServiceStartType != ""
${AndIf} $ServiceStartType != 2
SimpleSC::SetServiceStartType "clash_verge_service" $ServiceStartType
Pop $0
${EndIf}
${If} $0 == 0
${AndIf} $ServiceWasRunning == 0
SimpleSC::StopService "clash_verge_service" 1 30
Pop $0
${EndIf}
${If} $0 != 0
DetailPrint "Reinstalling ${PRODUCTNAME} Service failed ($0)"
MessageBox MB_OK|MB_ICONEXCLAMATION "$(serviceReinstallFailed)" /SD IDOK
SetErrorLevel 2
${EndIf}
!macroend
!macro RemoveVergeService
; Check if the service exists
SimpleSC::ExistsService "clash_verge_service"
@@ -985,7 +1049,11 @@ Section Install
${IfThen} $0 != 0 ${|} DetailPrint "Staging verge-mihomo-alpha returned $0" ${|}
!endif
!insertmacro StartVergeService
${If} $ServiceExisted = 1
!insertmacro ReinstallVergeService
${Else}
!insertmacro StartVergeService
${EndIf}
; Create file associations
{{#each file_associations as |association| ~}}
+4 -6
View File
@@ -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 {
+59 -4
View File
@@ -5,10 +5,16 @@ use crate::{
core::{
handle::Handle,
proxy_view::{GroupScope, ProxyViewBuilder, ProxyViewInput, ProxyViewV1},
tray::Tray,
},
process::AsyncHandler,
};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use std::collections::{BTreeMap, HashSet};
use std::{
collections::{BTreeMap, HashSet},
sync::atomic::{AtomicBool, Ordering},
};
/// Merges one selection pair into fresh backend state to avoid stale-list overwrites.
#[tauri::command]
@@ -21,12 +27,15 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult
#[tauri::command]
#[specta::specta]
pub async fn switch_proxy_node(group_name: String, proxy_name: String, unfix: bool) -> CmdResult<()> {
crate::feat::switch_proxy_node(&group_name, &proxy_name, unfix)
pub async fn forget_selected_node(group_name: String) -> CmdResult<()> {
crate::config::profiles::forget_selected_node(&group_name)
.await
.stringify_err()
}
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
fn runtime_group_order(config: Option<&Mapping>) -> Vec<String> {
let mut seen = HashSet::new();
@@ -83,7 +92,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 {
@@ -114,3 +124,48 @@ pub async fn get_proxy_view() -> CmdResult<ProxyViewV1> {
providers: providers.ok(),
}))
}
#[tauri::command]
#[specta::specta]
pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
if TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
AsyncHandler::spawn(move || async move {
run_tray_sync_loop().await;
});
} else {
TRAY_SYNC_PENDING.store(true, Ordering::Release);
}
Ok(())
}
async fn run_tray_sync_loop() {
// Permanent watcher: keeps syncing until a pass finds no pending sync request.
loop {
match Tray::global().update_menu().await {
Ok(_) => {
logging!(debug, Type::Cmd, "Tray proxy selection synced successfully");
}
Err(e) => {
logging!(error, Type::Cmd, "Failed to sync tray proxy selection: {e:#}");
}
}
if !TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel) {
TRAY_SYNC_RUNNING.store(false, Ordering::Release);
if TRAY_SYNC_PENDING.swap(false, Ordering::AcqRel)
&& TRAY_SYNC_RUNNING
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
continue;
}
break;
}
}
}
+7
View File
@@ -141,6 +141,13 @@ pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
#[specta::specta]
#[cfg_attr(not(target_os = "linux"), allow(clippy::missing_const_for_fn))]
pub fn take_service_upgrade_state_notice() -> Option<String> {
crate::core::service::take_service_upgrade_state_notice()
}
#[tauri::command]
#[specta::specta]
pub fn take_service_owner_notice() -> Option<String> {
+1 -1
View File
@@ -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, "")
+3 -4
View File
@@ -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}');
-2
View File
@@ -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.
+2 -1
View File
@@ -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));
}
+6 -4
View File
@@ -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)]
+5 -5
View File
@@ -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])?;
+26 -11
View File
@@ -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;
@@ -749,6 +750,20 @@ impl CoreManager {
self.controlled_stop_core_inner().await
}
/// Holds the core lifecycle for a package install, stopping a Service-run core first and returning
/// whether it did, so no core start or restart overlaps the package scripts reinstalling the Service.
#[cfg(target_os = "linux")]
pub(crate) async fn pause_for_package_install(
&'static self,
) -> Result<(tokio::sync::MutexGuard<'static, ()>, bool)> {
let life = self.lifecycle_lock.lock().await;
if !matches!(*self.get_running_mode(), RunningMode::Service) {
return Ok((life, false));
}
self.controlled_stop_core_inner().await?;
Ok((life, true))
}
pub(crate) async fn controlled_stop_core_inner(&self) -> Result<()> {
self.controlled_stop_core_with_intent(ProxyStopIntent::Clear).await
}
@@ -794,9 +809,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 +980,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;
+4 -1
View File
@@ -22,8 +22,11 @@ pub enum NoticeStatus {
#[serde(rename = "service_core::app_data_not_owned")]
ServiceCoreAppDataNotOwned,
#[serde(rename = "service_core::sidecar_fallback")]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "windows", target_os = "linux")), allow(dead_code))]
ServiceCoreSidecarFallback,
#[serde(rename = "service_core::upgrade_state_not_restored")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
ServiceCoreUpgradeStateNotRestored,
#[serde(rename = "dns_override::auto_disabled")]
DnsOverrideAutoDisabled,
#[serde(rename = "enhance::discarded_keys")]
+3 -1
View File
@@ -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();
+2 -1
View File
@@ -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 {
+165 -2
View File
@@ -42,6 +42,8 @@ static ACTIVE_SERVICE_SESSION: Mutex<Option<ActiveServiceSession>> = Mutex::new(
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
#[cfg(target_os = "linux")]
static PENDING_SERVICE_UPGRADE: Mutex<Option<PendingServiceUpgrade>> = Mutex::new(None);
/// Why the Service was unavailable when the core fell back to Sidecar.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)]
@@ -50,6 +52,7 @@ pub enum ServiceFallbackNotice {
Unavailable,
CoreRejected(String),
NotAutoStarted,
UpgradeIncomplete,
}
/// The release installer registers AutoStart.
@@ -66,9 +69,118 @@ pub(crate) fn notify_service_fallback(reason: &str) {
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
/// Written by the Linux packages when an upgrade found the Service and may not have kept it.
#[cfg(target_os = "linux")]
const SERVICE_UPGRADE_MARKER: &str = "/run/clash-verge-service-upgrade";
const SERVICE_UPGRADE_INCOMPLETE: &str = "the package upgrade did not keep the Service ready";
/// Offers a reinstall once per package upgrade whose Service is not ready, since package script output never reaches the app.
#[cfg(target_os = "linux")]
pub(crate) async fn notify_incomplete_service_upgrade() {
let Ok(marker) = std::fs::read_to_string(SERVICE_UPGRADE_MARKER) else {
return;
};
let Some(id) = marker.lines().find_map(|line| line.strip_prefix("id=")) else {
return;
};
let ack = match dirs::app_home_dir() {
Ok(dir) => dir.join("service-upgrade-ack"),
Err(error) => {
logging!(warn, Type::Service, "无法定位服务升级确认文件: {error:#}");
return;
}
};
if std::fs::read_to_string(&ack).is_ok_and(|seen| seen == id) {
return;
}
let status = SERVICE_MANAGER.current().await;
let notice = service_upgrade_notice(&marker, matches!(status, ServiceStatus::Ready));
if !notice.reinstall && notice.unrestored_state.is_none() {
acknowledge_service_upgrade(&ack, id);
return;
}
// Without a window (silent start) the events are dropped; the frontend replays them on subscription.
*PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade {
ack,
id: id.to_owned(),
reinstall: notice.reinstall,
unrestored_state: notice.unrestored_state.map(str::to_owned),
});
if notice.reinstall {
logging!(warn, Type::Service, "安装包升级后服务未就绪: {status:?}");
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(SERVICE_UPGRADE_INCOMPLETE.to_owned());
Handle::notice(NoticeStatus::ServiceCoreSidecarFallback, "");
}
if let Some(state) = notice.unrestored_state {
logging!(warn, Type::Service, "安装包升级后未能恢复服务原状态: {state}");
Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, "");
}
}
#[cfg(target_os = "linux")]
fn acknowledge_service_upgrade(ack: &Path, id: &str) {
if let Err(error) = std::fs::write(ack, id) {
logging!(warn, Type::Service, "无法记录服务升级确认: {error}");
}
}
/// An upgrade whose notices are acknowledged on disk only after the frontend has taken all of them.
#[cfg(target_os = "linux")]
struct PendingServiceUpgrade {
ack: PathBuf,
id: String,
reinstall: bool,
unrestored_state: Option<String>,
}
#[cfg(target_os = "linux")]
fn settle_service_upgrade<T>(take: impl FnOnce(&mut PendingServiceUpgrade) -> T) -> Option<T> {
let (taken, settled) = {
let mut pending = PENDING_SERVICE_UPGRADE.lock();
let upgrade = pending.as_mut()?;
let taken = take(upgrade);
let settled = !upgrade.reinstall && upgrade.unrestored_state.is_none();
(taken, if settled { pending.take() } else { None })
};
if let Some(upgrade) = settled {
acknowledge_service_upgrade(&upgrade.ack, &upgrade.id);
}
Some(taken)
}
#[cfg(target_os = "linux")]
pub(crate) fn take_service_upgrade_state_notice() -> Option<String> {
settle_service_upgrade(|upgrade| upgrade.unrestored_state.take()).flatten()
}
#[cfg(not(target_os = "linux"))]
pub(crate) const fn take_service_upgrade_state_notice() -> Option<String> {
None
}
#[cfg(target_os = "linux")]
#[derive(Debug, PartialEq, Eq)]
struct ServiceUpgradeNotice<'a> {
reinstall: bool,
/// Reported regardless of readiness: a reinstall enables and starts the Service, so it cannot restore this state.
unrestored_state: Option<&'a str>,
}
#[cfg(target_os = "linux")]
fn service_upgrade_notice(marker: &str, ready: bool) -> ServiceUpgradeNotice<'_> {
ServiceUpgradeNotice {
reinstall: !ready,
unrestored_state: marker.lines().find_map(|line| line.strip_prefix("restore=")),
}
}
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
Some(if reason == SERVICE_UPGRADE_INCOMPLETE {
#[cfg(target_os = "linux")]
settle_service_upgrade(|upgrade| upgrade.reinstall = false);
ServiceFallbackNotice::UpgradeIncomplete
} else if reason.starts_with(CORE_REJECTED_PREFIX) {
ServiceFallbackNotice::CoreRejected(reason)
} else if is_not_auto_started(&reason) {
ServiceFallbackNotice::NotAutoStarted
@@ -1470,7 +1582,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),
@@ -2121,6 +2236,11 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod tests {
#[cfg(target_os = "linux")]
use super::{
PENDING_SERVICE_UPGRADE, PendingServiceUpgrade, ServiceUpgradeNotice, service_upgrade_notice,
settle_service_upgrade, take_service_upgrade_state_notice,
};
use super::{
ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation,
generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy,
@@ -2783,4 +2903,47 @@ mod tests {
assert!(!store.state().sidecar_allowed);
assert_eq!(status_of(&store), ServiceStatus::Ready);
}
#[cfg(target_os = "linux")]
#[test]
fn upgrade_marker_reports_unrestored_state_regardless_of_readiness() {
let restored = "gid=995\nid=1\n";
let unrestored = "gid=995\nid=1\nrestore=disabled,active\n";
let notice = |reinstall, unrestored_state| ServiceUpgradeNotice {
reinstall,
unrestored_state,
};
assert_eq!(service_upgrade_notice(restored, true), notice(false, None));
assert_eq!(service_upgrade_notice(restored, false), notice(true, None));
assert_eq!(
service_upgrade_notice(unrestored, true),
notice(false, Some("disabled,active"))
);
assert_eq!(
service_upgrade_notice(unrestored, false),
notice(true, Some("disabled,active"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn upgrade_marker_is_acknowledged_only_after_every_notice_is_taken() {
let ack = std::env::temp_dir().join(format!("verge-service-upgrade-ack-{}", std::process::id()));
let _ = std::fs::remove_file(&ack);
*PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade {
ack: ack.clone(),
id: "7".to_owned(),
reinstall: true,
unrestored_state: Some("disabled,active".to_owned()),
});
assert_eq!(take_service_upgrade_state_notice().as_deref(), Some("disabled,active"));
assert!(!ack.exists());
assert_eq!(take_service_upgrade_state_notice(), None);
settle_service_upgrade(|upgrade| upgrade.reinstall = false);
assert_eq!(std::fs::read_to_string(&ack).ok().as_deref(), Some("7"));
assert!(PENDING_SERVICE_UPGRADE.lock().is_none());
let _ = std::fs::remove_file(&ack);
}
}
+3 -3
View File
@@ -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;
+30 -29
View File
@@ -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)
@@ -997,9 +1000,7 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) {
Some((g, p)) => (g, p),
None => return,
};
if let Err(err) = feat::switch_proxy_node(group_name, proxy_name, false).await {
logging!(error, Type::Tray, "Failed to switch proxy: {err:#}");
}
feat::switch_proxy_node(group_name, proxy_name).await;
}
_ => {
logging!(debug, Type::Tray, "Unhandled tray menu event: {:?}", event.id);
+57 -8
View File
@@ -292,8 +292,61 @@ impl SilentUpdater {
Ok(updater_builder.build()?.check().await?)
}
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
async fn install(update: Update, bytes: Vec<u8>, timeout: Option<Duration>) -> Result<()> {
#[cfg(target_os = "linux")]
let mut install = Self::install_with_core_paused(update, bytes).await?;
#[cfg(not(target_os = "linux"))]
let mut install = tokio::task::spawn_blocking(move || update.install(&bytes));
let joined = match timeout {
Some(limit) => match tokio::time::timeout(limit, &mut install).await {
Ok(joined) => Ok(joined),
Err(_) => {
// The caller resumes the old app; once the package manager succeeds, restart on the new version.
#[cfg(target_os = "linux")]
tokio::spawn(async move {
if matches!(install.await, Ok(Ok(()))) {
crate::feat::restart_app().await;
}
});
Err(anyhow::anyhow!("install timed out ({}s)", limit.as_secs()))
}
},
None => Ok(install.await),
};
joined.and_then(|joined| {
joined??;
Ok(())
})
}
/// The package scripts reinstall an existing Service, killing the core it runs and its session, so the
/// core stays paused until the package manager exits, even after the caller stops waiting.
#[cfg(target_os = "linux")]
async fn install_with_core_paused(update: Update, bytes: Vec<u8>) -> Result<tokio::task::JoinHandle<Result<()>>> {
let manager = crate::core::CoreManager::global();
let (lifecycle, stopped_service_core) = manager
.pause_for_package_install()
.await
.context("failed to stop the Service core before installing the update")?;
let install = tokio::task::spawn_blocking(move || update.install(&bytes));
Ok(tokio::spawn(async move {
let result = match install.await {
Ok(result) => result.map_err(anyhow::Error::from),
Err(error) => Err(error.into()),
};
drop(lifecycle);
if result.is_err()
&& stopped_service_core
&& let Err(error) = manager.start_core().await
{
logging!(
error,
Type::System,
"Failed to restart the core after the update install failed: {error:#}"
);
}
result
}))
}
/// Installs a newer cached update before normal startup, if the user confirms.
@@ -369,11 +422,7 @@ impl SilentUpdater {
Self::show_update_splash(app_handle, &version);
// `install()` may hang (#2558); on Windows NSIS can take over without returning.
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
match install
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
{
match Self::install(update, bytes, Some(Duration::from_secs(30))).await {
Ok(()) => {
logging!(info, Type::System, "Update v{version} install triggered at startup");
Self::delete_cache();
@@ -417,7 +466,7 @@ impl SilentUpdater {
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::install(update, bytes, None).await?;
Self::delete_cache();
Ok(InstallUpdateOutcome::Installed)
}
+36 -6
View File
@@ -1,10 +1,13 @@
use anyhow::Result;
use nanoid::nanoid;
use scopeguard::defer;
use serde::Serialize;
use smartstring::alias::String;
use std::{
borrow::Cow,
fmt,
hash::{DefaultHasher, Hash as _, Hasher as _},
mem::DropGuard,
path::Path,
sync::LazyLock,
sync::atomic::{AtomicBool, Ordering},
};
@@ -329,7 +332,7 @@ impl CoreConfigValidator {
Ok(result) => result,
Err(err) => {
logging!(warn, Type::Validate, "无法确定文件类型: {}, 错误: {}", config_path, err);
return Self::validate_config_internal_outcome(config_path).await;
return Self::validate_profile_yaml_outcome(config_path).await;
}
};
@@ -344,7 +347,33 @@ impl CoreConfigValidator {
}
logging!(info, Type::Validate, "使用Clash内核验证配置文件: {}", config_path);
Self::validate_config_internal_outcome(config_path).await
Self::validate_profile_yaml_outcome(config_path).await
}
/// The user file is saved as written, so the core checks a copy carrying the tolerated short-ids.
async fn validate_profile_yaml_outcome(config_path: &str) -> Result<ValidationOutcome> {
let Some(tolerant) = Self::short_id_tolerant_text(config_path).await else {
return Self::validate_config_internal_outcome(config_path).await;
};
let Ok(app_dir) = dirs::app_home_dir() else {
return Self::validate_config_internal_outcome(config_path).await;
};
let check_path = app_dir.join(format!("short-id-check-{}.yaml", nanoid!()));
defer! {
let _ = std::fs::remove_file(&check_path);
}
if help::save_yaml_str(&check_path, &tolerant).await.is_err() {
return Self::validate_config_internal_outcome(config_path).await;
}
Self::validate_config_internal_outcome(dirs::path_to_str(&check_path)?).await
}
async fn short_id_tolerant_text(config_path: &str) -> Option<std::string::String> {
let yaml = fs::read_to_string(config_path).await.ok()?;
match help::short_id_tolerant(&yaml, Path::new(config_path)) {
Ok((Cow::Owned(tolerant), _)) => Some(tolerant),
_ => None,
}
}
async fn validate_config_internal_outcome(config_path: &str) -> Result<ValidationOutcome> {
@@ -421,9 +450,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 +478,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 {
+52 -1
View File
@@ -3,8 +3,10 @@ use crate::{
config::PrfItem,
utils::{dirs, help},
};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::Mapping;
use smartstring::alias::String;
use std::{borrow::Cow, path::Path};
use tokio::fs;
#[derive(Debug, Clone)]
@@ -48,7 +50,8 @@ impl AsyncChainItemFrom for Option<ChainItem> {
data: ChainType::Merge(help::read_mapping(&path).await.ok()?),
}),
"rules" | "proxies" | "groups" => {
let seq_map = help::read_yaml::<SeqMap>(&path).await.ok()?;
let yaml = fs::read_to_string(&path).await.ok()?;
let seq_map = parse_seq_map(&yaml, &path)?;
let data = match itype {
"rules" => ChainType::Rules(seq_map),
"proxies" => ChainType::Proxies(seq_map),
@@ -60,3 +63,51 @@ impl AsyncChainItemFrom for Option<ChainItem> {
}
}
}
/// Typed parsing reads `delete: [1234]` as strings where a `Value` round trip would reject it.
fn parse_seq_map(yaml: &str, path: &Path) -> Option<SeqMap> {
let original = serde_yaml_ng::from_str::<SeqMap>(yaml).ok()?;
let Ok((Cow::Owned(tolerant), _)) = help::short_id_tolerant(yaml, path) else {
return Some(original);
};
match serde_yaml_ng::from_str::<SeqMap>(&tolerant) {
Ok(seq_map) => Some(seq_map),
Err(_) => {
logging!(
warn,
Type::Config,
"kept numeric short-id in {}: quoted text does not load",
path.display()
);
Some(original)
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::parse_seq_map;
use serde_yaml_ng::Value;
use std::path::Path;
#[test]
fn seq_short_id_keeps_typed_parse() {
for (input, delete) in [
("prepend: []\nappend: []\ndelete: [1234]\n", "1234"),
("prepend: []\nappend: []\ndelete: [true]\n", "true"),
] {
let seq_map = parse_seq_map(input, Path::new("b8.yaml")).expect("seq file should load");
assert_eq!(seq_map.delete, [delete]);
}
let input = "prepend:\n - name: r\n reality-opts:\n short-id: 1234e123\nappend: []\ndelete: []\n";
let seq_map = parse_seq_map(input, Path::new("seq.yaml")).expect("seq file should load");
let short_id = seq_map
.prepend
.first()
.and_then(|proxy| proxy.get("reality-opts"))
.and_then(|opts| opts.get("short-id"));
assert_eq!(short_id, Some(&Value::String("1234e123".into())));
}
}
+17 -24
View File
@@ -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);
+11 -4
View File
@@ -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#"
+4 -2
View File
@@ -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(),
+2 -2
View File
@@ -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"),
+1 -1
View File
@@ -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())
+7 -7
View File
@@ -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");
+24 -73
View File
@@ -4,7 +4,6 @@ use crate::{
cmd,
config::{Config, PrfItem, PrfOption, profiles::profiles_update_item_safe},
core::{CoreManager, handle, tray, validate::ValidationOutcome},
process::AsyncHandler,
utils::help::{mask_err, mask_url},
};
use anyhow::{Result, bail};
@@ -22,85 +21,37 @@ pub async fn toggle_proxy_profile(profile_index: String) {
}
}
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str, unfix: bool) -> Result<()> {
let mihomo = handle::Handle::mihomo();
let previous_proxy = if Config::verge().await.data_arc().auto_close_connection.unwrap_or(false) {
match mihomo.get_proxy_by_name(group_name).await {
Ok(group) => group.now.filter(|name| !name.is_empty()),
Err(err) => {
logging!(
warn,
Type::Core,
"Failed to read previous proxy selection for {group_name}: {err}"
);
None
}
}
} else {
None
};
if unfix {
mihomo.unfixed_proxy(group_name).await?;
} else {
mihomo.select_node_for_group(group_name, proxy_name).await?;
}
let save_result = if unfix {
crate::config::profiles::forget_selected_node(group_name).await
} else {
crate::config::profiles::record_selected_node(group_name, proxy_name).await
};
// The core selection is already applied; persistence failure must not report it as failed.
if let Err(error) = save_result {
/// Tell the profile which node this group is on now.
///
/// The core is already switched by the time this runs, so failing to record is not a reason to
/// report the switch as failed — but it is worth a log line, because what the profile holds is
/// what gets re-applied the next time a core starts.
async fn record_switched_node(group_name: &str, proxy_name: &str) {
if let Err(error) = crate::config::profiles::record_selected_node(group_name, proxy_name).await {
logging!(
warn,
Type::Config,
"Failed to persist proxy selection for {group_name}: {error:#}"
"切换代理成功但未能记录到配置: {} -> {}, 错误: {error:#}",
group_name,
proxy_name
);
}
announce(Refresh::Proxies);
}
if let Some(previous_proxy) = previous_proxy {
AsyncHandler::spawn(move || async move {
let connections = match mihomo.get_connections().await {
Ok(connections) => connections,
Err(err) => {
logging!(
warn,
Type::Core,
"Failed to read connections after switching proxy: {err}"
);
return;
}
};
futures::future::join_all(
connections
.connections
.unwrap_or_default()
.into_iter()
.filter(|connection| connection.chains.contains(&previous_proxy))
.map(|connection| async move {
if let Err(err) = mihomo.close_connection(&connection.id).await {
logging!(
warn,
Type::Core,
"Failed to close connection {} after switching proxy: {err}",
connection.id
);
}
}),
)
.await;
});
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
.await
{
Ok(_) => {
record_switched_node(group_name, proxy_name).await;
announce(Refresh::Proxies);
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
logging!(error, Type::Tray, "切换代理失败: {err:?}");
}
}
if let Err(err) = tray::Tray::global().update_menu().await {
logging!(
warn,
Type::Tray,
"Failed to refresh tray after switching proxy: {err:#}"
);
}
Ok(())
}
async fn should_update_profile(uid: &String, ignore_auto_update: bool) -> Result<Option<(String, Option<PrfOption>)>> {
+4 -6
View File
@@ -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,
+3 -1
View File
@@ -45,8 +45,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
cmd::update_proxy_chain_config_in_runtime,
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::switch_proxy_node,
cmd::forget_selected_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::set_dns_override,
@@ -55,6 +56,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
cmd::get_core_startup_error,
cmd::take_service_repair_notice,
cmd::take_service_owner_notice,
cmd::take_service_upgrade_state_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
}
}
+241 -64
View File
@@ -3,10 +3,12 @@ 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,
borrow::Cow,
ops::Range,
path::{Path, PathBuf},
str::FromStr,
};
@@ -45,8 +47,8 @@ pub async fn read_mapping(path: &Path) -> Result<Mapping> {
}
fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
match parse_short_id_text(&yaml_str, &path) {
Ok(mut val) => {
match short_id_tolerant(&yaml_str, &path) {
Ok((_, mut val)) => {
val.apply_merge()
.with_context(|| format!("failed to apply merge \"{}\"", path.display()))?;
@@ -66,74 +68,150 @@ fn parse_mapping(yaml_str: String, path: PathBuf) -> Result<Mapping> {
}
}
/// A float like `1234e123` loses its hex text, so retry with short-ids quoted and keep that only if nothing else changed.
fn parse_short_id_text(yaml: &str, path: &Path) -> serde_yaml_ng::Result<Value> {
let value = serde_yaml_ng::from_str::<Value>(yaml)?;
if !has_numeric_short_id(&value) {
return Ok(value);
}
match serde_yaml_ng::from_str::<Value>(&quote_short_ids(yaml)) {
Ok(quoted) if same_except_short_id(&value, &quoted) => Ok(quoted),
_ => {
logging!(warn, Type::Config, "kept numeric short-id in {}", path.display());
Ok(value)
}
}
}
const SHORT_ID_KEY: &str = "short-id:";
fn quote_short_ids(yaml: &str) -> String {
let mut quoted = String::with_capacity(yaml.len());
let mut rest = yaml;
while let Some((head, tail)) = rest.split_once("short-id:") {
let (gap, value) = tail.split_at(tail.len() - tail.trim_start_matches([' ', '\t']).len());
let (id, after) = value.split_at(value.bytes().take_while(u8::is_ascii_hexdigit).count());
quoted.push_str(head);
quoted.push_str("short-id:");
quoted.push_str(gap);
if !id.is_empty() && (after.is_empty() || after.starts_with([' ', '\t', '\r', '\n', ',', '}'])) {
quoted.push('"');
quoted.push_str(id);
quoted.push('"');
} else {
quoted.push_str(id);
/// A float like `1234e123` loses its hex text, so quote short-ids before parsing and keep that only if nothing else changed.
pub(crate) fn short_id_tolerant<'a>(yaml: &'a str, path: &Path) -> serde_yaml_ng::Result<(Cow<'a, str>, Value)> {
let original = serde_yaml_ng::from_str::<Value>(yaml)?;
if !has_numeric_short_id(&original) {
return Ok((Cow::Borrowed(yaml), original));
}
let quoted = quote_plain_short_ids(yaml);
if matches!(quoted, Cow::Borrowed(_)) {
return Ok((quoted, original));
}
match serde_yaml_ng::from_str::<Value>(&quoted) {
Ok(tolerant) if same_except_short_id_text(&original, &tolerant) => Ok((quoted, tolerant)),
_ => {
logging!(
warn,
Type::Config,
"kept numeric short-id in {}: quoting it would change other values",
path.display()
);
Ok((Cow::Borrowed(yaml), original))
}
rest = after;
}
quoted.push_str(rest);
quoted
}
fn has_numeric_short_id(value: &Value) -> bool {
match value {
Value::Mapping(map) => map
.iter()
.any(|(key, value)| (is_short_id(key) && matches!(value, Value::Number(_))) || has_numeric_short_id(value)),
.any(|(key, value)| (is_short_id_key(key) && value.is_number()) || has_numeric_short_id(value)),
Value::Sequence(seq) => seq.iter().any(has_numeric_short_id),
Value::Tagged(tagged) => has_numeric_short_id(&tagged.value),
_ => false,
}
}
fn same_except_short_id(old: &Value, new: &Value) -> bool {
match (old, new) {
(Value::Mapping(old), Value::Mapping(new)) => {
old.len() == new.len()
&& old.iter().zip(new).all(|((key, old), (new_key, new))| {
key == new_key
&& (same_except_short_id(old, new)
|| (is_short_id(key) && matches!((old, new), (Value::Number(_), Value::String(_)))))
})
fn same_except_short_id_text(original: &Value, tolerant: &Value) -> bool {
match (original, tolerant) {
(Value::Mapping(original), Value::Mapping(tolerant)) => {
original.len() == tolerant.len()
&& original
.iter()
.zip(tolerant.iter())
.all(|((key, old), (new_key, new))| {
key == new_key && (same_except_short_id_text(old, new) || is_short_id_text(key, old, new))
})
}
(Value::Sequence(old), Value::Sequence(new)) => {
old.len() == new.len() && old.iter().zip(new).all(|(old, new)| same_except_short_id(old, new))
(Value::Sequence(original), Value::Sequence(tolerant)) => {
original.len() == tolerant.len()
&& original
.iter()
.zip(tolerant.iter())
.all(|(old, new)| same_except_short_id_text(old, new))
}
_ => old == new,
(Value::Tagged(original), Value::Tagged(tolerant)) => {
original.tag == tolerant.tag && same_except_short_id_text(&original.value, &tolerant.value)
}
_ => original == tolerant,
}
}
fn is_short_id(key: &Value) -> bool {
fn is_short_id_text(key: &Value, old: &Value, new: &Value) -> bool {
is_short_id_key(key)
&& old.is_number()
&& matches!(new, Value::String(text) if serde_yaml_ng::from_str::<Value>(text).ok().as_ref() == Some(old))
}
fn is_short_id_key(key: &Value) -> bool {
key.as_str() == Some("short-id")
}
fn quote_plain_short_ids(yaml: &str) -> Cow<'_, str> {
let mut quoted = String::new();
let mut copied = 0;
for (key, _) in yaml.match_indices(SHORT_ID_KEY) {
let Some(value) = plain_short_id_at(yaml, key) else {
continue;
};
let (Some(head), Some(id)) = (yaml.get(copied..value.start), yaml.get(value.clone())) else {
continue;
};
quoted.push_str(head);
quoted.push('"');
quoted.push_str(id);
quoted.push('"');
copied = value.end;
}
if copied == 0 {
return Cow::Borrowed(yaml);
}
quoted.push_str(yaml.get(copied..).unwrap_or_default());
Cow::Owned(quoted)
}
fn plain_short_id_at(yaml: &str, key: usize) -> Option<Range<usize>> {
let bytes = yaml.as_bytes();
let before = bytes.get(..key)?;
let flow = matches!(
before.iter().rev().find(|byte| !byte.is_ascii_whitespace()),
Some(b'{' | b',')
);
let line_start = before
.iter()
.rev()
.take_while(|&&byte| byte != b'\n')
.all(|&byte| is_blank(byte));
if !flow && !line_start {
return None;
}
let start = key + SHORT_ID_KEY.len();
let gap = bytes.get(start..)?.iter().take_while(|&&byte| is_blank(byte)).count();
if gap == 0 {
return None;
}
let start = start + gap;
let len = bytes
.get(start..)?
.iter()
.take_while(|byte| byte.is_ascii_hexdigit())
.count();
if len == 0 || !ends_plain_short_id(bytes.get(start + len..)?, flow) {
return None;
}
Some(start..start + len)
}
fn ends_plain_short_id(rest: &[u8], flow: bool) -> bool {
let blanks = rest.iter().take_while(|&&byte| is_blank(byte)).count();
match rest.get(blanks..) {
Some([] | [b'\n', ..] | [b'\r', b'\n', ..]) => true,
Some([b'#', ..]) => blanks > 0,
Some([b',' | b'}', ..]) => flow,
_ => false,
}
}
const fn is_blank(byte: u8) -> bool {
matches!(byte, b' ' | b'\t')
}
pub async fn save_yaml<T: Serialize + Sync>(path: &Path, data: &T, prefix: Option<&str>) -> Result<()> {
let data_str = with_encryption(|| async { serde_yaml_ng::to_string(data) }).await?;
@@ -168,7 +246,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 +273,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(())
}
@@ -391,37 +469,136 @@ pub fn snapshot_path(original_path: &Path) -> Result<PathBuf> {
#[cfg(test)]
#[allow(clippy::expect_used, reason = "tests assert by panicking")]
mod tests {
use super::{parse_mapping, parse_short_id_text};
use super::{has_numeric_short_id, parse_mapping, quote_plain_short_ids, short_id_tolerant};
use serde_yaml_ng::Value;
use std::path::{Path, PathBuf};
use std::{
borrow::Cow,
path::{Path, PathBuf},
};
#[test]
fn plain_short_id_stays_text() {
fn quotes_plain_short_ids() {
let cases = [
("short-id: 1234e123", "short-id: \"1234e123\""),
("short-id: 0e5", "short-id: \"0e5\""),
("short-id: 12345678", "short-id: \"12345678\""),
("short-id: 6ba85179", "short-id: \"6ba85179\""),
("short-id: \"1234e123\"", "short-id: \"1234e123\""),
("short-id: '1234e123'", "short-id: '1234e123'"),
("short-id: \"\"", "short-id: \"\""),
("short-id:\npublic-key: k", "short-id:\npublic-key: k"),
("short-id: null", "short-id: null"),
("short-id: ~", "short-id: ~"),
("short-id: 1234e123 # note", "short-id: \"1234e123\" # note"),
("# short-id: 1234e123", "# short-id: 1234e123"),
(
"reality-opts: {public-key: k, short-id: 1234e123}",
"reality-opts: {public-key: k, short-id: \"1234e123\"}",
),
(
"reality-opts: {short-id: 1234e123, public-key: k}",
"reality-opts: {short-id: \"1234e123\", public-key: k}",
),
(
"{\n public-key: k,\n short-id: 1234e123\n}",
"{\n public-key: k,\n short-id: \"1234e123\"\n}",
),
("short-id: &sid 1234e123", "short-id: &sid 1234e123"),
("short-id: !!str 1234e123", "short-id: !!str 1234e123"),
("short-id: abc,def", "short-id: abc,def"),
("short-id: 1234e123\r\n", "short-id: \"1234e123\"\r\n"),
("my-short-id: 1234e123", "my-short-id: 1234e123"),
("xshort-id: 1", "xshort-id: 1"),
("short-id:1234e123", "short-id:1234e123"),
("short-id: 1.5e3", "short-id: 1.5e3"),
];
for (input, expected) in cases {
let output = quote_plain_short_ids(input);
assert_eq!(output, expected, "input: {input:?}");
assert_eq!(
matches!(output, Cow::Borrowed(_)),
input == expected,
"input: {input:?}"
);
assert_eq!(quote_plain_short_ids(&output), expected, "rerun: {input:?}");
}
}
#[test]
fn plain_short_id_survives_profile_parse() {
let profile = "\
proxies:
- name: a
- name: r
type: vless
reality-opts:
public-key: SFv3pZQdZ1fpnNg9yI2RwXKqAOz4mZHRbT2Wq4kz8Ws
short-id: 1234e123
- {name: b, reality-opts: {public-key: k, short-id: 0e5}}
";
let mapping = parse_mapping(profile.into(), PathBuf::from("profile.yaml")).expect("profile should parse");
let short_id = mapping
.get("proxies")
.and_then(|proxies| proxies.get(0))
.and_then(|proxy| proxy.get("reality-opts"))
.and_then(|opts| opts.get("short-id"));
assert_eq!(short_id, Some(&Value::String("1234e123".into())));
let output = serde_yaml_ng::to_string(&mapping).expect("mapping should serialize");
assert!(output.contains("short-id: '1234e123'"), "{output}");
assert!(output.contains("short-id: '0e5'"), "{output}");
}
#[test]
fn short_id_quoting_changing_other_values_is_dropped() {
let profile = "\
fn short_id_rewrite_falls_back_to_original_parse() {
// (case, input, has a numeric short-id today)
let cases = [
("B2", "proxies:\n - name: \"node, short-id: 1234e123}\"\n", false),
(
"B3",
"proxies:\n - name: \"node, short-id: 1234e123}\"\n - name: r\n reality-opts:\n short-id: 1234e123\n",
true,
),
(
"B4",
"payload: |\n short-id: 1234e123\nreality-opts:\n short-id: 1234e123\n",
true,
),
("B5", "reality-opts:\n short-id: 1234e123\n more\n", false),
("B6", "reality-opts: {public-key: k, # c\n short-id: 1234e123}\n", true),
];
for (case, input, numeric) in cases {
let today = serde_yaml_ng::from_str::<Value>(input).expect("input should parse");
assert_eq!(has_numeric_short_id(&today), numeric, "{case}");
let (text, value) = short_id_tolerant(input, Path::new("b.yaml")).expect("input should parse");
assert!(matches!(text, Cow::Borrowed(_)), "{case}");
assert_eq!(value, today, "{case}");
}
}
#[test]
fn short_id_rewrite_keeps_every_numeric_short_id() {
let input = "\
proxies:
- name: a
reality-opts:
short-id: 1234e123
note: |
short-id: 1234e123
- {name: b, reality-opts: {short-id: 0e5}}
";
let today = serde_yaml_ng::from_str::<Value>(profile).expect("profile should parse");
let value = parse_short_id_text(profile, Path::new("profile.yaml")).expect("profile should parse");
assert_eq!(value, today);
let (text, value) = short_id_tolerant(input, Path::new("b7.yaml")).expect("input should parse");
assert!(matches!(text, Cow::Owned(_)));
let short_ids: Vec<_> = value
.get("proxies")
.and_then(Value::as_sequence)
.expect("proxies should be a sequence")
.iter()
.map(|proxy| proxy.get("reality-opts").and_then(|opts| opts.get("short-id")).cloned())
.collect();
assert_eq!(
short_ids,
[
Some(Value::String("1234e123".into())),
Some(Value::String("0e5".into()))
]
);
}
}
+2
View File
@@ -78,6 +78,8 @@ async fn resolve_setup() {
let core_init = AsyncHandler::spawn(|| async {
init_core_manager().await;
#[cfg(target_os = "linux")]
crate::core::service::notify_incomplete_service_upgrade().await;
});
let _ = futures::join!(
+3 -3
View File
@@ -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.
+8 -2
View File
@@ -11,7 +11,10 @@
"conflicts": ["clash-verge"],
"replaces": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
"preRemoveScript": "./packages/linux/pre-remove.sh",
"files": {
"/usr/lib/clash-verge-rev/clash-verge-service-uninstall": "./sidecar/clash-verge-service-uninstall-bin"
}
},
"rpm": {
"depends": ["openssl", "libayatana-appindicator-gtk3"],
@@ -21,7 +24,10 @@
"conflicts": ["clash-verge"],
"obsoletes": ["clash-verge"],
"postInstallScript": "./packages/linux/post-install-rpm.sh",
"preRemoveScript": "./packages/linux/pre-remove.sh"
"preRemoveScript": "./packages/linux/pre-remove.sh",
"files": {
"/usr/lib/clash-verge-rev/clash-verge-service-uninstall": "./sidecar/clash-verge-service-uninstall-bin"
}
}
},
"externalBin": [
+10 -29
View File
@@ -389,7 +389,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,
@@ -397,7 +397,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,
@@ -405,7 +405,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,
@@ -413,7 +413,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,
@@ -798,7 +798,6 @@ export const ConnectionTable = (props: Props) => {
onClick={() => toggleSorting(column.field)}
style={{
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
justifyContent:
@@ -810,33 +809,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) =>
+7 -9
View File
@@ -606,6 +606,9 @@ export const CurrentProxyCard = () => {
)
const { select } = useProxySelection({
onSuccess: () => {
refreshProxy()
},
onError: (error) => {
console.error('代理切换失败', error)
refreshProxy()
@@ -752,9 +755,10 @@ export const CurrentProxyCard = () => {
if (!selectedGroup || !option || !isInteractableMember(option.member)) {
return
}
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// The profile selection is the durable source across core restarts and run modes.
select(selectedGroup.name, nextName, selectedGroup.fixed!)
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
},
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
)
@@ -1008,13 +1012,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 +1067,6 @@ export const CurrentProxyCard = () => {
size="small"
label={delayManager.formatDelay(currentDelay)}
color={convertDelayColor(currentDelay)}
sx={{ flexShrink: 0, ml: 1 }}
/>
)}
</Box>

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