Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ interface Window {
message?: string;
error?: string;
}>;
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
appendRecordingChunk: (
fileName: string,
chunk: ArrayBuffer,
) => Promise<{ success: boolean; error?: string }>;
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
Expand Down
61 changes: 59 additions & 2 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import { RECORDINGS_DIR } from "../main";
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
import { registerNativeBridgeHandlers } from "./nativeBridge";
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";

const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
Expand Down Expand Up @@ -265,6 +267,30 @@ function resolveRecordingOutputPath(fileName: string): string {
return path.join(RECORDINGS_DIR, parsedPath.base);
}

function isValidDurationMs(value: number | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value > 0;
}

/**
* Finalize a single recording file: if it was streamed to disk, flush and close
* the stream; otherwise (a short recording, or the stream failed to open and the
* renderer fell back to in-memory buffering) write the buffered bytes. Returns
* whether the file was streamed, which the caller uses to decide whether the
* WebM duration needs patching on disk.
*/
async function finalizeRecordingFile(
registry: RecordingStreamRegistry,
fileName: string,
filePath: string,
videoData?: ArrayBuffer,
): Promise<boolean> {
const streamed = await registry.finalize(fileName);
if (!streamed && videoData && videoData.byteLength > 0) {
await fs.writeFile(filePath, Buffer.from(videoData));
}
return streamed;
}

async function getApprovedProjectSession(
project: unknown,
projectFilePath?: string,
Expand Down Expand Up @@ -2141,6 +2167,12 @@ export function registerIpcHandlers(
},
);

// On-disk write streams for in-progress recordings, keyed by output file name.
// Chunks are appended as they arrive from ondataavailable so the renderer
// never buffers the full video in memory (the #616 fix).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);

ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
return await storeRecordedSessionFiles(payload);
Expand All @@ -2161,12 +2193,37 @@ export function registerIpcHandlers(
: Date.now();
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
const screenStreamed = await finalizeRecordingFile(
recordingStreams,
payload.screen.fileName,
screenVideoPath,
payload.screen.videoData,
);

let webcamVideoPath: string | undefined;
let webcamStreamed = false;
if (payload.webcam) {
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
webcamStreamed = await finalizeRecordingFile(
recordingStreams,
payload.webcam.fileName,
webcamVideoPath,
payload.webcam.videoData,
);
}

// Streamed files lack the WebM Duration header (the renderer no longer holds
// the blob to patch). Patch on disk so the editor's seek bar and timeline
// work. Best-effort and independent per file, so the patches run together.
if (isValidDurationMs(payload.durationMs)) {
const patches: Promise<unknown>[] = [];
if (screenStreamed) {
patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs));
}
if (webcamStreamed && webcamVideoPath) {
patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs));
}
await Promise.all(patches);
}

const session: RecordingSession = webcamVideoPath
Expand Down
84 changes: 84 additions & 0 deletions electron/ipc/recordingStream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RecordingStreamRegistry } from "./recordingStream";

describe("RecordingStreamRegistry", () => {
let dir: string;
const pathFor = (name: string) => path.join(dir, name);

beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it("streams chunks to disk in order and reports streamed on finalize", async () => {
const registry = new RecordingStreamRegistry();
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("hello "));
await registry.append("rec.webm", Buffer.from("world"));

const streamed = await registry.finalize("rec.webm");

expect(streamed).toBe(true);
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
// A second finalize has nothing to close.
expect(await registry.finalize("rec.webm")).toBe(false);
});

it("reports not-streamed when no stream was opened", async () => {
const registry = new RecordingStreamRegistry();
expect(await registry.finalize("missing.webm")).toBe(false);
expect(registry.has("missing.webm")).toBe(false);
});

it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
const registry = new RecordingStreamRegistry();
// Parent directory does not exist, so createWriteStream emits 'error' on open.
await expect(
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
).rejects.toThrow();
// A failed open must not register a stream the renderer would treat as live.
expect(registry.has("rec.webm")).toBe(false);
});

it("rejects append when no stream is open", async () => {
const registry = new RecordingStreamRegistry();
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
/No active recording stream/,
);
});

it("discard closes the stream and removes the partial file", async () => {
const registry = new RecordingStreamRegistry();
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("partial"));

await registry.discard("rec.webm", pathFor("rec.webm"));

expect(registry.has("rec.webm")).toBe(false);
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
// Nothing left to finalize after a discard.
expect(await registry.finalize("rec.webm")).toBe(false);
});

it("discard tolerates a missing file", async () => {
const registry = new RecordingStreamRegistry();
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
});

it("opening the same file twice replaces the prior stream", async () => {
const registry = new RecordingStreamRegistry();
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("first"));
await registry.open("rec.webm", pathFor("rec.webm"));
await registry.append("rec.webm", Buffer.from("second"));
await registry.finalize("rec.webm");

expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
});
});
147 changes: 147 additions & 0 deletions electron/ipc/recordingStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { createWriteStream, type WriteStream } from "node:fs";
import { unlink } from "node:fs/promises";
import type { IpcMain } from "electron";

