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
32 changes: 15 additions & 17 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
import { reindexRecordingOnDisk } from "../recording/webm-seek-index";
import { registerNativeBridgeHandlers } from "./nativeBridge";
import { registerRecordingPrefsHandlers } from "./recordingPrefs";
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";

const PROJECT_FILE_EXTENSION = "openscreen";
Expand Down Expand Up @@ -587,8 +588,8 @@ let currentRecordingSession: RecordingSession | null = null;
// useScreenRecorder (a separate renderer, own process, own React tree) picks
// up those choices instead of silently reverting to its own defaults when
// startNewRecording() switches windows. Mirrors the selectedSource pattern
// above (in-memory, broadcast on change) rather than persisting to disk —
// this is a live session preference, not project content.
// above (in-memory, broadcast on change). Auto-zoom is the one durable choice;
// the device selections remain session preferences, not project content.
export interface RecordingPrefs {
micEnabled: boolean;
micDeviceId: string | null;
Expand All @@ -608,15 +609,18 @@ export interface RecordingPrefs {
camDeviceId: string | null;
systemAudioEnabled: boolean;
cursorCaptureMode: CursorCaptureMode;
/** After a take, suggest cursor-dwell zooms. Default on, matching 1.5. */
autoZoomEnabled: boolean;
}
let recordingPrefs: RecordingPrefs = {
const defaultRecordingPrefs: RecordingPrefs = {
micEnabled: false,
micDeviceId: null,
micDeviceName: null,
camEnabled: false,
camDeviceId: null,
systemAudioEnabled: false,
cursorCaptureMode: "editable-overlay",
autoZoomEnabled: true,
};

// Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture.
Expand Down Expand Up @@ -1951,18 +1955,7 @@ export function registerIpcHandlers(
return selectedSource;
});

ipcMain.handle("get-recording-prefs", () => {
return recordingPrefs;
});

ipcMain.handle("set-recording-prefs", (_, prefs: Partial<RecordingPrefs>) => {
recordingPrefs = { ...recordingPrefs, ...prefs };
const mainWin = getMainWindow();
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("recording-prefs-changed", recordingPrefs);
}
return recordingPrefs;
});
registerRecordingPrefsHandlers(defaultRecordingPrefs, getMainWindow);

ipcMain.handle("request-camera-access", async () => {
if (process.platform !== "darwin") {
Expand Down Expand Up @@ -3484,11 +3477,16 @@ export function registerIpcHandlers(
...(cursorCaptureMode ? { cursorCaptureMode } : {}),
}
: { screenVideoPath, createdAt, ...(cursorCaptureMode ? { cursorCaptureMode } : {}) };
// Sidecar BEFORE the session is published, as the three native stop paths already
// do it. Publishing first opens a window where `getCurrentRecordingSession` hands
// the editor a take whose `.cursor.json` is not on disk yet, and the editor's
// fresh-take auto-zoom reads that file the moment it imports -- an empty read there
// is indistinguishable from a take with no dwell, so the zooms are silently
// skipped.
await writePendingCursorTelemetry(screenVideoPath);
setCurrentRecordingSessionState(session);
currentProjectPath = null;

await writePendingCursorTelemetry(screenVideoPath);

const sessionManifestPath = path.join(
RECORDINGS_DIR,
`${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`,
Expand Down
85 changes: 85 additions & 0 deletions electron/ipc/recordingPrefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { BrowserWindow } from "electron";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RecordingPrefs } from "./handlers";
import { registerRecordingPrefsHandlers } from "./recordingPrefs";

const electron = vi.hoisted(() => ({ getPath: vi.fn(), handle: vi.fn() }));
vi.mock("electron", () => ({
app: { getPath: electron.getPath },
ipcMain: { handle: electron.handle },
}));

const defaults: RecordingPrefs = {
micEnabled: false,
micDeviceId: null,
micDeviceName: null,
camEnabled: false,
camDeviceId: null,
systemAudioEnabled: false,
cursorCaptureMode: "editable-overlay",
autoZoomEnabled: true,
};
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-recording-ipc-"));
electron.getPath.mockReturnValue(dir);
electron.handle.mockClear();
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

function start(getWindow: () => BrowserWindow | null = () => null) {
electron.handle.mockClear();
registerRecordingPrefsHandlers(defaults, getWindow);
const get = electron.handle.mock.calls.find(
([name]) => name === "get-recording-prefs",
)?.[1] as () => RecordingPrefs;
const set = electron.handle.mock.calls.find(([name]) => name === "set-recording-prefs")?.[1] as (
_event: unknown,
prefs: Partial<RecordingPrefs>,
) => RecordingPrefs;
return { get, set: (prefs: Partial<RecordingPrefs>) => set(undefined, prefs) };
}

describe("recording preferences IPC", () => {
it("restores false on restart while device preferences reset", () => {
const first = start();
expect(first.get().autoZoomEnabled).toBe(true);
expect(first.set({ autoZoomEnabled: false }).autoZoomEnabled).toBe(false);
first.set({ micEnabled: true, micDeviceId: "temporary-device" });
const disk = JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8"));
expect(disk).toEqual({ autoZoomEnabled: false });
const restarted = start();
expect(restarted.get()).toEqual({ ...defaults, autoZoomEnabled: false });
restarted.set({ autoZoomEnabled: true });
expect(start().get().autoZoomEnabled).toBe(true);
});

it("broadcasts the saved value and tolerates an absent or destroyed window", () => {
const send = vi.fn();
const isDestroyed = vi.fn(() => false);
const window = { isDestroyed, webContents: { send } } as unknown as BrowserWindow;
const session = start(() => window);
const updated = session.set({ autoZoomEnabled: false });
expect(send).toHaveBeenCalledWith("recording-prefs-changed", updated);
isDestroyed.mockReturnValue(true);
session.set({ micEnabled: true });
expect(send).toHaveBeenCalledTimes(1);
});

it("does not publish an invalid or failed preference write", () => {
const session = start();
expect(() =>
session.set({ autoZoomEnabled: null } as unknown as Partial<RecordingPrefs>),
).toThrow(TypeError);
expect(session.get().autoZoomEnabled).toBe(true);
session.set({ autoZoomEnabled: false });
session.set({ autoZoomEnabled: undefined, camEnabled: true });
expect(session.get().autoZoomEnabled).toBe(false);
rmSync(dir, { recursive: true, force: true });
expect(() => session.set({ autoZoomEnabled: true })).toThrow();
expect(session.get().autoZoomEnabled).toBe(false);
});
});
30 changes: 30 additions & 0 deletions electron/ipc/recordingPrefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { app, type BrowserWindow, ipcMain } from "electron";
import { loadAutoZoomEnabled, saveAutoZoomEnabled } from "../recording-settings";
import type { RecordingPrefs } from "./handlers";

/** Shared session preferences, with only the auto-zoom choice retained on disk. */
export function registerRecordingPrefsHandlers(
defaults: RecordingPrefs,
getMainWindow: () => BrowserWindow | null,
): void {
const userData = app.getPath("userData");
let recordingPrefs = { ...defaults, autoZoomEnabled: loadAutoZoomEnabled(userData) };

ipcMain.handle("get-recording-prefs", () => recordingPrefs);
ipcMain.handle("set-recording-prefs", (_, prefs: Partial<RecordingPrefs>) => {
if (prefs.autoZoomEnabled !== undefined) {
// Persist before publishing: a failed save must not report a durable change.
saveAutoZoomEnabled(userData, prefs.autoZoomEnabled);
}
recordingPrefs = {
...recordingPrefs,
...prefs,
autoZoomEnabled: prefs.autoZoomEnabled ?? recordingPrefs.autoZoomEnabled,
};
const mainWin = getMainWindow();
if (mainWin && !mainWin.isDestroyed()) {
mainWin.webContents.send("recording-prefs-changed", recordingPrefs);
}
return recordingPrefs;
});
}
70 changes: 70 additions & 0 deletions electron/recording-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { loadAutoZoomEnabled, saveAutoZoomEnabled } from "./recording-settings";

const temps: string[] = [];
const tmp = () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-recording-settings-"));
temps.push(dir);
return dir;
};
afterEach(() => {
for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true });
});

