diff --git a/electron/app-settings.test.ts b/electron/app-settings.test.ts new file mode 100644 index 000000000..902d7a8a2 --- /dev/null +++ b/electron/app-settings.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { AppSettingsStore, DEFAULT_RECORDING_PREFERENCES } from "./app-settings"; + +const dirs: string[] = []; +const temp = () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-app-settings-")); + dirs.push(dir); + return dir; +}; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("app settings store", () => { + it("uses defaults for absent fields and preserves unknown keys", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + writeFileSync(file, JSON.stringify({ future: { keep: true } })); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording).toEqual(DEFAULT_RECORDING_PREFERENCES); + store.setRecordingPreferences({ micEnabled: true, camDeviceName: "Camera A" }); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ + future: { keep: true }, + micEnabled: true, + camDeviceName: "Camera A", + }); + }); + + it("uses validated defaults for absent, corrupt, and invalid fields", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording).toEqual(DEFAULT_RECORDING_PREFERENCES); + for (const raw of ["{broken", "[]", JSON.stringify({ micEnabled: "yes" })]) { + writeFileSync(file, raw); + expect(store.getSnapshot().recording.micEnabled).toBe(false); + } + }); + + it("stores the last source beside the recording preferences", () => { + const dir = temp(); + const store = new AppSettingsStore(dir); + const source = { + platform: "win32", + kind: "screen", + id: "screen:1", + name: "Display", + displayId: "1", + } as const; + store.setRecordingPreferences({ micEnabled: true, micDeviceId: "mic" }); + expect(store.setLastSource(source).lastSource).toEqual(source); + expect( + JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")), + ).toMatchObject({ micEnabled: true, micDeviceId: "mic", lastSource: source }); + expect(store.setLastSource(null).lastSource).toBeNull(); + expect(store.getSnapshot().recording).toMatchObject({ micEnabled: true, micDeviceId: "mic" }); + }); + + it("rejects invalid or failed writes without changing the published durable value", () => { + const dir = temp(); + const store = new AppSettingsStore(dir); + store.setRecordingPreferences({ micEnabled: true }); + expect(() => store.setRecordingPreferences({ micEnabled: "yes" as never })).toThrow(TypeError); + expect(store.getSnapshot().recording.micEnabled).toBe(true); + const missing = new AppSettingsStore(path.join(dir, "missing")); + expect(() => missing.setRecordingPreferences({ micEnabled: true })).toThrow(); + expect(missing.getSnapshot().recording.micEnabled).toBe(false); + }); +}); diff --git a/electron/app-settings.ts b/electron/app-settings.ts new file mode 100644 index 000000000..a678a6c02 --- /dev/null +++ b/electron/app-settings.ts @@ -0,0 +1,157 @@ +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { CursorCaptureMode } from "../src/lib/recordingSession"; + +export interface RecordingPreferences { + micEnabled: boolean; + micDeviceId: string | null; + micDeviceName: string | null; + camEnabled: boolean; + camDeviceId: string | null; + camDeviceName: string | null; + systemAudioEnabled: boolean; + cursorCaptureMode: CursorCaptureMode; +} + +export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", +}; + +export interface RecordingSourceDescriptor { + platform: NodeJS.Platform; + kind: "screen" | "window"; + id: string; + name: string; + displayId: string | null; +} + +export interface AppSettingsSnapshot { + recording: RecordingPreferences; + lastSource: RecordingSourceDescriptor | null; +} + +type RawSettings = Record; + +function readRaw(userData: string): RawSettings { + 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 RawSettings) + : {}; + } catch { + return {}; + } +} + +function atomicWrite(userData: string, value: RawSettings): void { + const destination = path.join(userData, "recording-settings.json"); + const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + renameSync(temporary, destination); + } finally { + rmSync(temporary, { force: true }); + } +} + +const bool = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback); +const nullableString = (value: unknown, fallback: string | null) => + value === null || typeof value === "string" ? value : fallback; + +function parseRecording(raw: RawSettings): RecordingPreferences { + return { + micEnabled: bool(raw.micEnabled, DEFAULT_RECORDING_PREFERENCES.micEnabled), + micDeviceId: nullableString(raw.micDeviceId, DEFAULT_RECORDING_PREFERENCES.micDeviceId), + micDeviceName: nullableString(raw.micDeviceName, DEFAULT_RECORDING_PREFERENCES.micDeviceName), + camEnabled: bool(raw.camEnabled, DEFAULT_RECORDING_PREFERENCES.camEnabled), + camDeviceId: nullableString(raw.camDeviceId, DEFAULT_RECORDING_PREFERENCES.camDeviceId), + camDeviceName: nullableString(raw.camDeviceName, DEFAULT_RECORDING_PREFERENCES.camDeviceName), + systemAudioEnabled: bool( + raw.systemAudioEnabled, + DEFAULT_RECORDING_PREFERENCES.systemAudioEnabled, + ), + cursorCaptureMode: + raw.cursorCaptureMode === "system" || raw.cursorCaptureMode === "editable-overlay" + ? raw.cursorCaptureMode + : DEFAULT_RECORDING_PREFERENCES.cursorCaptureMode, + }; +} + +function parseSource(value: unknown): RecordingSourceDescriptor | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Record; + if ( + (candidate.platform !== "win32" && + candidate.platform !== "darwin" && + candidate.platform !== "linux") || + (candidate.kind !== "screen" && candidate.kind !== "window") || + typeof candidate.id !== "string" || + candidate.id.length === 0 || + typeof candidate.name !== "string" || + candidate.name.length === 0 || + !(candidate.displayId === null || typeof candidate.displayId === "string") + ) { + return null; + } + return candidate as unknown as RecordingSourceDescriptor; +} + +function validateRecordingPatch(patch: Partial): void { + const allowed = new Set(Object.keys(DEFAULT_RECORDING_PREFERENCES)); + for (const [key, value] of Object.entries(patch)) { + if (!allowed.has(key)) throw new TypeError(`unknown recording preference: ${key}`); + if (value === undefined) continue; + if (key.endsWith("Enabled") && typeof value !== "boolean") { + throw new TypeError(`${key} must be a boolean`); + } + if ( + (key.endsWith("DeviceId") || key.endsWith("DeviceName")) && + value !== null && + typeof value !== "string" + ) { + throw new TypeError(`${key} must be a string or null`); + } + if (key === "cursorCaptureMode" && value !== "system" && value !== "editable-overlay") { + throw new TypeError("cursorCaptureMode is invalid"); + } + } +} + +export class AppSettingsStore { + constructor(private readonly userData: string) {} + + getSnapshot(): AppSettingsSnapshot { + const raw = readRaw(this.userData); + return { + recording: parseRecording(raw), + lastSource: parseSource(raw.lastSource), + }; + } + + setRecordingPreferences(patch: Partial): AppSettingsSnapshot { + validateRecordingPatch(patch); + const raw = readRaw(this.userData); + const current = parseRecording(raw); + const next = Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== undefined), + ) as Partial; + atomicWrite(this.userData, { ...raw, ...current, ...next }); + return this.getSnapshot(); + } + + setLastSource(source: RecordingSourceDescriptor | null): AppSettingsSnapshot { + if (source !== null && !parseSource(source)) throw new TypeError("last source is invalid"); + const raw = readRaw(this.userData); + atomicWrite(this.userData, { ...raw, lastSource: source }); + return this.getSnapshot(); + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index d742d2d09..f96b0f205 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -51,9 +51,14 @@ interface Window { opened: boolean; reason?: string; }>; - selectSource: (source: ProcessedDesktopSource) => Promise; + selectSource: ( + source: ProcessedDesktopSource, + options?: { persist?: boolean }, + ) => Promise; getSelectedSource: () => Promise; - onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => () => void; + onSelectedSourceChanged: ( + callback: (source: ProcessedDesktopSource | null) => void, + ) => () => void; getRecordingPrefs: () => Promise; setRecordingPrefs: ( prefs: Partial, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b3ae42364..2b464240f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -56,6 +56,7 @@ import { import type { CursorTelemetryReader } from "../ai-edition/deep-agent/service"; import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; +import { AppSettingsStore } from "../app-settings"; import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; @@ -103,10 +104,20 @@ import { } from "../recording/nativeWindowsCaptureStop"; import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { reindexRecordingOnDisk } from "../recording/webm-seek-index"; +import { + describeRecordingSource, + enumerationIncludesSourceKind, + mergeEnumeratedSources, + resolveRecordingSource, + restoreRecordingSourceAfterEnumeration, + shouldEnumerateRecordingSources, + shouldPersistSelectedSource, +} from "../recording-source-settings"; import { registerNativeBridgeHandlers } from "./nativeBridge"; import { createNativeMacMidCaptureErrorWatch } from "./nativeMacMidCaptureErrorWatch"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; +import { type SelectSourceContext, selectSourceWithOwnership } from "./selectSourceOwnership"; const PROJECT_FILE_EXTENSION = "openscreen"; export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); @@ -592,16 +603,16 @@ type AttachNativeMacWebcamRecordingInput = { let selectedSource: SelectedSource | null = null; let selectedDesktopSource: DesktopCapturerSource | null = null; let lastEnumeratedSources = new Map(); +const selectSourceGeneration = { value: 0 }; let currentProjectPath: string | null = null; let currentRecordingSession: RecordingSession | null = null; -// single source of truth for the mic/camera/system-audio/cursor +// Durable source of truth for the mic/camera/system-audio/cursor/source // choices a user makes in the editor's Rec-mode stage, so the HUD window's // 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). Auto-zoom is the one durable choice; -// the device selections remain session preferences, not project content. +// startNewRecording() switches windows. Persisted in AppSettingsStore and +// broadcast on change; not project content. export interface RecordingPrefs { micEnabled: boolean; micDeviceId: string | null; @@ -619,6 +630,8 @@ export interface RecordingPrefs { micDeviceName: string | null; camEnabled: boolean; camDeviceId: string | null; + /** Camera label paired with the preferred id for restart-safe resolution. */ + camDeviceName: string | null; systemAudioEnabled: boolean; cursorCaptureMode: CursorCaptureMode; } @@ -628,6 +641,7 @@ const defaultRecordingPrefs: RecordingPrefs = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", }; @@ -1837,6 +1851,17 @@ export function registerIpcHandlers( onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, ) { + const appSettings = new AppSettingsStore(app.getPath("userData")); + const broadcastSelectedSource = (source: SelectedSource | null) => { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send("selected-source-changed", source); + } + } + }; + const sameSelectedSource = (left: SelectedSource | null, right: SelectedSource | null) => + left?.id === right?.id && left?.name === right?.name && left?.display_id === right?.display_id; + async function requestScreenAccess() { if (process.platform !== "darwin") { return { success: true, granted: true, status: "granted" }; @@ -1917,7 +1942,39 @@ export function registerIpcHandlers( `[get-sources] returned ${sources.length} source(s) in ${Date.now() - startedAt}ms (types=${(opts?.types ?? []).join(",")})`, ); } - lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); + lastEnumeratedSources = mergeEnumeratedSources(lastEnumeratedSources, sources, opts?.types); + const previousSelectedSource = selectedSource; + const currentLive = selectedSource?.id + ? sources.find((source) => source.id === selectedSource?.id) + : null; + if (currentLive) { + selectedSource = { + id: currentLive.id, + name: currentLive.name, + display_id: currentLive.display_id, + }; + selectedDesktopSource = currentLive; + } else if (enumerationIncludesSourceKind(opts?.types, selectedSource?.id)) { + selectedSource = null; + selectedDesktopSource = null; + const restored = resolveRecordingSource( + appSettings.getSnapshot().lastSource, + process.platform, + sources, + { waylandPortal: process.platform === "linux" && Boolean(findPipeWireCursorHelperPath()) }, + ); + if (restored) { + selectedSource = { + id: restored.id, + name: restored.name, + display_id: restored.display_id, + }; + selectedDesktopSource = lastEnumeratedSources.get(restored.id) ?? null; + } + } + if (!sameSelectedSource(previousSelectedSource, selectedSource)) { + broadcastSelectedSource(selectedSource); + } return sources.map((source) => ({ id: source.id, name: source.name, @@ -1927,42 +1984,116 @@ export function registerIpcHandlers( })); }); - ipcMain.handle("select-source", async (_, source: SelectedSource) => { - selectedSource = source; - // Reuse the exact source object returned during enumeration to avoid - // Windows window-source id mismatches across separate getSources() calls. - selectedDesktopSource = - typeof source.id === "string" ? (lastEnumeratedSources.get(source.id) ?? null) : null; + const selectSourceContext: SelectSourceContext = { + generation: selectSourceGeneration, + getSelected: () => ({ source: selectedSource, live: selectedDesktopSource }), + setSelected: (source, live) => { + selectedSource = source; + selectedDesktopSource = live; + }, + getCached: (id) => lastEnumeratedSources.get(id) ?? null, + replaceCache: (sources) => { + lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate])); + }, + }; - if (!selectedDesktopSource && typeof source.id === "string") { - try { - const sources = await desktopCapturer.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 0, height: 0 }, - fetchWindowIcons: true, - }); - lastEnumeratedSources = new Map(sources.map((candidate) => [candidate.id, candidate])); - selectedDesktopSource = lastEnumeratedSources.get(source.id) ?? null; - } catch { - selectedDesktopSource = null; + ipcMain.handle( + "select-source", + async (_, source: SelectedSource, options?: { persist?: boolean }) => { + const next = await selectSourceWithOwnership( + selectSourceContext, + { id: source.id, name: source.name, display_id: source.display_id }, + options, + { + getSources: () => + desktopCapturer.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 0, height: 0 }, + fetchWindowIcons: true, + }), + persist: (live) => { + appSettings.setLastSource( + describeRecordingSource(process.platform, live as Required), + ); + }, + broadcast: broadcastSelectedSource, + shouldPersist: shouldPersistSelectedSource, + }, + ); + if (next) { + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.close(); + } } + return next; + }, + ); + + ipcMain.handle("get-selected-source", async () => { + const previousSelectedSource = selectedSource; + if (process.platform === "linux" && findPipeWireCursorHelperPath()) { + selectedSource = null; + selectedDesktopSource = null; + if (!sameSelectedSource(previousSelectedSource, null)) { + broadcastSelectedSource(null); + } + return null; + } + const lastSource = appSettings.getSnapshot().lastSource; + const liveSelected = + selectedSource?.id != null + ? { + id: selectedSource.id, + name: selectedSource.name, + display_id: selectedSource.display_id ?? "", + } + : null; + if (!shouldEnumerateRecordingSources(liveSelected, lastSource)) { + return selectedSource; } - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - mainWin.webContents.send("selected-source-changed", selectedSource); + const sources = await withDeadline( + desktopCapturer.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 0, height: 0 }, + fetchWindowIcons: false, + }), + GET_SOURCES_TIMEOUT_MS, + `Desktop source restoration did not return within ${GET_SOURCES_TIMEOUT_MS}ms.`, + ); + const decision = restoreRecordingSourceAfterEnumeration({ + selectedBefore: liveSelected, + selectedAfter: + selectedSource?.id != null + ? { + id: selectedSource.id, + name: selectedSource.name, + display_id: selectedSource.display_id ?? "", + } + : null, + lastSourceBefore: lastSource, + lastSourceAfter: appSettings.getSnapshot().lastSource, + platform: process.platform, + sources, + }); + if (!decision.apply) { + return selectedSource; } - const sourceSelectorWin = getSourceSelectorWindow(); - if (sourceSelectorWin) { - sourceSelectorWin.close(); + lastEnumeratedSources = new Map(sources.map((source) => [source.id, source])); + const restored = decision.restored; + selectedDesktopSource = restored ? (lastEnumeratedSources.get(restored.id) ?? null) : null; + selectedSource = restored + ? { id: restored.id, name: restored.name, display_id: restored.display_id } + : null; + if (!sameSelectedSource(previousSelectedSource, selectedSource)) { + broadcastSelectedSource(selectedSource); } return selectedSource; }); - ipcMain.handle("get-selected-source", () => { - return selectedSource; - }); - - registerRecordingPrefsHandlers(defaultRecordingPrefs, getMainWindow); + registerRecordingPrefsHandlers(defaultRecordingPrefs, getMainWindow, () => + BrowserWindow.getAllWindows(), + ); ipcMain.handle("request-camera-access", async () => { if (process.platform !== "darwin") { diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index aaa08b190..0db57c795 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -1,10 +1,14 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { BrowserWindow } from "electron"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RecordingPrefs } from "./handlers"; import { registerRecordingPrefsHandlers } from "./recordingPrefs"; -const electron = vi.hoisted(() => ({ handle: vi.fn() })); +const electron = vi.hoisted(() => ({ getPath: vi.fn(), handle: vi.fn() })); vi.mock("electron", () => ({ + app: { getPath: electron.getPath }, ipcMain: { handle: electron.handle }, })); @@ -14,17 +18,24 @@ const defaults: RecordingPrefs = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", }; - +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) { +function start( + getWindow: () => BrowserWindow | null = () => null, + getAppWindows?: () => BrowserWindow[], +) { electron.handle.mockClear(); - registerRecordingPrefsHandlers(defaults, getWindow); + registerRecordingPrefsHandlers(defaults, getWindow, getAppWindows); const get = electron.handle.mock.calls.find( ([name]) => name === "get-recording-prefs", )?.[1] as () => RecordingPrefs; @@ -32,28 +43,74 @@ function start(getWindow: () => BrowserWindow | null = () => null) { _event: unknown, prefs: Partial, ) => RecordingPrefs; - return { get, set: (prefs: Partial) => set(undefined, prefs) }; + return { + get, + set: (prefs: Partial) => set(undefined, prefs), + }; } describe("recording preferences IPC", () => { - it("returns defaults and updates session preferences", () => { - const session = start(); - expect(session.get()).toEqual(defaults); - const updated = session.set({ micEnabled: true, micDeviceId: "test-mic" }); - expect(updated.micEnabled).toBe(true); - expect(updated.micDeviceId).toBe("test-mic"); - expect(session.get().micEnabled).toBe(true); + it("restores toggles and device preferences on restart", () => { + const first = start(); + expect(first.get().micEnabled).toBe(false); + expect(first.get().camDeviceName).toBeNull(); + expect(first.set({ camDeviceName: "Camera A" }).camDeviceName).toBe("Camera A"); + first.set({ micEnabled: true, micDeviceId: "temporary-device" }); + const disk = JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")); + expect(disk).toMatchObject({ + camDeviceName: "Camera A", + micEnabled: true, + micDeviceId: "temporary-device", + }); + const restarted = start(); + expect(restarted.get()).toEqual({ + ...defaults, + camDeviceName: "Camera A", + micEnabled: true, + micDeviceId: "temporary-device", + }); + restarted.set({ camDeviceName: "Camera B" }); + expect(start().get().camDeviceName).toBe("Camera B"); + }); + + it("broadcasts saved values to every live application window", () => { + const firstSend = vi.fn(); + const secondSend = vi.fn(); + const destroyedSend = vi.fn(); + const first = { + isDestroyed: () => false, + webContents: { send: firstSend }, + } as unknown as BrowserWindow; + const second = { + isDestroyed: () => false, + webContents: { send: secondSend }, + } as unknown as BrowserWindow; + const destroyed = { + isDestroyed: () => true, + webContents: { send: destroyedSend }, + } as unknown as BrowserWindow; + const session = start( + () => first, + () => [first, second, first, destroyed], + ); + const updated = session.set({ micEnabled: true }); + expect(firstSend).toHaveBeenCalledWith("recording-prefs-changed", updated); + expect(secondSend).toHaveBeenCalledWith("recording-prefs-changed", updated); + expect(firstSend).toHaveBeenCalledTimes(1); + expect(destroyedSend).not.toHaveBeenCalled(); }); - it("broadcasts changes to main window 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({ camEnabled: true }); - expect(send).toHaveBeenCalledWith("recording-prefs-changed", updated); - isDestroyed.mockReturnValue(true); + it("does not publish an invalid or failed preference write", () => { + const session = start(); + expect(() => session.set({ micEnabled: null } as unknown as Partial)).toThrow( + TypeError, + ); + expect(session.get().micEnabled).toBe(false); session.set({ micEnabled: true }); - expect(send).toHaveBeenCalledTimes(1); + session.set({ micEnabled: undefined, camEnabled: true }); + expect(session.get().micEnabled).toBe(true); + rmSync(dir, { recursive: true, force: true }); + expect(() => session.set({ micEnabled: false })).toThrow(); + expect(session.get().micEnabled).toBe(true); }); }); diff --git a/electron/ipc/recordingPrefs.ts b/electron/ipc/recordingPrefs.ts index 65516201d..d92171465 100644 --- a/electron/ipc/recordingPrefs.ts +++ b/electron/ipc/recordingPrefs.ts @@ -1,24 +1,33 @@ -import type { BrowserWindow } from "electron"; -import { ipcMain } from "electron"; +import { app, type BrowserWindow, ipcMain } from "electron"; +import { AppSettingsStore } from "../app-settings"; import type { RecordingPrefs } from "./handlers"; -/** Shared session preferences. */ +/** Shared durable recording preferences. Persist before publishing any new snapshot. */ export function registerRecordingPrefsHandlers( defaults: RecordingPrefs, getMainWindow: () => BrowserWindow | null, + getAppWindows: () => BrowserWindow[] = () => { + const mainWindow = getMainWindow(); + return mainWindow ? [mainWindow] : []; + }, ): void { - let recordingPrefs = { ...defaults }; + const userData = app.getPath("userData"); + const settings = new AppSettingsStore(userData); + let recordingPrefs = { ...defaults, ...settings.getSnapshot().recording }; + const publish = () => { + for (const window of new Set(getAppWindows())) { + if (!window.isDestroyed()) { + window.webContents.send("recording-prefs-changed", recordingPrefs); + } + } + }; ipcMain.handle("get-recording-prefs", () => 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); - } + // Persist every validated field first. A failed save must leave both the + // durable value and the main-process published snapshot unchanged. + recordingPrefs = settings.setRecordingPreferences(prefs).recording; + publish(); return recordingPrefs; }); } diff --git a/electron/ipc/selectSourceOwnership.test.ts b/electron/ipc/selectSourceOwnership.test.ts new file mode 100644 index 000000000..8aaaf9258 --- /dev/null +++ b/electron/ipc/selectSourceOwnership.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import { type SelectSourceContext, selectSourceWithOwnership } from "./selectSourceOwnership"; + +type Live = { id: string; name: string; display_id: string }; + +function createContext() { + let selected: { + source: { name: string; id?: string; display_id?: string } | null; + live: Live | null; + } = { + source: null, + live: null, + }; + let cache = new Map(); + const ctx: SelectSourceContext = { + generation: { value: 0 }, + getSelected: () => selected, + setSelected: (source, live) => { + selected = { source, live }; + }, + getCached: (id) => cache.get(id) ?? null, + replaceCache: (sources) => { + cache = new Map(sources.map((source) => [source.id, source])); + }, + }; + return { + ctx, + get selected() { + return selected; + }, + seedCache(source: Live) { + cache.set(source.id, source); + }, + }; +} + +const sourceA: Live = { id: "screen:a", name: "Display A", display_id: "1" }; +const sourceB: Live = { id: "screen:b", name: "Display B", display_id: "2" }; + +describe("selectSourceWithOwnership", () => { + it("keeps B when a slower A enumeration finishes later", async () => { + const harness = createContext(); + harness.seedCache(sourceB); + let resolveA!: (sources: Live[]) => void; + const persist = vi.fn(); + const broadcast = vi.fn(); + + const pendingA = selectSourceWithOwnership( + harness.ctx, + sourceA, + { persist: true }, + { + getSources: () => + new Promise((resolve) => { + resolveA = resolve; + }), + persist, + broadcast, + shouldPersist: () => true, + }, + ); + + await selectSourceWithOwnership( + harness.ctx, + sourceB, + { persist: true }, + { + getSources: async () => [sourceA, sourceB], + persist, + broadcast, + shouldPersist: () => true, + }, + ); + expect(harness.selected.source).toMatchObject({ id: "screen:b" }); + expect(persist).toHaveBeenCalledTimes(1); + expect(persist.mock.calls[0]?.[0]).toMatchObject({ id: "screen:b" }); + + resolveA([sourceA, sourceB]); + await pendingA; + + expect(harness.selected.source).toMatchObject({ id: "screen:b" }); + expect(persist).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenLastCalledWith(expect.objectContaining({ id: "screen:b" })); + }); +}); diff --git a/electron/ipc/selectSourceOwnership.ts b/electron/ipc/selectSourceOwnership.ts new file mode 100644 index 000000000..3777d505a --- /dev/null +++ b/electron/ipc/selectSourceOwnership.ts @@ -0,0 +1,79 @@ +export type SelectedSourceSnapshot = { + name: string; + id?: string; + display_id?: string; +}; + +export type SelectSourceContext = { + generation: { value: number }; + getSelected: () => { source: SelectedSourceSnapshot | null; live: TLive | null }; + setSelected: (source: SelectedSourceSnapshot | null, live: TLive | null) => void; + getCached: (id: string) => TLive | null; + replaceCache: (sources: TLive[]) => void; +}; + +export type SelectSourceDeps = { + getSources: () => Promise; + persist: (source: SelectedSourceSnapshot) => void; + broadcast: (source: SelectedSourceSnapshot | null) => void; + shouldPersist: (options?: { persist?: boolean }) => boolean; +}; + +export function bumpSelectSourceGeneration(generation: { value: number }): number { + generation.value += 1; + return generation.value; +} + +export async function selectSourceWithOwnership< + TLive extends { id: string; name: string; display_id: string }, +>( + ctx: SelectSourceContext, + source: { id?: string; name: string; display_id?: string }, + options: { persist?: boolean } | undefined, + deps: SelectSourceDeps, +): Promise { + const generation = bumpSelectSourceGeneration(ctx.generation); + let live: TLive | null = typeof source.id === "string" ? ctx.getCached(source.id) : null; + + if (!live && typeof source.id === "string") { + try { + const sources = await deps.getSources(); + if (generation !== ctx.generation.value) { + return ctx.getSelected().source; + } + ctx.replaceCache(sources); + live = ctx.getCached(source.id); + } catch { + if (generation !== ctx.generation.value) { + return ctx.getSelected().source; + } + live = null; + } + } + + if (generation !== ctx.generation.value) { + return ctx.getSelected().source; + } + + if (!live) { + ctx.setSelected(null, null); + deps.broadcast(null); + return null; + } + + const next: SelectedSourceSnapshot = { + id: live.id, + name: live.name, + display_id: live.display_id, + }; + ctx.setSelected(next, live); + if (deps.shouldPersist(options)) { + try { + deps.persist(next); + } catch (error) { + console.warn("Failed to persist the selected recording source:", error); + } + } + deps.broadcast(next); + return next; +} diff --git a/electron/preload.ts b/electron/preload.ts index 6b29edd87..4ea4cb5f3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -122,8 +122,8 @@ contextBridge.exposeInMainWorld("electronAPI", { openNotes: () => { return ipcRenderer.invoke("open-notes"); }, - selectSource: (source: ProcessedDesktopSource) => { - return ipcRenderer.invoke("select-source", source); + selectSource: (source: ProcessedDesktopSource, options?: { persist?: boolean }) => { + return ipcRenderer.invoke("select-source", source, options); }, getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); @@ -139,8 +139,8 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("recording-prefs-changed", listener); return () => ipcRenderer.removeListener("recording-prefs-changed", listener); }, - onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource) => void) => { - const listener = (_event: unknown, source: ProcessedDesktopSource) => callback(source); + onSelectedSourceChanged: (callback: (source: ProcessedDesktopSource | null) => void) => { + const listener = (_event: unknown, source: ProcessedDesktopSource | null) => callback(source); ipcRenderer.on("selected-source-changed", listener); return () => ipcRenderer.removeListener("selected-source-changed", listener); }, diff --git a/electron/recording-source-settings.test.ts b/electron/recording-source-settings.test.ts new file mode 100644 index 000000000..f56a684fa --- /dev/null +++ b/electron/recording-source-settings.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + describeRecordingSource, + enumerationIncludesSourceKind, + mergeEnumeratedSources, + resolveCurrentRecordingSource, + resolveRecordingSource, + restoreRecordingSourceAfterEnumeration, + shouldEnumerateRecordingSources, + shouldPersistSelectedSource, +} from "./recording-source-settings"; + +const display = { id: "screen:1:0", name: "Display 1", display_id: "stable-1" }; + +describe("recording source settings", () => { + it("restores a display by stable display id from the fresh list", () => { + const descriptor = describeRecordingSource("win32", display); + expect( + resolveRecordingSource(descriptor, "win32", [ + { ...display, id: "screen:new-id", name: "Renamed display" }, + ]), + ).toMatchObject({ id: "screen:new-id", display_id: "stable-1" }); + }); + + it("uses an exact display identity fallback and refuses missing or ambiguous matches", () => { + const descriptor = describeRecordingSource("win32", display); + expect(resolveRecordingSource(descriptor, "win32", [display])).toEqual(display); + expect(resolveRecordingSource(descriptor, "darwin", [display])).toBeNull(); + expect(resolveRecordingSource(descriptor, "win32", [])).toBeNull(); + expect( + resolveRecordingSource(descriptor, "win32", [{ ...display }, { ...display, id: "screen:2" }]), + ).toEqual(display); + expect( + resolveRecordingSource(descriptor, "win32", [{ ...display }, { ...display }]), + ).toBeNull(); + }); + + it("requires both live id and name for windows and never guesses by title", () => { + const saved = describeRecordingSource("win32", { + id: "window:42", + name: "Notes", + display_id: "", + }); + expect( + resolveRecordingSource(saved, "win32", [{ id: "window:99", name: "Notes", display_id: "" }]), + ).toBeNull(); + expect( + resolveRecordingSource(saved, "win32", [{ id: "window:42", name: "Notes", display_id: "" }]), + ).toMatchObject({ id: "window:42" }); + }); + + it("keeps a live window when only its title changed", () => { + const selected = { id: "window:42", name: "Notes", display_id: "" }; + const renamed = { id: "window:42", name: "Notes — saved", display_id: "" }; + expect(resolveCurrentRecordingSource(selected, null, "win32", [renamed])).toEqual(renamed); + expect( + resolveRecordingSource(describeRecordingSource("win32", selected), "win32", [renamed]), + ).toBeNull(); + }); + + it("persists interactive source picks and skips CLI ones", () => { + expect(shouldPersistSelectedSource()).toBe(true); + expect(shouldPersistSelectedSource({ persist: true })).toBe(true); + expect(shouldPersistSelectedSource({ persist: false })).toBe(false); + }); + + it("does not resurrect a persisted source after reset during enumeration", () => { + const persisted = describeRecordingSource("win32", display); + let selected: { id: string; name: string; display_id: string } | null = null; + let lastSource: ReturnType | null = persisted; + const selectedBefore = selected; + const lastSourceBefore = lastSource; + lastSource = null; + selected = null; + const decision = restoreRecordingSourceAfterEnumeration({ + selectedBefore, + selectedAfter: selected, + lastSourceBefore, + lastSourceAfter: lastSource, + platform: "win32", + sources: [display], + }); + expect(decision.apply).toBe(false); + expect(decision.restored).toBeNull(); + expect(selected).toBeNull(); + expect( + restoreRecordingSourceAfterEnumeration({ + selectedBefore: null, + selectedAfter: null, + lastSourceBefore: persisted, + lastSourceAfter: persisted, + platform: "win32", + sources: [display], + }).restored, + ).toEqual(display); + }); + + it("enumerates only when a live or persisted source exists", () => { + expect(shouldEnumerateRecordingSources(null, null)).toBe(false); + expect( + shouldEnumerateRecordingSources({ id: "window:1", name: "A", display_id: "" }, null), + ).toBe(true); + expect(shouldEnumerateRecordingSources(null, describeRecordingSource("win32", display))).toBe( + true, + ); + }); + + it("does not treat a screen-only enum as proof a window is gone", () => { + expect(enumerationIncludesSourceKind(["screen"], "window:42")).toBe(false); + expect(enumerationIncludesSourceKind(["window"], "window:42")).toBe(true); + expect(enumerationIncludesSourceKind(["screen", "window"], "window:42")).toBe(true); + expect(enumerationIncludesSourceKind(undefined, "window:42")).toBe(true); + expect(enumerationIncludesSourceKind(["screen"], null)).toBe(true); + }); + + it("keeps the other kind in the enumeration cache across a partial list", () => { + const screen = { id: "screen:1:0", name: "Display 1" }; + const windowSource = { id: "window:42", name: "Notes" }; + const previous = new Map([ + [screen.id, screen], + [windowSource.id, windowSource], + ]); + const merged = mergeEnumeratedSources( + previous, + [{ ...screen, name: "Display 1 HDR" }], + ["screen"], + ); + expect(merged.get(windowSource.id)).toEqual(windowSource); + expect(merged.get(screen.id)).toMatchObject({ name: "Display 1 HDR" }); + }); + + it("never restores a source when the Wayland portal owns selection", () => { + const descriptor = describeRecordingSource("linux", display); + expect( + resolveRecordingSource(descriptor, "linux", [display], { waylandPortal: true }), + ).toBeNull(); + expect( + resolveRecordingSource(descriptor, "linux", [display], { waylandPortal: false }), + ).toEqual(display); + }); +}); diff --git a/electron/recording-source-settings.ts b/electron/recording-source-settings.ts new file mode 100644 index 000000000..d8d89425b --- /dev/null +++ b/electron/recording-source-settings.ts @@ -0,0 +1,182 @@ +import type { RecordingSourceDescriptor } from "./app-settings"; + +export interface LiveRecordingSource { + id: string; + name: string; + display_id: string; +} + +export function recordingSourceKindFromId(id: string): RecordingSourceDescriptor["kind"] { + return id.startsWith("window:") ? "window" : "screen"; +} + +export function describeRecordingSource( + platform: NodeJS.Platform, + source: LiveRecordingSource, +): RecordingSourceDescriptor { + return { + platform, + kind: recordingSourceKindFromId(source.id), + id: source.id, + name: source.name, + displayId: source.display_id || null, + }; +} + +/** + * A screen-only (or window-only) enumeration cannot prove a source of the + * other kind is gone. Clearing the live pick in that case is a false negative. + */ +export function enumerationIncludesSourceKind( + requestedTypes: readonly string[] | undefined, + sourceId: string | null | undefined, +): boolean { + if (!sourceId) return true; + const types = requestedTypes && requestedTypes.length > 0 ? requestedTypes : ["screen", "window"]; + return types.includes(recordingSourceKindFromId(sourceId)); +} + +export function mergeEnumeratedSources( + previous: ReadonlyMap, + next: readonly T[], + requestedTypes: readonly string[] | undefined, +): Map { + const types = requestedTypes && requestedTypes.length > 0 ? requestedTypes : ["screen", "window"]; + const merged = new Map(previous); + for (const id of [...merged.keys()]) { + if (types.includes(recordingSourceKindFromId(id))) { + merged.delete(id); + } + } + for (const source of next) { + merged.set(source.id, source); + } + return merged; +} + +/** HUD/RecStage persist the pick; CLI capture must not overwrite that default. */ +export function shouldPersistSelectedSource(options?: { persist?: boolean }): boolean { + return options?.persist !== false; +} + +export function sameRecordingSourceDescriptor( + left: RecordingSourceDescriptor | null | undefined, + right: RecordingSourceDescriptor | null | undefined, +): boolean { + if (left == null && right == null) return true; + if (left == null || right == null) return false; + return ( + left.platform === right.platform && + left.kind === right.kind && + left.id === right.id && + left.name === right.name && + left.displayId === right.displayId + ); +} + +/** + * Drop a restore result when the live selection or the persisted descriptor + * changed while enumeration was in flight. This includes reset: both sides can + * still be null while lastSource went from A to empty. + */ +export function shouldCommitRestoredRecordingSource(options: { + selectedBeforeId?: string | null; + selectedAfterId?: string | null; + lastSourceBefore: RecordingSourceDescriptor | null | undefined; + lastSourceAfter: RecordingSourceDescriptor | null | undefined; +}): boolean { + return ( + (options.selectedBeforeId ?? null) === (options.selectedAfterId ?? null) && + sameRecordingSourceDescriptor(options.lastSourceBefore, options.lastSourceAfter) + ); +} + +export function restoreRecordingSourceAfterEnumeration(options: { + selectedBefore: LiveRecordingSource | null | undefined; + selectedAfter: LiveRecordingSource | null | undefined; + lastSourceBefore: RecordingSourceDescriptor | null | undefined; + lastSourceAfter: RecordingSourceDescriptor | null | undefined; + platform: NodeJS.Platform; + sources: readonly LiveRecordingSource[]; +}): { apply: boolean; restored: LiveRecordingSource | null } { + if ( + !shouldCommitRestoredRecordingSource({ + selectedBeforeId: options.selectedBefore?.id, + selectedAfterId: options.selectedAfter?.id, + lastSourceBefore: options.lastSourceBefore, + lastSourceAfter: options.lastSourceAfter, + }) + ) { + return { apply: false, restored: null }; + } + return { + apply: true, + restored: resolveCurrentRecordingSource( + options.selectedBefore, + options.lastSourceBefore, + options.platform, + options.sources, + ), + }; +} + +/** True when restoration or liveness checking has a source to look up. */ +export function shouldEnumerateRecordingSources( + selected: LiveRecordingSource | null | undefined, + lastSource: RecordingSourceDescriptor | null | undefined, +): boolean { + return Boolean(selected?.id || lastSource); +} + +/** + * In-memory selections stay bound to the live id: a browser tab or document title + * can change without the window going away. Disk restore stays strict. + */ +export function resolveLiveRecordingSource( + selected: LiveRecordingSource, + sources: readonly LiveRecordingSource[], +): LiveRecordingSource | null { + const exact = sources.filter((source) => source.id === selected.id); + return exact.length === 1 ? exact[0] : null; +} + +export function resolveCurrentRecordingSource( + selected: LiveRecordingSource | null | undefined, + lastSource: RecordingSourceDescriptor | null | undefined, + platform: NodeJS.Platform, + sources: readonly LiveRecordingSource[], + options: { waylandPortal?: boolean } = {}, +): LiveRecordingSource | null { + if (selected?.id) { + return resolveLiveRecordingSource(selected, sources); + } + return resolveRecordingSource(lastSource ?? null, platform, sources, options); +} + +/** Resolves a stored logical descriptor only to an item from a fresh enumeration. */ +export function resolveRecordingSource( + descriptor: RecordingSourceDescriptor | null, + platform: NodeJS.Platform, + sources: readonly LiveRecordingSource[], + options: { waylandPortal?: boolean } = {}, +): LiveRecordingSource | null { + if (!descriptor || descriptor.platform !== platform || options.waylandPortal) return null; + const kindMatches = sources.filter( + (source) => (source.id.startsWith("window:") ? "window" : "screen") === descriptor.kind, + ); + if (descriptor.kind === "window") { + const exact = kindMatches.filter( + (source) => source.id === descriptor.id && source.name === descriptor.name, + ); + return exact.length === 1 ? exact[0] : null; + } + + if (descriptor.displayId) { + const stable = kindMatches.filter((source) => source.display_id === descriptor.displayId); + if (stable.length === 1) return stable[0]; + } + const exact = kindMatches.filter( + (source) => source.id === descriptor.id && source.name === descriptor.name, + ); + return exact.length === 1 ? exact[0] : null; +} diff --git a/src/cli/CliRecordRunner.cameraIsolation.test.tsx b/src/cli/CliRecordRunner.cameraIsolation.test.tsx new file mode 100644 index 000000000..ea64b299c --- /dev/null +++ b/src/cli/CliRecordRunner.cameraIsolation.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { render, waitFor } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => key, +})); +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { CliRecordRunner } from "./CliRecordRunner"; + +const persistedPrefs = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: true, + camDeviceId: "cam1", + camDeviceName: "Camera 1", + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay" as const, +}; + +describe("CliRecordRunner camera isolation", () => { + const getUserMedia = vi.fn(async () => ({ + getTracks: () => [], + getVideoTracks: () => [], + })); + + beforeAll(() => { + window.history.replaceState(null, "", "/?windowType=cli-record"); + Object.defineProperty(global.navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: vi.fn(async () => []), + getUserMedia, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + }); + + beforeEach(() => { + getUserMedia.mockClear(); + window.electronAPI = { + getRecordingPrefs: vi.fn(async () => persistedPrefs), + onRecordingPrefsChanged: vi.fn(() => () => undefined), + getPlatform: vi.fn(() => "win32"), + getSelectedSource: vi.fn(async () => null), + cliGetRequest: vi.fn( + () => + new Promise(() => { + /* source/request stay pending */ + }), + ), + cliLog: vi.fn(), + cliDone: vi.fn(async () => undefined), + onCliStopRecording: vi.fn(() => () => undefined), + getCurrentRecordingSession: vi.fn(async () => ({ success: false, session: null })), + getSources: vi.fn( + () => + new Promise(() => { + /* enumeration pending */ + }), + ), + selectSource: vi.fn(), + } as unknown as typeof window.electronAPI; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("does not acquire webcam from GUI prefs while CLI source enumeration is pending", async () => { + render(); + await waitFor(() => expect(window.electronAPI.getRecordingPrefs).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(getUserMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/CliRecordRunner.test.tsx b/src/cli/CliRecordRunner.test.tsx new file mode 100644 index 000000000..3e01a0d47 --- /dev/null +++ b/src/cli/CliRecordRunner.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CliRecordRunner } from "./CliRecordRunner"; + +const recorder = vi.hoisted(() => ({ + recording: false, + saving: false, + startRecordingImmediately: vi.fn(async () => undefined), + toggleRecording: vi.fn(), + setMicrophoneEnabled: vi.fn(), + setMicrophoneDeviceId: vi.fn(), + setMicrophoneDeviceName: vi.fn(), + setSystemAudioEnabled: vi.fn(), + setCursorCaptureMode: vi.fn(), + setWebcamEnabled: vi.fn(async () => false), + setWebcamDeviceId: vi.fn(), + setWebcamDeviceName: vi.fn(), + recordingPrefsLoaded: true, + microphoneEnabled: false, + webcamEnabled: false, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay" as const, +})); + +vi.mock("@/hooks/useScreenRecorder", () => ({ + useScreenRecorder: () => recorder, +})); + +const screenSource = { + id: "screen:gone", + name: "Display 1", + display_id: "1", + thumbnail: null, + appIcon: null, +} satisfies ProcessedDesktopSource; + +const request = { + kind: "record" as const, + displayIndex: 0, + windowTitle: null, + mic: false, + micDevice: null, + systemAudio: false, + cursorMode: "editable-overlay" as const, + durationMs: null, + projectOut: null, +}; + +describe("CliRecordRunner", () => { + beforeEach(() => { + recorder.startRecordingImmediately.mockClear(); + recorder.toggleRecording.mockClear(); + window.electronAPI = { + cliGetRequest: vi.fn(async () => request), + cliLog: vi.fn(), + cliDone: vi.fn(async () => undefined), + onCliStopRecording: vi.fn(() => () => undefined), + getCurrentRecordingSession: vi.fn(async () => ({ success: false, session: null })), + getSources: vi.fn(async () => [screenSource]), + selectSource: vi.fn(async () => screenSource), + } as unknown as typeof window.electronAPI; + }); + + it("fails immediately when selectSource returns null", async () => { + vi.mocked(window.electronAPI.selectSource).mockResolvedValueOnce(null); + render(); + await waitFor(() => expect(window.electronAPI.cliDone).toHaveBeenCalled()); + expect(window.electronAPI.cliDone).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.stringContaining("openscreen sources"), + }), + ); + expect(recorder.startRecordingImmediately).not.toHaveBeenCalled(); + expect(recorder.toggleRecording).not.toHaveBeenCalled(); + }); + + it("starts recording when the selected source is still available", async () => { + render(); + await waitFor(() => expect(recorder.startRecordingImmediately).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.cliDone).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/CliRecordRunner.tsx b/src/cli/CliRecordRunner.tsx index f588caa4c..be04ab043 100644 --- a/src/cli/CliRecordRunner.tsx +++ b/src/cli/CliRecordRunner.tsx @@ -120,6 +120,10 @@ export function CliRecordRunner() { setMicrophoneDeviceName, setSystemAudioEnabled, setCursorCaptureMode, + setWebcamEnabled, + setWebcamDeviceId, + setWebcamDeviceName, + recordingPrefsLoaded, } = recorder; // Keep latest values in refs for the stop/finish effects. @@ -137,9 +141,11 @@ export function CliRecordRunner() { }; // Bootstrap: pick source, configure recorder, start. + // Wait for persisted GUI prefs to land first, then overwrite every field + // from the CLI request so a previous HUD session cannot enable mic/webcam. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional run-once bootstrap; startedRef guards re-entry useEffect(() => { - if (startedRef.current) return; + if (startedRef.current || !recordingPrefsLoaded) return; startedRef.current = true; void (async () => { @@ -157,18 +163,30 @@ export function CliRecordRunner() { requestRef.current = request; const source = await pickSource(request); - await window.electronAPI.selectSource(source); - window.electronAPI.cliLog("info", `Recording source: ${source.name}`); + const selected = await window.electronAPI.selectSource(source, { persist: false }); + if (!selected) { + throw new Error( + `Recording source "${source.name}" (${source.id}) is no longer available. ` + + "Re-list sources with `openscreen sources` and pick one that is currently shared.", + ); + } + window.electronAPI.cliLog("info", `Recording source: ${selected.name}`); + setMicrophoneEnabled(Boolean(request.mic)); if (request.mic) { const mic = await resolveMicDeviceId(request.micDevice); - setMicrophoneEnabled(true); setMicrophoneDeviceId(mic.deviceId); setMicrophoneDeviceName(mic.deviceName); if (mic.deviceName) { window.electronAPI.cliLog("info", `Microphone: ${mic.deviceName}`); } + } else { + setMicrophoneDeviceId(undefined); + setMicrophoneDeviceName(undefined); } + await setWebcamEnabled(false); + setWebcamDeviceId(undefined); + setWebcamDeviceName(undefined); setSystemAudioEnabled(request.systemAudio); setCursorCaptureMode(request.cursorMode); setStatus("Starting recording…"); @@ -177,7 +195,7 @@ export function CliRecordRunner() { await fail(error); } })(); - }, []); + }, [recordingPrefsLoaded]); // The setters above land on the *next* render; start only once they have. const configuredRef = useRef(false); @@ -185,10 +203,11 @@ export function CliRecordRunner() { useEffect(() => { const request = requestReady; if (!request || configuredRef.current || phaseRef.current !== "init") return; - const micReady = !request.mic || recorder.microphoneEnabled; + const micReady = recorder.microphoneEnabled === Boolean(request.mic); + const webcamReady = !recorder.webcamEnabled; const systemAudioReady = recorder.systemAudioEnabled === request.systemAudio; const cursorReady = recorder.cursorCaptureMode === request.cursorMode; - if (!micReady || !systemAudioReady || !cursorReady) return; + if (!micReady || !webcamReady || !systemAudioReady || !cursorReady) return; configuredRef.current = true; phaseRef.current = "recording"; @@ -214,6 +233,7 @@ export function CliRecordRunner() { }, [ requestReady, recorder.microphoneEnabled, + recorder.webcamEnabled, recorder.systemAudioEnabled, recorder.cursorCaptureMode, ]); diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index f2b0f0146..7c4e5612d 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RecStage } from "./RecStage"; @@ -8,13 +8,22 @@ vi.mock("@/contexts/I18nContext", () => ({ useScopedT: () => (key: string) => key, })); -vi.mock("@/hooks/useMicrophoneDevices", () => ({ - useMicrophoneDevices: () => ({ - devices: [], +const microphoneHook = vi.hoisted(() => ({ + call: vi.fn(), + value: { + devices: [] as Array<{ deviceId: string; label: string; groupId: string }>, selectedDeviceId: "default", setSelectedDeviceId: vi.fn(), isLoading: false, - }), + isReady: true, + error: null as string | null, + }, +})); +vi.mock("@/hooks/useMicrophoneDevices", () => ({ + useMicrophoneDevices: (...args: unknown[]) => { + microphoneHook.call(...args); + return microphoneHook.value; + }, })); vi.mock("@/hooks/useCameraDevices", () => ({ @@ -27,38 +36,80 @@ vi.mock("@/hooks/useCameraDevices", () => ({ }), })); +const audioMeter = vi.hoisted(() => ({ call: vi.fn() })); vi.mock("@/hooks/useAudioLevelMeter", () => ({ - useAudioLevelMeter: () => ({ level: 0 }), + useAudioLevelMeter: (options: unknown) => { + audioMeter.call(options); + return { level: 0 }; + }, })); +const cameraPreview = vi.hoisted(() => ({ call: vi.fn() })); vi.mock("@/hooks/useCameraPreviewStream", () => ({ - useCameraPreviewStream: () => ({ stream: null, error: null }), + useCameraPreviewStream: (options: unknown) => { + cameraPreview.call(options); + return { stream: null, error: null }; + }, })); vi.mock("@/hooks/usePortalOwnsSource", () => ({ usePortalOwnsSource: () => false, })); -function stubRecordingPrefs(prefs: Record = {}) { +type RecordingPrefs = Awaited>; +type SelectedSource = Awaited>; +let recordingPrefsListeners: Array<(prefs: RecordingPrefs) => void> = []; +let selectedSourceListeners: Array<(source: SelectedSource) => void> = []; + +function stubRecordingPrefs( + prefs: Record = {}, + selectedSource: SelectedSource = null, +) { const getRecordingPrefs = vi.fn(async () => prefs); const setRecordingPrefs = vi.fn(async () => undefined); (window as unknown as { electronAPI?: unknown }).electronAPI = { getRecordingPrefs, setRecordingPrefs, - getSelectedSource: vi.fn(async () => null), + getSelectedSource: vi.fn(async () => selectedSource), + onRecordingPrefsChanged: vi.fn((callback: (next: RecordingPrefs) => void) => { + recordingPrefsListeners.push(callback); + return () => { + recordingPrefsListeners = recordingPrefsListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + onSelectedSourceChanged: vi.fn((callback: (next: SelectedSource) => void) => { + selectedSourceListeners.push(callback); + return () => { + selectedSourceListeners = selectedSourceListeners.filter( + (listener) => listener !== callback, + ); + }; + }), }; return { getRecordingPrefs, setRecordingPrefs }; } function renderRecStage() { const onStartRecording = vi.fn(); - render(); - return { onStartRecording }; + const view = render(); + return { onStartRecording, ...view }; } describe("RecStage controls", () => { beforeEach(() => { vi.clearAllMocks(); + recordingPrefsListeners = []; + selectedSourceListeners = []; + microphoneHook.value = { + devices: [], + selectedDeviceId: "default", + setSelectedDeviceId: vi.fn(), + isLoading: false, + isReady: true, + error: null, + }; }); afterEach(() => { @@ -77,4 +128,148 @@ describe("RecStage controls", () => { }); expect(screen.queryByTestId("rec-auto-zoom-button")).toBeNull(); }); + + it("waits for microphone discovery before starting the meter and normalizes default", async () => { + stubRecordingPrefs({ + micEnabled: true, + micDeviceId: "saved-id", + micDeviceName: "Saved microphone", + }); + microphoneHook.value = { + ...microphoneHook.value, + isLoading: true, + isReady: false, + }; + const { rerender, onStartRecording } = renderRecStage(); + await waitFor(() => + expect(microphoneHook.call).toHaveBeenCalledWith(true, "saved-id", "Saved microphone"), + ); + expect(audioMeter.call).toHaveBeenLastCalledWith({ enabled: false, deviceId: undefined }); + + microphoneHook.value = { + ...microphoneHook.value, + devices: [{ deviceId: "default", label: "System default", groupId: "g" }], + selectedDeviceId: "default", + isLoading: false, + isReady: true, + }; + rerender(); + expect(audioMeter.call).toHaveBeenLastCalledWith({ enabled: true, deviceId: undefined }); + }); + + it("shows explicit empty and error states instead of an empty microphone select", async () => { + stubRecordingPrefs({ micEnabled: true }); + microphoneHook.value = { ...microphoneHook.value, devices: [], error: null }; + const { rerender, onStartRecording } = renderRecStage(); + expect(await screen.findByText("rec.noMicrophoneFound")).toBeInTheDocument(); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + + microphoneHook.value = { + ...microphoneHook.value, + devices: [], + error: "enumeration failed", + }; + rerender(); + expect(screen.getByText("rec.microphoneUnavailable")).toHaveAttribute( + "title", + "enumeration failed", + ); + }); + + it("ties microphone discovery to the toggle so off then on requests a retry", async () => { + stubRecordingPrefs({ micEnabled: true }); + renderRecStage(); + await screen.findByText("rec.noMicrophoneFound"); + const row = screen.getByText("rec.microphone").closest("div"); + if (!row?.parentElement) throw new Error("microphone row is missing"); + const toggle = within(row.parentElement).getByRole("button", { name: "rec.on" }); + microphoneHook.call.mockClear(); + fireEvent.click(toggle); + await waitFor(() => + expect(microphoneHook.call).toHaveBeenLastCalledWith(false, undefined, undefined), + ); + fireEvent.click(within(row.parentElement).getByRole("button", { name: "rec.off" })); + await waitFor(() => + expect(microphoneHook.call).toHaveBeenLastCalledWith(true, undefined, undefined), + ); + }); + + it("applies pushed preference events and ignores older initial preference and source reads", async () => { + let resolvePrefs: ((value: RecordingPrefs) => void) | undefined; + let resolveSource: ((value: SelectedSource) => void) | undefined; + const initialPrefs = new Promise((resolve) => { + resolvePrefs = resolve; + }); + const initialSource = new Promise((resolve) => { + resolveSource = resolve; + }); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + getRecordingPrefs: vi.fn(() => initialPrefs), + setRecordingPrefs: vi.fn(async () => undefined), + getSelectedSource: vi.fn(() => initialSource), + onRecordingPrefsChanged: vi.fn((callback: (next: RecordingPrefs) => void) => { + recordingPrefsListeners.push(callback); + return () => { + recordingPrefsListeners = recordingPrefsListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + onSelectedSourceChanged: vi.fn((callback: (next: SelectedSource) => void) => { + selectedSourceListeners.push(callback); + return () => { + selectedSourceListeners = selectedSourceListeners.filter( + (listener) => listener !== callback, + ); + }; + }), + }; + const { unmount } = renderRecStage(); + await waitFor(() => { + expect(recordingPrefsListeners).toHaveLength(1); + expect(selectedSourceListeners).toHaveLength(1); + }); + + const resetPrefs: RecordingPrefs = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", + }; + act(() => { + recordingPrefsListeners.forEach((listener) => listener(resetPrefs)); + selectedSourceListeners.forEach((listener) => listener(null)); + }); + await act(async () => { + resolvePrefs?.({ + ...resetPrefs, + micEnabled: true, + camEnabled: true, + systemAudioEnabled: true, + }); + resolveSource?.({ + id: "screen:stale", + name: "Stale source", + display_id: "1", + thumbnail: null, + appIcon: null, + }); + }); + + await waitFor(() => + expect(microphoneHook.call).toHaveBeenLastCalledWith(false, undefined, undefined), + ); + expect(audioMeter.call).toHaveBeenLastCalledWith({ enabled: false, deviceId: undefined }); + expect(cameraPreview.call).toHaveBeenLastCalledWith({ enabled: false, deviceId: undefined }); + expect(screen.getByRole("button", { name: "rec.selectSource" })).toBeInTheDocument(); + expect(screen.queryByText("Stale source")).not.toBeInTheDocument(); + + unmount(); + expect(recordingPrefsListeners).toEqual([]); + expect(selectedSourceListeners).toEqual([]); + }); }); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 23a39d0db..883360716 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -26,6 +26,7 @@ interface RecordingPrefsState { micDeviceName: string | null; camEnabled: boolean; camDeviceId: string | null; + camDeviceName: string | null; systemAudioEnabled: boolean; cursorCaptureMode: "editable-overlay" | "system"; } @@ -36,10 +37,18 @@ const DEFAULT_PREFS: RecordingPrefsState = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", }; +function normalizedRecordingPrefs(prefs: Partial): RecordingPrefsState { + return { + ...DEFAULT_PREFS, + ...prefs, + }; +} + /** * Rec-mode stage. The real capture pipeline lives in the standalone recorder * HUD window (`electronAPI.startNewRecording`); this stage is a pre-flight @@ -64,14 +73,16 @@ export function RecStage({ const [prefs, setPrefsState] = useState(DEFAULT_PREFS); useEffect(() => { let cancelled = false; + let receivedNewerSnapshot = false; + const unsubscribe = window.electronAPI?.onRecordingPrefsChanged?.((next) => { + receivedNewerSnapshot = true; + if (!cancelled) setPrefsState(normalizedRecordingPrefs(next)); + }); void window.electronAPI ?.getRecordingPrefs?.() .then((p) => { - if (!cancelled && p) { - setPrefsState({ - ...DEFAULT_PREFS, - ...p, - } as RecordingPrefsState); + if (!cancelled && !receivedNewerSnapshot && p) { + setPrefsState(normalizedRecordingPrefs(p)); } }) .catch((err) => { @@ -81,6 +92,7 @@ export function RecStage({ }); return () => { cancelled = true; + unsubscribe?.(); }; }, []); const updatePrefs = (patch: Partial) => { @@ -93,8 +105,16 @@ export function RecStage({ }); }; - const micDevices = useMicrophoneDevices(true); - const camDevices = useCameraDevices(true); + const micDevices = useMicrophoneDevices( + prefs.micEnabled, + prefs.micDeviceId ?? undefined, + prefs.micDeviceName ?? undefined, + ); + const camDevices = useCameraDevices( + true, + prefs.camDeviceId ?? undefined, + prefs.camDeviceName ?? undefined, + ); // Seed the device hooks' local "selected" state from the persisted prefs // once devices are enumerated, so the dropdown reflects the last real @@ -113,12 +133,15 @@ export function RecStage({ // Live proof the selected devices actually work — a level meter for mic, // a real