Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4f18cdf
fix(editor): fold a probed duration in on the shared write queue
My-Denia Sep 7, 2026
2130023
fix(timeline): read the document at write time in addZoomsBulk
My-Denia Sep 7, 2026
955a9ed
fix(editor): bind the probed duration to the take that produced it
My-Denia Sep 7, 2026
de5b93a
test(editor): cover the probed-duration decision, guards included
My-Denia Sep 7, 2026
e48d65d
style: apply biome formatting to the probed-duration changes
My-Denia Sep 7, 2026
3e840a1
fix(preview): mount the primary asset while the timeline is empty
My-Denia Sep 7, 2026
e8762b6
test: run the probed-duration decision test in the default environment
My-Denia Sep 7, 2026
1b6423b
feat(store): let a caller bound a document save that never answers
My-Denia Sep 7, 2026
b336c94
refactor(timeline): one auto-zoom collect/append path for both callers
My-Denia Sep 7, 2026
7986d28
feat(recording): suggest cursor-dwell zooms for a freshly imported take
My-Denia Sep 7, 2026
6af6c2d
feat(launch): add the after-recording auto-zoom toggle
My-Denia Sep 7, 2026
0c4961c
feat(editor): run the fresh-take auto-zoom pass on the metadata write…
My-Denia Sep 7, 2026
c5ca6d6
fix(editor): keep the placeholder duration on the asset, and off auto…
My-Denia Sep 7, 2026
01aca43
fix(timeline): re-collect auto-zoom suggestions when the clips move u…
My-Denia Sep 7, 2026
f29e011
fix(recording): write the cursor sidecar before publishing the session
EtienneLescot Sep 8, 2026
d996935
fix(recording): persist auto-zoom preferences and reconcile media dur…
My-Denia Sep 9, 2026
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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*.rs text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
# Vite's SSR hashbang detection requires LF in imported executable modules.
*.mjs text eol=lf
# Same drift, caught on the native helper's build file: an edit from Windows
# rewrote all 67 lines as CRLF and buried a 22-line change in a 156-line diff.
CMakeLists.txt text eol=lf
32 changes: 15 additions & 17 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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 @@ -584,8 +585,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 @@ -605,15 +606,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 @@ -1948,18 +1952,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 @@ -3471,11 +3464,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