Compare commits

..
Author SHA1 Message Date
renovate[bot] 7a28fb47bf chore(deps): update rust crate warp to 0.4.4 2026-11-14 00:41:29 +00:00
51 changed files with 3653 additions and 1995 deletions
-2
View File
@@ -68,8 +68,6 @@ the ownership evidence the AI-slop review evaluates (see
- For code review and verification of review fixes, follow [REVIEW.md](REVIEW.md).
Review is read-only unless changes are authorized; findings do not expand
that authorization to unrelated fixes, commits, pushes, or GitHub updates.
- For macOS frontend performance or WebView memory investigations, follow
[scripts/perf/AGENTS.md](scripts/perf/AGENTS.md).
- When handing work between agents or review rounds, use the
[AI context sharing guidance](REVIEW.md#ai-context-sharing).
- Read the relevant sections and linked module rules, expanding only when the
Generated
+20 -20
View File
@@ -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]]
-5
View File
@@ -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>
+1 -1
View File
@@ -14,7 +14,7 @@
"duplicates",
"cycles"
],
"ignoreBinaries": ["rustc"],
"ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"],
+9 -2
View File
@@ -36,7 +36,11 @@
"test": "vitest run",
"test:watch": "vitest",
"knip:check": "knip",
"knip:report": "knip --no-exit-code"
"knip:report": "knip --no-exit-code",
"perf:build": "node scripts/perf/build.mjs",
"perf:run": "node scripts/perf/run.mjs",
"perf:compare": "node scripts/perf/compare.mjs",
"perf:test": "node --test scripts/perf/report.test.mjs"
},
"dependencies": {
"@dnd-kit/collision": "^0.5.0",
@@ -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}": [
+1728 -4
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -50,7 +50,12 @@
},
{
"description": "Update Tauri npm packages and Cargo crates together so frontend and backend stay in sync",
"matchPackageNames": ["@tauri-apps/**", "tauri", "tauri-*"],
"matchPackageNames": [
"@tauri-apps/**",
"@wdio/tauri-service",
"tauri",
"tauri-*"
],
"groupName": "tauri",
"addLabels": ["tauri"]
},
-28
View File
@@ -1,28 +0,0 @@
# Product Measurement Adapter Guidelines
[GUIDE.md](GUIDE.md) defines the product adapter boundary and explicit tool invocation.
Keep real component imports, initialization and the isolated native entry here,
using product frontend/native build dependencies. Do not copy components into the
tool. Preserve perf build gates; ordinary builds must not require the external tool.
The adapter must not load or modify daily configuration or control the daily app,
Core, Service, TUN, DNS or system proxy. Preserve loopback isolation, owned-process
PID/start attribution, nonactivation, visibility/occlusion and source continuity.
Do not weaken gates or substitute Chrome/Vite for native WebView evidence. Label
JavaScript adapters separately from native plugin/IPC paths. Expected startup errors
must use the owned app's normal nonzero exit, never panic through Tauri setup.
Review adapter source changes, including initialization and native/build configuration,
for changed measurement meaning. Matching interfaces alone do not prove equivalence;
an adapter review cannot waive other acceptance conditions.
For implementation changes, run scoped ESLint, both perf TypeScript projects
(`tsconfig.json` and `tsconfig.node.json`), formatting and relevant production checks,
including Rust checks for native changes. Run scoped Rust Clippy before freezing
the optimized artifact. Check ordinary-build exclusion and application-entry prerequisites.
Follow the external AGENTS.md's
[measurement-validation requirements](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/AGENTS.md)
and README's
[validation order](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#validate-before-freezing-a-build).
These govern affected native coverage, comparison, failure cleanup and evidence reuse.
+132 -27
View File
@@ -1,35 +1,140 @@
# Product measurement adapter
# macOS Traffic Performance Harness
This directory owns the isolated native entry, real Traffic/Logs/Connections
imports, initialization, JavaScript socket adapter, replay receiver and minimal DOM
layer. These evolve with product APIs and use the product's React/Tauri/Wry
versions. Keep the explicit perf build gates; ordinary builds need no external tool.
This guide is the operating and maintenance contract for humans and coding agents.
The only scenario is the production home traffic graph in a dedicated, optimized
Tauri WKWebView. It measures foreground updates, native hide, then restoration.
It does not initialize the complete homepage or run Mihomo, Service or networking.
The external [clash-verge-rev-harness operating guide](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md)
owns running, sampling, reports, comparison and owned cleanup. Follow its
[first setup and installation instructions](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#build-and-run),
reusing an existing checkout. For subsequent calls, pass both absolute roots:
## Build And Run
Use this repository's pnpm, Rust and Xcode Command Line Tools on macOS. No global
WebDriver installation is needed. Keep each binary beside its generated manifest.
```sh
project=/absolute/path/to/clash-verge-rev
harness=/absolute/path/to/clash-verge-rev-harness
node "$harness/build.mjs" --project "$project" --output target/perf/build
caffeinate -di node "$harness/run.mjs" --project "$project" --binary target/perf/build/clash-verge --output target/perf/smoke --repeat 1 --seconds 5 --warmup 2
node "$harness/compare.mjs" "$project/target/perf/smoke" "$project/target/perf/smoke"
pnpm install --frozen-lockfile
pnpm perf:build target/perf/baseline-build
caffeinate -di pnpm perf:run -- --binary target/perf/baseline-build/clash-verge --output target/perf/baseline --repeat 3 --seconds 20 --warmup 10
pnpm perf:compare -- target/perf/baseline target/perf/baseline
pnpm perf:test
```
JavaScript socket replacement does not measure native plugin IPC. DOM mutation/text
hashes show view-input delivery, not painting or cache ownership. Adapter changes
require reviewing measurement meaning under the external validation requirements.
`perf:build [directory]` uses release optimization, Vite `--mode perf` and the
explicit Cargo `perf-harness` feature. It preserves the binary and records HEAD,
dirty state, compiled source, lockfile, frontend, build-settings and binary hashes.
Source changes during compilation reject the manifest. Each run also hashes the
external collector and runner. Ordinary builds exclude the driver and measurement
entry; debug assertions are not the gate. Do not edit compiled inputs during builds.
For virtual display mode, this adapter places only the hidden measurement window
on the exact owned NSScreen, checks complete visibleFrame containment, mode/Space
flags and nonactivation, and exits on drift. The tool owns helper lifecycle and topology
restoration; see its
[virtual display guide](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#optional-virtual-display).
Output directories must be new and Git ignored; use `target/perf/`. JSON/CSV keep
raw process samples, observations, phase markers, attribution and cleanup evidence.
`summary.json` and `report.md` provide distributions by independent run, phase and
process role. Failed runs exit nonzero and retain their evidence. The explicit
`traffic-webview-v3` schema rejects older reports instead of silently migrating them.
`isolation.json` owns the product configuration and process watch scope; keep it
aligned with product data directories and executable names. The tool owns
[command isolation snapshots](https://github.com/clash-verge-rev/clash-verge-rev-harness/blob/main/README.md#command-isolation-snapshots).
The adapter must not load daily configuration or control Core, Service, TUN, DNS
or system proxy.
## Isolation And Visibility
The dedicated measurement main bypasses normal initialization and shutdown. It
does not read or write daily configuration, subscriptions, proxy, TUN or Service.
It uses a distinct app identity and incognito WebView. Both automatic services
bind loopback and are owned by the current run; never kill processes by name.
The measurement window stays above ordinary windows and covers part of the screen,
without activation or mouse capture: clicks pass through to the application behind it.
Native topmost and click-through states are verified, while native focus and application
activation must remain false. WebDriver uses the embedded WebView directly; DOM focus
is recorded but not required. Daily windows are unchanged; the topmost window exits
with its owned measurement instance. You may keep using another application.
The console must be unlocked. Lock state is checked before startup and recorded
at phase boundaries; unknown state fails and observed drift rejects comparison.
Boundary checks do not provide continuous lock monitoring or unlock the computer.
No focus does not mean no visibility: shown stages require `document.hidden=false`,
fresh Worker data, advancing Canvas draw timestamps and changed Canvas pixels.
Topmost placement avoids ordinary-window occlusion; system overlays or another Space
can still pause WebKit. Such a run fails; do not weaken Canvas assertions to make it
pass. `caffeinate -di` lasts only for the command and cannot prevent deliberate lock.
Hidden stages still require continuous input and Worker data.
## Fixed Workload
The native window is 1000 x 700 logical pixels. Verify driver dimensions against
DPR and record the actual CSS viewport; titlebar space is excluded. The graph is
920 x 360 with a ten-minute range, default bezier style and real data retention.
The measurement preload sets blur pause false without changing production defaults.
Deterministic loopback WebSocket replay enters the public traffic append boundary,
then the production Worker and Canvas. It does not cover Mihomo or network traffic.
There is no history prefill. Absolute 200 ms deadlines sustain 5 Hz without cumulative
timer drift or catch-up bursts; missing a complete period fails the source.
Warmup is at least the requested duration with a five-second readiness allowance.
Fixed input anchors reserve two seconds for each native transition; measured stages
retain their full duration. Readiness uses observable state and deadlines, not sleep.
Before the first show, wait for actual replay input and Worker data so the graph's
visibility listeners are installed; then require fresh Canvas output after showing.
Total replay, including allowances, must remain below ten minutes for raw history.
Two-input endpoint variation is 400 ms; larger differences reject comparison.
Worker snapshots are asynchronous. Count/latest-value checks verify deterministic
upload/download values at the retained point count, with a joint period of 60 and
received-minus-retained count below 60. This detects net missing/duplicate points,
not every historical element or exactly cancelling losses and duplicates.
Source age must be below 2500 ms, Worker age below 3500 ms and visible draw age below
2500 ms. Future timestamps, lost sequence, process changes and failed restore fail.
## Metrics And Comparison
The native command obtains the actual WKWebView process ID using the private
`_webProcessIdentifier` selector, with availability checking. External collection
verifies the canonical system WebContent executable and process start identity.
It handles PID reuse and exits; unsupported ownership fails closed. Main and WebView
remain separate. Mihomo is not started; shared GPU/network attribution is unavailable.
CPU is cumulative process time converted from Mach ticks; interval percentages use
adjacent valid samples, with one occupied core equal to 100%. The first difference
is unavailable. Memory is RSS bytes, not physical footprint or exclusive memory.
Reports include CPU mean/P50/P95/peak/seconds, RSS median/P95/peak/delta, sample count
and time coverage. Memory slope requires at least 60 seconds of continuous samples.
RSS growth is not proof of a leak; process statistics are not function profiles.
The embedded driver adds a 20 Hz macOS runloop timer and observation overhead.
For A/B, build the candidate separately and alternate independent instances:
```sh
pnpm perf:build target/perf/candidate-build
for i in 1 2 3; do
caffeinate -di pnpm perf:run -- --binary target/perf/baseline-build/clash-verge --repeat 1 --output target/perf/ab-baseline/$i
caffeinate -di pnpm perf:run -- --binary target/perf/candidate-build/clash-verge --repeat 1 --output target/perf/ab-candidate/$i
done
pnpm perf:compare -- target/perf/ab-baseline target/perf/ab-candidate
pnpm perf:run -- --binary target/perf/candidate-build/clash-verge --repeat 1 --seconds 5 --warmup 2 --fail-phase hidden --output target/perf/failed
```
Comparison accepts summary files, run directories or groups of run directories.
Build mode, toolchain, platform, host, power, WebKit, viewport and workload must match.
Invalid and incomparable reports exit 2. A/A remains inconclusive. A/B compares
independent-run summaries, not adjacent time samples. An optional third numeric
argument is a percentage floor justified by baseline noise; there is no universal
pass threshold. Fewer than three runs per group, overlapping ranges or differences
within either group's range remain inconclusive. Background load limits inference.
## Automation Delivery Boundary
When the operator is a coding agent, the final change keeps only the
performance-optimization code. Harness additions introduced during the
work — measurement entries, configs, scripts, reports, manifests — must
stay untracked by Git and out of every commit; keep them on ignored
paths or remove them before delivery. Commits remain minimal and
necessary for the optimization itself. Track or commit harness files
only when a human explicitly requests it at the end.
## Maintenance And Acceptance
Keep measurement code behind the explicit build gates. Reuse the existing driver,
Worker, graph, attribution and cleanup; do not add mock IPC or replace real WebView
acceptance with Chrome/Vite. Preserve source/binary/report hashes and every failed run.
After changes, run scoped ESLint, both perf TypeScript projects, `pnpm perf:test`,
format checks and relevant production checks. Verify a real three-phase smoke,
three independent runs, A/A, invalid/incomparable reports and injected-failure cleanup.
Verify ordinary builds exclude measurement code and daily instance state is intact.
Changes to contracts require a schema bump and new reports. Use bounded WebKit CPU
or Allocation/Heap captures for deeper diagnosis; do not infer hotspots from counters.
-57
View File
@@ -1,57 +0,0 @@
import { MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import { subscribe, scenario } from './replay'
// Only this perf entry replaces socket transport; the normal application never imports it.
export function installAdapter() {
const connect = async (kind: string) => {
const data = document.documentElement.dataset
data.perfSubscriptions = String(Number(data.perfSubscriptions || 0) + 1)
const callbacks = new Set<
(message: { type: 'Text'; data: string }) => void
>()
const unsubscribe = subscribe((frame) => {
const messages =
kind === 'logs'
? (frame.payload as ILogItem[])
: [
kind === 'traffic'
? { up: frame.sequence * 1000, down: frame.sequence * 5000 }
: frame.payload,
]
for (const payload of messages) {
const message = { type: 'Text' as const, data: JSON.stringify(payload) }
callbacks.forEach((callback) => {
callback(message)
data.perfDelivered = String(Number(data.perfDelivered || 0) + 1)
})
}
})
return {
addListener: (
callback: (message: { type: 'Text'; data: string }) => void,
) => {
callbacks.add(callback)
return () => callbacks.delete(callback)
},
close: async () => {
unsubscribe()
callbacks.clear()
data.perfSubscriptions = String(Number(data.perfSubscriptions) - 1)
},
} as unknown as MihomoWebSocket
}
MihomoWebSocket.connect_logs = async () => connect('logs')
MihomoWebSocket.connect_connections = async () => connect('connections')
MihomoWebSocket.connect_traffic = async () => connect('traffic')
if (scenario === 'logs')
localStorage.setItem(
'clash-log',
JSON.stringify({
enable: true,
logLevel: 'INFO',
logFilter: 'all',
logOrder: 'desc',
}),
)
}
+21 -227
View File
@@ -1,136 +1,5 @@
use objc2::{class, msg_send, runtime::AnyObject, sel};
use objc2_foundation::{NSPoint, NSRect, ns_string};
use tauri::{Manager as _, WebviewWindow};
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGDisplayCopyDisplayMode(display: u32) -> *mut std::ffi::c_void;
fn CGDisplayModeGetRefreshRate(mode: *mut std::ffi::c_void) -> f64;
fn CGDisplayModeRelease(mode: *mut std::ffi::c_void);
fn CGMainDisplayID() -> u32;
fn CGDisplayIsInMirrorSet(display: u32) -> u32;
fn CGDisplayModeGetWidth(mode: *mut std::ffi::c_void) -> usize;
fn CGDisplayModeGetHeight(mode: *mut std::ffi::c_void) -> usize;
fn CGDisplayModeGetPixelWidth(mode: *mut std::ffi::c_void) -> usize;
fn CGDisplayModeGetPixelHeight(mode: *mut std::ffi::c_void) -> usize;
}
fn expected_display() -> Result<Option<u32>, String> {
match std::env::var("PERF_DISPLAY_MODE").as_deref() {
Ok("physical") | Err(std::env::VarError::NotPresent) => Ok(None),
Ok("virtual") => {
let id = std::env::var("PERF_DISPLAY_ID")
.map_err(|e| e.to_string())?
.parse::<u32>()
.map_err(|e| e.to_string())?;
if id == 0 {
return Err("invalid owned display ID".into());
}
Ok(Some(id))
}
_ => Err("unknown measurement display mode".into()),
}
}
const fn bounds(rect: NSRect) -> [f64; 4] {
[rect.origin.x, rect.origin.y, rect.size.width, rect.size.height]
}
fn contained(frame: NSRect, screen: NSRect) -> bool {
frame.origin.x >= screen.origin.x
&& frame.origin.y >= screen.origin.y
&& frame.origin.x + frame.size.width <= screen.origin.x + screen.size.width
&& frame.origin.y + frame.size.height <= screen.origin.y + screen.size.height
}
unsafe fn screen_id(screen: *mut AnyObject) -> u32 {
unsafe {
if screen.is_null() {
return 0;
}
let dictionary: *mut AnyObject = msg_send![screen, deviceDescription];
let value: *mut AnyObject = msg_send![dictionary, objectForKey: ns_string!("NSScreenNumber")];
msg_send![value, unsignedIntValue]
}
}
unsafe fn display_state(native: &AnyObject) -> Result<Option<serde_json::Value>, String> {
let Some(wanted) = expected_display()? else {
return Ok(None);
};
unsafe {
let screen: *mut AnyObject = msg_send![native, screen];
let id = screen_id(screen);
if id == 0 || id != wanted || id == CGMainDisplayID() || CGDisplayIsInMirrorSet(id) != 0 {
return Err("owned virtual screen unavailable".into());
}
let frame: NSRect = msg_send![screen, frame];
let visible: NSRect = msg_send![screen, visibleFrame];
let window_frame: NSRect = msg_send![native, frame];
let scale: f64 = msg_send![screen, backingScaleFactor];
let behavior: usize = msg_send![native, collectionBehavior];
let mode = CGDisplayCopyDisplayMode(id);
if mode.is_null() {
return Err("owned display mode unavailable".into());
}
let rate = CGDisplayModeGetRefreshRate(mode);
let logical = [CGDisplayModeGetWidth(mode), CGDisplayModeGetHeight(mode)];
let pixels = [CGDisplayModeGetPixelWidth(mode), CGDisplayModeGetPixelHeight(mode)];
CGDisplayModeRelease(mode);
if logical != [1280, 900]
|| pixels != [2560, 1800]
|| scale != 2.0
|| (rate - 60.0).abs() > 0.01
|| frame.size.width != 1280.0
|| frame.size.height != 900.0
|| !contained(window_frame, visible)
|| behavior & 1 == 0
|| behavior & 2 != 0
{
return Err("owned virtual screen geometry/mode changed".into());
}
Ok(Some(
serde_json::json!({"id": id, "frame": bounds(frame), "visibleFrame": bounds(visible), "windowFrame": bounds(window_frame), "scale": scale, "refreshHz": rate, "collectionBehavior": behavior, "contained": true, "main": false, "mirrored": false, "logical": logical, "pixels": pixels}),
))
}
}
unsafe fn target_screen(wanted: u32) -> Result<*mut AnyObject, String> {
unsafe {
let screens: *mut AnyObject = msg_send![class!(NSScreen), screens];
let count: usize = msg_send![screens, count];
let mut target = std::ptr::null_mut::<AnyObject>();
for index in 0..count {
let screen: *mut AnyObject = msg_send![screens, objectAtIndex: index];
if screen_id(screen) == wanted {
target = screen;
break;
}
}
if target.is_null() || wanted == CGMainDisplayID() {
return Err("exact owned virtual display missing".into());
}
Ok(target)
}
}
unsafe fn place_virtual(window: &WebviewWindow) -> Result<(), String> {
unsafe {
let Some(wanted) = expected_display()? else {
return Ok(());
};
let target = target_screen(wanted)?;
let native = &*(window.ns_window().map_err(|e| e.to_string())? as *const AnyObject);
let visible: NSRect = msg_send![target, visibleFrame];
let frame: NSRect = msg_send![native, frame];
if frame.size.width > visible.size.width || frame.size.height > visible.size.height {
return Err("owned window does not fit virtual visibleFrame".into());
}
let behavior: usize = msg_send![native, collectionBehavior];
let _: () = msg_send![native, setCollectionBehavior: (behavior & !2usize) | 1usize];
let origin = NSPoint::new(
visible.origin.x + (visible.size.width - frame.size.width) / 2.0,
visible.origin.y + (visible.size.height - frame.size.height) / 2.0,
);
let _: () = msg_send![native, setFrameOrigin: origin];
display_state(native)?;
Ok(())
}
}
use tauri::WebviewWindow;
#[tauri::command]
async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json::Value, String> {
@@ -138,28 +7,16 @@ async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json:
return Err("unknown measurement action".into());
}
let (tx, rx) = std::sync::mpsc::channel();
let fail_app = window.app_handle().clone();
window
.with_webview(move |view| unsafe {
let native = &*(view.ns_window() as *const AnyObject);
let sender = std::ptr::null::<AnyObject>();
let display = match display_state(native) {
Ok(state) => state,
Err(error) => {
let _: () = msg_send![native, orderOut: sender];
let _ = tx.send(Err(error));
fail_app.exit(2);
return;
}
};
if action == "show" {
let _: () = msg_send![native, orderBack: sender];
} else if action == "hide" {
let _: () = msg_send![native, orderOut: sender];
}
let visible: bool = msg_send![native, isVisible];
let occlusion: usize = msg_send![native, occlusionState];
let active_space: bool = msg_send![native, isOnActiveSpace];
let focused: bool = msg_send![native, isKeyWindow];
let click_through: bool = msg_send![native, ignoresMouseEvents];
let app: *const AnyObject = msg_send![class!(NSApplication), sharedApplication];
@@ -171,41 +28,21 @@ async fn perf_state(window: WebviewWindow, action: String) -> Result<serde_json:
} else {
0
};
let _ = tx.send(Ok((
pid,
visible,
focused,
active,
click_through,
occlusion & 2 == 0,
active_space,
display,
)));
let _ = tx.send((pid, visible, focused, active, click_through));
})
.map_err(|e| e.to_string())?;
let (pid, visible, focused, active, click_through, occluded, active_space, display) = rx
let (pid, visible, focused, active, click_through) = rx
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|e| e.to_string())??;
.map_err(|e| e.to_string())?;
if pid <= 0 {
return Err("WKWebView process ownership unavailable".into());
}
let top = window.is_always_on_top().map_err(|e| e.to_string())?;
Ok(serde_json::json!({"main": std::process::id(), "webview": pid,
"visible": visible, "nativeFocused": focused, "appActive": active,
"virtualScreen": display, "expectedDisplayID": expected_display()?,
"top": top, "occluded": occluded, "activeSpace": active_space, "clickThrough": click_through,
"top": top, "clickThrough": click_through,
"token": std::env::var("PERF_TOKEN").map_err(|e| e.to_string())?}))
}
#[tauri::command]
const fn get_clash_logs() -> Vec<String> {
Vec::new()
}
#[tauri::command]
fn get_verge_config() -> serde_json::Value {
serde_json::json!({"language": "en", "theme_mode": "light"})
}
pub fn run() -> std::process::ExitCode {
#[cfg(feature = "clippy")]
let context = tauri::test::mock_context(tauri::test::noop_assets());
@@ -213,66 +50,23 @@ pub fn run() -> std::process::ExitCode {
let context = tauri::generate_context!();
let result = tauri::Builder::default()
.plugin(tauri_plugin_wdio_webdriver::init())
.invoke_handler(tauri::generate_handler![perf_state, get_clash_logs, get_verge_config])
.invoke_handler(tauri::generate_handler![perf_state])
.setup(|app| {
let startup = (|| -> Result<(), Box<dyn std::error::Error>> {
app.handle()
.set_activation_policy(tauri::ActivationPolicy::Prohibited)?;
let scenario = std::env::var("PERF_SCENARIO").unwrap_or_else(|_| "traffic".into());
let layer = std::env::var("PERF_LAYER").unwrap_or_else(|_| "component".into());
if !matches!(scenario.as_str(), "traffic" | "logs" | "connections")
|| !matches!(layer.as_str(), "page" | "component" | "dom")
{
return Err("invalid perf scene".into());
}
if let Some(wanted) = expected_display().map_err(std::io::Error::other)? {
unsafe {
target_screen(wanted).map_err(std::io::Error::other)?;
}
}
let port: u16 = std::env::var("PERF_REPLAY_PORT")?.parse()?;
let window = tauri::WebviewWindowBuilder::new(
app,
"perf",
tauri::WebviewUrl::App(format!("index.html?port={port}&scenario={scenario}&layer={layer}").into()),
)
.title("Clash Verge - performance replay")
.inner_size(1000., 700.)
.visible(false)
.focused(false)
.focusable(false)
.always_on_top(true)
.incognito(true)
.build()?;
window.set_ignore_cursor_events(true)?;
unsafe {
place_virtual(&window).map_err(std::io::Error::other)?;
}
if expected_display().map_err(std::io::Error::other)?.is_some() {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if matches!(
event,
tauri::WindowEvent::Moved(_) | tauri::WindowEvent::ScaleFactorChanged { .. }
) && let Some(window) = handle.get_webview_window("perf")
{
let valid = window
.ns_window()
.ok()
.is_some_and(|native| unsafe { display_state(&*(native as *const AnyObject)).is_ok() });
if !valid {
let _ = window.hide();
handle.exit(2);
}
}
});
}
Ok(())
})();
if let Err(error) = startup {
eprintln!("Measurement startup failed: {error}");
app.handle().exit(1);
}
app.handle().set_activation_policy(tauri::ActivationPolicy::Accessory)?;
let port: u16 = std::env::var("PERF_REPLAY_PORT")?.parse()?;
tauri::WebviewWindowBuilder::new(
app,
"perf",
tauri::WebviewUrl::App(format!("index.html?port={port}").into()),
)
.title("Clash Verge - performance replay")
.inner_size(1000., 700.)
.visible(false)
.focused(false)
.always_on_top(true)
.incognito(true)
.build()?
.set_ignore_cursor_events(true)?;
Ok(())
})
.build(context);
+107
View File
@@ -0,0 +1,107 @@
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from 'node:fs'
import { resolve } from 'node:path'
const output = resolve(process.argv[2] ?? 'target/perf/build')
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex')
const command = (cmd, args) =>
execFileSync(cmd, args, { encoding: 'utf8' }).trim()
const source = () =>
command('git', ['ls-files', '-co', '--exclude-standard'])
.split('\n')
.filter(existsSync)
.filter(
(path) =>
!path.startsWith('scripts/perf/') ||
/\.(rs|tsx|html|mts|json)$/.test(path),
)
.sort()
.map((path) => `${path}\0${hash(readFileSync(path))}`)
.join('\n')
mkdirSync(output, { recursive: true })
const before = hash(source())
const buildSettingsSha256 = hash(
['Cargo.toml', '.cargo/config.toml']
.filter(existsSync)
.map((path) => `${path}\0${hash(readFileSync(path))}`)
.join('\n'),
)
execFileSync(
'pnpm',
[
'exec',
'vite',
'build',
'--mode',
'perf',
'--config',
'scripts/perf/vite.mts',
],
{ stdio: 'inherit' },
)
const mode = 'release + perf-harness (build settings hash recorded)'
const buildCommand =
'TAURI_CONFIG=<scripts/perf/tauri.json> cargo build -p clash-verge --release --features perf-harness --locked --target-dir target/perf/cargo'
execFileSync(
'cargo',
[
'build',
'-p',
'clash-verge',
'--release',
'--features',
'perf-harness',
'--locked',
'--target-dir',
'target/perf/cargo',
],
{
stdio: 'inherit',
env: {
...process.env,
TAURI_CONFIG: readFileSync('scripts/perf/tauri.json', 'utf8'),
},
},
)
if (before !== hash(source()))
throw new Error('Source changed during build; refuse manifest')
const binary = `${output}/clash-verge`
copyFileSync('target/perf/cargo/release/clash-verge', binary)
const files = command('find', ['target/perf/dist', '-type', 'f'])
.split('\n')
.sort()
writeFileSync(
`${binary}.json`,
JSON.stringify(
{
head: command('git', ['rev-parse', 'HEAD']),
dirty: command('git', ['status', '--porcelain']),
sourceSha256: before,
binarySha256: hash(readFileSync(binary)),
frontendSha256: hash(
files.map((path) => `${path}\0${hash(readFileSync(path))}`).join('\n'),
),
mode,
buildSettingsSha256,
buildEnvironment: Object.fromEntries(
Object.entries(process.env).filter(([key]) =>
/^(RUSTFLAGS|CARGO_ENCODED_RUSTFLAGS|CARGO_PROFILE_|RUSTC_WRAPPER)/.test(
key,
),
),
),
buildCommand,
binary,
toolchain: `${command('rustc', ['--version'])}; node ${process.version}; pnpm ${command('pnpm', ['--version'])}`,
},
null,
2,
),
)
console.log(`Measurement binary and manifest: ${binary}`)
+41
View File
@@ -0,0 +1,41 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { compare } from './report.mjs'
const args = process.argv.slice(2).filter((v) => v !== '--')
if (args.length < 2)
throw new Error(
'Usage: pnpm perf:compare -- baseline/summary.json candidate/summary.json [noise-threshold-percent]',
)
const threshold = args[2] === undefined ? null : Number(args[2])
if (threshold !== null && (!Number.isFinite(threshold) || threshold < 0))
throw new Error(
'threshold must be a nonnegative percentage justified by baseline noise',
)
function load(path) {
if (statSync(path).isFile()) return JSON.parse(readFileSync(path))
if (existsSync(join(path, 'summary.json')))
return load(join(path, 'summary.json'))
const reports = readdirSync(path)
.sort()
.map((name) => load(join(path, name)))
const first = reports[0]
if (
!first ||
reports.some(
(r) =>
r.build?.binarySha256 !== first.build?.binarySha256 ||
['invalid', 'incomparable'].includes(compare(first, r).verdict),
)
)
throw new Error('group contains mixed builds, contracts, or invalid runs')
return { ...first, runs: reports.flatMap((r) => r.runs) }
}
let result
try {
result = compare(...args.slice(0, 2).map(load), threshold)
} catch (error) {
result = { verdict: 'invalid', reason: String(error) }
}
console.log(JSON.stringify(result, null, 2))
if (['invalid', 'incomparable'].includes(result.verdict)) process.exitCode = 2
+49 -26
View File
@@ -1,29 +1,52 @@
import { layer, scenario, startReplay } from './replay'
import { ThemeProvider, createTheme } from '@mui/material'
import { useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { SWRConfig } from 'swr'
if (scenario === 'traffic') {
await import('./traffic')
} else {
const root = document.getElementById('root')!
document.body.style.cssText = 'margin:0;font:14px -apple-system,sans-serif'
root.style.cssText = 'width:1000px;height:700px;overflow:hidden'
new MutationObserver(() => {
document.documentElement.dataset.perfDraw = String(Date.now())
}).observe(root, { childList: true, characterData: true, subtree: true })
window.addEventListener('error', (event) => {
document.documentElement.dataset.perfError = event.message
})
window.addEventListener('unhandledrejection', (event) => {
document.documentElement.dataset.perfError = String(event.reason)
})
if (layer === 'dom') {
const { mount } = await import('./minimal')
mount(root)
} else {
if (layer === 'page') {
const { installAdapter } = await import('./adapter')
installAdapter()
import { EnhancedCanvasTrafficGraph } from '../../src/components/home/enhanced-canvas-traffic-graph'
import { useTrafficMonitorEnhanced } from '../../src/hooks/use-traffic-monitor'
import { initializeLanguage } from '../../src/services/i18n'
import { setPreloadConfig } from '../../src/services/preload'
setPreloadConfig({
pause_render_traffic_stats_on_blur: false,
} as IVergeConfig)
await initializeLanguage('en')
export function Replay() {
const {
graphData: { appendData },
} = useTrafficMonitorEnhanced({ subscribe: false })
useEffect(() => {
let received = 0,
previous = 0,
gaps = 0
const socket = new WebSocket(
`ws://127.0.0.1:${new URLSearchParams(location.search).get('port')}`,
)
socket.onmessage = ({ data }) => {
const point = JSON.parse(data)
appendData(point)
if (previous && point.sequence !== previous + 1) gaps++
previous = point.sequence
document.documentElement.dataset.perfReceived = String(++received)
document.documentElement.dataset.perfGaps = String(gaps)
document.documentElement.dataset.perfSourceTime = String(point.timestamp)
}
await import('./scenes')
}
startReplay()
return () => socket.close()
}, [appendData])
return (
<main
aria-label="Home traffic graph replay"
style={{ width: 920, height: 360, margin: 32 }}
>
<EnhancedCanvasTrafficGraph />
</main>
)
}
createRoot(document.getElementById('root')!).render(
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
<ThemeProvider theme={createTheme()}>
<Replay />
</ThemeProvider>
</SWRConfig>,
)
-13
View File
@@ -1,13 +0,0 @@
{
"configRoots": [
"Library/Application Support/io.github.clash-verge-rev.clash-verge-rev",
"Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev"
],
"configDirectories": ["profiles"],
"configExtensions": [".yaml", ".yml", ".json", ".js"],
"processNames": {
"app": "clash-verge",
"core": "verge-mihomo",
"service": "clash-verge-service"
}
}
-21
View File
@@ -1,21 +0,0 @@
import { scenario, subscribe } from './replay'
export function mount(root: HTMLElement) {
const rows = Array.from({ length: 20 }, () => {
const row = document.createElement('div')
row.style.cssText = 'height:32px;white-space:nowrap;overflow:hidden'
root.appendChild(row)
return row
})
subscribe(({ payload }) => {
const texts =
scenario === 'logs'
? (payload as ILogItem[]).map((log) => log.payload)
: (payload as IConnections).connections.map(
(connection) => connection.metadata.host,
)
rows.forEach((row, index) => {
row.textContent = texts[index % texts.length] ?? ''
})
})
}
-37
View File
@@ -1,37 +0,0 @@
export interface Frame {
sequence: number
timestamp: number
payload: ILogItem[] | IConnections
}
export const params = new URLSearchParams(location.search)
export const scenario = params.get('scenario') ?? 'traffic'
export const layer = params.get('layer') ?? 'component'
const listeners = new Set<(frame: Frame) => void>()
export const subscribe = (listener: (frame: Frame) => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
export function startReplay() {
const data = document.documentElement.dataset
let previous = 0
data.perfReceived = '0'
data.perfGaps = '0'
const socket = new WebSocket(`ws://127.0.0.1:${params.get('port')}`)
socket.onmessage = ({ data: raw }) => {
const frame = JSON.parse(raw) as Frame
if (frame.sequence !== previous + 1)
data.perfGaps = String(Number(data.perfGaps) + 1)
previous = frame.sequence
data.perfReceived = String(previous)
data.perfSourceTime = String(frame.timestamp)
for (const listener of listeners) listener(frame)
}
socket.onerror = () => {
data.perfError = 'replay socket failed'
}
socket.onclose = () => {
data.perfError = 'replay socket closed'
}
}
+443
View File
@@ -0,0 +1,443 @@
import { isDeepStrictEqual } from 'node:util'
export const SCHEMA = 'traffic-webview-v3'
export const phases = ['foreground', 'hidden', 'restored']
export const roles = ['webview', 'main']
export const quantile = (values, p) => {
const v = values.filter(Number.isFinite).sort((a, b) => a - b)
if (!v.length) return null
const index = (v.length - 1) * p
return (
v[Math.floor(index)] +
(v[Math.ceil(index)] - v[Math.floor(index)]) * (index % 1)
)
}
export function delta(previous, next) {
if (
!previous ||
previous.pid !== next.pid ||
previous.start !== next.start ||
![previous.time, next.time, previous.cpu, next.cpu].every(
Number.isFinite,
) ||
next.time <= previous.time ||
next.cpu < previous.cpu
)
return null
return ((next.cpu - previous.cpu) / (next.time - previous.time)) * 100000
}
export function summarize(rows, seconds) {
const output = {}
for (const phase of phases)
for (const role of roles) {
const selected = rows.filter((r) => r.phase === phase && r.role === role)
const samples = selected.map((r, i) => ({
...r,
percent: delta(selected[i - 1], r),
cpuDelta: i ? r.cpu - selected[i - 1].cpu : null,
}))
const valid = samples.filter((r) => Number.isFinite(r.percent))
const memory = samples.map((r) => r.rss)
const span =
samples.length > 1 ? samples.at(-1).time - samples[0].time : 0
const continuous =
samples.length > 1 &&
samples.every(
(r) =>
[r.time, r.cpu, r.rss].every(Number.isFinite) &&
Number.isInteger(r.pid) &&
r.pid > 0 &&
/^\d+$/.test(r.start) &&
r.time >= 0 &&
r.cpu >= 0 &&
r.rss > 0,
) &&
samples.every(
(r, i) =>
!i ||
(r.time > samples[i - 1].time &&
r.time - samples[i - 1].time < 2500 &&
r.cpu >= samples[i - 1].cpu &&
r.pid === samples[i - 1].pid &&
r.start === samples[i - 1].start),
)
output[`${phase}/${role}`] = {
count: samples.length,
cpuCount: valid.length,
missing: Math.max(0, Math.floor(seconds) - samples.length),
coverage: Math.min(1, samples.length / seconds),
continuous,
observedSeconds: span / 1000,
intervalCoverage: Math.min(1, span / ((seconds - 1) * 1000)),
cpuMean: null,
cpuP50: quantile(
valid.map((r) => r.percent),
0.5,
),
cpuP95: quantile(
valid.map((r) => r.percent),
0.95,
),
cpuPeak: quantile(
valid.map((r) => r.percent),
1,
),
cpuSeconds: valid.length
? valid.reduce((sum, r) => sum + r.cpuDelta, 0)
: null,
rssP50: quantile(memory, 0.5),
rssP95: quantile(memory, 0.95),
rssPeak: quantile(memory, 1),
rssDelta: continuous ? samples.at(-1).rss - samples[0].rss : null,
rssSlopeBytesPerSecond:
span >= 60000 && continuous
? (samples.at(-1).rss - samples[0].rss) / (span / 1000)
: null,
}
output[`${phase}/${role}`].cpuMean =
valid.length && span > 0
? (output[`${phase}/${role}`].cpuSeconds / span) * 100000
: null
}
return output
}
export function replayValues(sequence) {
return {
up: 100000 + (sequence % 20) * 10000,
down: 500000 + (sequence % 30) * 20000,
}
}
export const validVisibility = (s) =>
typeof s.hidden === 'boolean' &&
(s.phase === 'hidden'
? s.visible === false && s.hidden
: phases.includes(s.phase) && s.visible === true && !s.hidden)
export const validObservation = (s) =>
validWorkload(s) &&
validVisibility(s) &&
s.nativeFocused === false &&
s.appActive === false &&
s.top === true &&
s.clickThrough === true &&
[s.now, s.source, s.worker, s.draw].every(
(v) => Number.isFinite(v) && v > 0,
) &&
s.now >= s.source &&
s.now - s.source < 2500 &&
s.now >= s.worker &&
s.now - s.worker < 3500 &&
s.now >= s.draw &&
(s.phase === 'hidden' || s.now - s.draw < 2500)
export function validWorkload(s) {
if (!s) return false
const expected = replayValues(s.points)
return (
Number.isInteger(s.received) &&
Number.isInteger(s.points) &&
s.points > 0 &&
s.received >= s.points &&
s.received - s.points < 60 &&
s.lastUp === expected.up &&
s.lastDown === expected.down &&
s.gaps === 0
)
}
export function compare(a, b, threshold = null) {
const stats = new Map()
const text = (value) => typeof value === 'string' && value.length > 0
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value)
const expectedMarks = [
'ready',
...phases.flatMap((phase) => [phase, `${phase}:end`]),
]
const valid = (x) =>
x?.schema === SCHEMA &&
text(x?.harnessSha256) &&
[
'head',
'sourceSha256',
'binarySha256',
'frontendSha256',
'buildSettingsSha256',
'mode',
'buildCommand',
'binary',
'toolchain',
].every((key) => text(x.build?.[key])) &&
typeof x.build.dirty === 'string' &&
object(x.build.buildEnvironment) &&
[
'method',
'mode',
'toolchain',
'buildSettingsSha256',
'platform',
'machine',
'arch',
'host',
'hardware',
'power',
'powerSource',
'webkit',
'replay',
'automation',
].every((key) => text(x.contract?.[key])) &&
object(x.contract.buildEnvironment) &&
x.build.buildSettingsSha256 === x.contract.buildSettingsSha256 &&
[
'warmup',
'intervalMs',
'transitionSeconds',
'readinessAllowanceSeconds',
].every((key) => Number.isFinite(x.contract[key]) && x.contract[key] > 0) &&
x.contract.scenario === 'traffic' &&
typeof x.contract.consoleLocked === 'boolean' &&
x.contract.windowPolicy === 'nonactivating-top-click-through-v1' &&
x.contract.replayMs === 200 &&
x.contract.rangeMinutes === 10 &&
['window', 'viewport'].every(
(key) =>
Array.isArray(x.contract[key]) &&
x.contract[key].length === (key === 'window' ? 2 : 3) &&
x.contract[key].every((v) => Number.isFinite(v) && v > 0),
) &&
Number.isFinite(x.contract?.seconds) &&
x.contract.seconds >= 5 &&
Array.isArray(x.contract.phaseTargets) &&
x.contract.phaseTargets.length === phases.length &&
x.contract.phaseTargets.every((n) => Number.isInteger(n) && n > 0) &&
Array.isArray(x.runs) &&
x.runs.length &&
x.runs.every((r) => {
if (
!r?.ok ||
!Array.isArray(r.samples) ||
!r.samples.every(
(s) => s && roles.includes(s.role) && phases.includes(s.phase),
) ||
!Array.isArray(r.marks) ||
!r.marks.every((m) => m && typeof m.name === 'string')
)
return false
stats.set(r, summarize(r.samples, x.contract.seconds))
return (
r.cleanup?.mainExited &&
r.cleanup?.driverClosed &&
r.cleanup?.webviewExited &&
Array.isArray(r.consoleStates) &&
r.consoleStates.length === expectedMarks.length &&
r.consoleStates.every(
(s, i) =>
s &&
s.name === (i ? expectedMarks[i] : 'initial') &&
typeof s.locked === 'boolean' &&
Number.isFinite(s.time) &&
s.time > 0 &&
(!i || s.time >= r.consoleStates[i - 1].time),
) &&
Number.isInteger(r.replay?.sent) &&
r.replay.sent > 0 &&
Number.isFinite(r.replay.maxLateMs) &&
r.replay.maxLateMs >= 0 &&
r.replay.maxLateMs < x.contract.replayMs &&
!r.replay.error &&
r.actualWarmupMs >= x.contract.warmup * 1000 &&
Array.isArray(r.observations) &&
r.observations.length > 0 &&
r.observations.every(
(s, i) =>
validObservation(s) && (!i || s.now > r.observations[i - 1].now),
) &&
r.marks.length === expectedMarks.length &&
r.marks.every(
(m, i) =>
m.name === expectedMarks[i] &&
Number.isFinite(m.time) &&
m.time > 0 &&
(!i ||
(validObservation({ ...m, phase: m.name.split(':')[0] }) &&
/^[a-f0-9]{64}$/.test(m.pixelsSha256))) &&
(!phases.includes(m.name) ||
(m.target === x.contract.phaseTargets[phases.indexOf(m.name)] &&
m.received === m.target)) &&
(!i || m.time > r.marks[i - 1].time),
) &&
roles.every((role) =>
r.samples
.filter((s) => s.role === role)
.every(
(s, i, rows) =>
!i ||
(s.pid === rows[i - 1].pid &&
s.start === rows[i - 1].start &&
s.time > rows[i - 1].time &&
s.cpu >= rows[i - 1].cpu &&
phases.indexOf(s.phase) >= phases.indexOf(rows[i - 1].phase)),
),
) &&
validStats(stats.get(r)) &&
phases.every((phase) => {
const start = r.marks.find((m) => m.name === phase)
const end = r.marks.find((m) => m.name === `${phase}:end`)
const observed = r.observations.filter((s) => s.phase === phase)
return (
start &&
end &&
observed.length > 0 &&
[
start.deadlineIndex,
end.deadlineIndex,
start.senderSent,
end.senderSent,
].every((n) => Number.isInteger(n) && n >= 0) &&
end.deadlineIndex > start.deadlineIndex &&
end.senderSent >= start.senderSent &&
end.senderSent <= r.replay.sent &&
start.senderSent >= start.received &&
end.senderSent >= end.received &&
Math.abs(
end.received -
start.received -
(end.deadlineIndex - start.deadlineIndex),
) <= 2 &&
observed.every(
(s) =>
s.now >= start.now &&
s.now <= end.now &&
s.received >= start.received &&
s.received <= end.received,
) &&
end.worker > start.worker &&
(phase === 'hidden' ||
(end.draw > start.draw &&
end.pixelsSha256 !== start.pixelsSha256))
)
})
)
})
if (![a, b].every(valid))
return { verdict: 'invalid', reason: 'failed, malformed or missing runs' }
if (
[a, b].some((x) =>
x.runs.some((r) =>
r.consoleStates.some((s) => s.locked !== x.contract.consoleLocked),
),
)
)
return {
verdict: 'incomparable',
reason: 'console lock state changed at observed boundaries',
}
if (
!isDeepStrictEqual(a.contract, b.contract) ||
['mode', 'buildCommand', 'toolchain', 'buildEnvironment'].some(
(key) => !isDeepStrictEqual(a.build[key], b.build[key]),
) ||
a.harnessSha256 !== b.harnessSha256
)
return {
verdict: 'incomparable',
reason: 'harness, platform, build mode, workload or environment differ',
}
for (const phase of phases) {
for (const name of [phase, `${phase}:end`]) {
const sizes = [a, b].map((x) =>
x.runs.map((r) => r.marks.find((m) => m.name === name)?.received),
)
if (!sizes.flat().every(Number.isFinite))
return { verdict: 'invalid', reason: 'missing observed workload size' }
if (
Math.abs(quantile(sizes[0], 0.5) - quantile(sizes[1], 0.5)) > 2 ||
sizes.some((s) => Math.max(...s) - Math.min(...s) > 2)
)
return {
verdict: 'incomparable',
reason: `${name}: input size varies by more than two replay points`,
}
}
}
const same = a.build.binarySha256 === b.build.binarySha256
const enough = a.runs.length >= 3 && b.runs.length >= 3
const metrics = {}
for (const phase of phases)
for (const role of roles)
for (const metric of ['cpuMean', 'rssP50']) {
const key = `${phase}/${role}`
const av = a.runs.map((r) => stats.get(r)[key][metric])
const bv = b.runs.map((r) => stats.get(r)[key][metric])
const before = quantile(av, 0.5),
after = quantile(bv, 0.5)
const noise = Math.max(...av) - Math.min(...av)
const candidateRange = Math.max(...bv) - Math.min(...bv)
const overlap =
Math.max(...av) >= Math.min(...bv) &&
Math.max(...bv) >= Math.min(...av)
metrics[`${key}/${metric}`] = {
before,
after,
delta: after - before,
baselineRange: noise,
candidateRange,
verdict:
same ||
!enough ||
threshold === null ||
!av.every(Number.isFinite) ||
!bv.every(Number.isFinite) ||
overlap ||
Math.abs(after - before) <=
Math.max(
noise,
candidateRange,
(Math.abs(before) * threshold) / 100,
)
? 'inconclusive'
: after < before
? 'lower'
: 'higher',
}
}
return {
verdict: 'inconclusive',
kind: same ? 'A/A' : 'A/B',
reason: same
? 'same binary; natural variation only'
: 'inspect independent-run metrics; no universal pass threshold',
metrics,
}
}
export function markdown(report) {
const lines = [
'# Home traffic graph - replay measurement',
'',
`Success: ${report.runs.filter((r) => r.ok).length}/${report.runs.length}`,
'',
'CPU: one fully occupied core = 100%. Memory: process resident size bytes, not exclusive memory. Null = unavailable; first CPU observation has no delta. Slope requires 60 seconds of continuous observations.',
'',
'| Run / stage / role | CPU mean / P50 / P95 / peak (%) | CPU s | RSS P50 / P95 / peak / delta (bytes) | Samples / coverage |',
'|---|---|---|---|---|',
]
for (const [i, run] of report.runs.entries()) {
if (run.error) lines.push(`\nRun ${i + 1} failed: ${run.error}\n`)
for (const [key, s] of Object.entries(run.stats))
lines.push(
`| ${i + 1}/${key} | ${[s.cpuMean, s.cpuP50, s.cpuP95, s.cpuPeak].map((v) => v?.toFixed(2) ?? 'unavailable').join(' / ')} | ${s.cpuSeconds?.toFixed(3) ?? 'unavailable'} | ${[s.rssP50, s.rssP95, s.rssPeak, s.rssDelta].map((v) => v ?? 'unavailable').join(' / ')} | ${s.count} / ${(s.coverage * 100).toFixed(1)}% |`,
)
}
lines.push(
'',
'Scope: loopback replay → public traffic append boundary → production Worker → homepage Canvas component. Mihomo and its network/subscription chain are not running. Shared GPU/network processes are unavailable and excluded. Process statistics do not identify function hotspots, component CPU or retained objects; memory growth is not proof of a leak.',
'',
'Next measurement: use WebKit CPU sampling for the highest-CPU foreground/restored phase; use Allocation/Heap captures for repeatable RSS growth. React Compiler is an untested local A/B candidate only.',
)
return lines.join('\n') + '\n'
}
export const validStats = (stats) =>
Object.values(stats).every(
(s) =>
s.continuous &&
s.coverage >= 0.8 &&
s.intervalCoverage >= 0.8 &&
s.cpuCount >= 3,
)
+340
View File
@@ -0,0 +1,340 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import { test } from 'node:test'
import {
compare,
delta,
phases,
roles,
quantile,
summarize,
validWorkload,
replayValues,
SCHEMA,
validVisibility,
} from './report.mjs'
const latest = (sequence) => {
const { up, down } = replayValues(sequence)
return { lastUp: up, lastDown: down }
}
const observation = (phase, now) => ({
phase,
now,
source: now - 100,
worker: now - 100,
draw: now - 100,
visible: phase !== 'hidden',
hidden: phase === 'hidden',
focused: false,
nativeFocused: false,
appActive: false,
top: true,
clickThrough: true,
})
const rows = phases.flatMap((phase, phaseIndex) =>
roles.flatMap((role) =>
Array.from({ length: 5 }, (_, i) => ({
phase,
role,
pid: role === 'main' ? 1 : 2,
start: '1',
time: (phaseIndex * 5 + i) * 1000,
cpu: (phaseIndex * 5 + i) / 10,
rss: 100 + i,
})),
),
)
const run = {
ok: true,
samples: rows,
cleanup: { mainExited: true, driverClosed: true, webviewExited: true },
replay: { sent: 130, maxLateMs: 10 },
actualWarmupMs: 3000,
observations: phases.map((phase, i) => ({
...observation(phase, 2000 + i * 10000),
received: 35 + i * 35,
points: 34 + i * 35,
gaps: 0,
...latest(34 + i * 35),
})),
marks: [
{ name: 'ready', time: 1 },
...phases.flatMap((name, i) => [
{
name,
target: 35 + i * 35,
deadlineIndex: 35 + i * 35,
senderSent: 35 + i * 35,
received: 35 + i * 35,
points: 34 + i * 35,
...latest(34 + i * 35),
gaps: 0,
time: 1000 + i * 10000,
...observation(name, 1000 + i * 10000),
pixelsSha256: 'a'.repeat(64),
},
{
name: `${name}:end`,
deadlineIndex: 60 + i * 35,
senderSent: 60 + i * 35,
received: 60 + i * 35,
points: 59 + i * 35,
...latest(59 + i * 35),
gaps: 0,
time: 6000 + i * 10000,
...observation(name, 6000 + i * 10000),
pixelsSha256: 'b'.repeat(64),
},
]),
],
}
run.consoleStates = run.marks.map((m, i) => ({
name: i ? m.name : 'initial',
time: m.time,
locked: false,
}))
const baseline = {
schema: SCHEMA,
harnessSha256: 'fixture-harness',
build: {
head: 'fixture-head',
dirty: '',
sourceSha256: 'fixture-source',
binarySha256: 'same',
frontendSha256: 'fixture-assets',
buildSettingsSha256: 'fixture-settings',
mode: 'release',
buildCommand: 'fixture-build',
binary: '/fixture/app',
toolchain: 'fixture-tools',
buildEnvironment: {},
},
contract: {
scenario: 'traffic',
replayMs: 200,
consoleLocked: false,
windowPolicy: 'nonactivating-top-click-through-v1',
seconds: 5,
warmup: 2,
phaseTargets: [35, 70, 105],
transitionSeconds: 2,
readinessAllowanceSeconds: 5,
intervalMs: 1000,
rangeMinutes: 10,
window: [1000, 700],
viewport: [1000, 668, 2],
method: 'fixture-method',
mode: 'release',
toolchain: 'fixture-tools',
buildSettingsSha256: 'fixture-settings',
platform: 'macOS',
machine: 'fixture-model',
arch: 'arm64',
host: 'fixture-host',
hardware: 'fixture-hardware',
power: 'fixture-power',
powerSource: 'AC',
webkit: 'fixture-webkit',
replay: 'fixture-5Hz',
automation: 'fixture-wdio',
buildEnvironment: {},
},
runs: [run, run, run],
}
test('quantiles, CPU units, first observation and PID reuse', () => {
assert.equal(quantile([0, 10, 20], 0.95), 19)
assert.equal(delta(undefined, rows[0]), null)
assert.equal(delta(rows[0], rows[1]), 10)
assert.equal(delta(rows[0], { ...rows[1], start: '2' }), null)
assert.equal(delta(rows[0], { ...rows[1], time: NaN }), null)
assert.equal(summarize(rows, 5)['hidden/main'].cpuMean, 10)
assert.equal(summarize([], 5)['hidden/main'].cpuSeconds, null)
})
test('report schema: A/A, failed, incomparable and corrupt samples', () => {
assert.equal(compare(baseline, baseline).kind, 'A/A')
assert.equal(
compare(baseline, { ...baseline, schema: 'old' }).verdict,
'invalid',
)
for (const change of [
{ appActive: true },
{ nativeFocused: true },
{ top: false },
{ top: undefined },
{ clickThrough: false },
{ clickThrough: undefined },
{ hidden: true },
{ worker: 3000 },
{ now: 6000 },
{ draw: 3000 },
{ worker: null },
]) {
const invalid = structuredClone(baseline)
Object.assign(invalid.runs[0].observations[0], change)
assert.equal(compare(baseline, invalid).verdict, 'invalid')
}
for (const key of ['draw', 'pixelsSha256']) {
const stalled = structuredClone(baseline)
stalled.runs[0].marks[2][key] = stalled.runs[0].marks[1][key]
assert.equal(compare(baseline, stalled).verdict, 'invalid')
}
const missingPhase = structuredClone(baseline)
missingPhase.runs[0].observations.pop()
assert.equal(compare(baseline, missingPhase).verdict, 'invalid')
const consoleDrift = structuredClone(baseline)
consoleDrift.runs[0].consoleStates[3].locked = true
assert.equal(compare(baseline, consoleDrift).verdict, 'incomparable')
consoleDrift.runs[0].consoleStates[3].locked = null
assert.equal(compare(baseline, consoleDrift).verdict, 'invalid')
assert.equal(
validVisibility({ phase: 'restored', visible: true, hidden: true }),
false,
)
const missedPeriod = structuredClone(baseline)
missedPeriod.runs[0].replay.maxLateMs = 200
assert.equal(compare(baseline, missedPeriod).verdict, 'invalid')
const delayed = {
received: 100,
points: 97,
gaps: 0,
lastUp: 270000,
lastDown: 640000,
}
assert.equal(validWorkload(delayed), true)
assert.equal(validWorkload(null), false)
assert.equal(validWorkload({ ...delayed, points: 96 }), false)
assert.equal(validWorkload({ ...delayed, points: 98 }), false)
assert.equal(validWorkload({ ...delayed, received: 157 }), false)
assert.equal(validWorkload({ ...delayed, lastDown: 660000 }), false)
const missedAnchor = structuredClone(baseline)
missedAnchor.runs[0].marks[1].received++
assert.equal(compare(baseline, missedAnchor).verdict, 'invalid')
const droppedInputs = structuredClone(baseline)
const end = droppedInputs.runs[0].marks[2]
end.received -= 10
end.points -= 10
Object.assign(end, latest(end.points))
assert.equal(compare(droppedInputs, droppedInputs).verdict, 'invalid')
const workerLag = structuredClone(baseline)
workerLag.runs[0].observations[0].points -= 3
assert.equal(compare(baseline, workerLag).verdict, 'invalid')
const inputDrift = structuredClone(baseline)
inputDrift.runs = baseline.runs.map((r) => structuredClone(r))
inputDrift.runs[0].marks[2].received += 3
inputDrift.runs[0].marks[2].points += 3
inputDrift.runs[0].marks[2].deadlineIndex += 3
inputDrift.runs[0].marks[2].senderSent += 3
Object.assign(
inputDrift.runs[0].marks[2],
latest(inputDrift.runs[0].marks[2].points),
)
assert.equal(compare(baseline, inputDrift).verdict, 'incomparable')
const snapshotPhase = structuredClone(baseline)
snapshotPhase.runs[0].marks[2].points++
Object.assign(
snapshotPhase.runs[0].marks[2],
latest(snapshotPhase.runs[0].marks[2].points),
)
assert.equal(compare(baseline, snapshotPhase).kind, 'A/A')
const changedSettings = structuredClone(baseline)
changedSettings.build.buildSettingsSha256 =
changedSettings.contract.buildSettingsSha256 = 'changed-settings'
assert.equal(compare(baseline, changedSettings).verdict, 'incomparable')
assert.equal(
compare(baseline, { ...baseline, harnessSha256: 'changed' }).verdict,
'incomparable',
)
assert.equal(
compare(baseline, {
...baseline,
contract: { ...baseline.contract, seconds: 6 },
}).verdict,
'incomparable',
)
assert.equal(
compare(baseline, { ...baseline, runs: [{ ...run, ok: false }] }).verdict,
'invalid',
)
const corrupt = structuredClone(baseline)
corrupt.runs[0].samples[1].time = 0
assert.equal(compare(baseline, corrupt).verdict, 'invalid')
const shifted = structuredClone(baseline)
shifted.runs[0].samples
.filter((s) => s.phase === 'hidden')
.forEach((s) => {
s.time -= 5000
})
assert.equal(compare(baseline, shifted).verdict, 'invalid')
const badMarks = structuredClone(baseline)
badMarks.runs[0].marks.forEach((m) => {
m.time = -1
})
assert.equal(compare(baseline, badMarks).verdict, 'invalid')
const clustered = structuredClone(baseline)
clustered.runs[0].samples.forEach((s) => {
s.time /= 1000
})
assert.equal(compare(baseline, clustered).verdict, 'invalid')
for (const [section, key] of [
['contract', 'platform'],
['build', 'sourceSha256'],
]) {
const missing = structuredClone(baseline)
delete missing[section][key]
assert.equal(compare(missing, missing).verdict, 'invalid')
}
assert.equal(
compare(baseline, {
...baseline,
contract: { ...baseline.contract, platform: 'other-macOS' },
}).verdict,
'incomparable',
)
for (const malformed of [
null,
{},
{ runs: [null] },
{ ...baseline, runs: [{ ...run, samples: null }] },
])
assert.equal(compare(baseline, malformed).verdict, 'invalid')
const noisy = structuredClone(baseline)
noisy.runs = baseline.runs.map((r) => structuredClone(r))
noisy.build.binarySha256 = 'different'
noisy.runs.forEach((r, i) =>
r.samples.forEach((s) => {
s.cpu *= [0.1, 0.2, 10][i]
}),
)
assert.equal(
compare(baseline, noisy, 5).metrics['foreground/webview/cpuMean'].verdict,
'inconclusive',
)
})
test('macOS collector agrees with process.cpuUsage on a busy owned process', {
skip: process.platform !== 'darwin',
}, () => {
mkdirSync('target/perf', { recursive: true })
execFileSync('cc', [
'-Wall',
'-Wextra',
'-Werror',
'scripts/perf/sample.c',
'-o',
'target/perf/test-sample',
])
const sample = () =>
Number(
execFileSync('target/perf/test-sample', [String(process.pid)], {
encoding: 'utf8',
}).split(' ')[2],
)
const first = sample(),
cpu = process.cpuUsage(),
start = performance.now()
while (performance.now() - start < 500) Math.sqrt(Math.random())
const used = process.cpuUsage(cpu),
measured = sample() - first
assert.ok(Math.abs(measured - (used.user + used.system) / 1e6) < 0.05)
})
+674
View File
@@ -0,0 +1,674 @@
import { spawn, execFileSync } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import {
mkdirSync,
readFileSync,
writeFileSync,
openSync,
closeSync,
realpathSync,
} from 'node:fs'
import { createServer } from 'node:net'
import { resolve, dirname } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { parseArgs } from 'node:util'
import { createTauriCapabilities } from '@wdio/tauri-service'
import { remote } from 'webdriverio'
import { WebSocketServer } from 'ws'
import {
delta,
summarize,
markdown,
phases,
roles,
validStats,
validWorkload,
replayValues,
SCHEMA,
validObservation,
validVisibility,
} from './report.mjs'
const { values } = parseArgs({
args: process.argv.slice(2).filter((v) => v !== '--'),
options: {
output: { type: 'string', default: `target/perf/runs/${Date.now()}` },
binary: { type: 'string', default: 'target/perf/build/clash-verge' },
repeat: { type: 'string', default: '3' },
seconds: { type: 'string', default: '20' },
warmup: { type: 'string', default: '10' },
'fail-phase': { type: 'string' },
},
})
const seconds = Number(values.seconds),
warmup = Number(values.warmup),
repeat = Number(values.repeat)
const replayMs = 200
if (process.platform !== 'darwin') throw new Error('macOS only')
function readConsole() {
const consoleState = JSON.parse(
execFileSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], {
input: execFileSync('/usr/sbin/ioreg', ['-n', 'Root', '-d1', '-a']),
encoding: 'utf8',
}),
)
const locked = consoleState.IOConsoleLocked
if (typeof locked !== 'boolean')
throw new Error('Environment unavailable: unknown console lock state')
return locked
}
function checkConsole() {
const locked = readConsole()
if (locked)
throw new Error(
'Environment unavailable: an unlocked graphical session is required; window focus is not required',
)
return locked
}
const consoleLocked = checkConsole()
if (
![seconds, warmup, repeat].every(Number.isFinite) ||
seconds < 5 ||
warmup < 2 ||
repeat < 1 ||
!Number.isInteger(repeat)
)
throw new Error('Require seconds >= 5, warmup >= 2, integer repeat >= 1')
const phaseTargets = phases.map(
(_, i) =>
Math.ceil(((warmup + 5) * 1000) / replayMs) +
i * Math.ceil(((seconds + 2) * 1000) / replayMs),
)
if ((phaseTargets[2] * replayMs) / 1000 + seconds + 5 >= 600)
throw new Error(
'Total replay including transition allowance must be < 10 minutes',
)
const binary = realpathSync(values.binary),
output = resolve(values.output)
const webcontentBinary = realpathSync(
'/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent',
)
const build = JSON.parse(readFileSync(`${binary}.json`))
const hash = (data) => createHash('sha256').update(data).digest('hex')
if (hash(readFileSync(binary)) !== build.binarySha256)
throw new Error('Binary does not match build manifest')
try {
execFileSync('git', ['check-ignore', '-q', '--no-index', output])
} catch {
throw new Error('Output directory must be Git ignored')
}
mkdirSync(dirname(output), { recursive: true })
mkdirSync(output)
execFileSync('cc', ['-O2', 'scripts/perf/sample.c', '-o', `${output}/sample`])
const command = (name, args) =>
execFileSync(name, args, { encoding: 'utf8' }).trim()
const report = {
schema: SCHEMA,
build,
contract: {
scenario: 'traffic',
replayMs,
consoleLocked,
method: 'mach-rusage-v0-seconds/rss-bytes-v1',
mode: build.mode,
buildSettingsSha256: build.buildSettingsSha256,
buildEnvironment: build.buildEnvironment,
toolchain: build.toolchain,
platform: command('sw_vers', []),
machine: command('sysctl', ['-n', 'hw.model']),
arch: process.arch,
host: hash(command('scutil', ['--get', 'LocalHostName'])),
hardware: command('sysctl', ['hw.ncpu', 'hw.memsize']),
power: command('pmset', ['-g', 'custom']),
powerSource: command('pmset', ['-g', 'batt']).split('\n')[0],
webkit: command('defaults', [
'read',
'/System/Library/Frameworks/WebKit.framework/Resources/Info',
'CFBundleVersion',
]),
seconds,
warmup,
phaseTargets,
transitionSeconds: 2,
readinessAllowanceSeconds: 5,
intervalMs: 1000,
window: [1000, 700],
windowPolicy: 'nonactivating-top-click-through-v1',
rangeMinutes: 10,
replay:
'deterministic-v1, 5Hz, no prefill, append boundary, default bezier, blur pause false',
automation:
'wdio plugin 1.3.0; 20Hz macOS runloop pump plus one WebDriver observation/second',
},
runs: [],
}
report.units = {
time: 'monotonic milliseconds',
cpu: 'cumulative CPU seconds',
percent: 'one CPU core = 100%',
rss: 'resident size bytes',
start: 'Mach process start ticks',
}
report.monotonicOriginEpochMs = performance.timeOrigin
report.harnessSha256 = hash(
['run.mjs', 'report.mjs', 'sample.c']
.map((p) => readFileSync(`scripts/perf/${p}`))
.join('\n'),
)
let interrupted = false
const abort = new AbortController()
const pause = (ms) => sleep(ms, undefined, { signal: abort.signal })
for (const signal of ['SIGINT', 'SIGTERM'])
process.on(signal, () => {
interrupted = true
abort.abort()
})
const assert = (value, reason) => {
if (!value) throw new Error(reason)
}
const drawn = (s) => s.now >= s.draw && s.now - s.draw < 2500
const marker = (s) => ({
...s,
pixelsSha256: hash(s.pixels),
pixels: undefined,
ua: undefined,
token: undefined,
})
async function freePort() {
const server = createServer()
await new Promise((done) => server.listen(0, '127.0.0.1', done))
const port = server.address().port
await new Promise((done) => server.close(done))
return port
}
for (let iteration = 0; iteration < repeat && !interrupted; iteration++) {
const initialLocked = checkConsole()
const token = randomUUID(),
port = await freePort()
const run = {
token,
ok: false,
samples: [],
observations: [],
consoleStates: [
{ name: 'initial', locked: initialLocked, time: Date.now() },
],
marks: [],
stats: {},
cleanup: {},
replay: { sent: 0, maxLateMs: 0 },
}
report.runs.push(run)
const replay = new WebSocketServer({ host: '127.0.0.1', port: 0 })
await new Promise((done) => replay.on('listening', done))
let sequence = 0,
replayStarted,
timer
replay.on('connection', () => {
if (replayStarted !== undefined) {
run.replay.error = 'unexpected replay reconnection'
clearTimeout(timer)
return
}
replayStarted = performance.now()
timer = setTimeout(send, replayMs)
})
const deadlineIndex = () =>
Math.floor((performance.now() - replayStarted) / replayMs)
function send() {
const deadline = replayStarted + (sequence + 1) * replayMs
const late = performance.now() - deadline
if (late < 0) {
timer = setTimeout(send, Math.ceil(-late))
return
}
run.replay.maxLateMs = Math.max(run.replay.maxLateMs, late)
if (late >= replayMs) {
run.replay.error = `replay source missed a complete ${replayMs} ms period`
return
}
sequence++
run.replay.sent = sequence
const timestamp = Date.now()
const point = JSON.stringify({
sequence,
timestamp,
...replayValues(sequence),
})
for (const socket of replay.clients)
if (socket.readyState === 1) socket.send(point)
timer = setTimeout(
send,
Math.max(
0,
replayStarted + (sequence + 1) * replayMs - performance.now(),
),
)
}
const log = openSync(`${output}/${iteration + 1}.log`, 'w')
const child = spawn(binary, [], {
env: {
...process.env,
PERF_TOKEN: token,
PERF_REPLAY_PORT: String(replay.address().port),
TAURI_WEBDRIVER_PORT: String(port),
},
stdio: ['ignore', log, log],
})
closeSync(log)
let browser, childError, previous, mainStart
const identities = {}
child.on('error', (error) => {
childError = error
})
const exited = new Promise((done) => child.once('exit', done))
const alive = () =>
!childError && child.exitCode === null && child.signalCode === null
const collect = (pids) =>
command(`${output}/sample`, pids.map(String))
.split('\n')
.map((line) => {
const [pid, start, cpu, rss, ...path] = line.split(' ')
return {
pid: Number(pid),
start,
cpu: Number(cpu),
rss: Number(rss),
path: path.join(' '),
time: performance.now(),
}
})
const identify = (role, row) => {
assert(
row.path === (role === 'main' ? binary : webcontentBinary),
`unexpected ${role} process executable: ${row.path}`,
)
assert(
BigInt(row.start) >= BigInt(mainStart),
'process predates owned application',
)
const identity = `${row.pid}/${row.start}`
assert(
!identities[role] || identities[role] === identity,
'process identity changed during scenario',
)
identities[role] = identity
}
const state = async (action = 'state', pixels = false) =>
browser.execute(
async (action, pixels) => {
const native = await window.__TAURI_INTERNALS__.invoke('perf_state', {
action,
})
const canvas = document.querySelector('canvas')
return {
...native,
now: Date.now(),
hidden: document.hidden,
focused: document.hasFocus(),
received: Number(document.documentElement.dataset.perfReceived),
source: Number(document.documentElement.dataset.perfSourceTime),
gaps: Number(document.documentElement.dataset.perfGaps),
worker: Number(document.documentElement.dataset.perfWorker),
points: Number(document.documentElement.dataset.perfPoints),
lastUp: Number(document.documentElement.dataset.perfLastUp),
lastDown: Number(document.documentElement.dataset.perfLastDown),
draw: Number(canvas?.dataset.perfDraw),
pixels: pixels ? canvas?.toDataURL() : null,
ua: navigator.userAgent,
}
},
action,
pixels,
)
const checked = async (action, pixels) => {
assert(!interrupted, 'interrupted')
assert(!run.replay.error, run.replay.error)
assert(
alive(),
`owned app exited: ${childError ?? child.exitCode ?? child.signalCode}`,
)
const value = await state(action, pixels)
assert(!run.replay.error, run.replay.error)
run.lastObservation = { ...value, pixels: undefined, ua: undefined }
assert(
value.token === token && value.main === child.pid,
'instance identity mismatch',
)
if (!identities.webview) {
run.initialAttribution = collect([value.webview])[0]
identify('webview', run.initialAttribution)
}
assert(
value.nativeFocused === false && value.appActive === false,
'measurement window unexpectedly activated',
)
assert(
value.top === true && value.clickThrough === true,
'measurement window must be natively topmost and click-through',
)
return value
}
try {
mainStart = collect([child.pid])[0].start
const deadline = Date.now() + 30000
while (true) {
assert(
Date.now() < deadline && !interrupted && alive(),
'embedded WebDriver readiness deadline or app exit',
)
try {
if (
(
await fetch(`http://127.0.0.1:${port}/status`, {
signal: AbortSignal.timeout(500),
})
).ok
)
break
} catch {}
await pause(100)
}
const listener = command('lsof', [
'-nP',
'-a',
'-p',
String(child.pid),
`-iTCP:${port}`,
'-sTCP:LISTEN',
])
assert(
listener.includes(`127.0.0.1:${port}`) && !listener.includes(`*:${port}`),
'driver must belong to this app and bind loopback',
)
const capabilities = createTauriCapabilities(binary)
delete capabilities['wdio:tauriServiceOptions']
browser = await remote({
hostname: '127.0.0.1',
port,
capabilities,
logLevel: 'error',
connectionRetryCount: 0,
connectionRetryTimeout: 5000,
})
const handles = await browser.getWindowHandles()
assert(handles.length === 1, 'expected exactly one measurement WebView')
await browser.switchToWindow(handles[0])
// Replay's parent effect starts the socket after the graph installs its listeners.
await browser.waitUntil(
async () => {
const s = await checked()
return (
validWorkload(s) &&
s.received >= 3 &&
s.now >= s.worker &&
s.now - s.worker < 2500
)
},
{
timeout: 15000,
interval: 100,
timeoutMsg: 'traffic effects/Worker not ready before show',
},
)
run.initialData = run.lastObservation
await checked('show')
await browser.waitUntil(
async () => {
const s = await checked()
return validObservation({ phase: 'foreground', ...s })
},
{
timeout: 15000,
interval: 250,
timeoutMsg: 'traffic data path/page not ready',
},
)
run.userAgent = (await checked()).ua
run.backgroundLoad = command('sysctl', ['-n', 'vm.loadavg'])
const viewport = await browser.execute(() => [
innerWidth,
innerHeight,
devicePixelRatio,
])
run.viewport = { dom: viewport, driver: await browser.getWindowRect() }
const driverScale = run.viewport.driver.width / 1000
assert(
viewport[0] === 1000 &&
viewport[1] > 0 &&
viewport[1] <= 700 &&
(driverScale === 1 || driverScale === viewport[2]) &&
run.viewport.driver.height / driverScale === 700,
`unexpected window dimensions: ${JSON.stringify(run.viewport)}`,
)
assert(
!report.contract.viewport ||
JSON.stringify(report.contract.viewport) === JSON.stringify(viewport),
'viewport changed between runs',
)
report.contract.viewport = viewport
run.marks.push({ name: 'ready', time: Date.now() })
const readyAt = performance.now()
assert(
phaseTargets[0] - (await checked()).received >=
(warmup * 1000) / replayMs,
'readiness exceeded fixed replay allowance',
)
for (const phase of phases) {
run.consoleStates.push({
name: phase,
locked: readConsole(),
time: Date.now(),
})
const operation = performance.now()
let s = await checked(phase === 'hidden' ? 'hide' : 'show', true)
assert(
s.visible === (phase !== 'hidden'),
`${phase}: native visibility mismatch`,
)
const beforeDraw = s.draw
await browser.waitUntil(
async () => {
s = await checked('state', true)
return (
validVisibility({ phase, ...s }) &&
(phase === 'hidden' || (s.draw > beforeDraw && drawn(s)))
)
},
{
timeout: 5000,
interval: 100,
timeoutMsg: `${phase}: window/Canvas readiness deadline`,
},
)
const operationMs = performance.now() - operation
const target = phaseTargets[phases.indexOf(phase)]
await browser.waitUntil(
async () => {
s = await checked('state', true)
assert(
s.received <= target,
`${phase}: missed replay anchor ${target}`,
)
return s.received === target
},
{
timeout: Math.max(1000, (target - s.received) * replayMs + 1000),
interval: 50,
timeoutMsg: `${phase}: replay anchor deadline`,
},
)
assert(
validObservation({ phase, ...s }),
`${phase}: invalid traffic observation`,
)
const first = s,
start = performance.now(),
firstIndex = deadlineIndex(),
phaseEpoch = Date.now()
if (phase === 'foreground') {
run.actualWarmupMs = start - readyAt
assert(
run.actualWarmupMs >= warmup * 1000,
'insufficient actual warmup',
)
}
run.marks.push({
name: phase,
time: phaseEpoch,
operationMs,
transitionMs: start - operation,
target,
...marker(s),
deadlineIndex: firstIndex,
senderSent: run.replay.sent,
})
previous = {}
if (values['fail-phase'] === phase)
throw new Error(`injected failure at ${phase}`)
while (performance.now() - start < seconds * 1000) {
const tick = performance.now()
s = await checked()
run.observations.push({ phase, ...run.lastObservation })
assert(
validObservation({ phase, ...s }),
`${phase}: invalid visibility, workload or freshness`,
)
assert(
performance.now() - replayStarted < 600000,
'replay exceeded raw history window',
)
const samples = collect(roles.map((role) => s[role]))
run.attribution = {
main: s.main,
webview: s.webview,
observed: samples,
}
for (let i = 0; i < roles.length; i++) {
const row = { ...samples[i], phase, role: roles[i] }
identify(row.role, row)
row.percent = delta(previous[row.role], row)
row.cpuDelta =
row.percent === null ? null : row.cpu - previous[row.role].cpu
run.samples.push(row)
previous[row.role] = row
}
await pause(Math.max(0, 1000 - (performance.now() - tick)))
}
s = await checked('state', true)
const endIndex = deadlineIndex()
const expected = endIndex - firstIndex
assert(
validObservation({ phase, ...s }) &&
s.worker > first.worker &&
Math.abs(s.received - first.received - expected) <= 2,
`${phase}: unstable or lost workload`,
)
if (phase !== 'hidden')
assert(
s.draw > first.draw && s.pixels !== first.pixels,
`${phase}: Canvas did not render fresh data`,
)
run.marks.push({
name: `${phase}:end`,
time: s.now,
...marker(s),
deadlineIndex: endIndex,
senderSent: run.replay.sent,
})
run.consoleStates.push({
name: `${phase}:end`,
locked: readConsole(),
time: Date.now(),
})
}
run.stats = summarize(run.samples, seconds)
assert(
validStats(run.stats),
'insufficient coverage or process discontinuity',
)
run.ok = true
} catch (error) {
run.error = String(error)
process.exitCode = 1
} finally {
await browser?.deleteSession().catch(() => {})
try {
if (alive() && collect([child.pid])[0].start === mainStart) {
child.kill('SIGTERM')
await Promise.race([exited, sleep(5000)])
if (alive() && collect([child.pid])[0].start === mainStart) {
child.kill('SIGKILL')
await Promise.race([exited, sleep(2000)])
}
}
} catch (error) {
run.cleanup.error = String(error)
}
clearTimeout(timer)
for (const socket of replay.clients) socket.terminate()
await new Promise((done) => replay.close(done))
run.cleanup.mainExited =
child.exitCode !== null || child.signalCode !== null
if (identities.webview) {
const [pid, start] = identities.webview.split('/')
const remains = () => {
try {
return collect([pid])[0].start === start
} catch {
return false
}
}
const deadline = Date.now() + 5000
while (remains() && Date.now() < deadline) await sleep(100)
run.cleanup.webviewExited = !remains()
}
try {
await fetch(`http://127.0.0.1:${port}/status`, {
signal: AbortSignal.timeout(500),
})
run.cleanup.driverClosed = false
} catch {
run.cleanup.driverClosed = true
}
if (
!run.cleanup.mainExited ||
!run.cleanup.driverClosed ||
run.cleanup.webviewExited === false
) {
run.ok = false
run.error = `${run.error ?? ''} cleanup failed`
process.exitCode = 1
}
run.stats = summarize(run.samples, seconds)
writeFileSync(
`${output}/${iteration + 1}.json`,
JSON.stringify(run, null, 2),
)
const columns = [
'phase',
'role',
'time',
'pid',
'start',
'cpu',
'percent',
'rss',
]
writeFileSync(
`${output}/${iteration + 1}.csv`,
[
columns.join(','),
...run.samples.map((r) =>
columns.map((c) => r[c] ?? 'unavailable').join(','),
),
].join('\n'),
)
writeFileSync(`${output}/summary.json`, JSON.stringify(report, null, 2))
writeFileSync(`${output}/report.md`, markdown(report))
}
console.log(`Run ${iteration + 1}: ${run.ok ? 'passed' : run.error}`)
}
console.log(`Reports: ${output}`)
+20
View File
@@ -0,0 +1,20 @@
#include <libproc.h>
#include <mach/mach_time.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
int main(int argc, char **argv) {
mach_timebase_info_data_t timebase;
if (mach_timebase_info(&timebase) != KERN_SUCCESS) return 1;
for (int i = 1; i < argc; i++) {
int pid = atoi(argv[i]);
struct rusage_info_v0 r;
char path[PROC_PIDPATHINFO_MAXSIZE];
if (pid <= 0 || proc_pid_rusage(pid, RUSAGE_INFO_V0, (rusage_info_t *)&r)) return 1;
if (proc_pidpath(pid, path, sizeof(path)) <= 0) return 1;
printf("%d %llu %.9f %llu %s\n", pid, r.ri_proc_start_abstime,
((double)r.ri_user_time + r.ri_system_time) * timebase.numer / timebase.denom / 1e9, r.ri_resident_size, path);
}
return 0;
}
-81
View File
@@ -1,81 +0,0 @@
import { ThemeProvider, createTheme } from '@mui/material'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { SWRConfig } from 'swr'
import { VirtualList } from '../../src/components/base/virtual-list'
import { ConnectionRowItem } from '../../src/components/connection/connection-row-item'
import { useConnectionRowViews } from '../../src/components/connection/connection-row-view'
import LogItem from '../../src/components/log/log-item'
import { initializeLanguage } from '../../src/services/i18n'
import { setPreloadConfig } from '../../src/services/preload'
import { layer, scenario, subscribe } from './replay'
import '../../src/assets/styles/page.scss'
const searchState = {
text: '',
matchCase: false,
matchWholeWord: false,
useRegularExpression: false,
}
export function Components() {
const [logs, setLogs] = useState<ILogItem[]>([])
const [connections, setConnections] = useState<IConnectionsItem[]>([])
useEffect(
() =>
subscribe(({ payload }) => {
if (scenario === 'logs')
setLogs((previous) =>
previous.concat(payload as ILogItem[]).slice(-1000),
)
else setConnections((payload as IConnections).connections)
}),
[],
)
const rows = useConnectionRowViews(connections)
return (
<VirtualList
count={scenario === 'logs' ? logs.length : rows.length}
estimateSize={scenario === 'logs' ? 50 : 56}
style={{ height: 640, margin: 24 }}
renderItem={(index) =>
scenario === 'logs' ? (
<LogItem
value={logs[logs.length - 1 - index]}
searchState={searchState}
/>
) : (
<ConnectionRowItem
row={rows[index]}
closed={false}
onShowDetail={() => {}}
/>
)
}
/>
)
}
dayjs.extend(relativeTime)
const root = document.getElementById('root')!
setPreloadConfig({
language: 'en',
theme_mode: 'light',
pause_render_traffic_stats_on_blur: false,
} as IVergeConfig)
await initializeLanguage('en')
const Scene =
layer === 'component'
? Components
: scenario === 'logs'
? (await import('../../src/pages/logs')).default
: (await import('../../src/pages/connections')).default
createRoot(root).render(
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
<ThemeProvider theme={createTheme()}>
<Scene />
</ThemeProvider>
</SWRConfig>,
)
-52
View File
@@ -1,52 +0,0 @@
import { ThemeProvider, createTheme } from '@mui/material'
import { useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { SWRConfig } from 'swr'
import { EnhancedCanvasTrafficGraph } from '../../src/components/home/enhanced-canvas-traffic-graph'
import { useTrafficMonitorEnhanced } from '../../src/hooks/use-traffic-monitor'
import { initializeLanguage } from '../../src/services/i18n'
import { setPreloadConfig } from '../../src/services/preload'
setPreloadConfig({
pause_render_traffic_stats_on_blur: false,
} as IVergeConfig)
await initializeLanguage('en')
export function Replay() {
const {
graphData: { appendData },
} = useTrafficMonitorEnhanced({ subscribe: false })
useEffect(() => {
let received = 0,
previous = 0,
gaps = 0
const socket = new WebSocket(
`ws://127.0.0.1:${new URLSearchParams(location.search).get('port')}`,
)
socket.onmessage = ({ data }) => {
const point = JSON.parse(data)
appendData(point)
if (previous && point.sequence !== previous + 1) gaps++
previous = point.sequence
document.documentElement.dataset.perfReceived = String(++received)
document.documentElement.dataset.perfGaps = String(gaps)
document.documentElement.dataset.perfSourceTime = String(point.timestamp)
}
return () => socket.close()
}, [appendData])
return (
<main
aria-label="Home traffic graph replay"
style={{ width: 920, height: 360, margin: 32 }}
>
<EnhancedCanvasTrafficGraph />
</main>
)
}
createRoot(document.getElementById('root')!).render(
<SWRConfig value={{ revalidateOnFocus: false, revalidateOnReconnect: false }}>
<ThemeProvider theme={createTheme()}>
<Replay />
</ThemeProvider>
</SWRConfig>,
)
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["../../src", "./*.tsx", "./*.ts"]
"include": ["../../src", "./*.tsx"]
}
+2 -2
View File
@@ -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
-2
View File
@@ -70,8 +70,6 @@ pub mod files {
pub mod tun {
pub const DEFAULT_STACK: &str = "mips";
pub const DEFAULT_MTU: u16 = 1500;
pub const DNS_HIJACK: &[&str] = &["any:53"];
/// Saved dialog fields; `enable` belongs to the verge switch.
+2 -15
View File
@@ -459,7 +459,7 @@ fn gui_tun_keys(clash_config: &Mapping) -> Vec<Value> {
keys.extend(
constants::tun::GUI_KEYS
.iter()
.filter(|key| tun.get(**key).is_some_and(|value| !value.is_null()))
.filter(|key| tun.contains_key(**key))
.map(|key| Value::from(*key)),
);
}
@@ -531,16 +531,6 @@ fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
config
}
fn ensure_tun_default_mtu(mut config: Mapping) -> Mapping {
if let Some(Value::Mapping(tun)) = config.get_mut("tun")
&& matches!(tun.get("mtu"), None | Some(Value::Null))
{
tun.insert(Value::from("mtu"), Value::from(constants::tun::DEFAULT_MTU));
}
config
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
@@ -558,9 +548,7 @@ fn merge_default_config(
});
let patch_tun = value.as_mapping().cloned().unwrap_or_else(Mapping::new);
for (key, value) in patch_tun.into_iter() {
if !value.is_null() {
tun.insert(key, value);
}
tun.insert(key, value);
}
config.insert("tun".into(), tun.into());
} else {
@@ -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);
+30 -131
View File
@@ -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 })
}
+14 -51
View File
@@ -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) =>
+4
View File
@@ -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,
])
+5 -6
View File
@@ -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>
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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"
}
}
-34
View File
@@ -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": "匹配域名"
}
}
-34
View File
@@ -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"
}
}
+2 -7
View File
@@ -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],
-32
View File
@@ -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',
-34
View File
@@ -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: {