/**
* Owns the lifecycle of on-disk write streams for in-progress recordings, keyed
* by the recording's output file name. Browser MediaRecorder chunks are appended
* here as they arrive so a long recording never buffers the whole video in the
* renderer (the #616 fix).
*
* The file name is the key because it is the one value the renderer and main
* process already exchange and it is globally unique per recording, so there is
* no derived/offset key to keep in sync across the IPC boundary.
*/
export class RecordingStreamRegistry {
private readonly streams = new Map<string, WriteStream>();

/**
* Open a write stream and resolve only once the OS confirms it is writable.
* Resolving on the `open` event (rather than on `createWriteStream` returning)
* means a bad path or permission error rejects here instead of surfacing as a
* silent chunk drop later, so the renderer's fallback can take over.
*/
async open(fileName: string, filePath: string): Promise<void> {
await this.endStream(fileName);

const ws = createWriteStream(filePath, { flags: "w" });
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error);
ws.once("error", onError);
ws.once("open", () => {
ws.removeListener("error", onError);
resolve();
});
});
// Keep a listener for the stream's lifetime so a late error logs rather
// than crashing the main process with an unhandled 'error' event. Per-write
// failures still surface through the `append` callback below.
ws.on("error", (error) => {
console.error(`[recording-stream] ${fileName}:`, error);
});

this.streams.set(fileName, ws);
}

has(fileName: string): boolean {
return this.streams.has(fileName);
}

/** Append a chunk; rejects if no stream is open or the write fails. */
async append(fileName: string, chunk: Buffer): Promise<void> {
const ws = this.streams.get(fileName);
if (!ws) {
throw new Error(`No active recording stream for ${fileName}`);
}
await new Promise<void>((resolve, reject) => {
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
});
}

/**
* Flush and close the stream, keeping the file. Returns whether a stream was
* open — i.e. whether the recording was streamed to disk (true) or needs its
* in-memory buffer written by the caller (false).
*/
async finalize(fileName: string): Promise<boolean> {
const ws = this.streams.get(fileName);
if (!ws) {
return false;
}
this.streams.delete(fileName);
await new Promise<void>((resolve, reject) => {
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
});
return true;
}

/**
* Close the stream (if any) and delete the partial file. Used when a streamed
* recording is discarded or fails before a successful save, so cancelled runs
* don't leak file descriptors or orphan partial recordings on disk.
*/
async discard(fileName: string, filePath: string): Promise<void> {
await this.endStream(fileName);
await unlink(filePath).catch(() => undefined);
}
Comment on lines +83 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Discard path hides real delete failures

lowkey risky: Line 85 swallows all unlink errors, so close-recording-stream can report success while a partial recording still exists on disk. Ignore only ENOENT; rethrow the rest.

Suggested fix
 async discard(fileName: string, filePath: string): Promise<void> {
 	await this.endStream(fileName);
-	await unlink(filePath).catch(() => undefined);
+	try {
+		await unlink(filePath);
+	} catch (error) {
+		const err = error as NodeJS.ErrnoException;
+		if (err.code !== "ENOENT") {
+			throw error;
+		}
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/ipc/recordingStream.ts` around lines 83 - 86, In discard, don't
swallow all unlink errors; after awaiting this.endStream(fileName) catch unlink
errors and only ignore those with err.code === 'ENOENT', rethrow any other error
so delete failures surface to the caller (update the error handler for unlink in
the discard method to check err.code and rethrow non-ENOENT errors) — this keeps
the close-recording-stream flow from reporting success when a real delete
failed.


private async endStream(fileName: string): Promise<void> {
const ws = this.streams.get(fileName);
if (!ws) {
return;
}
this.streams.delete(fileName);
await new Promise<void>((resolve) => ws.end(() => resolve()));
}
}

/**
* Register the streaming IPC handlers. Thin wrappers that translate the
* registry's throw-on-failure contract into the `{ success, error }` shape the
* renderer expects.
*/
export function registerRecordingStreamHandlers(
ipcMain: IpcMain,
registry: RecordingStreamRegistry,
resolveRecordingOutputPath: (fileName: string) => string,
): void {
ipcMain.handle(
"open-recording-stream",
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
try {
await registry.open(fileName, resolveRecordingOutputPath(fileName));
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);

ipcMain.handle(
"append-recording-chunk",
async (
_,
fileName: string,
chunk: ArrayBuffer,
): Promise<{ success: boolean; error?: string }> => {
try {
await registry.append(fileName, Buffer.from(chunk));
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);

ipcMain.handle(
"close-recording-stream",
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
try {
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
},
);
}
9 changes: 9 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
return ipcRenderer.invoke("store-recorded-session", payload);
},
openRecordingStream: (fileName: string) => {
return ipcRenderer.invoke("open-recording-stream", fileName);
},
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
},
closeRecordingStream: (fileName: string) => {
return ipcRenderer.invoke("close-recording-stream", fileName);
},

getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
Expand Down
Loading
Loading