| 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 |