Files
tutti/tools/scripts/capture-electron-trace.mjs
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

419 lines
11 KiB
JavaScript

#!/usr/bin/env node
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const defaultPort = 9223;
export const defaultTraceCategories = [
"devtools.timeline",
"disabled-by-default-devtools.timeline",
"disabled-by-default-devtools.timeline.frame",
"toplevel",
"blink.user_timing",
"loading",
"v8",
"disabled-by-default-v8.cpu_profiler",
"disabled-by-default-v8.cpu_profiler.hires",
"disabled-by-default-renderer.scheduler"
].join(",");
if (isMainModule()) {
main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
});
}
export async function main(argv) {
const options = parseArgs(argv);
if (options.help) {
printUsage();
return;
}
const port = positiveInteger(options.port, "--port");
const durationMs =
options.duration == null
? null
: Math.round(positiveNumber(options.duration, "--duration") * 1000);
const outputPath = resolve(
expandHome(options.output ?? defaultOutputPath(new Date()))
);
const categories = options.categories ?? defaultTraceCategories;
if (durationMs == null && !process.stdin.isTTY) {
throw new Error("stdin is not interactive; pass --duration <seconds>");
}
const websocketUrl = await resolveBrowserWebSocketUrl(port);
await mkdir(dirname(outputPath), { recursive: true });
const client = await CdpClient.connect(websocketUrl);
try {
log(`connected ${websocketUrl}`);
log(`writing ${outputPath}`);
log(`categories ${categories}`);
await client.send("Tracing.start", {
categories,
options: "sampling-frequency=10000",
transferMode: "ReturnAsStream"
});
log("recording started");
if (durationMs == null) {
log("press Enter to stop");
await waitForEnter();
} else {
log(`stopping after ${(durationMs / 1000).toFixed(1)}s`);
await delay(durationMs);
}
const complete = client.waitForEvent("Tracing.tracingComplete");
await client.send("Tracing.end");
const event = await complete;
const stream = event.params?.stream;
if (!stream) {
throw new Error(
"Tracing.tracingComplete did not include a stream handle"
);
}
const bytes = await writeStreamToFile(client, stream, outputPath);
log(`saved ${outputPath} (${formatBytes(bytes)})`);
} finally {
client.close();
}
}
export async function resolveBrowserWebSocketUrl(port) {
const baseUrl = `http://127.0.0.1:${port}`;
const version = await fetchJson(`${baseUrl}/json/version`).catch((error) => {
throw new Error(
`cannot reach Electron CDP at ${baseUrl}: ${error.message}\n` +
"Launch with: TUTTI_ELECTRON_REMOTE_DEBUGGING_PORT=9223 TUTTI_ELECTRON_JS_FLAGS=--max-old-space-size=8192 VITE_TUTTI_WHY_DID_YOU_RENDER=0 make dev-gui"
);
});
if (typeof version.webSocketDebuggerUrl === "string") {
return version.webSocketDebuggerUrl;
}
const targets = await fetchJson(`${baseUrl}/json/list`);
const pageTarget = Array.isArray(targets)
? targets.find(
(target) =>
target?.type === "page" &&
typeof target.webSocketDebuggerUrl === "string"
)
: null;
if (pageTarget) {
return pageTarget.webSocketDebuggerUrl;
}
throw new Error(`no browser or page websocket found at ${baseUrl}`);
}
export async function resolvePageWebSocketUrl(port) {
const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`);
const pageTarget = Array.isArray(targets)
? targets.find(
(target) =>
target?.type === "page" &&
typeof target.webSocketDebuggerUrl === "string"
)
: null;
if (!pageTarget) {
throw new Error(`no page websocket found at http://127.0.0.1:${port}`);
}
return pageTarget.webSocketDebuggerUrl;
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} returned HTTP ${response.status}`);
}
return await response.json();
}
export async function writeStreamToFile(client, stream, outputPath) {
const file = createWriteStream(outputPath, { flags: "w" });
let bytes = 0;
try {
while (true) {
const result = await client.send("IO.read", { handle: stream });
const data = String(result.data ?? "");
if (data.length > 0) {
const buffer = Buffer.from(
data,
result.base64Encoded ? "base64" : "utf8"
);
bytes += buffer.byteLength;
if (!file.write(buffer)) {
await once(file, "drain");
}
}
if (result.eof) {
break;
}
}
} finally {
await client.send("IO.close", { handle: stream }).catch(() => {});
await new Promise((resolveClose, rejectClose) => {
file.end((error) => {
if (error) {
rejectClose(error);
} else {
resolveClose();
}
});
});
}
return bytes;
}
export class CdpClient {
static async connect(url) {
const socket = new WebSocket(url);
const client = new CdpClient(socket);
await onceSocketOpen(socket);
return client;
}
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
this.eventWaiters = new Map();
this.eventListeners = new Map();
socket.addEventListener("message", (event) => {
this.handleMessage(event.data);
});
socket.addEventListener("close", () => {
const error = new Error("CDP websocket closed");
for (const { reject } of this.pending.values()) {
reject(error);
}
this.pending.clear();
});
}
send(method, params = {}) {
const id = this.nextId++;
const payload = JSON.stringify({ id, method, params });
const promise = new Promise((resolveSend, rejectSend) => {
this.pending.set(id, { resolve: resolveSend, reject: rejectSend });
});
this.socket.send(payload);
return promise;
}
waitForEvent(method) {
return new Promise((resolveEvent) => {
const waiters = this.eventWaiters.get(method) ?? [];
waiters.push(resolveEvent);
this.eventWaiters.set(method, waiters);
});
}
subscribe(method, listener) {
const listeners = this.eventListeners.get(method) ?? new Set();
listeners.add(listener);
this.eventListeners.set(method, listeners);
return () => {
listeners.delete(listener);
};
}
close() {
this.socket.close();
}
handleMessage(raw) {
const text =
typeof raw === "string"
? raw
: Buffer.from(raw instanceof ArrayBuffer ? raw : []).toString("utf8");
const message = JSON.parse(text);
if (message.id != null) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(
new Error(
`${message.error.message ?? "CDP error"} (${message.error.code ?? "unknown"})`
)
);
} else {
pending.resolve(message.result ?? {});
}
return;
}
if (message.method) {
const listeners = this.eventListeners.get(message.method);
for (const listener of listeners ?? []) {
listener(message);
}
const waiters = this.eventWaiters.get(message.method);
if (!waiters?.length) {
return;
}
this.eventWaiters.set(message.method, []);
for (const resolveEvent of waiters) {
resolveEvent(message);
}
}
}
}
function parseArgs(argv) {
const options = {
port: String(defaultPort)
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (arg === "--port") {
options.port = requiredValue(argv, (index += 1), arg);
continue;
}
if (arg === "--duration") {
options.duration = requiredValue(argv, (index += 1), arg);
continue;
}
if (arg === "--output" || arg === "-o") {
options.output = requiredValue(argv, (index += 1), arg);
continue;
}
if (arg === "--categories") {
options.categories = requiredValue(argv, (index += 1), arg);
continue;
}
throw new Error(`Unknown option: ${arg}`);
}
return options;
}
function requiredValue(argv, index, option) {
const value = argv[index];
if (!value || value.startsWith("--")) {
throw new Error(`${option} requires a value`);
}
return value;
}
function positiveInteger(value, option) {
const number = Number(value);
if (!Number.isInteger(number) || number <= 0) {
throw new Error(`${option} must be a positive integer`);
}
return number;
}
function positiveNumber(value, option) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
throw new Error(`${option} must be a positive number`);
}
return number;
}
function defaultOutputPath(date) {
const stamp = date
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}Z$/, "");
return join(homedir(), "Downloads", `TuttiTrace-${stamp}.json`);
}
function expandHome(path) {
if (path === "~") {
return homedir();
}
if (path.startsWith("~/")) {
return join(homedir(), path.slice(2));
}
return path;
}
function waitForEnter() {
process.stdin.resume();
process.stdin.setEncoding("utf8");
return new Promise((resolveEnter) => {
process.stdin.once("data", () => {
resolveEnter();
});
});
}
function once(emitter, eventName) {
return new Promise((resolveOnce) => {
emitter.once(eventName, resolveOnce);
});
}
function onceSocketOpen(socket) {
return new Promise((resolveOpen, rejectOpen) => {
socket.addEventListener("open", resolveOpen, { once: true });
socket.addEventListener(
"error",
() => rejectOpen(new Error("CDP websocket connection failed")),
{ once: true }
);
});
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KiB`;
}
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}
function isMainModule() {
return import.meta.url === `file://${process.argv[1]}`;
}
function log(message) {
process.stderr.write(`[capture-electron-trace] ${message}\n`);
}
function printUsage() {
process.stdout.write(`Capture an Electron performance trace through CDP.
Usage:
pnpm trace:desktop -- --duration 15
pnpm trace:desktop -- --output ~/Downloads/tutti-trace.json
Options:
--port <port> CDP remote debugging port. Default: ${defaultPort}
--duration <seconds> Stop automatically after this many seconds.
-o, --output <path> Output trace JSON path. Default: ~/Downloads/TuttiTrace-<timestamp>.json
--categories <list> Comma-separated CDP trace categories.
-h, --help Show this help.
Launch Tutti first:
TUTTI_ELECTRON_REMOTE_DEBUGGING_PORT=9223 TUTTI_ELECTRON_JS_FLAGS=--max-old-space-size=8192 VITE_TUTTI_WHY_DID_YOU_RENDER=0 make dev-gui
`);
}