返回 DeepSeek-Reasonix
recorder.ts
根目录 / desktop / electron / src / main / browser / recorder.ts
1 import { BrowserWindow, MessageChannelMain, session, type WebFrameMain } from "electron";
2 import { createHash, randomUUID } from "node:crypto";
3 import { readFile, writeFile, appendFile, rename, rm, stat } from "node:fs/promises";
4 import { join, isAbsolute } from "node:path";
5 import type { BrowserTab, BrowserSurfaceManager } from "./surfaceManager.js";
6 import type { GrantRegistry } from "./grants.js";
7 import { browserFailure } from "./errors.js";
8 import { abortable } from "./captureQueue.js";
9 import { scriptCall } from "./pageScripts.js";
10
11 interface Recording { id: string; tabId: string; grantId: string; state: "preparing" | "recording" | "finalizing" | "completed" | "cancelled" | "interrupted" | "failed"; bytes: number; width?: number; height?: number; durationMs?: number; path?: string; error?: string }
12 interface Job { info: Recording; stop(): Promise<Recording>; cancel(reason: string): Promise<void> }
13 export class BrowserRecorder {
14 private job: Job | null = null;
15 private readonly history = new Map<string, Recording>();
16 constructor(private readonly surfaces: BrowserSurfaceManager, private readonly grants: GrantRegistry, private readonly resources: string) {}
17 async close(): Promise<void> { await this.job?.cancel("browser service stopped"); }
18 async stopCurrent(): Promise<void> {
19 const job = this.job;
20 if (!job) return;
21 await job.stop();
22 }
23
24 async request(tab: BrowserTab, grantId: string, action: string, recordingId: string, directory: string, durationSeconds = 20, signal?: AbortSignal): Promise<Recording> {
25 if (action === "start") {
26 if (this.job) throw browserFailure("surface_unavailable", "another recording is active");
27 if (!isAbsolute(directory) || !(await stat(directory)).isDirectory()) throw new Error("recording requires an existing task directory");
28 if (!Number.isInteger(durationSeconds) || durationSeconds < 1 || durationSeconds > 90) throw new Error("recording duration must be 1–90 seconds");
29 signal?.throwIfAborted();
30 return this.start(tab, grantId, directory, durationSeconds, signal);
31 }
32 const info = this.history.get(recordingId);
33 if (!info || info.grantId !== grantId || info.tabId !== tab.id) throw new Error("recording does not belong to this task and tab");
34 if (action === "status") return { ...info };
35 if (action === "stop") return this.job?.info.id === recordingId ? this.job.stop() : { ...info };
36 if (action === "cancel") { if (this.job?.info.id === recordingId) await this.job.cancel("cancelled by user"); return { ...info }; }
37 throw new Error("invalid recording action");
38 }
39
40 async userRequest(tab: BrowserTab, action: string, directory: string): Promise<Omit<Recording, "grantId"> | null> {
41 const current = this.job?.info.tabId === tab.id ? this.job.info : [...this.history.values()].reverse().find(info => info.tabId === tab.id);
42 let result: Recording | undefined;
43 if (action === "start") result = await this.start(tab, "user", directory, 20, undefined, true);
44 else if (action === "status") result = current;
45 else if (action === "stop" && this.job?.info.tabId === tab.id) result = await this.job.stop();
46 else if (action === "cancel" && this.job?.info.tabId === tab.id) { await this.job.cancel("cancelled by user"); result = current; }
47 if (!result) return null;
48 const { grantId: _grant, ...publicResult } = result;
49 return publicResult;
50 }
51
52 private async start(tab: BrowserTab, grantId: string, directory: string, seconds: number, signal?: AbortSignal, userInitiated = false): Promise<Recording> {
53 if (this.job) throw browserFailure("surface_unavailable", "another recording is active");
54 const id = randomUUID();
55 const info: Recording = { id, tabId: tab.id, grantId, state: "preparing", bytes: 0 };
56 const controller = new AbortController();
57 const epoch = tab.epoch, lifecycleEpoch = tab.lifecycleEpoch, revision = tab.viewportRevision;
58 let win: BrowserWindow | undefined;
59 let release: (() => void) | undefined;
60 let finished = false;
61 let fileWrites = Promise.resolve();
62 let finishResolve!: (value: Recording) => void;
63 const completion = new Promise<Recording>(resolve => { finishResolve = resolve; });
64 const temporary = join(directory, `recording-${id}.webm.part`), target = join(directory, `recording-${id}.webm`), html = join(directory, `recorder-${id}.html`);
65 const captureSession = session.fromPartition(`reasonix-recorder-${id}`);
66 const { port1, port2 } = new MessageChannelMain();
67 let stopTimer: ReturnType<typeof setTimeout> | undefined;
68 let finalizationTimer: ReturnType<typeof setTimeout> | undefined;
69 let initialElapsed = 0;
70 let captureGeometry: { width: number; height: number; scale: number } | undefined;
71 const offSurfaces = this.surfaces.subscribe(() => {
72 const size = tab.view.captureSurfaceSize?.();
73 const changedSurface = captureGeometry && size && (size.width !== captureGeometry.width || size.height !== captureGeometry.height || (tab.view.inputScale?.() ?? 1) !== captureGeometry.scale);
74 const controlChanged = !userInitiated && (tab.mode !== "agent" || tab.epoch !== epoch);
75 if (this.surfaces.get(tab.id) !== tab || controlChanged || tab.view.page.isDestroyed() || tab.lifecycleEpoch !== lifecycleEpoch || tab.viewportRevision !== revision || changedSurface) void cancel("target closed, changed, crashed or taken over");
76 });
77 const offGrant = this.grants.onRevoke(grant => { if (!userInitiated && grant.grantId === grantId) void cancel("grant revoked"); });
78 const cleanup = async (discard: boolean, outcome: Partial<Recording>) => {
79 if (finished) return;
80 finished = true;
81 // Terminal status promises the global slot and all temporary resources
82 // are released. Keep cleanup visible as finalizing until that is true.
83 info.state = "finalizing";
84 clearTimeout(stopTimer); clearTimeout(finalizationTimer);
85 offSurfaces(); offGrant(); controller.abort();
86 captureSession.setDisplayMediaRequestHandler(null);
87 port1.close();
88 if (win && !win.isDestroyed()) win.destroy();
89 release?.();
90 await fileWrites.catch(() => {});
91 try {
92 await rm(html, { force: true });
93 if (discard) { await rm(temporary, { force: true }); await rm(target, { force: true }); }
94 } catch { outcome = { state: "interrupted", error: "recording cleanup failed", path: undefined }; }
95 finally {
96 if (this.job?.info.id === id) this.job = null;
97 Object.assign(info, outcome);
98 finishResolve({ ...info });
99 }
100 };
101 const cancel = async (reason: string, failure = false) => {
102 if (finished) { await completion; return; }
103 port1.postMessage({ type: "cancel" });
104 await cleanup(true, { state: failure ? "failed" : reason === "cancelled by user" ? "cancelled" : "interrupted", error: reason });
105 };
106 const stop = async () => {
107 if (finished) return completion;
108 if (info.state === "preparing") await cancel("cancelled by user");
109 if (info.state === "recording") {
110 info.state = "finalizing";
111 port1.postMessage({ type: "stop" });
112 finalizationTimer = setTimeout(() => { void cancel("WebM finalization deadline exceeded"); }, 10_000);
113 }
114 return completion;
115 };
116 this.job = { info, stop, cancel };
117 const abortStart = () => { void cancel("recording request cancelled"); };
118 signal?.addEventListener("abort", abortStart, { once: true });
119 this.history.set(id, info);
120 while (this.history.size > 10) this.history.delete(this.history.keys().next().value!);
121 try {
122 release = await tab.view.prepareCapture?.(controller.signal, true);
123 if (!userInitiated) this.grants.verifyTab(grantId, tab.taskId, tab.sessionId);
124 controller.signal.throwIfAborted();
125 const geometry = await abortable(tab.view.page.executeJavaScriptInIsolatedWorld(1, [{ code: scriptCall("pageReady", {}) }]), controller.signal) as { width: number; height: number };
126 const surface = tab.view.captureSurfaceSize?.() ?? geometry;
127 captureGeometry = { ...surface, scale: tab.view.inputScale?.() ?? 1 };
128 const scale = tab.view.inputScale?.() ?? 1;
129 if (geometry.width * scale > surface.width + 1 || geometry.height * scale > surface.height + 1) throw browserFailure("surface_unavailable", "recording viewport is clipped; choose Fit and show the page before retrying");
130 const cropWidth = Math.min(1, geometry.width * scale / surface.width), cropHeight = Math.min(1, geometry.height * scale / surface.height);
131 if (!Number.isInteger(geometry.width) || geometry.width <= 0 || geometry.width > 3840 || !Number.isInteger(geometry.height) || geometry.height <= 0 || geometry.height > 2160) throw new Error("recording viewport exceeds pixel budget");
132 const source = (await readFile(join(this.resources, "browser-recorder.js"), "utf8")).replace(/<\/script/gi, "<\\/script");
133 controller.signal.throwIfAborted();
134 const hash = createHash("sha256").update(source).digest("base64");
135 fileWrites = fileWrites.then(() => writeFile(html, `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-${hash}'; media-src blob:"><script>${source}</script>`, { mode: 0o600 }));
136 await fileWrites; controller.signal.throwIfAborted();
137 fileWrites = fileWrites.then(() => writeFile(temporary, Buffer.alloc(0), { mode: 0o600, flag: "wx" }));
138 await fileWrites; controller.signal.throwIfAborted();
139 win = new BrowserWindow({ show: false, focusable: false, skipTaskbar: true, webPreferences: { session: captureSession, sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, preload: join(this.resources, "recorder-preload.cjs") } });
140 const targetFrame = tab.view.page.mainFrame as WebFrameMain;
141 captureSession.setDisplayMediaRequestHandler((request, callback) => {
142 if (finished || request.frame !== win?.webContents.mainFrame || request.audioRequested || !request.videoRequested || targetFrame.detached || !userInitiated && tab.mode !== "agent") callback({});
143 else callback({ video: targetFrame });
144 });
145 win.webContents.once("render-process-gone", () => { void cancel("recorder renderer crashed"); });
146 const started = new Promise<void>((resolve, reject) => {
147 port1.on("message", ({ data }) => {
148 if (finished) return;
149 if (data.type === "ready") port1.postMessage({ type: "start", ...geometry, cropWidth, cropHeight });
150 if (data.type === "started") { initialElapsed = Number(data.elapsedMs) || 0; info.state = "recording"; resolve(); }
151 if (data.type === "error") { reject(new Error(String(data.message))); void cancel("recorder failed: " + String(data.message), true); }
152 if (data.type === "chunk") {
153 const chunk = Buffer.from(data.data as ArrayBuffer);
154 info.bytes += chunk.length;
155 if (info.bytes > 64 * 1024 * 1024) { void cancel("recording exceeded 64 MiB", true); return; }
156 fileWrites = fileWrites.then(() => appendFile(temporary, chunk));
157 void fileWrites.catch(() => cancel("recording file write failed", true));
158 }
159 if (data.type === "stopped") {
160 void (async () => {
161 try {
162 await fileWrites;
163 if (finished) return;
164 const bytes = await readFile(temporary);
165 if (bytes.length !== info.bytes || bytes.length < 32 || bytes.readUInt32BE(0) !== 0x1a45dfa3 || data.width !== geometry.width || data.height !== geometry.height || !Number.isFinite(data.durationMs) || data.durationMs <= 0) throw new Error("invalid WebM result");
166 await rename(temporary, target);
167 if (finished) { await rm(target, { force: true }); return; }
168 await cleanup(false, { state: "completed", width: data.width, height: data.height, durationMs: data.durationMs, path: target });
169 } catch { await cancel("WebM validation failed", true); }
170 })();
171 }
172 });
173 });
174 port1.start();
175 await win.loadFile(html);
176 controller.signal.throwIfAborted();
177 win.webContents.postMessage("reasonix-recorder-port", null, [port2]);
178 const timer = setTimeout(() => controller.abort(), 10_000);
179 try { await abortable(started, controller.signal); } finally { clearTimeout(timer); }
180 stopTimer = setTimeout(() => { void stop(); }, Math.max(0, seconds * 1000 - initialElapsed));
181 return { ...info };
182 } catch (error) { await cancel(String(error), true); throw error; }
183 finally { signal?.removeEventListener("abort", abortStart); }
184 }
185 }
186
186 lines TYPESCRIPT