Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.
Open
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
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ interface Window {
recordingId: number;
webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput;
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
durationMs?: number;
}) => Promise<{
success: boolean;
path?: string;
Expand Down
49 changes: 42 additions & 7 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,12 @@ type AttachNativeMacWebcamRecordingInput = {
recordingId?: number;
webcam?: RecordedVideoAssetInput;
cursorCaptureMode?: CursorCaptureMode;
/**
* Recording duration in ms. Present when the webcam took the streaming path so
* its on-disk WebM (which lacks a Duration header) can be patched here, exactly
* as store-recorded-session patches streamed screen/webcam files.
*/
durationMs?: number;
};

let selectedSource: SelectedSource | null = null;
Expand Down Expand Up @@ -2134,9 +2140,20 @@ 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). Declared before the
// handlers that consume it (attach-native-mac-webcam-recording finalizes a
// streamed webcam through this registry).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);

ipcMain.handle(
"attach-native-mac-webcam-recording",
async (_, payload: AttachNativeMacWebcamRecordingInput) => {
// When a streamed webcam is finalized to disk but a later step throws, this
// holds its path so the catch can remove the orphaned file.
let streamedWebcamRollbackPath: string | undefined;
try {
if (process.platform !== "darwin") {
return { success: false, error: "Native macOS webcam attachment requires macOS." };
Expand All @@ -2152,12 +2169,30 @@ export function registerIpcHandlers(

await fs.access(screenVideoPath, fsConstants.R_OK);

if (!payload.webcam?.fileName || !payload.webcam.videoData) {
if (!payload.webcam?.fileName) {
return { success: false, error: "Native macOS webcam attachment is missing video data." };
}

const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
// Streamed webcam bytes are already on disk (appended chunk-by-chunk during
// recording, the #616 fix); finalize() closes the stream and keeps the file.
// Otherwise the renderer sent the whole clip in memory and we write it here.
const webcamStreamed = await recordingStreams.finalize(payload.webcam.fileName);
if (webcamStreamed) {
// The file is now kept on disk; mark it so a later failure rolls it back.
streamedWebcamRollbackPath = webcamVideoPath;
if (isValidDurationMs(payload.durationMs)) {
await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs);
}
} else {
if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) {
return {
success: false,
error: "Native macOS webcam attachment is missing video data.",
};
}
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
}

const createdAt =
typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId)
Expand Down Expand Up @@ -2187,6 +2222,11 @@ export function registerIpcHandlers(
};
} catch (error) {
console.error("Failed to attach native macOS webcam recording:", error);
// A streamed webcam was already finalized to disk before this failure;
// remove the orphan so no stray *-webcam.webm lingers without a session.
if (streamedWebcamRollbackPath) {
await fs.unlink(streamedWebcamRollbackPath).catch(() => undefined);
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
Expand All @@ -2195,11 +2235,6 @@ export function registerIpcHandlers(
},
);

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

ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
return await storeRecordedSessionFiles(payload);
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
recordingId: number;
webcam: { fileName: string; videoData: ArrayBuffer };
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
durationMs?: number;
}) => {
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
},
Expand Down
15 changes: 15 additions & 0 deletions src/hooks/recorderHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,22 @@ export function createRecorderHandle(
return new Blob(memoryChunks, { type: mimeType });
}

let discarded = false;
async function discard(): Promise<void> {
// Idempotent: several early-exit paths may each call discard() for the same
// recorder (e.g. an inner catch plus an outer error handler). Set the flag
// synchronously so concurrent or repeated calls don't double-close the stream.
if (discarded) {
return;
}
discarded = true;
// Wait for a pending open to settle before deciding. Otherwise an open that
// resolves just after an early discard() would set streamOpened = true and
// flush chunks to a file this discard already skipped — orphaning the stream
// and partial file. Draining writeChain too keeps the close ordered after any
// flushed chunks.
await openPromise.catch(() => undefined);
await writeChain;
if (streamOpened && fileName && api?.closeRecordingStream) {
await api.closeRecordingStream(fileName);
}
Expand Down
Loading
Loading