mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-15 07:12:09 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a28fb47bf |
@@ -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
+20
-20
@@ -1328,7 +1328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2043,7 +2043,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2302,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2818,7 +2818,7 @@ dependencies = [
|
||||
"gobject-sys 0.21.5",
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3372,7 +3372,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4252,7 +4252,7 @@ dependencies = [
|
||||
"png",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4419,7 +4419,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4896,7 +4896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5576,7 +5576,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6129,7 +6129,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6188,7 +6188,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6808,7 +6808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7867,7 +7867,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8533,7 +8533,7 @@ dependencies = [
|
||||
"png",
|
||||
"serde",
|
||||
"thiserror 2.0.20",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8642,7 +8642,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8820,9 +8820,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warp"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a808122a8a77eecdabaefd88ddb1913c4be5ea1465399f63ba64c7aa705fea"
|
||||
checksum = "c92be475cc42b6b3913eeac77f0cbc0dd8f6a1ad6400c0722dbe36ba0c3ee799"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -9184,7 +9184,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9205,7 +9205,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
"raw-window-handle",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
@@ -9780,7 +9780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
|
||||
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
|
||||
- 修复窗口较窄时,首页「当前节点」卡片中过长的节点名把延迟标签挤出卡片
|
||||
- 修复断开链式代理时清空已选节点,导致再次连接需要重新选择的问题
|
||||
- 修复「连接」页表格排序时表头换行、标题位置跳动的问题
|
||||
- 修复「TUN 设置」未手动保存时,界面显示的默认 MTU 1500 未生效的问题
|
||||
- 修复连接详情较窄时操作按钮偏离右侧
|
||||
|
||||
**🍎 macOS**
|
||||
|
||||
@@ -47,7 +43,6 @@
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
- 新增连接页面按网络协议(TCP/UDP)筛选连接
|
||||
- 新增从连接详情快捷设置域名、进程或源/目标 IP 规则,支持父域及子域匹配与修改已有规则
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"duplicates",
|
||||
"cycles"
|
||||
],
|
||||
"ignoreBinaries": ["rustc"],
|
||||
"ignoreBinaries": ["rustc", "cc"],
|
||||
"ignoreDependencies": ["lint-staged"],
|
||||
"ignoreIssues": {
|
||||
"src/types/generated/**": ["exports", "types"],
|
||||
|
||||
+9
-2
@@ -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",
|
||||
@@ -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",
|
||||
@@ -126,7 +131,9 @@
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^5.0.1"
|
||||
"vitest": "^5.0.1",
|
||||
"webdriverio": "10.2.0",
|
||||
"ws": "8.22.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,mjs}": [
|
||||
|
||||
Generated
+1728
-4
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -50,7 +50,12 @@
|
||||
},
|
||||
{
|
||||
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
|
||||
"matchPackageNames": ["@tauri-apps/**", "tauri", "tauri-*"],
|
||||
"matchPackageNames": [
|
||||
"@tauri-apps/**",
|
||||
"@wdio/tauri-service",
|
||||
"tauri",
|
||||
"tauri-*"
|
||||
],
|
||||
"groupName": "tauri",
|
||||
"addLabels": ["tauri"]
|
||||
},
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# Product Measurement Adapter Guidelines
|
||||
|
||||
[GUIDE.md](GUIDE.md) defines the product adapter boundary and explicit tool invocation.
|
||||
Keep real component imports, initialization and the isolated native entry here,
|
||||
using product frontend/native build dependencies. Do not copy components into the
|
||||
tool. Preserve perf build gates; ordinary builds must not require the external tool.
|
||||
|
||||
The adapter must not load or modify daily configuration or control the daily app,
|
||||
Core, Service, TUN, DNS or system proxy. Preserve loopback isolation, owned-process
|
||||
PID/start attribution, nonactivation, visibility/occlusion and source continuity.
|
||||
Do not weaken gates or substitute Chrome/Vite for native WebView evidence. Label
|
||||
JavaScript adapters separately from native plugin/IPC paths. Expected startup errors
|
||||
must use the owned app's normal nonzero exit, never panic through Tauri setup.
|
||||
|
||||
Review adapter source changes, including initialization and native/build configuration,
|
||||
for changed measurement meaning. Matching interfaces alone do not prove equivalence;
|
||||
an adapter review cannot waive other acceptance conditions.
|
||||
|
||||
For implementation changes, run scoped ESLint, both perf TypeScript projects
|
||||
(`tsconfig.json` and `tsconfig.node.json`), formatting and relevant production checks,
|
||||
including Rust checks for native changes. Run scoped Rust Clippy before freezing
|
||||
the optimized artifact. Check ordinary-build exclusion and application-entry prerequisites.
|
||||
|
||||
Follow the external AGENTS.md's
|
||||
[measurement-validation requirements](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/AGENTS.md)
|
||||
and README's
|
||||
[validation order](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#validate-before-freezing-a-build).
|
||||
These govern affected native coverage, comparison, failure cleanup and evidence reuse.
|
||||
+132
-27
@@ -1,35 +1,140 @@
|
||||
# Product measurement adapter
|
||||
# macOS Traffic Performance Harness
|
||||
|
||||
This directory owns the isolated native entry, real Traffic/Logs/Connections
|
||||
imports, initialization, JavaScript socket adapter, replay receiver and minimal DOM
|
||||
layer. These evolve with product APIs and use the product's React/Tauri/Wry
|
||||
versions. Keep the explicit perf build gates; ordinary builds need no external tool.
|
||||
This guide is the operating and maintenance contract for humans and coding agents.
|
||||
The only scenario is the production home traffic graph in a dedicated, optimized
|
||||
Tauri WKWebView. It measures foreground updates, native hide, then restoration.
|
||||
It does not initialize the complete homepage or run Mihomo, Service or networking.
|
||||
|
||||
The external [clash-verge-rev-harness operating guide](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md)
|
||||
owns running, sampling, reports, comparison and owned cleanup. Follow its
|
||||
[first setup and installation instructions](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#build-and-run),
|
||||
reusing an existing checkout. For subsequent calls, pass both absolute roots:
|
||||
## Build And Run
|
||||
|
||||
Use this repository's pnpm, Rust and Xcode Command Line Tools on macOS. No global
|
||||
WebDriver installation is needed. Keep each binary beside its generated manifest.
|
||||
|
||||
```sh
|
||||
project=/absolute/path/to/clash-verge-rev
|
||||
harness=/absolute/path/to/clash-verge-rev-harness
|
||||
node "$harness/build.mjs" --project "$project" --output target/perf/build
|
||||
caffeinate -di node "$harness/run.mjs" --project "$project" --binary target/perf/build/clash-verge --output target/perf/smoke --repeat 1 --seconds 5 --warmup 2
|
||||
node "$harness/compare.mjs" "$project/target/perf/smoke" "$project/target/perf/smoke"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm perf:build target/perf/baseline-build
|
||||
caffeinate -di pnpm perf:run -- --binary target/perf/baseline-build/clash-verge --output target/perf/baseline --repeat 3 --seconds 20 --warmup 10
|
||||
pnpm perf:compare -- target/perf/baseline target/perf/baseline
|
||||
pnpm perf:test
|
||||
```
|
||||
|
||||
JavaScript socket replacement does not measure native plugin IPC. DOM mutation/text
|
||||
hashes show view-input delivery, not painting or cache ownership. Adapter changes
|
||||
require reviewing measurement meaning under the external validation requirements.
|
||||
`perf:build [directory]` uses release optimization, Vite `--mode perf` and the
|
||||
explicit Cargo `perf-harness` feature. It preserves the binary and records HEAD,
|
||||
dirty state, compiled source, lockfile, frontend, build-settings and binary hashes.
|
||||
Source changes during compilation reject the manifest. Each run also hashes the
|
||||
external collector and runner. Ordinary builds exclude the driver and measurement
|
||||
entry; debug assertions are not the gate. Do not edit compiled inputs during builds.
|
||||
|
||||
For virtual display mode, this adapter places only the hidden measurement window
|
||||
on the exact owned NSScreen, checks complete visibleFrame containment, mode/Space
|
||||
flags and nonactivation, and exits on drift. The tool owns helper lifecycle and topology
|
||||
restoration; see its
|
||||
[virtual display guide](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#optional-virtual-display).
|
||||
Output directories must be new and Git ignored; use `target/perf/`. JSON/CSV keep
|
||||
raw process samples, observations, phase markers, attribution and cleanup evidence.
|
||||
`summary.json` and `report.md` provide distributions by independent run, phase and
|
||||
process role. Failed runs exit nonzero and retain their evidence. The explicit
|
||||
`traffic-webview-v3` schema rejects older reports instead of silently migrating them.
|
||||
|
||||
`isolation.json` owns the product configuration and process watch scope; keep it
|
||||
aligned with product data directories and executable names. The tool owns
|
||||
[command isolation snapshots](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#command-isolation-snapshots).
|
||||
The adapter must not load daily configuration or control Core, Service, TUN, DNS
|
||||
or system proxy.
|
||||
## Isolation And Visibility
|
||||
|
||||
The dedicated measurement main bypasses normal initialization and shutdown. It
|
||||
does not read or write daily configuration, subscriptions, proxy, TUN or Service.
|
||||
It uses a distinct app identity and incognito WebView. Both automatic services
|
||||
bind loopback and are owned by the current run; never kill processes by name.
|
||||
|
||||
The measurement window stays above ordinary windows and covers part of the screen,
|
||||
without activation or mouse capture: clicks pass through to the application behind it.
|
||||
Native topmost and click-through states are verified, while native focus and application
|
||||
activation must remain false. WebDriver uses the embedded WebView directly; DOM focus
|
||||
is recorded but not required. Daily windows are unchanged; the topmost window exits
|
||||
with its owned measurement instance. You may keep using another application.
|
||||
The console must be unlocked. Lock state is checked before startup and recorded
|
||||
at phase boundaries; unknown state fails and observed drift rejects comparison.
|
||||
Boundary checks do not provide continuous lock monitoring or unlock the computer.
|
||||
|
||||
No focus does not mean no visibility: shown stages require `document.hidden=false`,
|
||||
fresh Worker data, advancing Canvas draw timestamps and changed Canvas pixels.
|
||||
Topmost placement avoids ordinary-window occlusion; system overlays or another Space
|
||||
can still pause WebKit. Such a run fails; do not weaken Canvas assertions to make it
|
||||
pass. `caffeinate -di` lasts only for the command and cannot prevent deliberate lock.
|
||||
Hidden stages still require continuous input and Worker data.
|
||||
|
||||
## Fixed Workload
|
||||
|
||||
The native window is 1000 x 700 logical pixels. Verify driver dimensions against
|
||||
DPR and record the actual CSS viewport; titlebar space is excluded. The graph is
|
||||
920 x 360 with a ten-minute range, default bezier style and real data retention.
|
||||
The measurement preload sets blur pause false without changing production defaults.
|
||||
|
||||
Deterministic loopback WebSocket replay enters the public traffic append boundary,
|
||||
then the production Worker and Canvas. It does not cover Mihomo or network traffic.
|
||||
There is no history prefill. Absolute 200 ms deadlines sustain 5 Hz without cumulative
|
||||
timer drift or catch-up bursts; missing a complete period fails the source.
|
||||
Warmup is at least the requested duration with a five-second readiness allowance.
|
||||
Fixed input anchors reserve two seconds for each native transition; measured stages
|
||||
retain their full duration. Readiness uses observable state and deadlines, not sleep.
|
||||
Before the first show, wait for actual replay input and Worker data so the graph's
|
||||
visibility listeners are installed; then require fresh Canvas output after showing.
|
||||
Total replay, including allowances, must remain below ten minutes for raw history.
|
||||
Two-input endpoint variation is 400 ms; larger differences reject comparison.
|
||||
|
||||
Worker snapshots are asynchronous. Count/latest-value checks verify deterministic
|
||||
upload/download values at the retained point count, with a joint period of 60 and
|
||||
received-minus-retained count below 60. This detects net missing/duplicate points,
|
||||
not every historical element or exactly cancelling losses and duplicates.
|
||||
Source age must be below 2500 ms, Worker age below 3500 ms and visible draw age below
|
||||
2500 ms. Future timestamps, lost sequence, process changes and failed restore fail.
|
||||
|
||||
## Metrics And Comparison
|
||||
|
||||
The native command obtains the actual WKWebView process ID using the private
|
||||
`_webProcessIdentifier` selector, with availability checking. External collection
|
||||
verifies the canonical system WebContent executable and process start identity.
|
||||
It handles PID reuse and exits; unsupported ownership fails closed. Main and WebView
|
||||
remain separate. Mihomo is not started; shared GPU/network attribution is unavailable.
|
||||
|
||||
CPU is cumulative process time converted from Mach ticks; interval percentages use
|
||||
adjacent valid samples, with one occupied core equal to 100%. The first difference
|
||||
is unavailable. Memory is RSS bytes, not physical footprint or exclusive memory.
|
||||
Reports include CPU mean/P50/P95/peak/seconds, RSS median/P95/peak/delta, sample count
|
||||
and time coverage. Memory slope requires at least 60 seconds of continuous samples.
|
||||
RSS growth is not proof of a leak; process statistics are not function profiles.
|
||||
The embedded driver adds a 20 Hz macOS runloop timer and observation overhead.
|
||||
|
||||
For A/B, build the candidate separately and alternate independent instances:
|
||||
|
||||
```sh
|
||||
pnpm perf:build target/perf/candidate-build
|
||||
for i in 1 2 3; do
|
||||
caffeinate -di pnpm perf:run -- --binary target/perf/baseline-build/clash-verge --repeat 1 --output target/perf/ab-baseline/$i
|
||||
caffeinate -di pnpm perf:run -- --binary target/perf/candidate-build/clash-verge --repeat 1 --output target/perf/ab-candidate/$i
|
||||
done
|
||||
pnpm perf:compare -- target/perf/ab-baseline target/perf/ab-candidate
|
||||
pnpm perf:run -- --binary target/perf/candidate-build/clash-verge --repeat 1 --seconds 5 --warmup 2 --fail-phase hidden --output target/perf/failed
|
||||
```
|
||||
|
||||
Comparison accepts summary files, run directories or groups of run directories.
|
||||
Build mode, toolchain, platform, host, power, WebKit, viewport and workload must match.
|
||||
Invalid and incomparable reports exit 2. A/A remains inconclusive. A/B compares
|
||||
independent-run summaries, not adjacent time samples. An optional third numeric
|
||||
argument is a percentage floor justified by baseline noise; there is no universal
|
||||
pass threshold. Fewer than three runs per group, overlapping ranges or differences
|
||||
within either group's range remain inconclusive. Background load limits inference.
|
||||
|
||||
## Automation Delivery Boundary
|
||||
|
||||
When the operator is a coding agent, the final change keeps only the
|
||||
performance-optimization code. Harness additions introduced during the
|
||||
work — measurement entries, configs, scripts, reports, manifests — must
|
||||
stay untracked by Git and out of every commit; keep them on ignored
|
||||
paths or remove them before delivery. Commits remain minimal and
|
||||
necessary for the optimization itself. Track or commit harness files
|
||||
only when a human explicitly requests it at the end.
|
||||
|
||||
## Maintenance And Acceptance
|
||||
|
||||
Keep measurement code behind the explicit build gates. Reuse the existing driver,
|
||||
Worker, graph, attribution and cleanup; do not add mock IPC or replace real WebView
|
||||
acceptance with Chrome/Vite. Preserve source/binary/report hashes and every failed run.
|
||||
After changes, run scoped ESLint, both perf TypeScript projects, `pnpm perf:test`,
|
||||
format checks and relevant production checks. Verify a real three-phase smoke,
|
||||
three independent runs, A/A, invalid/incomparable reports and injected-failure cleanup.
|
||||
Verify ordinary builds exclude measurement code and daily instance state is intact.
|
||||
Changes to contracts require a schema bump and new reports. Use bounded WebKit CPU
|
||||
or Allocation/Heap captures for deeper diagnosis; do not infer hotspots from counters.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { subscribe, scenario } from './replay'
|
||||
|
||||
// Only this perf entry replaces socket transport; the normal application never imports it.
|
||||
export function installAdapter() {
|
||||
const connect = async (kind: string) => {
|
||||
const data = document.documentElement.dataset
|
||||
data.perfSubscriptions = String(Number(data.perfSubscriptions || 0) + 1)
|
||||
const callbacks = new Set<
|
||||
(message: { type: 'Text'; data: string }) => void
|
||||
>()
|
||||
const unsubscribe = subscribe((frame) => {
|
||||
const messages =
|
||||
kind === 'logs'
|
||||
? (frame.payload as ILogItem[])
|
||||
: [
|
||||
kind === 'traffic'
|
||||
? { up: frame.sequence * 1000, down: frame.sequence * 5000 }
|
||||
: frame.payload,
|
||||
]
|
||||
for (const payload of messages) {
|
||||
const message = { type: 'Text' as const, data: JSON.stringify(payload) }
|
||||
callbacks.forEach((callback) => {
|
||||
callback(message)
|
||||
data.perfDelivered = String(Number(data.perfDelivered || 0) + 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
return {
|
||||
addListener: (
|
||||
callback: (message: { type: 'Text'; data: string }) => void,
|
||||
) => {
|
||||
callbacks.add(callback)
|
||||
return () => callbacks.delete(callback)
|
||||
},
|
||||
close: async () => {
|
||||
unsubscribe()
|
||||
callbacks.clear()
|
||||
data.perfSubscriptions = String(Number(data.perfSubscriptions) - 1)
|
||||
},
|
||||
} as unknown as MihomoWebSocket
|
||||
}
|
||||
MihomoWebSocket.connect_logs = async () => connect('logs')
|
||||
MihomoWebSocket.connect_connections = async () => connect('connections')
|
||||
MihomoWebSocket.connect_traffic = async () => connect('traffic')
|
||||
if (scenario === 'logs')
|
||||
localStorage.setItem(
|
||||
'clash-log',
|
||||
JSON.stringify({
|
||||
enable: true,
|
||||
logLevel: 'INFO',
|
||||
logFilter: 'all',
|
||||
logOrder: 'desc',
|
||||
}),
|
||||
)
|
||||
}
|
||||
+21
-227
@@ -1,136 +1,5 @@
|
||||
use objc2::{class, msg_send, runtime::AnyObject, sel};
|
||||
use objc2_foundation::{NSPoint, NSRect, ns_string};
|
||||
use tauri::{Manager as _, WebviewWindow};
|
||||
|
||||
#[link(name = "CoreGraphics", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CGDisplayCopyDisplayMode(display: u32) -> *mut std::ffi::c_void;
|
||||
fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64;
|
||||
fn CGDisplayModeRelease(mode: *mut std::ffi::c_void);
|
||||
fn CGMainDisplayID() -> u32;
|
||||
fn CGDisplayIsInMirrorSet(display: u32) -> u32;
|
||||
fn CGDisplayModeGetWidth(mode: *mut std::ffi::c_void) -> usize;
|
||||
fn CGDisplayModeGetHeight(mode: *mut std::ffi::c_void) -> usize;
|
||||
fn CGDisplayModeGetPixelWidth(mode: *mut std::ffi::c_void) -> usize;
|
||||
fn CGDisplayModeGetPixelHeight(mode: *mut std::ffi::c_void) -> usize;
|
||||
}
|
||||
|
||||
fn expected_display() -> Result<Option<u32>, String> {
|
||||
match std::env::var("PERF_DISPLAY_MODE").as_deref() {
|
||||
Ok("physical") | Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Ok("virtual") => {
|
||||
let id = std::env::var("PERF_DISPLAY_ID")
|
||||
.map_err(|e| e.to_string())?
|
||||
.parse::<u32>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if id == 0 {
|
||||
return Err("invalid owned display ID".into());
|
||||
}
|
||||
Ok(Some(id))
|
||||
}
|
||||
_ => Err("unknown measurement display mode".into()),
|
||||
}
|
||||
}
|
||||
const fn bounds(rect: NSRect) -> [f64; 4] {
|
||||
[rect.origin.x, rect.origin.y, rect.size.width, rect.size.height]
|
||||
}
|
||||
fn contained(frame: NSRect, screen: NSRect) -> bool {
|
||||
frame.origin.x >= screen.origin.x
|
||||
&& frame.origin.y >= screen.origin.y
|
||||
&& frame.origin.x + frame.size.width <= screen.origin.x + screen.size.width
|
||||
&& frame.origin.y + frame.size.height <= screen.origin.y + screen.size.height
|
||||
}
|
||||
unsafe fn screen_id(screen: *mut AnyObject) -> u32 {
|
||||
unsafe {
|
||||
if screen.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let dictionary: *mut AnyObject = msg_send![screen, deviceDescription];
|
||||
let value: *mut AnyObject = msg_send![dictionary, objectForKey: ns_string!("NSScreenNumber")];
|
||||
msg_send![value, unsignedIntValue]
|
||||
}
|
||||
}
|
||||
unsafe fn display_state(native: &AnyObject) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(wanted) = expected_display()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
unsafe {
|
||||
let screen: *mut AnyObject = msg_send![native, screen];
|
||||
let id = screen_id(screen);
|
||||
if id == 0 || id != wanted || id == CGMainDisplayID() || CGDisplayIsInMirrorSet(id) != 0 {
|
||||
return Err("owned virtual screen unavailable".into());
|
||||
}
|
||||
let frame: NSRect = msg_send![screen, frame];
|
||||
let visible: NSRect = msg_send![screen, visibleFrame];
|
||||
let window_frame: NSRect = msg_send![native, frame];
|
||||
let scale: f64 = msg_send![screen, backingScaleFactor];
|
||||
let behavior: usize = msg_send![native, collectionBehavior];
|
||||
let mode = CGDisplayCopyDisplayMode(id);
|
||||
if mode.is_null() {
|
||||
return Err("owned display mode unavailable".into());
|
||||
}
|
||||
let rate = CGDisplayModeGetRefreshRate(mode);
|
||||
let logical = [CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)];
|
||||
let pixels = [CGDisplayModeGetPixelWidth(mode), CGDisplayModeGetPixelHeight(mode)];
|
||||
CGDisplayModeRelease(mode);
|
||||
if logical != [1280, 900]
|
||||
|| pixels != [2560, 1800]
|
||||
|| scale != 2.0
|
||||
|| (rate - 60.0).abs() > 0.01
|
||||
|| frame.size.width != 1280.0
|
||||
|| frame.size.height != 900.0
|
||||
|| !contained(window_frame, visible)
|
||||
|| behavior & 1 == 0
|
||||
|| behavior & 2 != 0
|
||||
{
|
||||
return Err("owned virtual screen geometry/mode changed".into());
|
||||
}
|
||||
Ok(Some(
|
||||
serde_json::json!({"id": id, "frame": bounds(frame), "visibleFrame": bounds(visible), "windowFrame": bounds(window_frame), "scale": scale, "refreshHz": rate, "collectionBehavior": behavior, "contained": true, "main": false, "mirrored": false, "logical": logical, "pixels": pixels}),
|
||||
))
|
||||
}
|
||||
}
|
||||
unsafe fn target_screen(wanted: u32) -> Result<*mut AnyObject, String> {
|
||||
unsafe {
|
||||
let screens: *mut AnyObject = msg_send![class!(NSScreen), screens];
|
||||
let count: usize = msg_send![screens, count];
|
||||
let mut target = std::ptr::null_mut::<AnyObject>();
|
||||
for index in 0..count {
|
||||
let screen: *mut AnyObject = msg_send![screens, objectAtIndex: index];
|
||||
if screen_id(screen) == wanted {
|
||||
target = screen;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if target.is_null() || wanted == CGMainDisplayID() {
|
||||
return Err("exact owned virtual display missing".into());
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
unsafe fn place_virtual(window: &WebviewWindow) -> Result<(), String> {
|
||||
unsafe {
|
||||
let Some(wanted) = expected_display()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let target = target_screen(wanted)?;
|
||||
let native = &*(window.ns_window().map_err(|e| e.to_string())? as *const AnyObject);
|
||||
let visible: NSRect = msg_send![target, visibleFrame];
|
||||
let frame: NSRect = msg_send![native, frame];
|
||||
if frame.size.width > visible.size.width || frame.size.height > visible.size.height {
|
||||
return Err("owned window does not fit virtual visibleFrame".into());
|
||||
}
|
||||
let behavior: usize = msg_send![native, collectionBehavior];
|
||||
let _: () = msg_send![native, setCollectionBehavior: (behavior & !2usize) | 1usize];
|
||||
let origin = NSPoint::new(
|
||||
visible.origin.x + (visible.size.width - frame.size.width) / 2.0,
|
||||
visible.origin.y + (visible.size.height - frame.size.height) / 2.0,
|
||||
);
|
||||
let _: () = msg_send![native, setFrameOrigin: origin];
|
||||
display_state(native)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
#[tauri::command]
|
||||
async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json::Value, String> {
|
||||
@@ -138,28 +7,16 @@ async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json:
|
||||
return Err("unknown measurement action".into());
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let fail_app = window.app_handle().clone();
|
||||
window
|
||||
.with_webview(move |view| unsafe {
|
||||
let native = &*(view.ns_window() as *const AnyObject);
|
||||
let sender = std::ptr::null::<AnyObject>();
|
||||
let display = match display_state(native) {
|
||||
Ok(state) => state,
|
||||
Err(error) => {
|
||||
let _: () = msg_send![native, orderOut: sender];
|
||||
let _ = tx.send(Err(error));
|
||||
fail_app.exit(2);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if action == "show" {
|
||||
let _: () = msg_send![native, orderBack: sender];
|
||||
} else if action == "hide" {
|
||||
let _: () = msg_send![native, orderOut: sender];
|
||||
}
|
||||
let visible: bool = msg_send![native, isVisible];
|
||||
let occlusion: usize = msg_send![native, occlusionState];
|
||||
let active_space: bool = msg_send![native, isOnActiveSpace];
|
||||
let focused: bool = msg_send![native, isKeyWindow];
|
||||
let click_through: bool = msg_send![native, ignoresMouseEvents];
|
||||
let app: *const AnyObject = msg_send![class!(NSApplication), sharedApplication];
|
||||
@@ -171,41 +28,21 @@ async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json:
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let _ = tx.send(Ok((
|
||||
pid,
|
||||
visible,
|
||||
focused,
|
||||
active,
|
||||
click_through,
|
||||
occlusion & 2 == 0,
|
||||
active_space,
|
||||
display,
|
||||
)));
|
||||
let _ = tx.send((pid, visible, focused, active, click_through));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (pid, visible, focused, active, click_through, occluded, active_space, display) = rx
|
||||
let (pid, visible, focused, active, click_through) = rx
|
||||
.recv_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|e| e.to_string())??;
|
||||
.map_err(|e| e.to_string())?;
|
||||
if pid <= 0 {
|
||||
return Err("WKWebView process ownership unavailable".into());
|
||||
}
|
||||
let top = window.is_always_on_top().map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({"main": std::process::id(), "webview": pid,
|
||||
"visible": visible, "nativeFocused": focused, "appActive": active,
|
||||
"virtualScreen": display, "expectedDisplayID": expected_display()?,
|
||||
"top": top, "occluded": occluded, "activeSpace": active_space, "clickThrough": click_through,
|
||||
"top": top, "clickThrough": click_through,
|
||||
"token": std::env::var("PERF_TOKEN").map_err(|e| e.to_string())?}))
|
||||
}
|
||||
#[tauri::command]
|
||||
const fn get_clash_logs() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_verge_config() -> serde_json::Value {
|
||||
serde_json::json!({"language": "en", "theme_mode": "light"})
|
||||
}
|
||||
|
||||
pub fn run() -> std::process::ExitCode {
|
||||
#[cfg(feature = "clippy")]
|
||||
let context = tauri::test::mock_context(tauri::test::noop_assets());
|
||||
@@ -213,66 +50,23 @@ pub fn run() -> std::process::ExitCode {
|
||||
let context = tauri::generate_context!();
|
||||
let result = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_wdio_webdriver::init())
|
||||
.invoke_handler(tauri::generate_handler![perf_state, get_clash_logs, get_verge_config])
|
||||
.invoke_handler(tauri::generate_handler![perf_state])
|
||||
.setup(|app| {
|
||||
let startup = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
app.handle()
|
||||
.set_activation_policy(tauri::ActivationPolicy::Prohibited)?;
|
||||
let scenario = std::env::var("PERF_SCENARIO").unwrap_or_else(|_| "traffic".into());
|
||||
let layer = std::env::var("PERF_LAYER").unwrap_or_else(|_| "component".into());
|
||||
if !matches!(scenario.as_str(), "traffic" | "logs" | "connections")
|
||||
|| !matches!(layer.as_str(), "page" | "component" | "dom")
|
||||
{
|
||||
return Err("invalid perf scene".into());
|
||||
}
|
||||
if let Some(wanted) = expected_display().map_err(std::io::Error::other)? {
|
||||
unsafe {
|
||||
target_screen(wanted).map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
let port: u16 = std::env::var("PERF_REPLAY_PORT")?.parse()?;
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"perf",
|
||||
tauri::WebviewUrl::App(format!("index.html?port={port}&scenario={scenario}&layer={layer}").into()),
|
||||
)
|
||||
.title("Clash Verge - performance replay")
|
||||
.inner_size(1000., 700.)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.focusable(false)
|
||||
.always_on_top(true)
|
||||
.incognito(true)
|
||||
.build()?;
|
||||
window.set_ignore_cursor_events(true)?;
|
||||
unsafe {
|
||||
place_virtual(&window).map_err(std::io::Error::other)?;
|
||||
}
|
||||
if expected_display().map_err(std::io::Error::other)?.is_some() {
|
||||
let handle = app.handle().clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(
|
||||
event,
|
||||
tauri::WindowEvent::Moved(_) | tauri::WindowEvent::ScaleFactorChanged { .. }
|
||||
) && let Some(window) = handle.get_webview_window("perf")
|
||||
{
|
||||
let valid = window
|
||||
.ns_window()
|
||||
.ok()
|
||||
.is_some_and(|native| unsafe { display_state(&*(native as *const AnyObject)).is_ok() });
|
||||
if !valid {
|
||||
let _ = window.hide();
|
||||
handle.exit(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = startup {
|
||||
eprintln!("Measurement startup failed: {error}");
|
||||
app.handle().exit(1);
|
||||
}
|
||||
app.handle().set_activation_policy(tauri::ActivationPolicy::Accessory)?;
|
||||
let port: u16 = std::env::var("PERF_REPLAY_PORT")?.parse()?;
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"perf",
|
||||
tauri::WebviewUrl::App(format!("index.html?port={port}").into()),
|
||||
)
|
||||
.title("Clash Verge - performance replay")
|
||||
.inner_size(1000., 700.)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.always_on_top(true)
|
||||
.incognito(true)
|
||||
.build()?
|
||||
.set_ignore_cursor_events(true)?;
|
||||
Ok(())
|
||||
})
|
||||
.build(context);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const output = resolve(process.argv[2] ?? 'target/perf/build')
|
||||
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
const command = (cmd, args) =>
|
||||
execFileSync(cmd, args, { encoding: 'utf8' }).trim()
|
||||
const source = () =>
|
||||
command('git', ['ls-files', '-co', '--exclude-standard'])
|
||||
.split('\n')
|
||||
.filter(existsSync)
|
||||
.filter(
|
||||
(path) =>
|
||||
!path.startsWith('scripts/perf/') ||
|
||||
/\.(rs|tsx|html|mts|json)$/.test(path),
|
||||
)
|
||||
.sort()
|
||||
.map((path) => `${path}\0${hash(readFileSync(path))}`)
|
||||
.join('\n')
|
||||
mkdirSync(output, { recursive: true })
|
||||
const before = hash(source())
|
||||
const buildSettingsSha256 = hash(
|
||||
['Cargo.toml', '.cargo/config.toml']
|
||||
.filter(existsSync)
|
||||
.map((path) => `${path}\0${hash(readFileSync(path))}`)
|
||||
.join('\n'),
|
||||
)
|
||||
execFileSync(
|
||||
'pnpm',
|
||||
[
|
||||
'exec',
|
||||
'vite',
|
||||
'build',
|
||||
'--mode',
|
||||
'perf',
|
||||
'--config',
|
||||
'scripts/perf/vite.mts',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
const mode = 'release + perf-harness (build settings hash recorded)'
|
||||
const buildCommand =
|
||||
'TAURI_CONFIG=<scripts/perf/tauri.json> cargo build -p clash-verge --release --features perf-harness --locked --target-dir target/perf/cargo'
|
||||
execFileSync(
|
||||
'cargo',
|
||||
[
|
||||
'build',
|
||||
'-p',
|
||||
'clash-verge',
|
||||
'--release',
|
||||
'--features',
|
||||
'perf-harness',
|
||||
'--locked',
|
||||
'--target-dir',
|
||||
'target/perf/cargo',
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
TAURI_CONFIG: readFileSync('scripts/perf/tauri.json', 'utf8'),
|
||||
},
|
||||
},
|
||||
)
|
||||
if (before !== hash(source()))
|
||||
throw new Error('Source changed during build; refuse manifest')
|
||||
const binary = `${output}/clash-verge`
|
||||
copyFileSync('target/perf/cargo/release/clash-verge', binary)
|
||||
const files = command('find', ['target/perf/dist', '-type', 'f'])
|
||||
.split('\n')
|
||||
.sort()
|
||||
writeFileSync(
|
||||
`${binary}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
head: command('git', ['rev-parse', 'HEAD']),
|
||||
dirty: command('git', ['status', '--porcelain']),
|
||||
sourceSha256: before,
|
||||
binarySha256: hash(readFileSync(binary)),
|
||||
frontendSha256: hash(
|
||||
files.map((path) => `${path}\0${hash(readFileSync(path))}`).join('\n'),
|
||||
),
|
||||
mode,
|
||||
buildSettingsSha256,
|
||||
buildEnvironment: Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) =>
|
||||
/^(RUSTFLAGS|CARGO_ENCODED_RUSTFLAGS|CARGO_PROFILE_|RUSTC_WRAPPER)/.test(
|
||||
key,
|
||||
),
|
||||
),
|
||||
),
|
||||
buildCommand,
|
||||
binary,
|
||||
toolchain: `${command('rustc', ['--version'])}; node ${process.version}; pnpm ${command('pnpm', ['--version'])}`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
console.log(`Measurement binary and manifest: ${binary}`)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { compare } from './report.mjs'
|
||||
const args = process.argv.slice(2).filter((v) => v !== '--')
|
||||
if (args.length < 2)
|
||||
throw new Error(
|
||||
'Usage: pnpm perf:compare -- baseline/summary.json candidate/summary.json [noise-threshold-percent]',
|
||||
)
|
||||
const threshold = args[2] === undefined ? null : Number(args[2])
|
||||
if (threshold !== null && (!Number.isFinite(threshold) || threshold < 0))
|
||||
throw new Error(
|
||||
'threshold must be a nonnegative percentage justified by baseline noise',
|
||||
)
|
||||
function load(path) {
|
||||
if (statSync(path).isFile()) return JSON.parse(readFileSync(path))
|
||||
if (existsSync(join(path, 'summary.json')))
|
||||
return load(join(path, 'summary.json'))
|
||||
const reports = readdirSync(path)
|
||||
.sort()
|
||||
.map((name) => load(join(path, name)))
|
||||
const first = reports[0]
|
||||
if (
|
||||
!first ||
|
||||
reports.some(
|
||||
(r) =>
|
||||
r.build?.binarySha256 !== first.build?.binarySha256 ||
|
||||
['invalid', 'incomparable'].includes(compare(first, r).verdict),
|
||||
)
|
||||
)
|
||||
throw new Error('group contains mixed builds, contracts, or invalid runs')
|
||||
return { ...first, runs: reports.flatMap((r) => r.runs) }
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = compare(...args.slice(0, 2).map(load), threshold)
|
||||
} catch (error) {
|
||||
result = { verdict: 'invalid', reason: String(error) }
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
if (['invalid', 'incomparable'].includes(result.verdict)) process.exitCode = 2
|
||||
+49
-26
@@ -1,29 +1,52 @@
|
||||
import { layer, scenario, startReplay } from './replay'
|
||||
import { ThemeProvider, createTheme } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
if (scenario === 'traffic') {
|
||||
await import('./traffic')
|
||||
} else {
|
||||
const root = document.getElementById('root')!
|
||||
document.body.style.cssText = 'margin:0;font:14px -apple-system,sans-serif'
|
||||
root.style.cssText = 'width:1000px;height:700px;overflow:hidden'
|
||||
new MutationObserver(() => {
|
||||
document.documentElement.dataset.perfDraw = String(Date.now())
|
||||
}).observe(root, { childList: true, characterData: true, subtree: true })
|
||||
window.addEventListener('error', (event) => {
|
||||
document.documentElement.dataset.perfError = event.message
|
||||
})
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
document.documentElement.dataset.perfError = String(event.reason)
|
||||
})
|
||||
if (layer === 'dom') {
|
||||
const { mount } = await import('./minimal')
|
||||
mount(root)
|
||||
} else {
|
||||
if (layer === 'page') {
|
||||
const { installAdapter } = await import('./adapter')
|
||||
installAdapter()
|
||||
import { EnhancedCanvasTrafficGraph } from '../../src/components/home/enhanced-canvas-traffic-graph'
|
||||
import { useTrafficMonitorEnhanced } from '../../src/hooks/use-traffic-monitor'
|
||||
import { initializeLanguage } from '../../src/services/i18n'
|
||||
import { setPreloadConfig } from '../../src/services/preload'
|
||||
|
||||
setPreloadConfig({
|
||||
pause_render_traffic_stats_on_blur: false,
|
||||
} as IVergeConfig)
|
||||
await initializeLanguage('en')
|
||||
export function Replay() {
|
||||
const {
|
||||
graphData: { appendData },
|
||||
} = useTrafficMonitorEnhanced({ subscribe: false })
|
||||
useEffect(() => {
|
||||
let received = 0,
|
||||
previous = 0,
|
||||
gaps = 0
|
||||
const socket = new WebSocket(
|
||||
`ws://127.0.0.1:${new URLSearchParams(location.search).get('port')}`,
|
||||
)
|
||||
socket.onmessage = ({ data }) => {
|
||||
const point = JSON.parse(data)
|
||||
appendData(point)
|
||||
if (previous && point.sequence !== previous + 1) gaps++
|
||||
previous = point.sequence
|
||||
document.documentElement.dataset.perfReceived = String(++received)
|
||||
document.documentElement.dataset.perfGaps = String(gaps)
|
||||
document.documentElement.dataset.perfSourceTime = String(point.timestamp)
|
||||
}
|
||||
await import('./scenes')
|
||||
}
|
||||
startReplay()
|
||||
return () => socket.close()
|
||||
}, [appendData])
|
||||
return (
|
||||
<main
|
||||
aria-label="Home traffic graph replay"
|
||||
style={{ width: 920, height: 360, margin: 32 }}
|
||||
>
|
||||
<EnhancedCanvasTrafficGraph />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<Replay />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>,
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"configRoots": [
|
||||
"Library/Application Support/io.github.clash-verge-rev.clash-verge-rev",
|
||||
"Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev"
|
||||
],
|
||||
"configDirectories": ["profiles"],
|
||||
"configExtensions": [".yaml", ".yml", ".json", ".js"],
|
||||
"processNames": {
|
||||
"app": "clash-verge",
|
||||
"core": "verge-mihomo",
|
||||
"service": "clash-verge-service"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { scenario, subscribe } from './replay'
|
||||
|
||||
export function mount(root: HTMLElement) {
|
||||
const rows = Array.from({ length: 20 }, () => {
|
||||
const row = document.createElement('div')
|
||||
row.style.cssText = 'height:32px;white-space:nowrap;overflow:hidden'
|
||||
root.appendChild(row)
|
||||
return row
|
||||
})
|
||||
subscribe(({ payload }) => {
|
||||
const texts =
|
||||
scenario === 'logs'
|
||||
? (payload as ILogItem[]).map((log) => log.payload)
|
||||
: (payload as IConnections).connections.map(
|
||||
(connection) => connection.metadata.host,
|
||||
)
|
||||
rows.forEach((row, index) => {
|
||||
row.textContent = texts[index % texts.length] ?? ''
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface Frame {
|
||||
sequence: number
|
||||
timestamp: number
|
||||
payload: ILogItem[] | IConnections
|
||||
}
|
||||
export const params = new URLSearchParams(location.search)
|
||||
export const scenario = params.get('scenario') ?? 'traffic'
|
||||
export const layer = params.get('layer') ?? 'component'
|
||||
const listeners = new Set<(frame: Frame) => void>()
|
||||
export const subscribe = (listener: (frame: Frame) => void) => {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
export function startReplay() {
|
||||
const data = document.documentElement.dataset
|
||||
let previous = 0
|
||||
data.perfReceived = '0'
|
||||
data.perfGaps = '0'
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${params.get('port')}`)
|
||||
socket.onmessage = ({ data: raw }) => {
|
||||
const frame = JSON.parse(raw) as Frame
|
||||
if (frame.sequence !== previous + 1)
|
||||
data.perfGaps = String(Number(data.perfGaps) + 1)
|
||||
previous = frame.sequence
|
||||
data.perfReceived = String(previous)
|
||||
data.perfSourceTime = String(frame.timestamp)
|
||||
for (const listener of listeners) listener(frame)
|
||||
}
|
||||
socket.onerror = () => {
|
||||
data.perfError = 'replay socket failed'
|
||||
}
|
||||
socket.onclose = () => {
|
||||
data.perfError = 'replay socket closed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
export const SCHEMA = 'traffic-webview-v3'
|
||||
export const phases = ['foreground', 'hidden', 'restored']
|
||||
export const roles = ['webview', 'main']
|
||||
export const quantile = (values, p) => {
|
||||
const v = values.filter(Number.isFinite).sort((a, b) => a - b)
|
||||
if (!v.length) return null
|
||||
const index = (v.length - 1) * p
|
||||
return (
|
||||
v[Math.floor(index)] +
|
||||
(v[Math.ceil(index)] - v[Math.floor(index)]) * (index % 1)
|
||||
)
|
||||
}
|
||||
export function delta(previous, next) {
|
||||
if (
|
||||
!previous ||
|
||||
previous.pid !== next.pid ||
|
||||
previous.start !== next.start ||
|
||||
![previous.time, next.time, previous.cpu, next.cpu].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
next.time <= previous.time ||
|
||||
next.cpu < previous.cpu
|
||||
)
|
||||
return null
|
||||
return ((next.cpu - previous.cpu) / (next.time - previous.time)) * 100000
|
||||
}
|
||||
export function summarize(rows, seconds) {
|
||||
const output = {}
|
||||
for (const phase of phases)
|
||||
for (const role of roles) {
|
||||
const selected = rows.filter((r) => r.phase === phase && r.role === role)
|
||||
const samples = selected.map((r, i) => ({
|
||||
...r,
|
||||
percent: delta(selected[i - 1], r),
|
||||
cpuDelta: i ? r.cpu - selected[i - 1].cpu : null,
|
||||
}))
|
||||
const valid = samples.filter((r) => Number.isFinite(r.percent))
|
||||
const memory = samples.map((r) => r.rss)
|
||||
const span =
|
||||
samples.length > 1 ? samples.at(-1).time - samples[0].time : 0
|
||||
const continuous =
|
||||
samples.length > 1 &&
|
||||
samples.every(
|
||||
(r) =>
|
||||
[r.time, r.cpu, r.rss].every(Number.isFinite) &&
|
||||
Number.isInteger(r.pid) &&
|
||||
r.pid > 0 &&
|
||||
/^\d+$/.test(r.start) &&
|
||||
r.time >= 0 &&
|
||||
r.cpu >= 0 &&
|
||||
r.rss > 0,
|
||||
) &&
|
||||
samples.every(
|
||||
(r, i) =>
|
||||
!i ||
|
||||
(r.time > samples[i - 1].time &&
|
||||
r.time - samples[i - 1].time < 2500 &&
|
||||
r.cpu >= samples[i - 1].cpu &&
|
||||
r.pid === samples[i - 1].pid &&
|
||||
r.start === samples[i - 1].start),
|
||||
)
|
||||
output[`${phase}/${role}`] = {
|
||||
count: samples.length,
|
||||
cpuCount: valid.length,
|
||||
missing: Math.max(0, Math.floor(seconds) - samples.length),
|
||||
coverage: Math.min(1, samples.length / seconds),
|
||||
continuous,
|
||||
observedSeconds: span / 1000,
|
||||
intervalCoverage: Math.min(1, span / ((seconds - 1) * 1000)),
|
||||
cpuMean: null,
|
||||
cpuP50: quantile(
|
||||
valid.map((r) => r.percent),
|
||||
0.5,
|
||||
),
|
||||
cpuP95: quantile(
|
||||
valid.map((r) => r.percent),
|
||||
0.95,
|
||||
),
|
||||
cpuPeak: quantile(
|
||||
valid.map((r) => r.percent),
|
||||
1,
|
||||
),
|
||||
cpuSeconds: valid.length
|
||||
? valid.reduce((sum, r) => sum + r.cpuDelta, 0)
|
||||
: null,
|
||||
rssP50: quantile(memory, 0.5),
|
||||
rssP95: quantile(memory, 0.95),
|
||||
rssPeak: quantile(memory, 1),
|
||||
rssDelta: continuous ? samples.at(-1).rss - samples[0].rss : null,
|
||||
rssSlopeBytesPerSecond:
|
||||
span >= 60000 && continuous
|
||||
? (samples.at(-1).rss - samples[0].rss) / (span / 1000)
|
||||
: null,
|
||||
}
|
||||
output[`${phase}/${role}`].cpuMean =
|
||||
valid.length && span > 0
|
||||
? (output[`${phase}/${role}`].cpuSeconds / span) * 100000
|
||||
: null
|
||||
}
|
||||
return output
|
||||
}
|
||||
export function replayValues(sequence) {
|
||||
return {
|
||||
up: 100000 + (sequence % 20) * 10000,
|
||||
down: 500000 + (sequence % 30) * 20000,
|
||||
}
|
||||
}
|
||||
export const validVisibility = (s) =>
|
||||
typeof s.hidden === 'boolean' &&
|
||||
(s.phase === 'hidden'
|
||||
? s.visible === false && s.hidden
|
||||
: phases.includes(s.phase) && s.visible === true && !s.hidden)
|
||||
export const validObservation = (s) =>
|
||||
validWorkload(s) &&
|
||||
validVisibility(s) &&
|
||||
s.nativeFocused === false &&
|
||||
s.appActive === false &&
|
||||
s.top === true &&
|
||||
s.clickThrough === true &&
|
||||
[s.now, s.source, s.worker, s.draw].every(
|
||||
(v) => Number.isFinite(v) && v > 0,
|
||||
) &&
|
||||
s.now >= s.source &&
|
||||
s.now - s.source < 2500 &&
|
||||
s.now >= s.worker &&
|
||||
s.now - s.worker < 3500 &&
|
||||
s.now >= s.draw &&
|
||||
(s.phase === 'hidden' || s.now - s.draw < 2500)
|
||||
export function validWorkload(s) {
|
||||
if (!s) return false
|
||||
const expected = replayValues(s.points)
|
||||
return (
|
||||
Number.isInteger(s.received) &&
|
||||
Number.isInteger(s.points) &&
|
||||
s.points > 0 &&
|
||||
s.received >= s.points &&
|
||||
s.received - s.points < 60 &&
|
||||
s.lastUp === expected.up &&
|
||||
s.lastDown === expected.down &&
|
||||
s.gaps === 0
|
||||
)
|
||||
}
|
||||
export function compare(a, b, threshold = null) {
|
||||
const stats = new Map()
|
||||
const text = (value) => typeof value === 'string' && value.length > 0
|
||||
const object = (value) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
const expectedMarks = [
|
||||
'ready',
|
||||
...phases.flatMap((phase) => [phase, `${phase}:end`]),
|
||||
]
|
||||
const valid = (x) =>
|
||||
x?.schema === SCHEMA &&
|
||||
text(x?.harnessSha256) &&
|
||||
[
|
||||
'head',
|
||||
'sourceSha256',
|
||||
'binarySha256',
|
||||
'frontendSha256',
|
||||
'buildSettingsSha256',
|
||||
'mode',
|
||||
'buildCommand',
|
||||
'binary',
|
||||
'toolchain',
|
||||
].every((key) => text(x.build?.[key])) &&
|
||||
typeof x.build.dirty === 'string' &&
|
||||
object(x.build.buildEnvironment) &&
|
||||
[
|
||||
'method',
|
||||
'mode',
|
||||
'toolchain',
|
||||
'buildSettingsSha256',
|
||||
'platform',
|
||||
'machine',
|
||||
'arch',
|
||||
'host',
|
||||
'hardware',
|
||||
'power',
|
||||
'powerSource',
|
||||
'webkit',
|
||||
'replay',
|
||||
'automation',
|
||||
].every((key) => text(x.contract?.[key])) &&
|
||||
object(x.contract.buildEnvironment) &&
|
||||
x.build.buildSettingsSha256 === x.contract.buildSettingsSha256 &&
|
||||
[
|
||||
'warmup',
|
||||
'intervalMs',
|
||||
'transitionSeconds',
|
||||
'readinessAllowanceSeconds',
|
||||
].every((key) => Number.isFinite(x.contract[key]) && x.contract[key] > 0) &&
|
||||
x.contract.scenario === 'traffic' &&
|
||||
typeof x.contract.consoleLocked === 'boolean' &&
|
||||
x.contract.windowPolicy === 'nonactivating-top-click-through-v1' &&
|
||||
x.contract.replayMs === 200 &&
|
||||
x.contract.rangeMinutes === 10 &&
|
||||
['window', 'viewport'].every(
|
||||
(key) =>
|
||||
Array.isArray(x.contract[key]) &&
|
||||
x.contract[key].length === (key === 'window' ? 2 : 3) &&
|
||||
x.contract[key].every((v) => Number.isFinite(v) && v > 0),
|
||||
) &&
|
||||
Number.isFinite(x.contract?.seconds) &&
|
||||
x.contract.seconds >= 5 &&
|
||||
Array.isArray(x.contract.phaseTargets) &&
|
||||
x.contract.phaseTargets.length === phases.length &&
|
||||
x.contract.phaseTargets.every((n) => Number.isInteger(n) && n > 0) &&
|
||||
Array.isArray(x.runs) &&
|
||||
x.runs.length &&
|
||||
x.runs.every((r) => {
|
||||
if (
|
||||
!r?.ok ||
|
||||
!Array.isArray(r.samples) ||
|
||||
!r.samples.every(
|
||||
(s) => s && roles.includes(s.role) && phases.includes(s.phase),
|
||||
) ||
|
||||
!Array.isArray(r.marks) ||
|
||||
!r.marks.every((m) => m && typeof m.name === 'string')
|
||||
)
|
||||
return false
|
||||
stats.set(r, summarize(r.samples, x.contract.seconds))
|
||||
return (
|
||||
r.cleanup?.mainExited &&
|
||||
r.cleanup?.driverClosed &&
|
||||
r.cleanup?.webviewExited &&
|
||||
Array.isArray(r.consoleStates) &&
|
||||
r.consoleStates.length === expectedMarks.length &&
|
||||
r.consoleStates.every(
|
||||
(s, i) =>
|
||||
s &&
|
||||
s.name === (i ? expectedMarks[i] : 'initial') &&
|
||||
typeof s.locked === 'boolean' &&
|
||||
Number.isFinite(s.time) &&
|
||||
s.time > 0 &&
|
||||
(!i || s.time >= r.consoleStates[i - 1].time),
|
||||
) &&
|
||||
Number.isInteger(r.replay?.sent) &&
|
||||
r.replay.sent > 0 &&
|
||||
Number.isFinite(r.replay.maxLateMs) &&
|
||||
r.replay.maxLateMs >= 0 &&
|
||||
r.replay.maxLateMs < x.contract.replayMs &&
|
||||
!r.replay.error &&
|
||||
r.actualWarmupMs >= x.contract.warmup * 1000 &&
|
||||
Array.isArray(r.observations) &&
|
||||
r.observations.length > 0 &&
|
||||
r.observations.every(
|
||||
(s, i) =>
|
||||
validObservation(s) && (!i || s.now > r.observations[i - 1].now),
|
||||
) &&
|
||||
r.marks.length === expectedMarks.length &&
|
||||
r.marks.every(
|
||||
(m, i) =>
|
||||
m.name === expectedMarks[i] &&
|
||||
Number.isFinite(m.time) &&
|
||||
m.time > 0 &&
|
||||
(!i ||
|
||||
(validObservation({ ...m, phase: m.name.split(':')[0] }) &&
|
||||
/^[a-f0-9]{64}$/.test(m.pixelsSha256))) &&
|
||||
(!phases.includes(m.name) ||
|
||||
(m.target === x.contract.phaseTargets[phases.indexOf(m.name)] &&
|
||||
m.received === m.target)) &&
|
||||
(!i || m.time > r.marks[i - 1].time),
|
||||
) &&
|
||||
roles.every((role) =>
|
||||
r.samples
|
||||
.filter((s) => s.role === role)
|
||||
.every(
|
||||
(s, i, rows) =>
|
||||
!i ||
|
||||
(s.pid === rows[i - 1].pid &&
|
||||
s.start === rows[i - 1].start &&
|
||||
s.time > rows[i - 1].time &&
|
||||
s.cpu >= rows[i - 1].cpu &&
|
||||
phases.indexOf(s.phase) >= phases.indexOf(rows[i - 1].phase)),
|
||||
),
|
||||
) &&
|
||||
validStats(stats.get(r)) &&
|
||||
phases.every((phase) => {
|
||||
const start = r.marks.find((m) => m.name === phase)
|
||||
const end = r.marks.find((m) => m.name === `${phase}:end`)
|
||||
const observed = r.observations.filter((s) => s.phase === phase)
|
||||
return (
|
||||
start &&
|
||||
end &&
|
||||
observed.length > 0 &&
|
||||
[
|
||||
start.deadlineIndex,
|
||||
end.deadlineIndex,
|
||||
start.senderSent,
|
||||
end.senderSent,
|
||||
].every((n) => Number.isInteger(n) && n >= 0) &&
|
||||
end.deadlineIndex > start.deadlineIndex &&
|
||||
end.senderSent >= start.senderSent &&
|
||||
end.senderSent <= r.replay.sent &&
|
||||
start.senderSent >= start.received &&
|
||||
end.senderSent >= end.received &&
|
||||
Math.abs(
|
||||
end.received -
|
||||
start.received -
|
||||
(end.deadlineIndex - start.deadlineIndex),
|
||||
) <= 2 &&
|
||||
observed.every(
|
||||
(s) =>
|
||||
s.now >= start.now &&
|
||||
s.now <= end.now &&
|
||||
s.received >= start.received &&
|
||||
s.received <= end.received,
|
||||
) &&
|
||||
end.worker > start.worker &&
|
||||
(phase === 'hidden' ||
|
||||
(end.draw > start.draw &&
|
||||
end.pixelsSha256 !== start.pixelsSha256))
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
if (![a, b].every(valid))
|
||||
return { verdict: 'invalid', reason: 'failed, malformed or missing runs' }
|
||||
if (
|
||||
[a, b].some((x) =>
|
||||
x.runs.some((r) =>
|
||||
r.consoleStates.some((s) => s.locked !== x.contract.consoleLocked),
|
||||
),
|
||||
)
|
||||
)
|
||||
return {
|
||||
verdict: 'incomparable',
|
||||
reason: 'console lock state changed at observed boundaries',
|
||||
}
|
||||
if (
|
||||
!isDeepStrictEqual(a.contract, b.contract) ||
|
||||
['mode', 'buildCommand', 'toolchain', 'buildEnvironment'].some(
|
||||
(key) => !isDeepStrictEqual(a.build[key], b.build[key]),
|
||||
) ||
|
||||
a.harnessSha256 !== b.harnessSha256
|
||||
)
|
||||
return {
|
||||
verdict: 'incomparable',
|
||||
reason: 'harness, platform, build mode, workload or environment differ',
|
||||
}
|
||||
for (const phase of phases) {
|
||||
for (const name of [phase, `${phase}:end`]) {
|
||||
const sizes = [a, b].map((x) =>
|
||||
x.runs.map((r) => r.marks.find((m) => m.name === name)?.received),
|
||||
)
|
||||
if (!sizes.flat().every(Number.isFinite))
|
||||
return { verdict: 'invalid', reason: 'missing observed workload size' }
|
||||
if (
|
||||
Math.abs(quantile(sizes[0], 0.5) - quantile(sizes[1], 0.5)) > 2 ||
|
||||
sizes.some((s) => Math.max(...s) - Math.min(...s) > 2)
|
||||
)
|
||||
return {
|
||||
verdict: 'incomparable',
|
||||
reason: `${name}: input size varies by more than two replay points`,
|
||||
}
|
||||
}
|
||||
}
|
||||
const same = a.build.binarySha256 === b.build.binarySha256
|
||||
const enough = a.runs.length >= 3 && b.runs.length >= 3
|
||||
const metrics = {}
|
||||
for (const phase of phases)
|
||||
for (const role of roles)
|
||||
for (const metric of ['cpuMean', 'rssP50']) {
|
||||
const key = `${phase}/${role}`
|
||||
const av = a.runs.map((r) => stats.get(r)[key][metric])
|
||||
const bv = b.runs.map((r) => stats.get(r)[key][metric])
|
||||
const before = quantile(av, 0.5),
|
||||
after = quantile(bv, 0.5)
|
||||
const noise = Math.max(...av) - Math.min(...av)
|
||||
const candidateRange = Math.max(...bv) - Math.min(...bv)
|
||||
const overlap =
|
||||
Math.max(...av) >= Math.min(...bv) &&
|
||||
Math.max(...bv) >= Math.min(...av)
|
||||
metrics[`${key}/${metric}`] = {
|
||||
before,
|
||||
after,
|
||||
delta: after - before,
|
||||
baselineRange: noise,
|
||||
candidateRange,
|
||||
verdict:
|
||||
same ||
|
||||
!enough ||
|
||||
threshold === null ||
|
||||
!av.every(Number.isFinite) ||
|
||||
!bv.every(Number.isFinite) ||
|
||||
overlap ||
|
||||
Math.abs(after - before) <=
|
||||
Math.max(
|
||||
noise,
|
||||
candidateRange,
|
||||
(Math.abs(before) * threshold) / 100,
|
||||
)
|
||||
? 'inconclusive'
|
||||
: after < before
|
||||
? 'lower'
|
||||
: 'higher',
|
||||
}
|
||||
}
|
||||
return {
|
||||
verdict: 'inconclusive',
|
||||
kind: same ? 'A/A' : 'A/B',
|
||||
reason: same
|
||||
? 'same binary; natural variation only'
|
||||
: 'inspect independent-run metrics; no universal pass threshold',
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
export function markdown(report) {
|
||||
const lines = [
|
||||
'# Home traffic graph - replay measurement',
|
||||
'',
|
||||
`Success: ${report.runs.filter((r) => r.ok).length}/${report.runs.length}`,
|
||||
'',
|
||||
'CPU: one fully occupied core = 100%. Memory: process resident size bytes, not exclusive memory. Null = unavailable; first CPU observation has no delta. Slope requires 60 seconds of continuous observations.',
|
||||
'',
|
||||
'| Run / stage / role | CPU mean / P50 / P95 / peak (%) | CPU s | RSS P50 / P95 / peak / delta (bytes) | Samples / coverage |',
|
||||
'|---|---|---|---|---|',
|
||||
]
|
||||
for (const [i, run] of report.runs.entries()) {
|
||||
if (run.error) lines.push(`\nRun ${i + 1} failed: ${run.error}\n`)
|
||||
for (const [key, s] of Object.entries(run.stats))
|
||||
lines.push(
|
||||
`| ${i + 1}/${key} | ${[s.cpuMean, s.cpuP50, s.cpuP95, s.cpuPeak].map((v) => v?.toFixed(2) ?? 'unavailable').join(' / ')} | ${s.cpuSeconds?.toFixed(3) ?? 'unavailable'} | ${[s.rssP50, s.rssP95, s.rssPeak, s.rssDelta].map((v) => v ?? 'unavailable').join(' / ')} | ${s.count} / ${(s.coverage * 100).toFixed(1)}% |`,
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'Scope: loopback replay → public traffic append boundary → production Worker → homepage Canvas component. Mihomo and its network/subscription chain are not running. Shared GPU/network processes are unavailable and excluded. Process statistics do not identify function hotspots, component CPU or retained objects; memory growth is not proof of a leak.',
|
||||
'',
|
||||
'Next measurement: use WebKit CPU sampling for the highest-CPU foreground/restored phase; use Allocation/Heap captures for repeatable RSS growth. React Compiler is an untested local A/B candidate only.',
|
||||
)
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
export const validStats = (stats) =>
|
||||
Object.values(stats).every(
|
||||
(s) =>
|
||||
s.continuous &&
|
||||
s.coverage >= 0.8 &&
|
||||
s.intervalCoverage >= 0.8 &&
|
||||
s.cpuCount >= 3,
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
compare,
|
||||
delta,
|
||||
phases,
|
||||
roles,
|
||||
quantile,
|
||||
summarize,
|
||||
validWorkload,
|
||||
replayValues,
|
||||
SCHEMA,
|
||||
validVisibility,
|
||||
} from './report.mjs'
|
||||
const latest = (sequence) => {
|
||||
const { up, down } = replayValues(sequence)
|
||||
return { lastUp: up, lastDown: down }
|
||||
}
|
||||
const observation = (phase, now) => ({
|
||||
phase,
|
||||
now,
|
||||
source: now - 100,
|
||||
worker: now - 100,
|
||||
draw: now - 100,
|
||||
visible: phase !== 'hidden',
|
||||
hidden: phase === 'hidden',
|
||||
focused: false,
|
||||
nativeFocused: false,
|
||||
appActive: false,
|
||||
top: true,
|
||||
clickThrough: true,
|
||||
})
|
||||
const rows = phases.flatMap((phase, phaseIndex) =>
|
||||
roles.flatMap((role) =>
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
phase,
|
||||
role,
|
||||
pid: role === 'main' ? 1 : 2,
|
||||
start: '1',
|
||||
time: (phaseIndex * 5 + i) * 1000,
|
||||
cpu: (phaseIndex * 5 + i) / 10,
|
||||
rss: 100 + i,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const run = {
|
||||
ok: true,
|
||||
samples: rows,
|
||||
cleanup: { mainExited: true, driverClosed: true, webviewExited: true },
|
||||
replay: { sent: 130, maxLateMs: 10 },
|
||||
actualWarmupMs: 3000,
|
||||
observations: phases.map((phase, i) => ({
|
||||
...observation(phase, 2000 + i * 10000),
|
||||
received: 35 + i * 35,
|
||||
points: 34 + i * 35,
|
||||
gaps: 0,
|
||||
...latest(34 + i * 35),
|
||||
})),
|
||||
marks: [
|
||||
{ name: 'ready', time: 1 },
|
||||
...phases.flatMap((name, i) => [
|
||||
{
|
||||
name,
|
||||
target: 35 + i * 35,
|
||||
deadlineIndex: 35 + i * 35,
|
||||
senderSent: 35 + i * 35,
|
||||
received: 35 + i * 35,
|
||||
points: 34 + i * 35,
|
||||
...latest(34 + i * 35),
|
||||
gaps: 0,
|
||||
time: 1000 + i * 10000,
|
||||
...observation(name, 1000 + i * 10000),
|
||||
pixelsSha256: 'a'.repeat(64),
|
||||
},
|
||||
{
|
||||
name: `${name}:end`,
|
||||
deadlineIndex: 60 + i * 35,
|
||||
senderSent: 60 + i * 35,
|
||||
received: 60 + i * 35,
|
||||
points: 59 + i * 35,
|
||||
...latest(59 + i * 35),
|
||||
gaps: 0,
|
||||
time: 6000 + i * 10000,
|
||||
...observation(name, 6000 + i * 10000),
|
||||
pixelsSha256: 'b'.repeat(64),
|
||||
},
|
||||
]),
|
||||
],
|
||||
}
|
||||
run.consoleStates = run.marks.map((m, i) => ({
|
||||
name: i ? m.name : 'initial',
|
||||
time: m.time,
|
||||
locked: false,
|
||||
}))
|
||||
const baseline = {
|
||||
schema: SCHEMA,
|
||||
harnessSha256: 'fixture-harness',
|
||||
build: {
|
||||
head: 'fixture-head',
|
||||
dirty: '',
|
||||
sourceSha256: 'fixture-source',
|
||||
binarySha256: 'same',
|
||||
frontendSha256: 'fixture-assets',
|
||||
buildSettingsSha256: 'fixture-settings',
|
||||
mode: 'release',
|
||||
buildCommand: 'fixture-build',
|
||||
binary: '/fixture/app',
|
||||
toolchain: 'fixture-tools',
|
||||
buildEnvironment: {},
|
||||
},
|
||||
contract: {
|
||||
scenario: 'traffic',
|
||||
replayMs: 200,
|
||||
consoleLocked: false,
|
||||
windowPolicy: 'nonactivating-top-click-through-v1',
|
||||
seconds: 5,
|
||||
warmup: 2,
|
||||
phaseTargets: [35, 70, 105],
|
||||
transitionSeconds: 2,
|
||||
readinessAllowanceSeconds: 5,
|
||||
intervalMs: 1000,
|
||||
rangeMinutes: 10,
|
||||
window: [1000, 700],
|
||||
viewport: [1000, 668, 2],
|
||||
method: 'fixture-method',
|
||||
mode: 'release',
|
||||
toolchain: 'fixture-tools',
|
||||
buildSettingsSha256: 'fixture-settings',
|
||||
platform: 'macOS',
|
||||
machine: 'fixture-model',
|
||||
arch: 'arm64',
|
||||
host: 'fixture-host',
|
||||
hardware: 'fixture-hardware',
|
||||
power: 'fixture-power',
|
||||
powerSource: 'AC',
|
||||
webkit: 'fixture-webkit',
|
||||
replay: 'fixture-5Hz',
|
||||
automation: 'fixture-wdio',
|
||||
buildEnvironment: {},
|
||||
},
|
||||
runs: [run, run, run],
|
||||
}
|
||||
test('quantiles, CPU units, first observation and PID reuse', () => {
|
||||
assert.equal(quantile([0, 10, 20], 0.95), 19)
|
||||
assert.equal(delta(undefined, rows[0]), null)
|
||||
assert.equal(delta(rows[0], rows[1]), 10)
|
||||
assert.equal(delta(rows[0], { ...rows[1], start: '2' }), null)
|
||||
assert.equal(delta(rows[0], { ...rows[1], time: NaN }), null)
|
||||
assert.equal(summarize(rows, 5)['hidden/main'].cpuMean, 10)
|
||||
assert.equal(summarize([], 5)['hidden/main'].cpuSeconds, null)
|
||||
})
|
||||
test('report schema: A/A, failed, incomparable and corrupt samples', () => {
|
||||
assert.equal(compare(baseline, baseline).kind, 'A/A')
|
||||
assert.equal(
|
||||
compare(baseline, { ...baseline, schema: 'old' }).verdict,
|
||||
'invalid',
|
||||
)
|
||||
for (const change of [
|
||||
{ appActive: true },
|
||||
{ nativeFocused: true },
|
||||
{ top: false },
|
||||
{ top: undefined },
|
||||
{ clickThrough: false },
|
||||
{ clickThrough: undefined },
|
||||
{ hidden: true },
|
||||
{ worker: 3000 },
|
||||
{ now: 6000 },
|
||||
{ draw: 3000 },
|
||||
{ worker: null },
|
||||
]) {
|
||||
const invalid = structuredClone(baseline)
|
||||
Object.assign(invalid.runs[0].observations[0], change)
|
||||
assert.equal(compare(baseline, invalid).verdict, 'invalid')
|
||||
}
|
||||
for (const key of ['draw', 'pixelsSha256']) {
|
||||
const stalled = structuredClone(baseline)
|
||||
stalled.runs[0].marks[2][key] = stalled.runs[0].marks[1][key]
|
||||
assert.equal(compare(baseline, stalled).verdict, 'invalid')
|
||||
}
|
||||
const missingPhase = structuredClone(baseline)
|
||||
missingPhase.runs[0].observations.pop()
|
||||
assert.equal(compare(baseline, missingPhase).verdict, 'invalid')
|
||||
const consoleDrift = structuredClone(baseline)
|
||||
consoleDrift.runs[0].consoleStates[3].locked = true
|
||||
assert.equal(compare(baseline, consoleDrift).verdict, 'incomparable')
|
||||
consoleDrift.runs[0].consoleStates[3].locked = null
|
||||
assert.equal(compare(baseline, consoleDrift).verdict, 'invalid')
|
||||
assert.equal(
|
||||
validVisibility({ phase: 'restored', visible: true, hidden: true }),
|
||||
false,
|
||||
)
|
||||
const missedPeriod = structuredClone(baseline)
|
||||
missedPeriod.runs[0].replay.maxLateMs = 200
|
||||
assert.equal(compare(baseline, missedPeriod).verdict, 'invalid')
|
||||
const delayed = {
|
||||
received: 100,
|
||||
points: 97,
|
||||
gaps: 0,
|
||||
lastUp: 270000,
|
||||
lastDown: 640000,
|
||||
}
|
||||
assert.equal(validWorkload(delayed), true)
|
||||
assert.equal(validWorkload(null), false)
|
||||
assert.equal(validWorkload({ ...delayed, points: 96 }), false)
|
||||
assert.equal(validWorkload({ ...delayed, points: 98 }), false)
|
||||
assert.equal(validWorkload({ ...delayed, received: 157 }), false)
|
||||
assert.equal(validWorkload({ ...delayed, lastDown: 660000 }), false)
|
||||
const missedAnchor = structuredClone(baseline)
|
||||
missedAnchor.runs[0].marks[1].received++
|
||||
assert.equal(compare(baseline, missedAnchor).verdict, 'invalid')
|
||||
const droppedInputs = structuredClone(baseline)
|
||||
const end = droppedInputs.runs[0].marks[2]
|
||||
end.received -= 10
|
||||
end.points -= 10
|
||||
Object.assign(end, latest(end.points))
|
||||
assert.equal(compare(droppedInputs, droppedInputs).verdict, 'invalid')
|
||||
const workerLag = structuredClone(baseline)
|
||||
workerLag.runs[0].observations[0].points -= 3
|
||||
assert.equal(compare(baseline, workerLag).verdict, 'invalid')
|
||||
const inputDrift = structuredClone(baseline)
|
||||
inputDrift.runs = baseline.runs.map((r) => structuredClone(r))
|
||||
inputDrift.runs[0].marks[2].received += 3
|
||||
inputDrift.runs[0].marks[2].points += 3
|
||||
inputDrift.runs[0].marks[2].deadlineIndex += 3
|
||||
inputDrift.runs[0].marks[2].senderSent += 3
|
||||
Object.assign(
|
||||
inputDrift.runs[0].marks[2],
|
||||
latest(inputDrift.runs[0].marks[2].points),
|
||||
)
|
||||
assert.equal(compare(baseline, inputDrift).verdict, 'incomparable')
|
||||
const snapshotPhase = structuredClone(baseline)
|
||||
snapshotPhase.runs[0].marks[2].points++
|
||||
Object.assign(
|
||||
snapshotPhase.runs[0].marks[2],
|
||||
latest(snapshotPhase.runs[0].marks[2].points),
|
||||
)
|
||||
assert.equal(compare(baseline, snapshotPhase).kind, 'A/A')
|
||||
const changedSettings = structuredClone(baseline)
|
||||
changedSettings.build.buildSettingsSha256 =
|
||||
changedSettings.contract.buildSettingsSha256 = 'changed-settings'
|
||||
assert.equal(compare(baseline, changedSettings).verdict, 'incomparable')
|
||||
assert.equal(
|
||||
compare(baseline, { ...baseline, harnessSha256: 'changed' }).verdict,
|
||||
'incomparable',
|
||||
)
|
||||
assert.equal(
|
||||
compare(baseline, {
|
||||
...baseline,
|
||||
contract: { ...baseline.contract, seconds: 6 },
|
||||
}).verdict,
|
||||
'incomparable',
|
||||
)
|
||||
assert.equal(
|
||||
compare(baseline, { ...baseline, runs: [{ ...run, ok: false }] }).verdict,
|
||||
'invalid',
|
||||
)
|
||||
const corrupt = structuredClone(baseline)
|
||||
corrupt.runs[0].samples[1].time = 0
|
||||
assert.equal(compare(baseline, corrupt).verdict, 'invalid')
|
||||
const shifted = structuredClone(baseline)
|
||||
shifted.runs[0].samples
|
||||
.filter((s) => s.phase === 'hidden')
|
||||
.forEach((s) => {
|
||||
s.time -= 5000
|
||||
})
|
||||
assert.equal(compare(baseline, shifted).verdict, 'invalid')
|
||||
const badMarks = structuredClone(baseline)
|
||||
badMarks.runs[0].marks.forEach((m) => {
|
||||
m.time = -1
|
||||
})
|
||||
assert.equal(compare(baseline, badMarks).verdict, 'invalid')
|
||||
const clustered = structuredClone(baseline)
|
||||
clustered.runs[0].samples.forEach((s) => {
|
||||
s.time /= 1000
|
||||
})
|
||||
assert.equal(compare(baseline, clustered).verdict, 'invalid')
|
||||
for (const [section, key] of [
|
||||
['contract', 'platform'],
|
||||
['build', 'sourceSha256'],
|
||||
]) {
|
||||
const missing = structuredClone(baseline)
|
||||
delete missing[section][key]
|
||||
assert.equal(compare(missing, missing).verdict, 'invalid')
|
||||
}
|
||||
assert.equal(
|
||||
compare(baseline, {
|
||||
...baseline,
|
||||
contract: { ...baseline.contract, platform: 'other-macOS' },
|
||||
}).verdict,
|
||||
'incomparable',
|
||||
)
|
||||
for (const malformed of [
|
||||
null,
|
||||
{},
|
||||
{ runs: [null] },
|
||||
{ ...baseline, runs: [{ ...run, samples: null }] },
|
||||
])
|
||||
assert.equal(compare(baseline, malformed).verdict, 'invalid')
|
||||
const noisy = structuredClone(baseline)
|
||||
noisy.runs = baseline.runs.map((r) => structuredClone(r))
|
||||
noisy.build.binarySha256 = 'different'
|
||||
noisy.runs.forEach((r, i) =>
|
||||
r.samples.forEach((s) => {
|
||||
s.cpu *= [0.1, 0.2, 10][i]
|
||||
}),
|
||||
)
|
||||
assert.equal(
|
||||
compare(baseline, noisy, 5).metrics['foreground/webview/cpuMean'].verdict,
|
||||
'inconclusive',
|
||||
)
|
||||
})
|
||||
test('macOS collector agrees with process.cpuUsage on a busy owned process', {
|
||||
skip: process.platform !== 'darwin',
|
||||
}, () => {
|
||||
mkdirSync('target/perf', { recursive: true })
|
||||
execFileSync('cc', [
|
||||
'-Wall',
|
||||
'-Wextra',
|
||||
'-Werror',
|
||||
'scripts/perf/sample.c',
|
||||
'-o',
|
||||
'target/perf/test-sample',
|
||||
])
|
||||
const sample = () =>
|
||||
Number(
|
||||
execFileSync('target/perf/test-sample', [String(process.pid)], {
|
||||
encoding: 'utf8',
|
||||
}).split(' ')[2],
|
||||
)
|
||||
const first = sample(),
|
||||
cpu = process.cpuUsage(),
|
||||
start = performance.now()
|
||||
while (performance.now() - start < 500) Math.sqrt(Math.random())
|
||||
const used = process.cpuUsage(cpu),
|
||||
measured = sample() - first
|
||||
assert.ok(Math.abs(measured - (used.user + used.system) / 1e6) < 0.05)
|
||||
})
|
||||
@@ -0,0 +1,674 @@
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import {
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
realpathSync,
|
||||
} from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { createTauriCapabilities } from '@wdio/tauri-service'
|
||||
import { remote } from 'webdriverio'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
import {
|
||||
delta,
|
||||
summarize,
|
||||
markdown,
|
||||
phases,
|
||||
roles,
|
||||
validStats,
|
||||
validWorkload,
|
||||
replayValues,
|
||||
SCHEMA,
|
||||
validObservation,
|
||||
validVisibility,
|
||||
} from './report.mjs'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2).filter((v) => v !== '--'),
|
||||
options: {
|
||||
output: { type: 'string', default: `target/perf/runs/${Date.now()}` },
|
||||
binary: { type: 'string', default: 'target/perf/build/clash-verge' },
|
||||
repeat: { type: 'string', default: '3' },
|
||||
seconds: { type: 'string', default: '20' },
|
||||
warmup: { type: 'string', default: '10' },
|
||||
'fail-phase': { type: 'string' },
|
||||
},
|
||||
})
|
||||
const seconds = Number(values.seconds),
|
||||
warmup = Number(values.warmup),
|
||||
repeat = Number(values.repeat)
|
||||
const replayMs = 200
|
||||
if (process.platform !== 'darwin') throw new Error('macOS only')
|
||||
function readConsole() {
|
||||
const consoleState = JSON.parse(
|
||||
execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], {
|
||||
input: execFileSync('/usr/sbin/ioreg', ['-n', 'Root', '-d1', '-a']),
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
)
|
||||
const locked = consoleState.IOConsoleLocked
|
||||
if (typeof locked !== 'boolean')
|
||||
throw new Error('Environment unavailable: unknown console lock state')
|
||||
return locked
|
||||
}
|
||||
function checkConsole() {
|
||||
const locked = readConsole()
|
||||
if (locked)
|
||||
throw new Error(
|
||||
'Environment unavailable: an unlocked graphical session is required; window focus is not required',
|
||||
)
|
||||
return locked
|
||||
}
|
||||
const consoleLocked = checkConsole()
|
||||
if (
|
||||
![seconds, warmup, repeat].every(Number.isFinite) ||
|
||||
seconds < 5 ||
|
||||
warmup < 2 ||
|
||||
repeat < 1 ||
|
||||
!Number.isInteger(repeat)
|
||||
)
|
||||
throw new Error('Require seconds >= 5, warmup >= 2, integer repeat >= 1')
|
||||
const phaseTargets = phases.map(
|
||||
(_, i) =>
|
||||
Math.ceil(((warmup + 5) * 1000) / replayMs) +
|
||||
i * Math.ceil(((seconds + 2) * 1000) / replayMs),
|
||||
)
|
||||
if ((phaseTargets[2] * replayMs) / 1000 + seconds + 5 >= 600)
|
||||
throw new Error(
|
||||
'Total replay including transition allowance must be < 10 minutes',
|
||||
)
|
||||
const binary = realpathSync(values.binary),
|
||||
output = resolve(values.output)
|
||||
const webcontentBinary = realpathSync(
|
||||
'/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent',
|
||||
)
|
||||
const build = JSON.parse(readFileSync(`${binary}.json`))
|
||||
const hash = (data) => createHash('sha256').update(data).digest('hex')
|
||||
if (hash(readFileSync(binary)) !== build.binarySha256)
|
||||
throw new Error('Binary does not match build manifest')
|
||||
try {
|
||||
execFileSync('git', ['check-ignore', '-q', '--no-index', output])
|
||||
} catch {
|
||||
throw new Error('Output directory must be Git ignored')
|
||||
}
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
mkdirSync(output)
|
||||
execFileSync('cc', ['-O2', 'scripts/perf/sample.c', '-o', `${output}/sample`])
|
||||
const command = (name, args) =>
|
||||
execFileSync(name, args, { encoding: 'utf8' }).trim()
|
||||
const report = {
|
||||
schema: SCHEMA,
|
||||
build,
|
||||
contract: {
|
||||
scenario: 'traffic',
|
||||
replayMs,
|
||||
consoleLocked,
|
||||
method: 'mach-rusage-v0-seconds/rss-bytes-v1',
|
||||
mode: build.mode,
|
||||
buildSettingsSha256: build.buildSettingsSha256,
|
||||
buildEnvironment: build.buildEnvironment,
|
||||
toolchain: build.toolchain,
|
||||
platform: command('sw_vers', []),
|
||||
machine: command('sysctl', ['-n', 'hw.model']),
|
||||
arch: process.arch,
|
||||
host: hash(command('scutil', ['--get', 'LocalHostName'])),
|
||||
hardware: command('sysctl', ['hw.ncpu', 'hw.memsize']),
|
||||
power: command('pmset', ['-g', 'custom']),
|
||||
powerSource: command('pmset', ['-g', 'batt']).split('\n')[0],
|
||||
webkit: command('defaults', [
|
||||
'read',
|
||||
'/System/Library/Frameworks/WebKit.framework/Resources/Info',
|
||||
'CFBundleVersion',
|
||||
]),
|
||||
seconds,
|
||||
warmup,
|
||||
phaseTargets,
|
||||
transitionSeconds: 2,
|
||||
readinessAllowanceSeconds: 5,
|
||||
intervalMs: 1000,
|
||||
window: [1000, 700],
|
||||
windowPolicy: 'nonactivating-top-click-through-v1',
|
||||
rangeMinutes: 10,
|
||||
replay:
|
||||
'deterministic-v1, 5Hz, no prefill, append boundary, default bezier, blur pause false',
|
||||
automation:
|
||||
'wdio plugin 1.3.0; 20Hz macOS runloop pump plus one WebDriver observation/second',
|
||||
},
|
||||
runs: [],
|
||||
}
|
||||
report.units = {
|
||||
time: 'monotonic milliseconds',
|
||||
cpu: 'cumulative CPU seconds',
|
||||
percent: 'one CPU core = 100%',
|
||||
rss: 'resident size bytes',
|
||||
start: 'Mach process start ticks',
|
||||
}
|
||||
report.monotonicOriginEpochMs = performance.timeOrigin
|
||||
report.harnessSha256 = hash(
|
||||
['run.mjs', 'report.mjs', 'sample.c']
|
||||
.map((p) => readFileSync(`scripts/perf/${p}`))
|
||||
.join('\n'),
|
||||
)
|
||||
let interrupted = false
|
||||
const abort = new AbortController()
|
||||
const pause = (ms) => sleep(ms, undefined, { signal: abort.signal })
|
||||
for (const signal of ['SIGINT', 'SIGTERM'])
|
||||
process.on(signal, () => {
|
||||
interrupted = true
|
||||
abort.abort()
|
||||
})
|
||||
const assert = (value, reason) => {
|
||||
if (!value) throw new Error(reason)
|
||||
}
|
||||
const drawn = (s) => s.now >= s.draw && s.now - s.draw < 2500
|
||||
const marker = (s) => ({
|
||||
...s,
|
||||
pixelsSha256: hash(s.pixels),
|
||||
pixels: undefined,
|
||||
ua: undefined,
|
||||
token: undefined,
|
||||
})
|
||||
async function freePort() {
|
||||
const server = createServer()
|
||||
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
||||
const port = server.address().port
|
||||
await new Promise((done) => server.close(done))
|
||||
return port
|
||||
}
|
||||
for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
|
||||
const initialLocked = checkConsole()
|
||||
const token = randomUUID(),
|
||||
port = await freePort()
|
||||
const run = {
|
||||
token,
|
||||
ok: false,
|
||||
samples: [],
|
||||
observations: [],
|
||||
consoleStates: [
|
||||
{ name: 'initial', locked: initialLocked, time: Date.now() },
|
||||
],
|
||||
marks: [],
|
||||
stats: {},
|
||||
cleanup: {},
|
||||
replay: { sent: 0, maxLateMs: 0 },
|
||||
}
|
||||
report.runs.push(run)
|
||||
const replay = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
||||
await new Promise((done) => replay.on('listening', done))
|
||||
let sequence = 0,
|
||||
replayStarted,
|
||||
timer
|
||||
replay.on('connection', () => {
|
||||
if (replayStarted !== undefined) {
|
||||
run.replay.error = 'unexpected replay reconnection'
|
||||
clearTimeout(timer)
|
||||
return
|
||||
}
|
||||
replayStarted = performance.now()
|
||||
timer = setTimeout(send, replayMs)
|
||||
})
|
||||
const deadlineIndex = () =>
|
||||
Math.floor((performance.now() - replayStarted) / replayMs)
|
||||
function send() {
|
||||
const deadline = replayStarted + (sequence + 1) * replayMs
|
||||
const late = performance.now() - deadline
|
||||
if (late < 0) {
|
||||
timer = setTimeout(send, Math.ceil(-late))
|
||||
return
|
||||
}
|
||||
run.replay.maxLateMs = Math.max(run.replay.maxLateMs, late)
|
||||
if (late >= replayMs) {
|
||||
run.replay.error = `replay source missed a complete ${replayMs} ms period`
|
||||
return
|
||||
}
|
||||
sequence++
|
||||
run.replay.sent = sequence
|
||||
const timestamp = Date.now()
|
||||
const point = JSON.stringify({
|
||||
sequence,
|
||||
timestamp,
|
||||
...replayValues(sequence),
|
||||
})
|
||||
for (const socket of replay.clients)
|
||||
if (socket.readyState === 1) socket.send(point)
|
||||
timer = setTimeout(
|
||||
send,
|
||||
Math.max(
|
||||
0,
|
||||
replayStarted + (sequence + 1) * replayMs - performance.now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
const log = openSync(`${output}/${iteration + 1}.log`, 'w')
|
||||
const child = spawn(binary, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
PERF_TOKEN: token,
|
||||
PERF_REPLAY_PORT: String(replay.address().port),
|
||||
TAURI_WEBDRIVER_PORT: String(port),
|
||||
},
|
||||
stdio: ['ignore', log, log],
|
||||
})
|
||||
closeSync(log)
|
||||
let browser, childError, previous, mainStart
|
||||
const identities = {}
|
||||
child.on('error', (error) => {
|
||||
childError = error
|
||||
})
|
||||
const exited = new Promise((done) => child.once('exit', done))
|
||||
const alive = () =>
|
||||
!childError && child.exitCode === null && child.signalCode === null
|
||||
const collect = (pids) =>
|
||||
command(`${output}/sample`, pids.map(String))
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const [pid, start, cpu, rss, ...path] = line.split(' ')
|
||||
return {
|
||||
pid: Number(pid),
|
||||
start,
|
||||
cpu: Number(cpu),
|
||||
rss: Number(rss),
|
||||
path: path.join(' '),
|
||||
time: performance.now(),
|
||||
}
|
||||
})
|
||||
const identify = (role, row) => {
|
||||
assert(
|
||||
row.path === (role === 'main' ? binary : webcontentBinary),
|
||||
`unexpected ${role} process executable: ${row.path}`,
|
||||
)
|
||||
assert(
|
||||
BigInt(row.start) >= BigInt(mainStart),
|
||||
'process predates owned application',
|
||||
)
|
||||
const identity = `${row.pid}/${row.start}`
|
||||
assert(
|
||||
!identities[role] || identities[role] === identity,
|
||||
'process identity changed during scenario',
|
||||
)
|
||||
identities[role] = identity
|
||||
}
|
||||
const state = async (action = 'state', pixels = false) =>
|
||||
browser.execute(
|
||||
async (action, pixels) => {
|
||||
const native = await window.__TAURI_INTERNALS__.invoke('perf_state', {
|
||||
action,
|
||||
})
|
||||
const canvas = document.querySelector('canvas')
|
||||
return {
|
||||
...native,
|
||||
now: Date.now(),
|
||||
hidden: document.hidden,
|
||||
focused: document.hasFocus(),
|
||||
received: Number(document.documentElement.dataset.perfReceived),
|
||||
source: Number(document.documentElement.dataset.perfSourceTime),
|
||||
gaps: Number(document.documentElement.dataset.perfGaps),
|
||||
worker: Number(document.documentElement.dataset.perfWorker),
|
||||
points: Number(document.documentElement.dataset.perfPoints),
|
||||
lastUp: Number(document.documentElement.dataset.perfLastUp),
|
||||
lastDown: Number(document.documentElement.dataset.perfLastDown),
|
||||
draw: Number(canvas?.dataset.perfDraw),
|
||||
pixels: pixels ? canvas?.toDataURL() : null,
|
||||
ua: navigator.userAgent,
|
||||
}
|
||||
},
|
||||
action,
|
||||
pixels,
|
||||
)
|
||||
const checked = async (action, pixels) => {
|
||||
assert(!interrupted, 'interrupted')
|
||||
assert(!run.replay.error, run.replay.error)
|
||||
assert(
|
||||
alive(),
|
||||
`owned app exited: ${childError ?? child.exitCode ?? child.signalCode}`,
|
||||
)
|
||||
const value = await state(action, pixels)
|
||||
assert(!run.replay.error, run.replay.error)
|
||||
run.lastObservation = { ...value, pixels: undefined, ua: undefined }
|
||||
assert(
|
||||
value.token === token && value.main === child.pid,
|
||||
'instance identity mismatch',
|
||||
)
|
||||
if (!identities.webview) {
|
||||
run.initialAttribution = collect([value.webview])[0]
|
||||
identify('webview', run.initialAttribution)
|
||||
}
|
||||
assert(
|
||||
value.nativeFocused === false && value.appActive === false,
|
||||
'measurement window unexpectedly activated',
|
||||
)
|
||||
assert(
|
||||
value.top === true && value.clickThrough === true,
|
||||
'measurement window must be natively topmost and click-through',
|
||||
)
|
||||
return value
|
||||
}
|
||||
try {
|
||||
mainStart = collect([child.pid])[0].start
|
||||
const deadline = Date.now() + 30000
|
||||
while (true) {
|
||||
assert(
|
||||
Date.now() < deadline && !interrupted && alive(),
|
||||
'embedded WebDriver readiness deadline or app exit',
|
||||
)
|
||||
try {
|
||||
if (
|
||||
(
|
||||
await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
).ok
|
||||
)
|
||||
break
|
||||
} catch {}
|
||||
await pause(100)
|
||||
}
|
||||
const listener = command('lsof', [
|
||||
'-nP',
|
||||
'-a',
|
||||
'-p',
|
||||
String(child.pid),
|
||||
`-iTCP:${port}`,
|
||||
'-sTCP:LISTEN',
|
||||
])
|
||||
assert(
|
||||
listener.includes(`127.0.0.1:${port}`) && !listener.includes(`*:${port}`),
|
||||
'driver must belong to this app and bind loopback',
|
||||
)
|
||||
const capabilities = createTauriCapabilities(binary)
|
||||
delete capabilities['wdio:tauriServiceOptions']
|
||||
browser = await remote({
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
capabilities,
|
||||
logLevel: 'error',
|
||||
connectionRetryCount: 0,
|
||||
connectionRetryTimeout: 5000,
|
||||
})
|
||||
const handles = await browser.getWindowHandles()
|
||||
assert(handles.length === 1, 'expected exactly one measurement WebView')
|
||||
await browser.switchToWindow(handles[0])
|
||||
// Replay's parent effect starts the socket after the graph installs its listeners.
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const s = await checked()
|
||||
return (
|
||||
validWorkload(s) &&
|
||||
s.received >= 3 &&
|
||||
s.now >= s.worker &&
|
||||
s.now - s.worker < 2500
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: 15000,
|
||||
interval: 100,
|
||||
timeoutMsg: 'traffic effects/Worker not ready before show',
|
||||
},
|
||||
)
|
||||
run.initialData = run.lastObservation
|
||||
await checked('show')
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
const s = await checked()
|
||||
return validObservation({ phase: 'foreground', ...s })
|
||||
},
|
||||
{
|
||||
timeout: 15000,
|
||||
interval: 250,
|
||||
timeoutMsg: 'traffic data path/page not ready',
|
||||
},
|
||||
)
|
||||
run.userAgent = (await checked()).ua
|
||||
run.backgroundLoad = command('sysctl', ['-n', 'vm.loadavg'])
|
||||
const viewport = await browser.execute(() => [
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
devicePixelRatio,
|
||||
])
|
||||
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
|
||||
const driverScale = run.viewport.driver.width / 1000
|
||||
assert(
|
||||
viewport[0] === 1000 &&
|
||||
viewport[1] > 0 &&
|
||||
viewport[1] <= 700 &&
|
||||
(driverScale === 1 || driverScale === viewport[2]) &&
|
||||
run.viewport.driver.height / driverScale === 700,
|
||||
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
|
||||
)
|
||||
assert(
|
||||
!report.contract.viewport ||
|
||||
JSON.stringify(report.contract.viewport) === JSON.stringify(viewport),
|
||||
'viewport changed between runs',
|
||||
)
|
||||
report.contract.viewport = viewport
|
||||
run.marks.push({ name: 'ready', time: Date.now() })
|
||||
const readyAt = performance.now()
|
||||
assert(
|
||||
phaseTargets[0] - (await checked()).received >=
|
||||
(warmup * 1000) / replayMs,
|
||||
'readiness exceeded fixed replay allowance',
|
||||
)
|
||||
for (const phase of phases) {
|
||||
run.consoleStates.push({
|
||||
name: phase,
|
||||
locked: readConsole(),
|
||||
time: Date.now(),
|
||||
})
|
||||
const operation = performance.now()
|
||||
let s = await checked(phase === 'hidden' ? 'hide' : 'show', true)
|
||||
assert(
|
||||
s.visible === (phase !== 'hidden'),
|
||||
`${phase}: native visibility mismatch`,
|
||||
)
|
||||
const beforeDraw = s.draw
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
s = await checked('state', true)
|
||||
return (
|
||||
validVisibility({ phase, ...s }) &&
|
||||
(phase === 'hidden' || (s.draw > beforeDraw && drawn(s)))
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
interval: 100,
|
||||
timeoutMsg: `${phase}: window/Canvas readiness deadline`,
|
||||
},
|
||||
)
|
||||
const operationMs = performance.now() - operation
|
||||
const target = phaseTargets[phases.indexOf(phase)]
|
||||
await browser.waitUntil(
|
||||
async () => {
|
||||
s = await checked('state', true)
|
||||
assert(
|
||||
s.received <= target,
|
||||
`${phase}: missed replay anchor ${target}`,
|
||||
)
|
||||
return s.received === target
|
||||
},
|
||||
{
|
||||
timeout: Math.max(1000, (target - s.received) * replayMs + 1000),
|
||||
interval: 50,
|
||||
timeoutMsg: `${phase}: replay anchor deadline`,
|
||||
},
|
||||
)
|
||||
assert(
|
||||
validObservation({ phase, ...s }),
|
||||
`${phase}: invalid traffic observation`,
|
||||
)
|
||||
const first = s,
|
||||
start = performance.now(),
|
||||
firstIndex = deadlineIndex(),
|
||||
phaseEpoch = Date.now()
|
||||
if (phase === 'foreground') {
|
||||
run.actualWarmupMs = start - readyAt
|
||||
assert(
|
||||
run.actualWarmupMs >= warmup * 1000,
|
||||
'insufficient actual warmup',
|
||||
)
|
||||
}
|
||||
run.marks.push({
|
||||
name: phase,
|
||||
time: phaseEpoch,
|
||||
operationMs,
|
||||
transitionMs: start - operation,
|
||||
target,
|
||||
...marker(s),
|
||||
deadlineIndex: firstIndex,
|
||||
senderSent: run.replay.sent,
|
||||
})
|
||||
previous = {}
|
||||
if (values['fail-phase'] === phase)
|
||||
throw new Error(`injected failure at ${phase}`)
|
||||
while (performance.now() - start < seconds * 1000) {
|
||||
const tick = performance.now()
|
||||
s = await checked()
|
||||
run.observations.push({ phase, ...run.lastObservation })
|
||||
assert(
|
||||
validObservation({ phase, ...s }),
|
||||
`${phase}: invalid visibility, workload or freshness`,
|
||||
)
|
||||
assert(
|
||||
performance.now() - replayStarted < 600000,
|
||||
'replay exceeded raw history window',
|
||||
)
|
||||
const samples = collect(roles.map((role) => s[role]))
|
||||
run.attribution = {
|
||||
main: s.main,
|
||||
webview: s.webview,
|
||||
observed: samples,
|
||||
}
|
||||
for (let i = 0; i < roles.length; i++) {
|
||||
const row = { ...samples[i], phase, role: roles[i] }
|
||||
identify(row.role, row)
|
||||
row.percent = delta(previous[row.role], row)
|
||||
row.cpuDelta =
|
||||
row.percent === null ? null : row.cpu - previous[row.role].cpu
|
||||
run.samples.push(row)
|
||||
previous[row.role] = row
|
||||
}
|
||||
await pause(Math.max(0, 1000 - (performance.now() - tick)))
|
||||
}
|
||||
s = await checked('state', true)
|
||||
const endIndex = deadlineIndex()
|
||||
const expected = endIndex - firstIndex
|
||||
assert(
|
||||
validObservation({ phase, ...s }) &&
|
||||
s.worker > first.worker &&
|
||||
Math.abs(s.received - first.received - expected) <= 2,
|
||||
`${phase}: unstable or lost workload`,
|
||||
)
|
||||
if (phase !== 'hidden')
|
||||
assert(
|
||||
s.draw > first.draw && s.pixels !== first.pixels,
|
||||
`${phase}: Canvas did not render fresh data`,
|
||||
)
|
||||
run.marks.push({
|
||||
name: `${phase}:end`,
|
||||
time: s.now,
|
||||
...marker(s),
|
||||
deadlineIndex: endIndex,
|
||||
senderSent: run.replay.sent,
|
||||
})
|
||||
run.consoleStates.push({
|
||||
name: `${phase}:end`,
|
||||
locked: readConsole(),
|
||||
time: Date.now(),
|
||||
})
|
||||
}
|
||||
run.stats = summarize(run.samples, seconds)
|
||||
assert(
|
||||
validStats(run.stats),
|
||||
'insufficient coverage or process discontinuity',
|
||||
)
|
||||
run.ok = true
|
||||
} catch (error) {
|
||||
run.error = String(error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await browser?.deleteSession().catch(() => {})
|
||||
try {
|
||||
if (alive() && collect([child.pid])[0].start === mainStart) {
|
||||
child.kill('SIGTERM')
|
||||
await Promise.race([exited, sleep(5000)])
|
||||
if (alive() && collect([child.pid])[0].start === mainStart) {
|
||||
child.kill('SIGKILL')
|
||||
await Promise.race([exited, sleep(2000)])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
run.cleanup.error = String(error)
|
||||
}
|
||||
clearTimeout(timer)
|
||||
for (const socket of replay.clients) socket.terminate()
|
||||
await new Promise((done) => replay.close(done))
|
||||
run.cleanup.mainExited =
|
||||
child.exitCode !== null || child.signalCode !== null
|
||||
if (identities.webview) {
|
||||
const [pid, start] = identities.webview.split('/')
|
||||
const remains = () => {
|
||||
try {
|
||||
return collect([pid])[0].start === start
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const deadline = Date.now() + 5000
|
||||
while (remains() && Date.now() < deadline) await sleep(100)
|
||||
run.cleanup.webviewExited = !remains()
|
||||
}
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/status`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
run.cleanup.driverClosed = false
|
||||
} catch {
|
||||
run.cleanup.driverClosed = true
|
||||
}
|
||||
if (
|
||||
!run.cleanup.mainExited ||
|
||||
!run.cleanup.driverClosed ||
|
||||
run.cleanup.webviewExited === false
|
||||
) {
|
||||
run.ok = false
|
||||
run.error = `${run.error ?? ''} cleanup failed`
|
||||
process.exitCode = 1
|
||||
}
|
||||
run.stats = summarize(run.samples, seconds)
|
||||
writeFileSync(
|
||||
`${output}/${iteration + 1}.json`,
|
||||
JSON.stringify(run, null, 2),
|
||||
)
|
||||
const columns = [
|
||||
'phase',
|
||||
'role',
|
||||
'time',
|
||||
'pid',
|
||||
'start',
|
||||
'cpu',
|
||||
'percent',
|
||||
'rss',
|
||||
]
|
||||
writeFileSync(
|
||||
`${output}/${iteration + 1}.csv`,
|
||||
[
|
||||
columns.join(','),
|
||||
...run.samples.map((r) =>
|
||||
columns.map((c) => r[c] ?? 'unavailable').join(','),
|
||||
),
|
||||
].join('\n'),
|
||||
)
|
||||
writeFileSync(`${output}/summary.json`, JSON.stringify(report, null, 2))
|
||||
writeFileSync(`${output}/report.md`, markdown(report))
|
||||
}
|
||||
console.log(`Run ${iteration + 1}: ${run.ok ? 'passed' : run.error}`)
|
||||
}
|
||||
console.log(`Reports: ${output}`)
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <libproc.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/resource.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
mach_timebase_info_data_t timebase;
|
||||
if (mach_timebase_info(&timebase) != KERN_SUCCESS) return 1;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
int pid = atoi(argv[i]);
|
||||
struct rusage_info_v0 r;
|
||||
char path[PROC_PIDPATHINFO_MAXSIZE];
|
||||
if (pid <= 0 || proc_pid_rusage(pid, RUSAGE_INFO_V0, (rusage_info_t *)&r)) return 1;
|
||||
if (proc_pidpath(pid, path, sizeof(path)) <= 0) return 1;
|
||||
printf("%d %llu %.9f %llu %s\n", pid, r.ri_proc_start_abstime,
|
||||
((double)r.ri_user_time + r.ri_system_time) * timebase.numer / timebase.denom / 1e9, r.ri_resident_size, path);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ThemeProvider, createTheme } from '@mui/material'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import { VirtualList } from '../../src/components/base/virtual-list'
|
||||
import { ConnectionRowItem } from '../../src/components/connection/connection-row-item'
|
||||
import { useConnectionRowViews } from '../../src/components/connection/connection-row-view'
|
||||
import LogItem from '../../src/components/log/log-item'
|
||||
import { initializeLanguage } from '../../src/services/i18n'
|
||||
import { setPreloadConfig } from '../../src/services/preload'
|
||||
|
||||
import { layer, scenario, subscribe } from './replay'
|
||||
import '../../src/assets/styles/page.scss'
|
||||
|
||||
const searchState = {
|
||||
text: '',
|
||||
matchCase: false,
|
||||
matchWholeWord: false,
|
||||
useRegularExpression: false,
|
||||
}
|
||||
export function Components() {
|
||||
const [logs, setLogs] = useState<ILogItem[]>([])
|
||||
const [connections, setConnections] = useState<IConnectionsItem[]>([])
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(({ payload }) => {
|
||||
if (scenario === 'logs')
|
||||
setLogs((previous) =>
|
||||
previous.concat(payload as ILogItem[]).slice(-1000),
|
||||
)
|
||||
else setConnections((payload as IConnections).connections)
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const rows = useConnectionRowViews(connections)
|
||||
return (
|
||||
<VirtualList
|
||||
count={scenario === 'logs' ? logs.length : rows.length}
|
||||
estimateSize={scenario === 'logs' ? 50 : 56}
|
||||
style={{ height: 640, margin: 24 }}
|
||||
renderItem={(index) =>
|
||||
scenario === 'logs' ? (
|
||||
<LogItem
|
||||
value={logs[logs.length - 1 - index]}
|
||||
searchState={searchState}
|
||||
/>
|
||||
) : (
|
||||
<ConnectionRowItem
|
||||
row={rows[index]}
|
||||
closed={false}
|
||||
onShowDetail={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
dayjs.extend(relativeTime)
|
||||
const root = document.getElementById('root')!
|
||||
setPreloadConfig({
|
||||
language: 'en',
|
||||
theme_mode: 'light',
|
||||
pause_render_traffic_stats_on_blur: false,
|
||||
} as IVergeConfig)
|
||||
await initializeLanguage('en')
|
||||
const Scene =
|
||||
layer === 'component'
|
||||
? Components
|
||||
: scenario === 'logs'
|
||||
? (await import('../../src/pages/logs')).default
|
||||
: (await import('../../src/pages/connections')).default
|
||||
createRoot(root).render(
|
||||
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<Scene />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>,
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ThemeProvider, createTheme } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import { EnhancedCanvasTrafficGraph } from '../../src/components/home/enhanced-canvas-traffic-graph'
|
||||
import { useTrafficMonitorEnhanced } from '../../src/hooks/use-traffic-monitor'
|
||||
import { initializeLanguage } from '../../src/services/i18n'
|
||||
import { setPreloadConfig } from '../../src/services/preload'
|
||||
|
||||
setPreloadConfig({
|
||||
pause_render_traffic_stats_on_blur: false,
|
||||
} as IVergeConfig)
|
||||
await initializeLanguage('en')
|
||||
export function Replay() {
|
||||
const {
|
||||
graphData: { appendData },
|
||||
} = useTrafficMonitorEnhanced({ subscribe: false })
|
||||
useEffect(() => {
|
||||
let received = 0,
|
||||
previous = 0,
|
||||
gaps = 0
|
||||
const socket = new WebSocket(
|
||||
`ws://127.0.0.1:${new URLSearchParams(location.search).get('port')}`,
|
||||
)
|
||||
socket.onmessage = ({ data }) => {
|
||||
const point = JSON.parse(data)
|
||||
appendData(point)
|
||||
if (previous && point.sequence !== previous + 1) gaps++
|
||||
previous = point.sequence
|
||||
document.documentElement.dataset.perfReceived = String(++received)
|
||||
document.documentElement.dataset.perfGaps = String(gaps)
|
||||
document.documentElement.dataset.perfSourceTime = String(point.timestamp)
|
||||
}
|
||||
return () => socket.close()
|
||||
}, [appendData])
|
||||
return (
|
||||
<main
|
||||
aria-label="Home traffic graph replay"
|
||||
style={{ width: 920, height: 360, margin: 32 }}
|
||||
>
|
||||
<EnhancedCanvasTrafficGraph />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
|
||||
<ThemeProvider theme={createTheme()}>
|
||||
<Replay />
|
||||
</ThemeProvider>
|
||||
</SWRConfig>,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["../../src", "./*.tsx", "./*.ts"]
|
||||
"include": ["../../src", "./*.tsx"]
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
smartstring = { workspace = true, features = ["serde"] }
|
||||
socket2 = { workspace = true }
|
||||
warp = { version = "0.4.3", features = ["server"] }
|
||||
warp = { version = "0.4.4", features = ["server"] }
|
||||
open = "5.4.5"
|
||||
rfd = { version = "0.18.0", default-features = false, features = ["common-controls-v6", "xdg-portal"] }
|
||||
dirs = "7.0.0"
|
||||
@@ -153,7 +153,7 @@ tauri-plugin-updater = "2.14.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
warp = { version = "0.4.3", features = ["test"] }
|
||||
warp = { version = "0.4.4", features = ["test"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
@@ -890,7 +878,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);
|
||||
|
||||
@@ -5,24 +5,10 @@ import { useCallback, useImperativeHandle, useState, type Ref } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { closeConnection } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
import {
|
||||
getConnectionRuleTargets,
|
||||
type ConnectionRuleKind,
|
||||
} from './connection-rule'
|
||||
import {
|
||||
ConnectionRuleDialog,
|
||||
type ConnectionRuleTarget,
|
||||
} from './connection-rule-dialog'
|
||||
|
||||
export interface ConnectionDetailRef {
|
||||
open: (
|
||||
detail: IConnectionsItem,
|
||||
closed: boolean,
|
||||
preferredKind?: ConnectionRuleKind,
|
||||
) => void
|
||||
open: (detail: IConnectionsItem, closed: boolean) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
@@ -30,12 +16,7 @@ export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<IConnectionsItem | null>(null)
|
||||
const [closed, setClosed] = useState(false)
|
||||
const [preferredKind, setPreferredKind] =
|
||||
useState<ConnectionRuleKind>('domain')
|
||||
const theme = useTheme()
|
||||
const [ruleTarget, setRuleTarget] = useState<ConnectionRuleTarget | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
@@ -44,90 +25,51 @@ export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: (
|
||||
detail: IConnectionsItem,
|
||||
closed: boolean,
|
||||
preferredKind = 'domain',
|
||||
) => {
|
||||
open: (detail: IConnectionsItem, closed: boolean) => {
|
||||
if (open) return
|
||||
setOpen(true)
|
||||
setDetail(detail)
|
||||
setClosed(closed)
|
||||
setPreferredKind(preferredKind)
|
||||
},
|
||||
close: onClose,
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'.MuiSnackbarContent-root': {
|
||||
width: '520px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
boxSizing: 'border-box',
|
||||
maxHeight: '480px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
'.MuiSnackbarContent-message': { width: '100%', minWidth: 0 },
|
||||
}}
|
||||
message={
|
||||
detail ? (
|
||||
<InnerConnectionDetail
|
||||
data={detail}
|
||||
closed={closed}
|
||||
preferredKind={preferredKind}
|
||||
onClose={onClose}
|
||||
onAddRule={(target) => {
|
||||
setRuleTarget(target)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{ruleTarget && (
|
||||
<ConnectionRuleDialog
|
||||
target={ruleTarget}
|
||||
onClose={() => setRuleTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
'.MuiSnackbarContent-root': {
|
||||
maxWidth: '520px',
|
||||
maxHeight: '480px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}}
|
||||
message={
|
||||
detail ? (
|
||||
<InnerConnectionDetail
|
||||
data={detail}
|
||||
closed={closed}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
data: IConnectionsItem
|
||||
closed: boolean
|
||||
preferredKind: ConnectionRuleKind
|
||||
onClose?: () => void
|
||||
onAddRule: (target: ConnectionRuleTarget) => void
|
||||
}
|
||||
|
||||
const InnerConnectionDetail = ({
|
||||
data,
|
||||
closed,
|
||||
preferredKind,
|
||||
onClose,
|
||||
onAddRule,
|
||||
}: InnerProps) => {
|
||||
const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { current } = useProfiles()
|
||||
const { metadata, rulePayload } = data
|
||||
const targets = getConnectionRuleTargets(metadata)
|
||||
const canAddRule = Boolean(
|
||||
current?.uid &&
|
||||
current.option?.rules &&
|
||||
(targets.domain ||
|
||||
targets.process ||
|
||||
targets.source ||
|
||||
targets.destination),
|
||||
)
|
||||
const theme = useTheme()
|
||||
const chains = [...data.chains].reverse().join(' / ')
|
||||
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule
|
||||
@@ -163,7 +105,7 @@ const InnerConnectionDetail = ({
|
||||
{ label: t('connections.components.fields.rule'), value: rule },
|
||||
{
|
||||
label: t('connections.components.fields.process'),
|
||||
value: `${metadata.process ?? ''}${metadata.processPath ? `(${metadata.processPath})` : ''}`,
|
||||
value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ''}`,
|
||||
},
|
||||
{
|
||||
label: t('connections.components.fields.time'),
|
||||
@@ -190,14 +132,7 @@ const InnerConnectionDetail = ({
|
||||
const onDelete = useLockFn(async () => closeConnection(data.id))
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
userSelect: 'text',
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ userSelect: 'text', color: theme.palette.text.secondary }}>
|
||||
{information.map((each) => (
|
||||
<div key={each.label}>
|
||||
<b>{each.label}</b>
|
||||
@@ -212,32 +147,8 @@ const InnerConnectionDetail = ({
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!canAddRule}
|
||||
onClick={() => {
|
||||
if (!current?.uid || !current.option?.rules) return
|
||||
onAddRule({
|
||||
metadata: { ...metadata },
|
||||
profileUid: current.uid,
|
||||
profileName: current.name || current.uid,
|
||||
rulesUid: current.option.rules,
|
||||
preferredKind,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('connections.ruleDialog.entry')}
|
||||
</Button>
|
||||
{!closed && (
|
||||
{!closed && (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
title={t('connections.components.actions.closeConnection')}
|
||||
@@ -248,18 +159,6 @@ const InnerConnectionDetail = ({
|
||||
>
|
||||
{t('connections.components.actions.closeConnection')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{!canAddRule && (
|
||||
<Box sx={{ mt: 1, typography: 'caption' }}>
|
||||
{t(
|
||||
targets.domain ||
|
||||
targets.process ||
|
||||
targets.source ||
|
||||
targets.destination
|
||||
? 'connections.ruleDialog.noProfile'
|
||||
: 'connections.ruleDialog.noMetadata',
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -7,12 +7,11 @@ import { closeConnection } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { RelativeTime } from './connection-relative-time'
|
||||
import type { ConnectionRowView } from './connection-row-view'
|
||||
import type { ConnectionRuleKind } from './connection-rule'
|
||||
|
||||
interface Props {
|
||||
row: ConnectionRowView
|
||||
closed: boolean
|
||||
onShowDetail: (id: string, preferredKind?: ConnectionRuleKind) => void
|
||||
onShowDetail: (id: string) => void
|
||||
}
|
||||
|
||||
const tagStyle = {
|
||||
@@ -87,17 +86,7 @@ export const ConnectionRowItem = memo(
|
||||
<div style={tagsStyle}>
|
||||
<span style={tagStyle}>{row.network}</span>
|
||||
<span style={tagStyle}>{row.type}</span>
|
||||
{row.process && (
|
||||
<span
|
||||
style={tagStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onShowDetail(row.id, 'process')
|
||||
}}
|
||||
>
|
||||
{row.process}
|
||||
</span>
|
||||
)}
|
||||
{row.process && <span style={tagStyle}>{row.process}</span>}
|
||||
{row.chains && <span style={tagStyle}>{row.chains}</span>}
|
||||
<span style={tagStyle}>
|
||||
<RelativeTime start={row.time} />
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
MenuItem,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
useClashConfigData,
|
||||
useProxiesData,
|
||||
} from '@/providers/app-data-context'
|
||||
import { getProfiles, readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
import {
|
||||
getConnectionRuleTargets,
|
||||
getConnectionRules,
|
||||
isConnectionRuleDomain,
|
||||
findConnectionRule,
|
||||
saveConnectionRule,
|
||||
type ConnectionRuleKind,
|
||||
} from './connection-rule'
|
||||
|
||||
export interface ConnectionRuleTarget {
|
||||
metadata: IConnectionsItem['metadata']
|
||||
profileUid: string
|
||||
profileName: string
|
||||
rulesUid: string
|
||||
preferredKind: ConnectionRuleKind
|
||||
}
|
||||
|
||||
function getInitialKind(target: ConnectionRuleTarget) {
|
||||
const targets = getConnectionRuleTargets(target.metadata)
|
||||
return targets[target.preferredKind]
|
||||
? target.preferredKind
|
||||
: ((['domain', 'process', 'source', 'destination'] as const).find(
|
||||
(kind) => targets[kind],
|
||||
) ?? 'domain')
|
||||
}
|
||||
|
||||
export function ConnectionRuleDialog({
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
target: ConnectionRuleTarget
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { proxyView } = useProxiesData()
|
||||
const { clashConfig } = useClashConfigData()
|
||||
const targets = getConnectionRuleTargets(target.metadata)
|
||||
const [kind, setKind] = useState<ConnectionRuleKind>(() =>
|
||||
getInitialKind(target),
|
||||
)
|
||||
const [policy, setPolicy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<'DOMAIN' | 'DOMAIN-SUFFIX'>('DOMAIN')
|
||||
const [domainValue, setDomainValue] = useState(targets.domain?.value ?? '')
|
||||
const [rules, setRules] = useState<string[] | null>(null)
|
||||
const [loadAttempt, setLoadAttempt] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const selected = targets[kind]
|
||||
const editingDomain = kind === 'domain' && scope === 'DOMAIN-SUFFIX'
|
||||
const invalidDomain =
|
||||
editingDomain &&
|
||||
!isConnectionRuleDomain(domainValue, targets.domain?.value ?? '')
|
||||
const condition = selected
|
||||
? {
|
||||
type: kind === 'domain' ? scope : selected.type,
|
||||
value: editingDomain ? domainValue.trim() : selected.value,
|
||||
}
|
||||
: undefined
|
||||
const existing =
|
||||
rules && condition && !invalidDomain
|
||||
? findConnectionRule(rules, condition)
|
||||
: undefined
|
||||
const modifying = Boolean(existing)
|
||||
const groups = proxyView
|
||||
? proxyView.groups.length || !proxyView.global
|
||||
? proxyView.groups
|
||||
: [proxyView.global]
|
||||
: []
|
||||
const policies = [
|
||||
...new Set(['DIRECT', 'REJECT', ...groups.map((group) => group.name)]),
|
||||
]
|
||||
|
||||
const kindLabels = {
|
||||
domain: t('connections.ruleDialog.domain'),
|
||||
process: t('connections.components.fields.process'),
|
||||
source: t('connections.ruleDialog.sourceIp'),
|
||||
destination: t('connections.ruleDialog.destinationIp'),
|
||||
}
|
||||
|
||||
const unavailablePolicy = Boolean(policy && !policies.includes(policy))
|
||||
const blocked = loading || saving || rules === null
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
readProfileFile(target.rulesUid)
|
||||
.then((content) => {
|
||||
const loadedRules = getConnectionRules(content)
|
||||
const ruleTargets = getConnectionRuleTargets(target.metadata)
|
||||
const initialKind = getInitialKind(target)
|
||||
const selectedTarget = ruleTargets[initialKind]
|
||||
const initial = selectedTarget
|
||||
? (findConnectionRule(loadedRules, selectedTarget) ??
|
||||
(initialKind === 'domain'
|
||||
? findConnectionRule(loadedRules, {
|
||||
...selectedTarget,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined))
|
||||
: undefined
|
||||
if (cancelled) return
|
||||
setRules(loadedRules)
|
||||
setPolicy(initial?.policy ?? null)
|
||||
setScope(initial?.type === 'DOMAIN-SUFFIX' ? 'DOMAIN-SUFFIX' : 'DOMAIN')
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [target, loadAttempt])
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
if (
|
||||
blocked ||
|
||||
!condition ||
|
||||
invalidDomain ||
|
||||
!policy ||
|
||||
!policies.includes(policy)
|
||||
)
|
||||
return
|
||||
if (/[\r\n,]/u.test(condition.value) || /[\r\n,]/u.test(policy)) {
|
||||
setError(t('connections.ruleDialog.unsupportedValue'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await mutate(
|
||||
async () => {
|
||||
const checkProfile = async () => {
|
||||
const profiles = await getProfiles()
|
||||
const current = profiles.items?.find(
|
||||
(item) => item.uid === profiles.current,
|
||||
)
|
||||
if (
|
||||
profiles.current !== target.profileUid ||
|
||||
current?.option?.rules !== target.rulesUid
|
||||
) {
|
||||
throw new Error(t('connections.ruleDialog.profileChanged'))
|
||||
}
|
||||
}
|
||||
await checkProfile()
|
||||
const content = await readProfileFile(target.rulesUid)
|
||||
const updated = saveConnectionRule(content, condition, policy)
|
||||
await checkProfile()
|
||||
return saveProfileFile(target.rulesUid, updated)
|
||||
},
|
||||
{ id: `save-profile-file:${target.rulesUid}`, errorNotice: false },
|
||||
)
|
||||
if (!result.ok || !result.value) {
|
||||
setError(t('connections.ruleDialog.applyFailed'))
|
||||
return
|
||||
}
|
||||
showNotice.success('connections.ruleDialog.saved')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>
|
||||
{t(
|
||||
modifying
|
||||
? 'connections.ruleDialog.modifyTitle'
|
||||
: 'connections.ruleDialog.title',
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('connections.ruleDialog.matchBy')}
|
||||
value={kind}
|
||||
disabled={blocked}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value as ConnectionRuleKind
|
||||
const nextTarget = targets[next]
|
||||
const rule =
|
||||
rules && nextTarget
|
||||
? (findConnectionRule(rules, nextTarget) ??
|
||||
(next === 'domain'
|
||||
? findConnectionRule(rules, {
|
||||
...nextTarget,
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
})
|
||||
: undefined))
|
||||
: undefined
|
||||
setDomainValue(targets.domain?.value ?? '')
|
||||
setKind(next)
|
||||
setPolicy(rule?.policy ?? null)
|
||||
setScope(
|
||||
rule?.type === 'DOMAIN-SUFFIX' ? 'DOMAIN-SUFFIX' : 'DOMAIN',
|
||||
)
|
||||
}}
|
||||
>
|
||||
{(['domain', 'process', 'source', 'destination'] as const).map(
|
||||
(option) => (
|
||||
<MenuItem
|
||||
key={option}
|
||||
value={option}
|
||||
disabled={!targets[option]}
|
||||
>
|
||||
{kindLabels[option]}
|
||||
{!targets[option] &&
|
||||
` · ${t('connections.ruleDialog.unavailable')}`}
|
||||
</MenuItem>
|
||||
),
|
||||
)}
|
||||
</TextField>
|
||||
{kind === 'process' ? (
|
||||
<>
|
||||
{target.metadata.process?.trim() && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={kindLabels.process}
|
||||
value={target.metadata.process}
|
||||
slotProps={{
|
||||
input: { readOnly: true },
|
||||
htmlInput: { title: target.metadata.process },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected?.type === 'PROCESS-PATH' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={t('connections.ruleDialog.fullPath')}
|
||||
value={selected.value}
|
||||
slotProps={{
|
||||
input: { readOnly: true },
|
||||
htmlInput: { title: selected.value },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label={
|
||||
editingDomain
|
||||
? t('connections.ruleDialog.matchingDomain')
|
||||
: kindLabels[kind]
|
||||
}
|
||||
value={editingDomain ? domainValue : (selected?.value ?? '')}
|
||||
disabled={editingDomain && blocked}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setDomainValue(value)
|
||||
const match =
|
||||
rules &&
|
||||
isConnectionRuleDomain(value, targets.domain?.value ?? '')
|
||||
? findConnectionRule(rules, {
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
value: value.trim(),
|
||||
})
|
||||
: undefined
|
||||
if (match) setPolicy(match.policy)
|
||||
}}
|
||||
error={invalidDomain}
|
||||
helperText={
|
||||
editingDomain ? (
|
||||
<>
|
||||
{t('connections.ruleDialog.connectionHost', {
|
||||
host: targets.domain?.value,
|
||||
})}
|
||||
{invalidDomain && (
|
||||
<>
|
||||
<br />
|
||||
{t('connections.ruleDialog.invalidDomain')}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
slotProps={{
|
||||
input: { readOnly: !editingDomain },
|
||||
htmlInput: {
|
||||
title: editingDomain ? domainValue : selected?.value,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{kind === 'domain' && (
|
||||
<RadioGroup
|
||||
row
|
||||
aria-label={t('connections.ruleDialog.domainScope')}
|
||||
value={scope}
|
||||
onChange={(_, value) => {
|
||||
const nextScope = value as 'DOMAIN' | 'DOMAIN-SUFFIX'
|
||||
setScope(nextScope)
|
||||
setDomainValue(targets.domain?.value ?? '')
|
||||
const match =
|
||||
rules && targets.domain
|
||||
? findConnectionRule(rules, {
|
||||
...targets.domain,
|
||||
type: nextScope,
|
||||
})
|
||||
: undefined
|
||||
setPolicy(match?.policy ?? null)
|
||||
}}
|
||||
sx={{ mt: 0.5 }}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="DOMAIN"
|
||||
control={<Radio size="small" />}
|
||||
disabled={blocked}
|
||||
label={t('connections.ruleDialog.exactDomain')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="DOMAIN-SUFFIX"
|
||||
control={<Radio size="small" />}
|
||||
disabled={blocked}
|
||||
label={t('connections.ruleDialog.allSubdomains')}
|
||||
/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
{(kind === 'source' || kind === 'destination') && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{t(
|
||||
kind === 'source'
|
||||
? 'connections.ruleDialog.sourceHint'
|
||||
: 'connections.ruleDialog.destinationHint',
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
{kind === 'process' && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{selected?.type === 'PROCESS-PATH'
|
||||
? t('connections.ruleDialog.processPathHint')
|
||||
: t('connections.ruleDialog.processNameHint')}
|
||||
</Typography>
|
||||
)}
|
||||
<Autocomplete
|
||||
size="small"
|
||||
options={
|
||||
unavailablePolicy && policy ? [policy, ...policies] : policies
|
||||
}
|
||||
getOptionLabel={(value) =>
|
||||
value === 'DIRECT'
|
||||
? t('proxies.components.enums.policies.DIRECT')
|
||||
: value === 'REJECT'
|
||||
? t('proxies.components.enums.policies.REJECT')
|
||||
: value
|
||||
}
|
||||
getOptionDisabled={(value) => !policies.includes(value)}
|
||||
value={policy}
|
||||
onChange={(_, value) => setPolicy(value)}
|
||||
disabled={blocked}
|
||||
loading={loading}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t('rules.modals.editor.form.labels.proxyPolicy')}
|
||||
margin="normal"
|
||||
error={unavailablePolicy}
|
||||
helperText={
|
||||
unavailablePolicy
|
||||
? t('connections.ruleDialog.policyUnavailable')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{clashConfig && clashConfig.mode !== 'rule' && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t('connections.ruleDialog.ruleModeHint')}
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="error">
|
||||
{error}
|
||||
{rules === null && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setLoadAttempt((attempt) => attempt + 1)
|
||||
}}
|
||||
>
|
||||
{t('shared.actions.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mt: 2, overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{t(
|
||||
modifying
|
||||
? 'connections.ruleDialog.editDestination'
|
||||
: 'connections.ruleDialog.destination',
|
||||
{
|
||||
name: target.profileName,
|
||||
},
|
||||
)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{t('connections.ruleDialog.newConnections')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={saving} onClick={onClose}>
|
||||
{t('shared.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
loading={saving}
|
||||
disabled={
|
||||
blocked ||
|
||||
!condition ||
|
||||
invalidDomain ||
|
||||
!policy ||
|
||||
!policies.includes(policy)
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t(
|
||||
modifying
|
||||
? 'connections.ruleDialog.saveAndApply'
|
||||
: 'connections.ruleDialog.addAndApply',
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { dump, load } from 'js-yaml'
|
||||
import isFQDN from 'validator/es/lib/isFQDN'
|
||||
import isIP from 'validator/es/lib/isIP'
|
||||
|
||||
export type ConnectionRuleKind = 'domain' | 'process' | 'source' | 'destination'
|
||||
|
||||
function getAddressRule(address: string | undefined, source: boolean) {
|
||||
const ip = address?.trim()
|
||||
if (!ip || !isIP(ip)) return undefined
|
||||
const ipv4 = isIP(ip, 4)
|
||||
return {
|
||||
type: source ? 'SRC-IP-CIDR' : ipv4 ? 'IP-CIDR' : 'IP-CIDR6',
|
||||
value: `${ip}/${ipv4 ? 32 : 128}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectionRuleTargets(
|
||||
metadata: Pick<
|
||||
IConnectionsItem['metadata'],
|
||||
| 'host'
|
||||
| 'process'
|
||||
| 'processPath'
|
||||
| 'sourceIP'
|
||||
| 'destinationIP'
|
||||
| 'remoteDestination'
|
||||
>,
|
||||
) {
|
||||
const host = metadata.host?.trim() ?? ''
|
||||
const path = metadata.processPath
|
||||
const name = metadata.process
|
||||
const domain =
|
||||
host && !isIP(host) && isFQDN(host, { require_tld: false })
|
||||
? { type: 'DOMAIN', value: host }
|
||||
: undefined
|
||||
const process = path?.trim()
|
||||
? { type: 'PROCESS-PATH', value: path }
|
||||
: name?.trim()
|
||||
? { type: 'PROCESS-NAME', value: name }
|
||||
: undefined
|
||||
const source = getAddressRule(metadata.sourceIP, true)
|
||||
const destination =
|
||||
getAddressRule(metadata.destinationIP, false) ??
|
||||
getAddressRule(metadata.remoteDestination, false)
|
||||
return { domain, process, source, destination }
|
||||
}
|
||||
|
||||
export function isConnectionRuleDomain(value: string, host: string) {
|
||||
const domain = value.trim()
|
||||
if (!domain || isIP(domain) || !isFQDN(domain, { require_tld: false }))
|
||||
return false
|
||||
const normalized = domain.toLowerCase()
|
||||
const current = host.toLowerCase()
|
||||
return (
|
||||
normalized === current ||
|
||||
(domain.split('.').length >= 2 && current.endsWith(`.${normalized}`))
|
||||
)
|
||||
}
|
||||
|
||||
function parseRuleExtension(content: string) {
|
||||
const parsed: unknown = load(content)
|
||||
if (
|
||||
parsed !== undefined &&
|
||||
parsed !== null &&
|
||||
(typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
Object.getPrototypeOf(parsed) !== Object.prototype)
|
||||
) {
|
||||
throw new Error('The rule extension must be a YAML mapping.')
|
||||
}
|
||||
const config = (parsed ?? {}) as Record<string, unknown>
|
||||
for (const key of ['prepend', 'append', 'delete']) {
|
||||
const sequence = config[key]
|
||||
if (
|
||||
sequence != null &&
|
||||
(!Array.isArray(sequence) ||
|
||||
sequence.some((item) => typeof item !== 'string'))
|
||||
) {
|
||||
throw new Error('The rule extension contains an invalid rule list.')
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
interface RuleCondition {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function matchesConnectionRule(raw: string, target: RuleCondition) {
|
||||
const parts = raw.split(',').map((part) => part.trim())
|
||||
const hasNoResolve =
|
||||
(target.type === 'IP-CIDR' || target.type === 'IP-CIDR6') &&
|
||||
parts.length === 4 &&
|
||||
parts[3] === 'no-resolve'
|
||||
if (parts.length !== 3 && !hasNoResolve) return false
|
||||
if (target.type === 'DOMAIN' || target.type === 'DOMAIN-SUFFIX') {
|
||||
return (
|
||||
parts[0] === target.type &&
|
||||
parts[1].toLowerCase() === target.value.toLowerCase()
|
||||
)
|
||||
}
|
||||
return parts[0] === target.type && parts[1] === target.value
|
||||
}
|
||||
|
||||
export function getConnectionRules(content: string): string[] {
|
||||
const config = parseRuleExtension(content)
|
||||
return [
|
||||
...((config.prepend ?? []) as string[]),
|
||||
...((config.append ?? []) as string[]),
|
||||
]
|
||||
}
|
||||
|
||||
export function findConnectionRule(rules: string[], target: RuleCondition) {
|
||||
for (const raw of rules) {
|
||||
if (matchesConnectionRule(raw, target)) {
|
||||
const [type, , policy] = raw.split(',').map((part) => part.trim())
|
||||
return { type, policy }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function saveConnectionRule(
|
||||
content: string,
|
||||
target: RuleCondition,
|
||||
policy: string,
|
||||
): string {
|
||||
const config = parseRuleExtension(content)
|
||||
const rule = `${target.type},${target.value},${policy}`
|
||||
let replaced = false
|
||||
for (const key of ['prepend', 'append']) {
|
||||
if (!Array.isArray(config[key])) continue
|
||||
config[key] = (config[key] as string[]).flatMap((raw) => {
|
||||
if (!matchesConnectionRule(raw, target)) return [raw]
|
||||
if (replaced) return []
|
||||
replaced = true
|
||||
return [raw.split(',').length === 4 ? `${rule},no-resolve` : rule]
|
||||
})
|
||||
}
|
||||
if (!replaced) {
|
||||
config.prepend = [rule, ...((config.prepend ?? []) as string[])]
|
||||
}
|
||||
return dump(config, { forceQuotes: true })
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getConnectionStartTime,
|
||||
getConnectionTypeLabel,
|
||||
} from './connection-row-view'
|
||||
import type { ConnectionRuleKind } from './connection-rule'
|
||||
|
||||
const ROW_HEIGHT = 40
|
||||
const RESIZE_HANDLE_WIDTH = 6
|
||||
@@ -248,7 +247,7 @@ const renderCell = (
|
||||
interface RowComponentProps {
|
||||
row: IConnectionsItem
|
||||
columns: DisplayColumn[]
|
||||
onShowDetail: (id: string, preferredKind?: ConnectionRuleKind) => void
|
||||
onShowDetail: (id: string) => void
|
||||
getSnapshot: (row: IConnectionsItem) => TableRowSnapshot
|
||||
borderColor: string
|
||||
virtualTop: number
|
||||
@@ -264,22 +263,7 @@ const RowComponent = memo(
|
||||
virtualTop,
|
||||
}: RowComponentProps) {
|
||||
const handleClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const field =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('[data-connection-field]')
|
||||
?.dataset.connectionField
|
||||
: undefined
|
||||
const preferredKind =
|
||||
field === 'process'
|
||||
? 'process'
|
||||
: field === 'source'
|
||||
? 'source'
|
||||
: field === 'remoteDestination'
|
||||
? 'destination'
|
||||
: 'domain'
|
||||
onShowDetail(row.id, preferredKind)
|
||||
},
|
||||
() => onShowDetail(row.id),
|
||||
[onShowDetail, row.id],
|
||||
)
|
||||
const snapshot = getSnapshot(row)
|
||||
@@ -301,7 +285,6 @@ const RowComponent = memo(
|
||||
{columns.map((column) => (
|
||||
<div
|
||||
key={column.field}
|
||||
data-connection-field={column.field}
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
flex: `0 0 ${column.size}px`,
|
||||
@@ -335,7 +318,7 @@ const RowComponent = memo(
|
||||
|
||||
interface Props {
|
||||
connections: IConnectionsItem[]
|
||||
onShowDetail: (id: string, preferredKind?: ConnectionRuleKind) => void
|
||||
onShowDetail: (id: string) => void
|
||||
columnManagerOpen: boolean
|
||||
onCloseColumnManager: () => void
|
||||
}
|
||||
@@ -350,8 +333,7 @@ export const ConnectionTable = (props: Props) => {
|
||||
const onShowDetailRef = useRef(rawOnShowDetail)
|
||||
onShowDetailRef.current = rawOnShowDetail
|
||||
const onShowDetail = useCallback(
|
||||
(id: string, preferredKind?: ConnectionRuleKind) =>
|
||||
onShowDetailRef.current(id, preferredKind),
|
||||
(id: string) => onShowDetailRef.current(id),
|
||||
[],
|
||||
)
|
||||
const { t } = useTranslation()
|
||||
@@ -407,7 +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,
|
||||
@@ -415,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,
|
||||
@@ -423,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,
|
||||
@@ -431,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,
|
||||
@@ -816,7 +798,6 @@ export const ConnectionTable = (props: Props) => {
|
||||
onClick={() => toggleSorting(column.field)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent:
|
||||
@@ -828,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) =>
|
||||
|
||||
@@ -463,12 +463,15 @@ export const ProxyChain = ({
|
||||
|
||||
localStorage.removeItem('proxy-chain-group')
|
||||
localStorage.removeItem('proxy-chain-exit-node')
|
||||
localStorage.removeItem('proxy-chain-items')
|
||||
|
||||
await mutate(() => closeAllConnections(), {
|
||||
id: 'close-all-connections',
|
||||
errorNotice: false,
|
||||
})
|
||||
await refreshProxy()
|
||||
|
||||
onUpdateChain([])
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from proxy chain:', error)
|
||||
alert(t('proxies.page.chain.disconnectFailed'))
|
||||
@@ -547,6 +550,7 @@ export const ProxyChain = ({
|
||||
mode,
|
||||
proxyView,
|
||||
selectedGroup,
|
||||
onUpdateChain,
|
||||
recordSelection,
|
||||
])
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
autoDetectInterface: true,
|
||||
dnsHijack: ['any:53'],
|
||||
strictRoute: false,
|
||||
mtu: 1500 as number | undefined,
|
||||
mtu: 1500,
|
||||
dnsMode: '',
|
||||
})
|
||||
|
||||
@@ -374,15 +374,14 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
sx={{ width: 250 }}
|
||||
value={values.mtu ?? ''}
|
||||
value={values.mtu}
|
||||
placeholder="1500"
|
||||
onChange={(e) => {
|
||||
const next = Number.parseInt(e.target.value, 10)
|
||||
onChange={(e) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
mtu: Number.isNaN(next) ? undefined : next,
|
||||
mtu: parseInt(e.target.value),
|
||||
}))
|
||||
}}
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "الأعمدة",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Spalten",
|
||||
"dragHandle": "Ziehpunkt"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Columns",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Columnas",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "ستونها",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Kolom",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "列",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "열",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Colunas",
|
||||
"dragHandle": "Alça de arrastar"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Столбцы",
|
||||
"dragHandle": "Маркер перетаскивания"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Sütunlar",
|
||||
"dragHandle": "Sürükleme tutamacı"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "Баганалар",
|
||||
"dragHandle": "Drag handle"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "列设置",
|
||||
"dragHandle": "拖拽控件"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "添加规则…",
|
||||
"matchBy": "匹配对象",
|
||||
"domain": "域名",
|
||||
"unavailable": "未获取",
|
||||
"destination": "添加到「{{name}}」的前置规则。",
|
||||
"newConnections": "新建立的连接将按更新后的配置匹配。",
|
||||
"processPathHint": "匹配此路径对应进程访问的所有目标。",
|
||||
"processNameHint": "匹配所有同名进程访问的所有目标。",
|
||||
"ruleModeHint": "规则分流在规则模式下生效。",
|
||||
"addAndApply": "添加并应用",
|
||||
"profileChanged": "当前订阅已切换,请关闭弹窗后重新添加规则。",
|
||||
"applyFailed": "规则未能应用,已保留当前选择。",
|
||||
"unsupportedValue": "此内容包含快捷规则不支持的字符。",
|
||||
"saved": "规则已保存,配置已应用。",
|
||||
"noMetadata": "此连接没有可用于设置规则的域名、进程或 IP 信息。",
|
||||
"noProfile": "当前没有可编辑规则扩展的活动订阅。",
|
||||
"entry": "设置规则…",
|
||||
"modifyTitle": "修改规则",
|
||||
"domainScope": "域名匹配范围",
|
||||
"exactDomain": "仅此域名",
|
||||
"allSubdomains": "此域名及所有子域名",
|
||||
"policyUnavailable": "此策略已不可用,请选择其他策略。",
|
||||
"editDestination": "修改「{{name}}」中的现有规则,保留排序。",
|
||||
"saveAndApply": "保存并应用",
|
||||
"sourceIp": "源 IP",
|
||||
"destinationIp": "目标 IP",
|
||||
"sourceHint": "匹配来自此 IP 的所有请求,可能包含其他应用,不限定端口。",
|
||||
"destinationHint": "匹配访问此目标 IP 的请求,覆盖所有域名,不限定端口。",
|
||||
"fullPath": "完整路径",
|
||||
"connectionHost": "当前连接域名:{{host}}",
|
||||
"invalidDomain": "请输入当前连接域名或其父域名,父域名至少包含两级。",
|
||||
"matchingDomain": "匹配域名"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,39 +30,5 @@
|
||||
"title": "欄位設定",
|
||||
"dragHandle": "拖曳以移動"
|
||||
}
|
||||
},
|
||||
"ruleDialog": {
|
||||
"title": "Add rule…",
|
||||
"matchBy": "Match by",
|
||||
"domain": "Domain",
|
||||
"unavailable": "Not available",
|
||||
"destination": "Add to the prepend rules of “{{name}}”.",
|
||||
"newConnections": "New connections will match the updated configuration.",
|
||||
"processPathHint": "Matches all destinations accessed by processes at this path.",
|
||||
"processNameHint": "Matches all destinations accessed by processes with this name.",
|
||||
"ruleModeHint": "Rule-based routing takes effect in Rule mode.",
|
||||
"addAndApply": "Add and apply",
|
||||
"profileChanged": "The active profile changed. Close this dialog and add the rule again.",
|
||||
"applyFailed": "The rule could not be applied. Your selection has been kept.",
|
||||
"unsupportedValue": "This value contains characters that cannot be used in a quick rule.",
|
||||
"saved": "Rule saved and configuration applied.",
|
||||
"noMetadata": "This connection has no domain, process or IP information for configuring a rule.",
|
||||
"noProfile": "No active profile with an editable rule extension is available.",
|
||||
"entry": "Configure rule…",
|
||||
"modifyTitle": "Modify rule",
|
||||
"domainScope": "Domain scope",
|
||||
"exactDomain": "Only this domain",
|
||||
"allSubdomains": "This domain and all its subdomains",
|
||||
"policyUnavailable": "This policy is no longer available. Choose another policy.",
|
||||
"editDestination": "Update the existing rule in “{{name}}”, keeping its position.",
|
||||
"saveAndApply": "Save and apply",
|
||||
"sourceIp": "Source IP",
|
||||
"destinationIp": "Destination IP",
|
||||
"sourceHint": "Matches all requests from this IP, including requests from other applications. No port filtering.",
|
||||
"destinationHint": "Matches this destination IP across all domains. No port filtering.",
|
||||
"fullPath": "Full path",
|
||||
"connectionHost": "Current connection domain: {{host}}",
|
||||
"invalidDomain": "Enter the current domain or a parent domain with at least two labels.",
|
||||
"matchingDomain": "Matching domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
getConnectionStartTime,
|
||||
useConnectionRowViews,
|
||||
} from '@/components/connection/connection-row-view'
|
||||
import type { ConnectionRuleKind } from '@/components/connection/connection-rule'
|
||||
import { ConnectionTable } from '@/components/connection/connection-table'
|
||||
import { useConnectionData } from '@/hooks/use-connection-data'
|
||||
import { useConnectionSetting } from '@/hooks/use-connection-setting'
|
||||
@@ -162,14 +161,10 @@ const ConnectionsPage = () => {
|
||||
)
|
||||
|
||||
const showDetailById = useCallback(
|
||||
(id: string, preferredKind: ConnectionRuleKind = 'domain') => {
|
||||
(id: string) => {
|
||||
const connection = filterConn.find((item) => item.id === id)
|
||||
if (connection) {
|
||||
detailRef.current?.open(
|
||||
connection,
|
||||
connectionsType === 'closed',
|
||||
preferredKind,
|
||||
)
|
||||
detailRef.current?.open(connection, connectionsType === 'closed')
|
||||
}
|
||||
},
|
||||
[connectionsType, filterConn],
|
||||
|
||||
@@ -22,38 +22,6 @@ export const translationKeys = [
|
||||
'connections.components.actions.closeConnection',
|
||||
'connections.components.columnManager.title',
|
||||
'connections.components.columnManager.dragHandle',
|
||||
'connections.ruleDialog.title',
|
||||
'connections.ruleDialog.matchBy',
|
||||
'connections.ruleDialog.domain',
|
||||
'connections.ruleDialog.unavailable',
|
||||
'connections.ruleDialog.destination',
|
||||
'connections.ruleDialog.newConnections',
|
||||
'connections.ruleDialog.processPathHint',
|
||||
'connections.ruleDialog.processNameHint',
|
||||
'connections.ruleDialog.ruleModeHint',
|
||||
'connections.ruleDialog.addAndApply',
|
||||
'connections.ruleDialog.profileChanged',
|
||||
'connections.ruleDialog.applyFailed',
|
||||
'connections.ruleDialog.unsupportedValue',
|
||||
'connections.ruleDialog.saved',
|
||||
'connections.ruleDialog.noMetadata',
|
||||
'connections.ruleDialog.noProfile',
|
||||
'connections.ruleDialog.entry',
|
||||
'connections.ruleDialog.modifyTitle',
|
||||
'connections.ruleDialog.domainScope',
|
||||
'connections.ruleDialog.exactDomain',
|
||||
'connections.ruleDialog.allSubdomains',
|
||||
'connections.ruleDialog.policyUnavailable',
|
||||
'connections.ruleDialog.editDestination',
|
||||
'connections.ruleDialog.saveAndApply',
|
||||
'connections.ruleDialog.sourceIp',
|
||||
'connections.ruleDialog.destinationIp',
|
||||
'connections.ruleDialog.sourceHint',
|
||||
'connections.ruleDialog.destinationHint',
|
||||
'connections.ruleDialog.fullPath',
|
||||
'connections.ruleDialog.connectionHost',
|
||||
'connections.ruleDialog.invalidDomain',
|
||||
'connections.ruleDialog.matchingDomain',
|
||||
'home.page.tooltips.lightweightMode',
|
||||
'home.page.tooltips.manual',
|
||||
'home.page.tooltips.settings',
|
||||
|
||||
@@ -36,40 +36,6 @@ export interface TranslationResources {
|
||||
page: {
|
||||
title: string
|
||||
}
|
||||
ruleDialog: {
|
||||
addAndApply: string
|
||||
allSubdomains: string
|
||||
applyFailed: string
|
||||
connectionHost: string
|
||||
destination: string
|
||||
destinationHint: string
|
||||
destinationIp: string
|
||||
domain: string
|
||||
domainScope: string
|
||||
editDestination: string
|
||||
entry: string
|
||||
exactDomain: string
|
||||
fullPath: string
|
||||
invalidDomain: string
|
||||
matchBy: string
|
||||
matchingDomain: string
|
||||
modifyTitle: string
|
||||
newConnections: string
|
||||
noMetadata: string
|
||||
noProfile: string
|
||||
policyUnavailable: string
|
||||
processNameHint: string
|
||||
processPathHint: string
|
||||
profileChanged: string
|
||||
ruleModeHint: string
|
||||
saveAndApply: string
|
||||
saved: string
|
||||
sourceHint: string
|
||||
sourceIp: string
|
||||
title: string
|
||||
unavailable: string
|
||||
unsupportedValue: string
|
||||
}
|
||||
}
|
||||
home: {
|
||||
components: {
|
||||
|
||||
Reference in New Issue
Block a user