diff --git a/.gitattributes b/.gitattributes index d9dbbd32f..60897224b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index fcda39e0c..357fce4de 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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"; @@ -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; @@ -605,8 +606,10 @@ 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, @@ -614,6 +617,7 @@ let recordingPrefs: RecordingPrefs = { 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. @@ -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, ...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") { @@ -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}`, diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts new file mode 100644 index 000000000..115a3c356 --- /dev/null +++ b/electron/ipc/recordingPrefs.test.ts @@ -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; + return { get, set: (prefs: Partial) => 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), + ).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); + }); +}); diff --git a/electron/ipc/recordingPrefs.ts b/electron/ipc/recordingPrefs.ts new file mode 100644 index 000000000..edeb8b919 --- /dev/null +++ b/electron/ipc/recordingPrefs.ts @@ -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) => { + 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; + }); +} diff --git a/electron/recording-settings.test.ts b/electron/recording-settings.test.ts new file mode 100644 index 000000000..49a1202f1 --- /dev/null +++ b/electron/recording-settings.test.ts @@ -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(); + }); +}); diff --git a/electron/recording-settings.ts b/electron/recording-settings.ts new file mode 100644 index 000000000..25370cdab --- /dev/null +++ b/electron/recording-settings.ts @@ -0,0 +1,38 @@ +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +function readSettings(userData: string): Record { + 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) + : {}; + } 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 }); + } +} diff --git a/src/components/ai-edition/NewEditorShell.loadedMetadata.test.ts b/src/components/ai-edition/NewEditorShell.loadedMetadata.test.ts new file mode 100644 index 000000000..f9c869553 --- /dev/null +++ b/src/components/ai-edition/NewEditorShell.loadedMetadata.test.ts @@ -0,0 +1,183 @@ +// What one `loadedmetadata` event does once it reaches the front of the queue. +// +// The queue is why this is worth its own test: the shell puts this step on +// `useSequentialTimelineOps` alongside the user's own edits, so a step that never +// finishes holds that queue — and everything behind it. The pure decision +// (`documentAfterProbedDuration`) is covered next door; this covers what surrounds +// it — the guards, the bounded save, and what the auto-zoom pass is handed. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/ShortcutsContext", async () => { + const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts"); + return { + useShortcuts: () => ({ + shortcuts: DEFAULT_SHORTCUTS, + isMac: false, + isConfigOpen: false, + openConfig: vi.fn(), + closeConfig: vi.fn(), + setShortcuts: vi.fn(), + persistShortcuts: () => Promise.resolve(true), + }), + }; +}); + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ locale: "en", setLocale: vi.fn() }), + useScopedT: () => (key: string) => key, +})); + +import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { runLoadedMetadataWrite } from "./NewEditorShell"; + +const PROJECT = "proj_a"; + +/** A fresh import: one video asset on the document, nothing on the timeline yet. */ +function freshImport(): AxcutDocument { + const doc = createEmptyDocument({ projectId: PROJECT, title: "A" }); + return { + ...doc, + project: { ...doc.project, primaryAssetId: "asset_1" }, + assets: [ + { + id: "asset_1", + kind: "video", + label: "screen.mp4", + originalPath: "/tmp/screen.mp4", + cameraTrack: null, + }, + ], + }; +} + +/** Stands in for a save that answers, installing its result the way the real one does. */ +function settlingSave() { + return vi.fn(async (document: AxcutDocument) => { + useProjectStore.setState({ document }); + return true; + }); +} + +describe("runLoadedMetadataWrite", () => { + beforeEach(() => { + vi.clearAllMocks(); + useProjectStore.setState({ document: freshImport() }); + }); + + it("folds the probed length in and hands auto-zoom the saved document", async () => { + const saveDocument = settlingSave(); + useProjectStore.setState({ saveDocument }); + const autoZoom = vi.fn(async () => undefined); + + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + + expect(saveDocument).toHaveBeenCalledTimes(1); + const saved = saveDocument.mock.calls[0][0]; + expect(saved.timeline.clips).toHaveLength(1); + expect(saved.assets[0].durationSec).toBe(12.5); + // Not the pre-save snapshot: auto-zoom appends to whatever is on the store now. + expect(autoZoom).toHaveBeenCalledWith(useProjectStore.getState().document); + expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1); + }); + + // THE reason the save is bounded. `saveDocument` awaits the bridge with no + // deadline of its own and never rejects, so a main process that stops answering + // leaves this step pending for the life of the renderer — and every edit queued + // behind it waits with it, this take's auto-zoom included. Without the deadline + // this test does not fail with a wrong value, it never finishes. + it("gives up on a save that never answers instead of holding the queue", async () => { + const saveDocument = vi.fn(() => new Promise(() => undefined)); + useProjectStore.setState({ saveDocument }); + const autoZoom = vi.fn(async () => undefined); + + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom, saveTimeoutMs: 20 }); + + expect(saveDocument).toHaveBeenCalledTimes(1); + // The step let go and carried on, with the document the store actually holds + // — the stuck write never installed one. + expect(autoZoom).toHaveBeenCalledTimes(1); + expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(0); + }); + + // The switch that happens DURING the save, which the guard at the top cannot + // see. Auto-zoom would not write zooms into the new project — the pending-path + // guard refuses it — but the passes before that check clear the pending flag on + // whatever document they are handed, so the take that was actually imported + // would lose its auto-zoom without a trace. + it("stops when the project changes while the save is in flight", async () => { + // Carrying assets on purpose: an assetless document is turned away a line + // later for a different reason, and this test would then pass without the + // ownership check it exists to cover. + const other = { ...freshImport(), project: { ...freshImport().project, id: "proj_b" } }; + const saveDocument = vi.fn(async () => { + useProjectStore.setState({ document: other }); + return true; + }); + useProjectStore.setState({ saveDocument }); + const autoZoom = vi.fn(async () => undefined); + + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + + expect(saveDocument).toHaveBeenCalledTimes(1); + expect(autoZoom).not.toHaveBeenCalled(); + }); + + // Same for the project being closed outright: there is nothing left for this + // event to belong to, and the pre-switch snapshot is not a stand-in for it. + it("stops when the project is closed while the save is in flight", async () => { + const saveDocument = vi.fn(async () => { + useProjectStore.setState({ document: null }); + return true; + }); + useProjectStore.setState({ saveDocument }); + const autoZoom = vi.fn(async () => undefined); + + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + + expect(autoZoom).not.toHaveBeenCalled(); + }); + + // The event is bound to the project that owned the video when it fired, and the + // queue puts real time between the two. + it("writes nothing when the project changed before it ran", async () => { + const saveDocument = settlingSave(); + useProjectStore.setState({ saveDocument }); + const autoZoom = vi.fn(async () => undefined); + + await runLoadedMetadataWrite(12.5, "asset_1", "proj_switched_away_from", { autoZoom }); + + expect(saveDocument).not.toHaveBeenCalled(); + expect(autoZoom).not.toHaveBeenCalled(); + }); + + it("writes nothing without a document, or without assets", async () => { + const saveDocument = settlingSave(); + const autoZoom = vi.fn(async () => undefined); + + useProjectStore.setState({ document: null, saveDocument }); + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + + const empty = createEmptyDocument({ projectId: PROJECT, title: "A" }); + useProjectStore.setState({ document: empty, saveDocument }); + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + + expect(saveDocument).not.toHaveBeenCalled(); + expect(autoZoom).not.toHaveBeenCalled(); + }); + + // Nothing to fold in is not a reason to skip auto-zoom: a second event for a + // document that already has its length still has to let the suggestion pass run. + it("still runs auto-zoom when the document needs no write", async () => { + const saveDocument = settlingSave(); + useProjectStore.setState({ saveDocument }); + const autoZoom = vi.fn(async () => undefined); + + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + saveDocument.mockClear(); + await runLoadedMetadataWrite(12.5, "asset_1", PROJECT, { autoZoom }); + + expect(saveDocument).not.toHaveBeenCalled(); + expect(autoZoom).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/ai-edition/NewEditorShell.probedDuration.test.tsx b/src/components/ai-edition/NewEditorShell.probedDuration.test.tsx new file mode 100644 index 000000000..56aa72e51 --- /dev/null +++ b/src/components/ai-edition/NewEditorShell.probedDuration.test.tsx @@ -0,0 +1,226 @@ +// The decision a `loadedmetadata` event makes, on its own. +// +// The event itself arrives through Preview -> PreviewCanvas -> VirtualPreview and a +// real