Skip to content
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
4 changes: 4 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ interface Window {
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
discarded?: boolean;
/** The take ended before it was stopped, but its recording was kept. */
warning?: string;
error?: string;
}>;
attachNativeMacWebcamRecording: (payload: {
Expand Down Expand Up @@ -317,6 +319,8 @@ interface Window {
success: boolean;
session?: RecordingSession | null;
canceled?: boolean;
/** Why this recording ended before it was stopped, when it did. */
warning?: string;
}>;
findRecordingCamera: (videoPath: string) => Promise<{
success: boolean;
Expand Down
208 changes: 121 additions & 87 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { toHelperRect } from "../native-bridge/helperCoordinates";
import { scoreDeviceNameMatch } from "../recording/deviceNameMatching";
import {
type NativeMacCaptureExit,
nativeMacDiscardTargets,
sendNativeMacStopCommand,
waitForNativeMacCaptureStop,
} from "../recording/nativeMacCaptureStop";
import {
isSalvageableFragmentedCapture,
NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES,
Expand Down Expand Up @@ -765,6 +771,25 @@ let nativeMacCursorRecordingStartMs = 0;
let nativeMacPauseStartedAtMs: number | null = null;
let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = [];
let nativeMacIsPaused = false;
/**
* How each macOS helper exited, recorded by its output drain on `close` — the
* point at which its output has been read in full.
*/
const nativeMacCaptureExits = new WeakMap<ChildProcessWithoutNullStreams, NativeMacCaptureExit>();
/**
* Each macOS helper's own output. The shared `nativeMacCaptureOutput` belongs to
* the current take; a helper whose stop timed out keeps talking after the next
* take has started, and its late `recording-stopped` must not settle that take.
*/
const nativeMacCaptureOutputs = new WeakMap<ChildProcessWithoutNullStreams, string>();
/**
* Why the last macOS take ended before it was stopped, keyed by the file it was
* kept in. Handed to whatever opens that recording next — the editor or the CLI —
* because the HUD that ran the stop closes as the editor opens.
*/
let nativeMacRecordingWarning: { screenVideoPath: string; message: string } | null = null;
/** True while stop-native-mac-recording runs: the take is ending on purpose. */
let nativeMacStopInFlight = false;
// Global frame of the region captured by the SCK helper (see getSelectedSourceBounds).
let activeMacCaptureBounds: Rectangle | null = null;
let linuxNativeCaptureSession: LinuxNativeCaptureSession | null = null;
Expand Down Expand Up @@ -1488,40 +1513,67 @@ function inspectNativeMacCaptureOutput() {

function attachNativeMacCaptureOutputDrain(
proc: ChildProcessWithoutNullStreams,
onErrorDuringCapture: () => void,
onTakeEnded: () => void,
) {
let lineBuffer = "";
// Hooked here rather than on `nativeMacCaptureEvents`, which the stop wait
// Hooked here rather than on `nativeMacCaptureEvents`, which the start wait
// replays from the buffer: the drain sees each line once, live.
const watchForMidCaptureError = createNativeMacMidCaptureErrorWatch(
() => nativeMacCaptureProcess === proc,
onErrorDuringCapture,
const watchLiveTake = createNativeMacMidCaptureErrorWatch(
() => nativeMacCaptureProcess === proc && !nativeMacStopInFlight,
onTakeEnded,
);
const drain = (chunk: Buffer) => {
const text = chunk.toString();
nativeMacCaptureOutput += text;
nativeMacCaptureOutputs.set(proc, (nativeMacCaptureOutputs.get(proc) ?? "") + text);
// Only the current take's helper feeds the shared buffer and event bus that the
// start wait, the microphone check and the diagnostics bundle read.
const isCurrent = nativeMacCaptureProcess === proc;
if (isCurrent) {
nativeMacCaptureOutput += text;
}
lineBuffer += text;
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() ?? "";
for (const line of lines) {
const event = tryParseNativeHelperEvent(line.trim());
if (event) {
dispatchNativeMacHelperEvent(event);
watchForMidCaptureError(event);
if (isCurrent) {
dispatchNativeMacHelperEvent(event);
}
watchLiveTake(event);
}
}
};
const cleanup = () => {
// Registered right after spawn, before the stop wait can listen for `close`, so
// the wait always finds the exit recorded when its own listener runs.
const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
nativeMacCaptureExits.set(proc, { code, signal });
proc.stdout.off("data", drain);
proc.stderr.off("data", drain);
proc.off("close", cleanup);
proc.off("error", cleanup);
watchLiveTake.exited();
};

proc.stdout.on("data", drain);
proc.stderr.on("data", drain);
proc.once("close", cleanup);
proc.once("error", cleanup);
proc.once("close", onClose);
// A ChildProcess `error` with no listener throws in the main process.
proc.on("error", (error) => {
console.warn("[native-sck] helper process error:", error);
});
// `sendNativeMacStopCommand` checks the pipe first, but the helper can still die
// between that check and the write. The main-process guard would swallow the
// EPIPE; a listener here keeps it from being raised as uncaught at all.
proc.stdin.on("error", (error) => {
console.warn("[native-sck] helper command pipe error:", error);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The output pipes too, as the Windows drain does: the guard only swallows a few
// codes, and any other stream error would take the main process down.
proc.stdout.on("error", (error) => {
console.warn("[native-sck] helper stdout error:", error);
});
proc.stderr.on("error", (error) => {
console.warn("[native-sck] helper stderr error:", error);
});
}

function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
Expand Down Expand Up @@ -1571,64 +1623,6 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) {
});
}

function waitForNativeMacCaptureStop(proc: ChildProcessWithoutNullStreams) {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(
new Error(
`Timed out waiting for native macOS capture to stop. Output path: ${
nativeMacCaptureTargetPath ?? "unknown"
}. Output: ${nativeMacCaptureOutput.trim()}`,
),
);
}, 30_000);

const inspect = (event: Record<string, unknown>) => {
if (event.event === "recording-stopped") {
cleanup();
resolve(String(event.screenPath ?? nativeMacCaptureTargetPath ?? ""));
return;
}
if (event.event === "error") {
cleanup();
reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed")));
}
};

const onOutput = (event: Record<string, unknown>) => inspect(event);
const onClose = (code: number | null) => {
if (code === 0 && nativeMacCaptureTargetPath) {
cleanup();
resolve(nativeMacCaptureTargetPath);
return;
}
cleanup();
reject(
new Error(
nativeMacCaptureOutput.trim() ||
`Native macOS capture exited with code=${code ?? "unknown"}`,
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const cleanup = () => {
clearTimeout(timer);
nativeMacCaptureEvents.off("helper-event", onOutput);
proc.off("close", onClose);
proc.off("error", onError);
};

nativeMacCaptureEvents.on("helper-event", onOutput);
proc.once("close", onClose);
proc.once("error", onError);
inspectNativeMacCaptureOutput();
});
}

function setCurrentRecordingSessionState(session: RecordingSession | null) {
currentRecordingSession = session;
currentVideoPath = session?.screenVideoPath ?? null;
Expand Down Expand Up @@ -2821,6 +2815,8 @@ export function registerIpcHandlers(
nativeMacPauseStartedAtMs = null;
nativeMacPauseRanges = [];
nativeMacIsPaused = false;
nativeMacStopInFlight = false;
nativeMacRecordingWarning = null;
activeMacCaptureBounds = null;

const cursorStartTimeMs = Date.now();
Expand All @@ -2836,8 +2832,9 @@ export function registerIpcHandlers(
stdio: ["pipe", "pipe", "pipe"],
});
nativeMacCaptureProcess = proc;
// Drives the renderer's own stop, the same one the tray's Stop Recording
// sends: it clears the HUD and surfaces the helper's error as the result.
// When the take ends without the user — the helper reported an error or
// exited — this drives the renderer's own stop, the same one the tray's Stop
// Recording sends: it clears the HUD and surfaces the result.
attachNativeMacCaptureOutputDrain(proc, () => {
const hudWindow = getMainWindow();
if (hudWindow && !hudWindow.isDestroyed()) {
Expand Down Expand Up @@ -3200,15 +3197,18 @@ export function registerIpcHandlers(
return { success: false, error: "Native macOS capture is not running." };
}

nativeMacStopInFlight = true;
try {
completeNativeMacCursorPauseRange();
const stoppedPathPromise = waitForNativeMacCaptureStop(proc);
proc.stdin.write("stop\n");
const stoppedPath = await stoppedPathPromise;
const screenVideoPath = stoppedPath || preferredPath;
if (!screenVideoPath) {
throw new Error("Native macOS capture did not return an output path.");
}
// Listen before sending, so a helper that stops at once cannot slip past.
const stopResultPromise = waitForNativeMacCaptureStop({
proc,
targetPath: preferredPath,
readOutput: () => nativeMacCaptureOutputs.get(proc) ?? "",
readExit: () => nativeMacCaptureExits.get(proc) ?? null,
});
sendNativeMacStopCommand(proc);
const stopResult = await stopResultPromise;

if (cursorCaptureMode === "editable-overlay") {
await stopCursorRecording();
Expand All @@ -3217,12 +3217,39 @@ export function registerIpcHandlers(
}
if (discard) {
pendingCursorRecordingData = null;
await Promise.all([
fs.rm(screenVideoPath, { force: true }),
fs.rm(`${screenVideoPath}.cursor.json`, { force: true }),
]);
await Promise.all(
nativeMacDiscardTargets(stopResult, preferredPath).map((target) =>
fs.rm(target, { force: true }),
),
);
if (!stopResult.ok) {
console.warn("[native-sck] discarded a take whose stop did not complete", {
reason: stopResult.reason,
message: stopResult.message,
});
}
return { success: true, discarded: true };
}
if (!stopResult.ok) {
pendingCursorRecordingData = null;
console.error("Failed to stop native macOS recording:", {
reason: stopResult.reason,
message: stopResult.message,
helperExited: stopResult.exited,
output: (nativeMacCaptureOutputs.get(proc) ?? "").trim(),
});
return { success: false, error: stopResult.message };
}
const screenVideoPath = stopResult.screenVideoPath;
nativeMacRecordingWarning = stopResult.warning
? { screenVideoPath, message: stopResult.warning }
: null;
if (stopResult.warning) {
console.warn("[native-sck] the take ended before it was stopped; its recording was kept", {
warning: stopResult.warning,
path: screenVideoPath,
});
}

if (cursorCaptureMode === "editable-overlay") {
compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges);
Expand Down Expand Up @@ -3250,12 +3277,14 @@ export function registerIpcHandlers(
path: screenVideoPath,
session,
message: "Native macOS recording session stored successfully",
...(stopResult.warning ? { warning: stopResult.warning } : {}),
};
} catch (error) {
console.error("Failed to stop native macOS recording:", error);
await stopCursorRecording();
return { success: false, error: error instanceof Error ? error.message : String(error) };
} finally {
nativeMacStopInFlight = false;
nativeMacCaptureProcess = null;
nativeMacCaptureTargetPath = null;
nativeMacCaptureRecordingId = null;
Expand Down Expand Up @@ -4254,9 +4283,14 @@ export function registerIpcHandlers(
});

ipcMain.handle("get-current-recording-session", () => {
return currentRecordingSession
? { success: true, session: currentRecordingSession }
: { success: false };
if (!currentRecordingSession) {
return { success: false };
}
const warning =
nativeMacRecordingWarning?.screenVideoPath === currentRecordingSession.screenVideoPath
? nativeMacRecordingWarning.message
: undefined;
return { success: true, session: currentRecordingSession, ...(warning ? { warning } : {}) };
});

// returns the webcam path (if any) for a given screen video by
Expand Down
33 changes: 33 additions & 0 deletions electron/ipc/nativeMacMidCaptureErrorWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,37 @@ describe("createNativeMacMidCaptureErrorWatch", () => {

expect(onError).not.toHaveBeenCalled();
});

/** Killed or crashed: no error line ever comes, only the process closing. */
it("fires when the helper exits in the middle of a take", () => {
const onTakeEnded = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded);

watch({ event: "recording-started" });
watch.exited();

expect(onTakeEnded).toHaveBeenCalledTimes(1);
});

it("leaves an exit before recording started to the start wait", () => {
const onTakeEnded = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded);

watch.exited();

expect(onTakeEnded).not.toHaveBeenCalled();
});

/** The exit a stop causes is the stop working, not the take ending under the user. */
it("ignores the exit of a take that is already being stopped", () => {
const onTakeEnded = vi.fn();
let live = true;
const watch = createNativeMacMidCaptureErrorWatch(() => live, onTakeEnded);

watch({ event: "recording-started" });
live = false;
watch.exited();

expect(onTakeEnded).not.toHaveBeenCalled();
});
});
Loading
Loading