Skip to content
4 changes: 4 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ interface Window {
meta: import("../src/native/contracts").CompositorSharedFrameMeta,
) => void,
) => () => void;
reportRecordingOutcome?: (
outcome: import("../src/lib/recordingSession").RecordingOutcome,
) => Promise<{ success: boolean }>;

getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
Expand Down
68 changes: 59 additions & 9 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
shell,
systemPreferences,
} from "electron";
import { z } from "zod";
import { DEFAULT_WEBCAM_QUALITY, type WebcamQualityId } from "../../src/hooks/webcamCaptureTarget";
import {
type AxcutDocument,
Expand Down Expand Up @@ -89,6 +90,7 @@ import { McpController } from "../mcp/mcp-controller";
import { McpSettingsStore } from "../mcp/mcp-settings-store";
import { createMediaTools, type MediaToolDeps, type RecordRequest } from "../mcp/media-tools";
import { createToolRunner, type McpToolDeps } from "../mcp/openscreen-mcp-server";
import { RecordingCompletion } from "../mcp/recording-completion";
import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks";
import {
readCursorRecordingFile as readCursorRecordingFileFrom,
Expand Down Expand Up @@ -1998,15 +2000,23 @@ export function registerIpcHandlers(
) {
/** The HUD is recording a take, the user's or an MCP client's. Set by `setAppRecording` only. */
let appRecording = false;
const recordingCompletion = new RecordingCompletion();
/**
* Every start and stop of a take goes through here: the browser path's `set-recording-state`
* and the native paths, which start and stop in this process. The MCP recorder waits on it
* for a client's take to start, and the tray shows it. A native path that called
* `onRecordingStateChange` itself left the MCP recorder waiting on a take already running.
*/
const setAppRecording = (recording: boolean, sourceName: string, preparing = false) => {
const setAppRecording = (
recording: boolean,
sourceName: string,
recordingId?: number,
preparing = false,
) => {
if (recording && !preparing && recordingId !== undefined)
recordingCompletion.started(recordingId);
appRecording = recording;
if (!preparing) mcpAppRecorder.recordingChanged(recording);
if (!preparing) mcpAppRecorder.recordingChanged(recording, recordingId);
onRecordingStateChange?.(recording, sourceName, preparing);
};
const appSettings = new AppSettingsStore(app.getPath("userData"));
Expand Down Expand Up @@ -2799,7 +2809,7 @@ export function registerIpcHandlers(
// "Screen" }`, so the tray confidently displayed the name of a
// window the capture had never been told about.
linuxNativeCaptureSourceLabel = linuxSourceLabel(session.grantedSourceKind);
setAppRecording(true, linuxNativeCaptureSourceLabel);
setAppRecording(true, linuxNativeCaptureSourceLabel, recordingId);

return { success: true, recordingId, path: outputPath };
} catch (error) {
Expand Down Expand Up @@ -3050,7 +3060,7 @@ export function registerIpcHandlers(
}
// Reserve startup before cursor/helper awaits without reporting a started take.
startReserved = true;
setAppRecording(true, selectedSource?.name ?? "Screen", true);
setAppRecording(true, selectedSource?.name ?? "Screen", undefined, true);
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
nativeWindowsCaptureOutput = "";
nativeWindowsCaptureTargetPath = outputPath;
Expand Down Expand Up @@ -3107,7 +3117,7 @@ export function registerIpcHandlers(
});

const source = selectedSource || { name: "Screen" };
setAppRecording(true, source.name);
setAppRecording(true, source.name, recordingId);

// Reported at start, not at stop: the helper decides the camera is a
// lost cause during its own init — before it announces "Recording
Expand Down Expand Up @@ -3381,7 +3391,7 @@ export function registerIpcHandlers(
: 0;

const source = selectedSource || { name: "Screen" };
setAppRecording(true, source.name);
setAppRecording(true, source.name, recordingId);

return {
success: true,
Expand Down Expand Up @@ -4188,7 +4198,7 @@ export function registerIpcHandlers(
const source = selectedSource || { name: "Screen" };
if (recording) {
// Claim the take synchronously, before cursor setup yields to an updater.
setAppRecording(recording, source.name, true);
setAppRecording(recording, source.name, undefined, true);
if (reserveOnly) return { success: true };
}
const normalizedCursorCaptureMode =
Expand All @@ -4199,7 +4209,7 @@ export function registerIpcHandlers(
await stopCursorRecording();
}

setAppRecording(recording, source.name);
setAppRecording(recording, source.name, recordingId);
return { success: true };
},
);
Expand Down Expand Up @@ -4942,10 +4952,48 @@ export function registerIpcHandlers(
setCurrentRecordingSessionState(normalizedSession);
currentVideoPath = normalizedSession?.screenVideoPath ?? null;
currentProjectPath = null;
if (normalizedSession) mcpAppRecorder.takeSaved(normalizedSession.screenVideoPath);
if (normalizedSession) {
mcpAppRecorder.takeSaved(normalizedSession.screenVideoPath);
// Every capture path stamps the session's `createdAt` with its recordingId (the
// `Date.now()` it started at), so it names the take `recordingCompletion` tracks.
// Any other session (an opened file) carries a fresh `Date.now()` and matches none.
recordingCompletion.saved(normalizedSession.createdAt, normalizedSession.screenVideoPath);
}
return { success: true, session: currentRecordingSession };
});

// A renderer acknowledgement of the actual hand-off, after the duration and
// automatic zoom saves. An imported/opened project never emits this signal.
const recordingOutcomeSchema = z.discriminatedUnion("status", [
z.object({
recordingId: z.number().finite(),
status: z.literal("ready"),
projectId: z.string(),
}),
z.object({
recordingId: z.number().finite(),
status: z.enum(["failed", "cancelled"]),
message: z.string(),
}),
]);
ipcMain.handle("recording-outcome", async (event, input: unknown) => {
// Only the recording bar or the editor, whichever is the main window, ends a take.
if (event.sender !== getMainWindow()?.webContents) return { success: false };
const parsed = recordingOutcomeSchema.safeParse(input);
if (!parsed.success) return { success: false };
const outcome = parsed.data;
if (outcome.status !== "ready") {
recordingCompletion.failed(outcome.recordingId, outcome.message);
mcpAppRecorder.recordingFinished(outcome.recordingId);
return { success: true };
}
await recordingCompletion.projectReady(outcome.recordingId, outcome.projectId, (id) =>
aiEditionDocuments.getProject(id),
);
mcpAppRecorder.recordingFinished(outcome.recordingId);
return { success: true };
});

ipcMain.handle("get-current-recording-session", () => {
if (!currentRecordingSession) {
return { success: false };
Expand Down Expand Up @@ -5229,6 +5277,8 @@ export function registerIpcHandlers(
host: mcpToolDeps.host,
});
const mcpMedia = createMediaTools({
recordingActive: () => recordingCompletion.isActive(),
waitForRecording: (timeoutMs, signal) => recordingCompletion.wait(timeoutMs, signal),
transport: "http",
// Recording and exporting change things too: the same switch as edits.
editsAllowed: mcpToolDeps.editsAllowed,
Expand Down
12 changes: 11 additions & 1 deletion electron/ipc/recordingStateRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe("recording state routing", () => {
const setter = source.slice(source.indexOf("const setAppRecording"));
const body = setter.slice(0, setter.indexOf("};"));
expect(body).toContain("appRecording = recording");
expect(body).toContain("mcpAppRecorder.recordingChanged(recording)");
expect(body).toContain("mcpAppRecorder.recordingChanged(recording, recordingId)");
expect(body).toContain("onRecordingStateChange?.(recording, sourceName, preparing)");
});

Expand All @@ -45,6 +45,16 @@ describe("recording state routing", () => {
expect(handlerBody(`stop-native-${platform}-recording`)).toContain("setAppRecording(false");
});

it("a take's outcome is accepted from the main window only, before anything else", () => {
const body = handlerBody("recording-outcome");
const guard = body.indexOf(
"if (event.sender !== getMainWindow()?.webContents) return { success: false };",
);
expect(guard).toBeGreaterThan(-1);
expect(guard).toBeLessThan(body.indexOf("recordingOutcomeSchema"));
expect(guard).toBeLessThan(body.indexOf("recordingCompletion."));
});

it("the browser path notifies it", () => {
expect(handlerBody("set-recording-state")).toContain("setAppRecording(recording");
});
Expand Down
14 changes: 14 additions & 0 deletions electron/mcp/app-recorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ describe("an MCP take in the app", () => {
expect(editor.applied).toEqual([]);
});

it("releases only the client take whose app lifecycle finished", async () => {
const { recorder, calls } = setup();
const starting = recorder.start(REQUEST);
await new Promise((resolve) => setTimeout(resolve, 5));
recorder.recordingChanged(true, 7);
await starting;
recorder.recordingFinished(6);
await expect(recorder.start(REQUEST)).rejects.toThrow("already recording");
recorder.recordingChanged(false, 7);
recorder.recordingFinished(7);
expect(calls.filter((call) => call === "restore")).toHaveLength(1);
await expect(started(recorder, calls)).resolves.toBeDefined();
});

it("refuses a take while one records, or while the editor holds unsaved changes", async () => {
const busy = setup({ isRecording: () => true });
await expect(busy.recorder.start(REQUEST)).rejects.toThrow("already recording");
Expand Down
13 changes: 11 additions & 2 deletions electron/mcp/app-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export interface AppRecorderDeps {

export interface AppRecorder extends TakeRecorder {
/** The app's recording state changed (`set-recording-state`). */
recordingChanged(recording: boolean): void;
recordingChanged(recording: boolean, recordingId?: number): void;
/** A completed or failed app take no longer owns the recording bar. */
recordingFinished(recordingId: number): void;
/** The recording bar handed a saved take to the editor (`set-current-recording-session`). */
takeSaved(videoPath: string): void;
}
Expand Down Expand Up @@ -145,6 +147,7 @@ interface Deferred<T> {
}

interface ClientTake {
recordingId?: number;
phase: "starting" | "recording" | "saving";
/** Resolves with when capture started. */
started: Deferred<number>;
Expand Down Expand Up @@ -214,10 +217,16 @@ export function createAppRecorder(deps: AppRecorderDeps): AppRecorder {
}

return {
recordingChanged(recording) {
recordingFinished(recordingId) {
if (current?.recordingId !== recordingId) return;
current.restore();
current = null;
},
recordingChanged(recording, recordingId) {
if (!current) return;
if (recording && current.phase === "starting") {
current.phase = "recording";
current.recordingId = recordingId;
current.started.resolve(Date.now());
} else if (!recording && current.phase === "recording") {
// The take is captured: the bar's own settings can come back while it saves.
Expand Down
Loading
Loading