describe("recording settings", () => {
it("defaults to on for absent, malformed, and invalid settings", () => {
const dir = tmp();
expect(loadAutoZoomEnabled(dir)).toBe(true);
for (const raw of ["{broken", "null", "[]", "42", "{}", '{"autoZoomEnabled":"false"}']) {
writeFileSync(path.join(dir, "recording-settings.json"), raw);
expect(loadAutoZoomEnabled(dir)).toBe(true);
}
});

it("round-trips false and true without overwriting unrelated keys", () => {
const dir = tmp();
const file = path.join(dir, "recording-settings.json");
writeFileSync(file, '{"futurePreference":"keep"}');
for (const enabled of [false, true]) {
saveAutoZoomEnabled(dir, enabled);
expect(loadAutoZoomEnabled(dir)).toBe(enabled);
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
futurePreference: "keep",
autoZoomEnabled: enabled,
});
}
});

it("loads the disabled preference in a separate Node process", () => {
const dir = tmp();
saveAutoZoomEnabled(dir, false);
const moduleUrl = pathToFileURL(path.resolve("electron/recording-settings.ts")).href;
const result = spawnSync(
process.execPath,
[
"--experimental-strip-types",
"--input-type=module",
"-e",
`import { loadAutoZoomEnabled } from ${JSON.stringify(moduleUrl)}; process.stdout.write(JSON.stringify(loadAutoZoomEnabled(process.argv[1])));`,
dir,
],
{ encoding: "utf8" },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe("false");
});

it("rejects invalid writes and reports a failed disk write", () => {
const dir = tmp();
saveAutoZoomEnabled(dir, false);
expect(() => saveAutoZoomEnabled(dir, "false" as unknown as boolean)).toThrow(TypeError);
expect(loadAutoZoomEnabled(dir)).toBe(false);
expect(() => saveAutoZoomEnabled(path.join(dir, "missing"), true)).toThrow();
});
});
38 changes: 38 additions & 0 deletions electron/recording-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";

function readSettings(userData: string): Record<string, unknown> {
try {
const value: unknown = JSON.parse(
readFileSync(path.join(userData, "recording-settings.json"), "utf8"),
);
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
} catch {
return {};
}
}

/** Default on for new users; a saved false must survive an app restart. */
export function loadAutoZoomEnabled(userData: string): boolean {
const value = readSettings(userData).autoZoomEnabled;
return typeof value === "boolean" ? value : true;
}

/** Save only this durable preference; device selection remains session-only. */
export function saveAutoZoomEnabled(userData: string, enabled: boolean): void {
if (typeof enabled !== "boolean") throw new TypeError("autoZoomEnabled must be a boolean");
const destination = path.join(userData, "recording-settings.json");
const temporary = `${destination}.${process.pid}.tmp`;
try {
writeFileSync(
temporary,
`${JSON.stringify({ ...readSettings(userData), autoZoomEnabled: enabled })}\n`,
"utf8",
);
renameSync(temporary, destination);
} finally {
rmSync(temporary, { force: true });
}
}
Loading
Loading