f3c5b8a65d
Addresses the review feedback on #658 (CodeRabbit + Codex) and the structural notes from the quality pass. Correctness: - Compute the recorder's streaming state at finalize time, not at construction. A stream that fails to open is now reported as not-streamed, so its buffered chunks are saved as a complete in-memory fallback instead of being dropped (was total data loss on open failure). - Await every in-flight chunk write before onstop resolves, so the main process never closes the write stream while a final chunk is still in flight (was truncating the tail of a recording under load). - Open the disk write stream by awaiting its 'open' event, so a bad path or permission error rejects up front instead of being acknowledged as success and then silently dropping bytes. - Close the stream and remove the partial file when a streamed recording is discarded or fails, so cancelled/failed runs don't leak descriptors or orphan partial recordings. - Surface a mid-stream write failure as a rejected recording rather than saving a silently truncated file. Structure: - Extract the streaming concern into electron/ipc/recordingStream.ts (RecordingStreamRegistry) and src/hooks/recorderHandle.ts, out of the 2.8k-line handlers.ts and the screen-recorder hook. - Key write streams by output file name, removing the implicit recordingId/+1 contract that spanned the IPC boundary. - Collapse the duplicated screen/webcam finalize blocks into one helper and the repeated duration-validity guard into one check; patch the screen and webcam durations in parallel. Adds unit tests for the registry (real temp-dir fs) and the recorder handle state machine (open-failure fallback, in-order writes awaited before stop, mid-stream failure). Extends the vitest include glob to collect electron-side tests. Verified: tsc --noEmit clean; biome clean; vitest 180/180. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
9.7 KiB
TypeScript
257 lines
9.7 KiB
TypeScript
import { contextBridge, ipcRenderer } from "electron";
|
|
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
|
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
|
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
|
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
|
|
|
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
|
// (see windows.ts). Sandboxed preloads cannot import node:path / node:url, so we
|
|
// can't compute it here.
|
|
const ASSET_BASE_URL_ARG_PREFIX = "--asset-base-url=";
|
|
const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL_ARG_PREFIX));
|
|
const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : "";
|
|
|
|
contextBridge.exposeInMainWorld("electronAPI", {
|
|
assetBaseUrl,
|
|
invokeNativeBridge: <TData>(request: NativeBridgeRequest) => {
|
|
return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise<TData>;
|
|
},
|
|
hudOverlayHide: () => {
|
|
ipcRenderer.send("hud-overlay-hide");
|
|
},
|
|
hudOverlayClose: () => {
|
|
ipcRenderer.send("hud-overlay-close");
|
|
},
|
|
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => {
|
|
ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore);
|
|
},
|
|
moveHudOverlayBy: (deltaX: number, deltaY: number) => {
|
|
ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY);
|
|
},
|
|
getSources: async (opts: Electron.SourcesOptions) => {
|
|
return await ipcRenderer.invoke("get-sources", opts);
|
|
},
|
|
switchToEditor: () => {
|
|
return ipcRenderer.invoke("switch-to-editor");
|
|
},
|
|
switchToHud: () => {
|
|
return ipcRenderer.invoke("switch-to-hud");
|
|
},
|
|
startNewRecording: () => {
|
|
return ipcRenderer.invoke("start-new-recording");
|
|
},
|
|
openSourceSelector: () => {
|
|
return ipcRenderer.invoke("open-source-selector");
|
|
},
|
|
selectSource: (source: ProcessedDesktopSource) => {
|
|
return ipcRenderer.invoke("select-source", source);
|
|
},
|
|
getSelectedSource: () => {
|
|
return ipcRenderer.invoke("get-selected-source");
|
|
},
|
|
requestCameraAccess: () => {
|
|
return ipcRenderer.invoke("request-camera-access");
|
|
},
|
|
requestScreenAccess: () => {
|
|
return ipcRenderer.invoke("request-screen-access");
|
|
},
|
|
requestNativeMacCursorAccess: () => {
|
|
return ipcRenderer.invoke("request-native-mac-cursor-access");
|
|
},
|
|
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
|
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
|
},
|
|
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");
|
|
},
|
|
setRecordingState: (
|
|
recording: boolean,
|
|
recordingId?: number,
|
|
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode,
|
|
) => {
|
|
return ipcRenderer.invoke("set-recording-state", recording, recordingId, cursorCaptureMode);
|
|
},
|
|
isNativeWindowsCaptureAvailable: () => {
|
|
return ipcRenderer.invoke("is-native-windows-capture-available");
|
|
},
|
|
isNativeMacCaptureAvailable: () => {
|
|
return ipcRenderer.invoke("is-native-mac-capture-available");
|
|
},
|
|
startNativeWindowsRecording: (request: NativeWindowsRecordingRequest) => {
|
|
return ipcRenderer.invoke("start-native-windows-recording", request);
|
|
},
|
|
stopNativeWindowsRecording: (discard?: boolean) => {
|
|
return ipcRenderer.invoke("stop-native-windows-recording", discard);
|
|
},
|
|
pauseNativeWindowsRecording: () => {
|
|
return ipcRenderer.invoke("pause-native-windows-recording");
|
|
},
|
|
resumeNativeWindowsRecording: () => {
|
|
return ipcRenderer.invoke("resume-native-windows-recording");
|
|
},
|
|
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
|
|
return ipcRenderer.invoke("start-native-mac-recording", request);
|
|
},
|
|
pauseNativeMacRecording: () => {
|
|
return ipcRenderer.invoke("pause-native-mac-recording");
|
|
},
|
|
resumeNativeMacRecording: () => {
|
|
return ipcRenderer.invoke("resume-native-mac-recording");
|
|
},
|
|
stopNativeMacRecording: (discard?: boolean) => {
|
|
return ipcRenderer.invoke("stop-native-mac-recording", discard);
|
|
},
|
|
attachNativeMacWebcamRecording: (payload: {
|
|
screenVideoPath: string;
|
|
recordingId: number;
|
|
webcam: { fileName: string; videoData: ArrayBuffer };
|
|
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
|
|
}) => {
|
|
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
|
|
},
|
|
getCursorTelemetry: (videoPath?: string) => {
|
|
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
|
},
|
|
discardCursorTelemetry: (recordingId: number) => {
|
|
return ipcRenderer.invoke("discard-cursor-telemetry", recordingId);
|
|
},
|
|
onStopRecordingFromTray: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("stop-recording-from-tray", listener);
|
|
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
|
|
},
|
|
openExternalUrl: (url: string) => {
|
|
return ipcRenderer.invoke("open-external-url", url);
|
|
},
|
|
pickExportSavePath: (fileName: string, exportFolder?: string) => {
|
|
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
|
|
},
|
|
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
|
|
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
|
|
},
|
|
openVideoFilePicker: () => {
|
|
return ipcRenderer.invoke("open-video-file-picker");
|
|
},
|
|
setCurrentVideoPath: (path: string) => {
|
|
return ipcRenderer.invoke("set-current-video-path", path);
|
|
},
|
|
setCurrentRecordingSession: (session: RecordingSession | null) => {
|
|
return ipcRenderer.invoke("set-current-recording-session", session);
|
|
},
|
|
getCurrentVideoPath: () => {
|
|
return ipcRenderer.invoke("get-current-video-path");
|
|
},
|
|
getCurrentRecordingSession: () => {
|
|
return ipcRenderer.invoke("get-current-recording-session");
|
|
},
|
|
readBinaryFile: (filePath: string) => {
|
|
return ipcRenderer.invoke("read-binary-file", filePath);
|
|
},
|
|
preparePreviewAudioTrack: (filePath: string) => {
|
|
return ipcRenderer.invoke("prepare-preview-audio-track", filePath);
|
|
},
|
|
clearCurrentVideoPath: () => {
|
|
return ipcRenderer.invoke("clear-current-video-path");
|
|
},
|
|
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
|
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
|
|
},
|
|
loadProjectFile: () => {
|
|
return ipcRenderer.invoke("load-project-file");
|
|
},
|
|
loadCurrentProjectFile: () => {
|
|
return ipcRenderer.invoke("load-current-project-file");
|
|
},
|
|
onMenuLoadProject: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("menu-load-project", listener);
|
|
return () => ipcRenderer.removeListener("menu-load-project", listener);
|
|
},
|
|
onMenuSaveProject: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("menu-save-project", listener);
|
|
return () => ipcRenderer.removeListener("menu-save-project", listener);
|
|
},
|
|
onMenuSaveProjectAs: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("menu-save-project-as", listener);
|
|
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
|
|
},
|
|
getPlatform: () => {
|
|
return ipcRenderer.invoke("get-platform");
|
|
},
|
|
revealInFolder: (filePath: string) => {
|
|
return ipcRenderer.invoke("reveal-in-folder", filePath);
|
|
},
|
|
getShortcuts: () => {
|
|
return ipcRenderer.invoke("get-shortcuts");
|
|
},
|
|
saveShortcuts: (shortcuts: unknown) => {
|
|
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
|
},
|
|
setLocale: (locale: string) => {
|
|
return ipcRenderer.invoke("set-locale", locale);
|
|
},
|
|
saveDiagnostic: (payload: {
|
|
error: string;
|
|
stack?: string;
|
|
projectState: unknown;
|
|
logs: string[];
|
|
}) => {
|
|
return ipcRenderer.invoke("save-diagnostic", payload);
|
|
},
|
|
setMicrophoneExpanded: (expanded: boolean) => {
|
|
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
|
},
|
|
setHasUnsavedChanges: (hasChanges: boolean) => {
|
|
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
|
},
|
|
showCountdownOverlay: (value: number, runId: number) => {
|
|
return ipcRenderer.invoke("countdown-overlay-show", value, runId);
|
|
},
|
|
setCountdownOverlayValue: (value: number, runId: number) => {
|
|
return ipcRenderer.invoke("countdown-overlay-set-value", value, runId);
|
|
},
|
|
hideCountdownOverlay: (runId: number) => {
|
|
return ipcRenderer.invoke("countdown-overlay-hide", runId);
|
|
},
|
|
onCountdownOverlayValue: (callback: (value: number | null) => void) => {
|
|
const listener = (_event: unknown, value: number | null) => callback(value);
|
|
ipcRenderer.on("countdown-overlay-value", listener);
|
|
return () => ipcRenderer.removeListener("countdown-overlay-value", listener);
|
|
},
|
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
|
|
const listener = async () => {
|
|
try {
|
|
const shouldClose = await callback();
|
|
ipcRenderer.send("save-before-close-done", shouldClose);
|
|
} catch {
|
|
ipcRenderer.send("save-before-close-done", false);
|
|
}
|
|
};
|
|
ipcRenderer.on("request-save-before-close", listener);
|
|
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
|
},
|
|
onRequestCloseConfirm: (callback: () => void) => {
|
|
const listener = () => callback();
|
|
ipcRenderer.on("request-close-confirm", listener);
|
|
return () => ipcRenderer.removeListener("request-close-confirm", listener);
|
|
},
|
|
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
|
|
ipcRenderer.send("close-confirm-response", choice);
|
|
},
|
|
});
|