From 8fa0740a26db18ab2813909849b0a97f449a8910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:05:15 +0200 Subject: [PATCH] feat(settings): persist recording setup and project appearance defaults Squashed onto main for a rebase merge: the branch carried two merge commits from main, which the repository's rebase-only merges cannot replay. The tree is the branch head merged with current main; the only conflict was a results-log row in the manual e2e checklist, and both rows are kept. Squashed commits: - feat(settings): persist recording setup and project appearance defaults - docs: record Windows settings reset validation - fix(settings): keep live HUD and CLI captures aligned after restore - fix(settings): keep CLI source picks from replacing HUD defaults - fix(settings): wait for camera restore and skip idle mic probes - fix(settings): keep source restore from racing a newer pick - fix(settings): drop source restore after a reset during enumeration - fix(settings): keep live HUD state across stale async work - fix(settings): accept optional cursor auto-hide on appearance copy Closes #509 --- .../document-service.defaults.test.ts | 51 +++ electron/ai-edition/document-service.ts | 12 +- electron/app-settings.test.ts | 84 +++++ electron/app-settings.ts | 217 +++++++++++++ electron/electron-env.d.ts | 15 +- electron/ipc/handlers.ts | 201 ++++++++++-- electron/ipc/recordingPrefs.test.ts | 85 ++++- electron/ipc/recordingPrefs.ts | 50 ++- electron/preload.ts | 17 +- electron/recording-settings.test.ts | 22 +- electron/recording-settings.ts | 48 +-- electron/recording-source-settings.test.ts | 141 ++++++++ electron/recording-source-settings.ts | 182 +++++++++++ src/App.tsx | 6 + src/cli/CliRecordRunner.tsx | 26 +- .../ai-edition/AppSettings.test.tsx | 187 +++++++++++ src/components/ai-edition/AppSettings.tsx | 300 ++++++++++++++++++ src/components/ai-edition/NewEditorShell.tsx | 1 + .../ai-edition/ProviderSettings.test.tsx | 1 + .../ai-edition/v4/EditorTopBar.test.tsx | 25 +- src/components/ai-edition/v4/EditorTopBar.tsx | 11 + .../ai-edition/v4/RecStage.test.tsx | 218 ++++++++++++- src/components/ai-edition/v4/RecStage.tsx | 84 +++-- src/components/launch/HudDeviceSettings.tsx | 2 +- src/components/launch/LaunchWindow.test.tsx | 209 +++++++++++- src/components/launch/LaunchWindow.tsx | 154 +++++++-- src/contexts/EditorDialogsContext.tsx | 2 +- src/hooks/useCameraDevices.test.ts | 52 +++ src/hooks/useCameraDevices.ts | 98 ++++-- src/hooks/useMicrophoneDevices.test.ts | 170 +++++++++- src/hooks/useMicrophoneDevices.ts | 193 +++++++---- .../useScreenRecorder.prefsRace.test.tsx | 69 ++++ src/hooks/useScreenRecorder.ts | 72 ++++- src/hooks/webcamConstraint.test.ts | 31 ++ src/i18n/locales/ar/editor.json | 28 ++ src/i18n/locales/en/editor.json | 28 ++ src/i18n/locales/es/editor.json | 28 ++ src/i18n/locales/fr/editor.json | 28 ++ src/i18n/locales/it/editor.json | 28 ++ src/i18n/locales/ja-JP/editor.json | 28 ++ src/i18n/locales/ko-KR/editor.json | 28 ++ src/i18n/locales/pt-BR/editor.json | 28 ++ src/i18n/locales/ru/editor.json | 28 ++ src/i18n/locales/tr/editor.json | 28 ++ src/i18n/locales/vi/editor.json | 28 ++ src/i18n/locales/zh-CN/editor.json | 28 ++ src/i18n/locales/zh-TW/editor.json | 28 ++ src/lib/ai-edition/store/editorSettings.ts | 44 +-- src/lib/projectDefaults.test.ts | 63 ++++ src/lib/projectDefaults.ts | 205 ++++++++++++ src/lib/userPreferences.test.ts | 7 + src/lib/userPreferences.ts | 6 +- src/native/browserShim.test.ts | 79 +++++ src/native/browserShim.ts | 139 +++++++- .../recording-defaults-reset-windows.png | Bin 0 -> 60529 bytes .../testing/manual-e2e-checklist.md | 2 + .../testing/recording-defaults-windows.md | 17 + tests/e2e/settings-defaults.spec.ts | 118 +++++++ 58 files changed, 3722 insertions(+), 358 deletions(-) create mode 100644 electron/ai-edition/document-service.defaults.test.ts create mode 100644 electron/app-settings.test.ts create mode 100644 electron/app-settings.ts create mode 100644 electron/recording-source-settings.test.ts create mode 100644 electron/recording-source-settings.ts create mode 100644 src/components/ai-edition/AppSettings.test.tsx create mode 100644 src/components/ai-edition/AppSettings.tsx create mode 100644 src/hooks/useScreenRecorder.prefsRace.test.tsx create mode 100644 src/hooks/webcamConstraint.test.ts create mode 100644 src/lib/projectDefaults.test.ts create mode 100644 src/lib/projectDefaults.ts create mode 100644 technical-documentation/testing/images/recording-defaults-reset-windows.png create mode 100644 technical-documentation/testing/recording-defaults-windows.md create mode 100644 tests/e2e/settings-defaults.spec.ts diff --git a/electron/ai-edition/document-service.defaults.test.ts b/electron/ai-edition/document-service.defaults.test.ts new file mode 100644 index 000000000..ddd7d208d --- /dev/null +++ b/electron/ai-edition/document-service.defaults.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getEditorSettings } from "../../src/lib/ai-edition/store/editorSettings"; +import { DEFAULT_PROJECT_APPEARANCE } from "../../src/lib/projectDefaults"; +import { DocumentService } from "./document-service"; + +const dirs: string[] = []; +function temp() { + const root = mkdtempSync(path.join(os.tmpdir(), "openscreen-default-doc-")); + dirs.push(root); + return root; +} +afterEach(() => { + for (const root of dirs.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("DocumentService project appearance defaults", () => { + it("materializes the current defaults before the new project's first write", async () => { + const root = temp(); + const projects = path.join(root, "projects"); + const service = new DocumentService(projects, path.join(root, "media"), undefined, () => ({ + ...DEFAULT_PROJECT_APPEARANCE, + wallpaper: "#123456", + padding: 22, + })); + const created = await service.createProject("New recording or CLI project"); + const onDisk = JSON.parse( + readFileSync(path.join(projects, `${created.project.id}.openscreen`), "utf8"), + ); + expect(getEditorSettings(onDisk)).toMatchObject({ wallpaper: "#123456", padding: 22 }); + expect(onDisk.assets).toEqual([]); + expect(onDisk.annotations).toEqual([]); + }); + + it("never reapplies changed defaults while opening an existing project", async () => { + const root = temp(); + let padding = 11; + const service = new DocumentService( + path.join(root, "projects"), + path.join(root, "media"), + undefined, + () => ({ ...DEFAULT_PROJECT_APPEARANCE, padding }), + ); + const created = await service.createProject("Existing"); + padding = 44; + const reopened = await service.getProject(created.project.id); + expect(getEditorSettings(reopened).padding).toBe(11); + }); +}); diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index e78a9c48e..75d6fb1ab 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -21,6 +21,10 @@ import { documentSchema, migrateRawDocumentToCurrent, } from "../../src/lib/ai-edition/schema"; +import { + applyProjectAppearanceDefaults, + type ProjectAppearanceDefaults, +} from "../../src/lib/projectDefaults"; import { ensureDocumentExtensions } from "../media/extensionClip"; import { relinkProjectMedia } from "../media/projectMediaRelinker"; @@ -165,15 +169,18 @@ export class DocumentService { * `electron` import. Optional so the tests and the CLI construct it as they always did. */ private readonly onProjectRead?: (document: AxcutDocument) => void; + private readonly loadProjectDefaults?: () => ProjectAppearanceDefaults; constructor( projectsRoot: string, mediaRegistryDir: string, onProjectRead?: (document: AxcutDocument) => void, + loadProjectDefaults?: () => ProjectAppearanceDefaults, ) { this.projectsRoot = projectsRoot; this.mediaRegistryDir = mediaRegistryDir; this.onProjectRead = onProjectRead; + this.loadProjectDefaults = loadProjectDefaults; } async ensureProjectsDir(): Promise { @@ -299,10 +306,13 @@ export class DocumentService { async createProject(title: string): Promise { await this.ensureProjectsDir(); const projectId = createId("proj"); - const doc = createEmptyDocument({ + const empty = createEmptyDocument({ projectId, title: title?.trim() || "Untitled Project", }); + const doc = this.loadProjectDefaults + ? applyProjectAppearanceDefaults(empty, this.loadProjectDefaults()) + : empty; await this.writeProject(doc); return doc; } diff --git a/electron/app-settings.test.ts b/electron/app-settings.test.ts new file mode 100644 index 000000000..8c6f9bc9e --- /dev/null +++ b/electron/app-settings.test.ts @@ -0,0 +1,84 @@ +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 { DEFAULT_PROJECT_APPEARANCE } from "../src/lib/projectDefaults"; +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("migrates the auto-zoom-only shape and preserves unknown keys", () => { + const dir = temp(); + const file = path.join(dir, "recording-settings.json"); + writeFileSync(file, JSON.stringify({ autoZoomEnabled: false, future: { keep: true } })); + const store = new AppSettingsStore(dir); + expect(store.getSnapshot().recording.autoZoomEnabled).toBe(false); + store.setRecordingPreferences({ micEnabled: true, camDeviceName: "Camera A" }); + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ + future: { keep: true }, + autoZoomEnabled: false, + 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 and resets versioned appearance and recording setup", () => { + const dir = temp(); + const store = new AppSettingsStore(dir); + const custom = { ...DEFAULT_PROJECT_APPEARANCE, wallpaper: "#010203", padding: 7 }; + expect(store.setAppearanceDefaults(custom).appearance).toMatchObject({ custom: true }); + store.setLastSource({ + platform: "win32", + kind: "screen", + id: "screen:1", + name: "Display", + displayId: "1", + }); + store.setRecordingPreferences({ micEnabled: true, micDeviceId: "mic" }); + expect(store.resetRecordingSetup()).toMatchObject({ + recording: DEFAULT_RECORDING_PREFERENCES, + lastSource: null, + }); + expect(store.resetAppearanceDefaults().appearance).toEqual({ + version: 1, + custom: false, + defaults: DEFAULT_PROJECT_APPEARANCE, + }); + expect( + JSON.parse(readFileSync(path.join(dir, "recording-settings.json"), "utf8")), + ).toMatchObject({ + projectAppearance: { version: 1, defaults: null }, + }); + }); + + 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..3c8445893 --- /dev/null +++ b/electron/app-settings.ts @@ -0,0 +1,217 @@ +import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + DEFAULT_PROJECT_APPEARANCE, + type ProjectAppearanceDefaults, + parseProjectAppearanceDefaults, +} from "../src/lib/projectDefaults"; +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; + autoZoomEnabled: boolean; +} + +export const DEFAULT_RECORDING_PREFERENCES: RecordingPreferences = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", + autoZoomEnabled: true, +}; + +export interface RecordingSourceDescriptor { + platform: NodeJS.Platform; + kind: "screen" | "window"; + id: string; + name: string; + displayId: string | null; +} + +export interface AppSettingsSnapshot { + recording: RecordingPreferences; + lastSource: RecordingSourceDescriptor | null; + appearance: { + version: 1; + custom: boolean; + defaults: ProjectAppearanceDefaults; + }; +} + +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, + autoZoomEnabled: bool(raw.autoZoomEnabled, DEFAULT_RECORDING_PREFERENCES.autoZoomEnabled), + }; +} + +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 parseAppearance(raw: RawSettings): AppSettingsSnapshot["appearance"] { + const value = raw.projectAppearance; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; + } + const candidate = value as Record; + if (candidate.version !== 1 || candidate.defaults === null) { + return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; + } + try { + return { + version: 1, + custom: true, + defaults: parseProjectAppearanceDefaults(candidate.defaults), + }; + } catch { + return { version: 1, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }; + } +} + +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), + appearance: parseAppearance(raw), + }; + } + + 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(); + } + + setAppearanceDefaults(defaults: ProjectAppearanceDefaults): AppSettingsSnapshot { + const parsed = parseProjectAppearanceDefaults(defaults); + const raw = readRaw(this.userData); + atomicWrite(this.userData, { + ...raw, + projectAppearance: { version: 1, defaults: parsed }, + }); + return this.getSnapshot(); + } + + resetAppearanceDefaults(): AppSettingsSnapshot { + const raw = readRaw(this.userData); + atomicWrite(this.userData, { ...raw, projectAppearance: { version: 1, defaults: null } }); + return this.getSnapshot(); + } + + resetRecordingSetup(): AppSettingsSnapshot { + const raw = readRaw(this.userData); + atomicWrite(this.userData, { + ...raw, + ...DEFAULT_RECORDING_PREFERENCES, + lastSource: null, + }); + return this.getSnapshot(); + } +} diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 81092d3b3..366cb6d57 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -51,13 +51,24 @@ 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, ) => Promise; + getAppSettings: () => Promise; + setProjectAppearanceDefaults: ( + defaults: import("../src/lib/projectDefaults").ProjectAppearanceDefaults, + ) => Promise; + resetProjectAppearanceDefaults: () => Promise; + resetRecordingSetup: () => Promise; onRecordingPrefsChanged: ( callback: (prefs: import("./ipc/handlers").RecordingPrefs) => void, ) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7b6d64d2d..a86a419e9 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,6 +104,15 @@ 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"; @@ -619,6 +629,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; /** After a take, suggest cursor-dwell zooms. Default on, matching 1.5. */ @@ -630,6 +642,7 @@ const defaultRecordingPrefs: RecordingPrefs = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", autoZoomEnabled: true, @@ -1840,6 +1853,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" }; @@ -1920,7 +1944,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, @@ -1930,42 +1986,128 @@ 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; + ipcMain.handle( + "select-source", + async (_, source: SelectedSource, options?: { persist?: boolean }) => { + // 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; + + 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; + } + } + if (!selectedDesktopSource) { + selectedSource = null; + broadcastSelectedSource(null); + return null; + } + selectedSource = { + id: selectedDesktopSource.id, + name: selectedDesktopSource.name, + display_id: selectedDesktopSource.display_id, + }; + // Persist only a descriptor built from the freshly enumerated live object. + // A failed write must not keep the picker open after a valid live pick. + if (shouldPersistSelectedSource(options)) { + try { + appSettings.setLastSource( + describeRecordingSource(process.platform, selectedSource as Required), + ); + } catch (error) { + console.warn("Failed to persist the selected recording source:", error); + } + } + broadcastSelectedSource(selectedSource); + const sourceSelectorWin = getSourceSelectorWindow(); + if (sourceSelectorWin) { + sourceSelectorWin.close(); + } + return selectedSource; + }, + ); - 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("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, + () => { + selectedSource = null; + selectedDesktopSource = null; + broadcastSelectedSource(null); + }, + () => BrowserWindow.getAllWindows(), + ); ipcMain.handle("request-camera-access", async () => { if (process.platform !== "darwin") { @@ -4470,6 +4612,7 @@ export function registerIpcHandlers( path.join(app.getPath("userData"), "projects"), RECORDINGS_DIR, approveDocumentMedia, + () => appSettings.getSnapshot().appearance.defaults, ); // LlmConfigStore is single-instance for a duller reason — its constructor does diff --git a/electron/ipc/recordingPrefs.test.ts b/electron/ipc/recordingPrefs.test.ts index 115a3c356..999cb3d18 100644 --- a/electron/ipc/recordingPrefs.test.ts +++ b/electron/ipc/recordingPrefs.test.ts @@ -18,6 +18,7 @@ const defaults: RecordingPrefs = { micDeviceName: null, camEnabled: false, camDeviceId: null, + camDeviceName: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", autoZoomEnabled: true, @@ -30,9 +31,13 @@ beforeEach(() => { }); afterEach(() => rmSync(dir, { recursive: true, force: true })); -function start(getWindow: () => BrowserWindow | null = () => null) { +function start( + getWindow: () => BrowserWindow | null = () => null, + onResetSource: () => void = () => undefined, + getAppWindows?: () => BrowserWindow[], +) { electron.handle.mockClear(); - registerRecordingPrefsHandlers(defaults, getWindow); + registerRecordingPrefsHandlers(defaults, getWindow, onResetSource, getAppWindows); const get = electron.handle.mock.calls.find( ([name]) => name === "get-recording-prefs", )?.[1] as () => RecordingPrefs; @@ -40,33 +45,83 @@ function start(getWindow: () => BrowserWindow | null = () => null) { _event: unknown, prefs: Partial, ) => RecordingPrefs; - return { get, set: (prefs: Partial) => set(undefined, prefs) }; + const reset = electron.handle.mock.calls.find( + ([name]) => name === "reset-recording-setup", + )?.[1] as (_event: unknown) => { recording: RecordingPrefs; lastSource: null }; + return { + get, + set: (prefs: Partial) => set(undefined, prefs), + reset: () => reset(undefined), + }; } describe("recording preferences IPC", () => { - it("restores false on restart while device preferences reset", () => { + it("restores toggles and device preferences on restart", () => { 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 }); + expect(disk).toMatchObject({ + autoZoomEnabled: false, + micEnabled: true, + micDeviceId: "temporary-device", + }); const restarted = start(); - expect(restarted.get()).toEqual({ ...defaults, autoZoomEnabled: false }); + expect(restarted.get()).toEqual({ + ...defaults, + autoZoomEnabled: false, + micEnabled: true, + micDeviceId: "temporary-device", + }); 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); + 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, + undefined, + () => [first, second, first, destroyed], + ); const updated = session.set({ autoZoomEnabled: false }); - expect(send).toHaveBeenCalledWith("recording-prefs-changed", updated); - isDestroyed.mockReturnValue(true); - session.set({ micEnabled: true }); - expect(send).toHaveBeenCalledTimes(1); + expect(firstSend).toHaveBeenCalledWith("recording-prefs-changed", updated); + expect(secondSend).toHaveBeenCalledWith("recording-prefs-changed", updated); + expect(firstSend).toHaveBeenCalledTimes(1); + expect(destroyedSend).not.toHaveBeenCalled(); + }); + + it("publishes the reset snapshot and runs the source reset callback", () => { + const send = vi.fn(); + const window = { + isDestroyed: () => false, + webContents: { send }, + } as unknown as BrowserWindow; + const onResetSource = vi.fn(); + const session = start(() => window, onResetSource); + session.set({ micEnabled: true, camEnabled: true, systemAudioEnabled: true }); + send.mockClear(); + + const snapshot = session.reset(); + expect(snapshot.recording).toEqual(defaults); + expect(snapshot.lastSource).toBeNull(); + expect(onResetSource).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith("recording-prefs-changed", defaults); }); it("does not publish an invalid or failed preference write", () => { diff --git a/electron/ipc/recordingPrefs.ts b/electron/ipc/recordingPrefs.ts index edeb8b919..b80b03430 100644 --- a/electron/ipc/recordingPrefs.ts +++ b/electron/ipc/recordingPrefs.ts @@ -1,30 +1,48 @@ import { app, type BrowserWindow, ipcMain } from "electron"; -import { loadAutoZoomEnabled, saveAutoZoomEnabled } from "../recording-settings"; +import type { ProjectAppearanceDefaults } from "../../src/lib/projectDefaults"; +import { AppSettingsStore } from "../app-settings"; import type { RecordingPrefs } from "./handlers"; -/** Shared session preferences, with only the auto-zoom choice retained on disk. */ +/** Shared durable recording preferences. Persist before publishing any new snapshot. */ export function registerRecordingPrefsHandlers( defaults: RecordingPrefs, getMainWindow: () => BrowserWindow | null, + onResetSource: () => void = () => undefined, + getAppWindows: () => BrowserWindow[] = () => { + const mainWindow = getMainWindow(); + return mainWindow ? [mainWindow] : []; + }, ): void { const userData = app.getPath("userData"); - let recordingPrefs = { ...defaults, autoZoomEnabled: loadAutoZoomEnabled(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) => { - 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); - } + // 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; }); + + ipcMain.handle("get-app-settings", () => settings.getSnapshot()); + ipcMain.handle("set-project-appearance-defaults", (_, value: ProjectAppearanceDefaults) => + settings.setAppearanceDefaults(value), + ); + ipcMain.handle("reset-project-appearance-defaults", () => settings.resetAppearanceDefaults()); + ipcMain.handle("reset-recording-setup", () => { + const snapshot = settings.resetRecordingSetup(); + recordingPrefs = snapshot.recording; + onResetSource(); + publish(); + return snapshot; + }); } diff --git a/electron/preload.ts b/electron/preload.ts index a273f89d8..80c83aba8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -2,10 +2,12 @@ import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { NativeLinuxRecordingRequest } from "../src/lib/nativeLinuxRecording"; import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; +import type { ProjectAppearanceDefaults } from "../src/lib/projectDefaults"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import type { ShortcutBinding } from "../src/lib/shortcuts"; import type { AiEditionChatEvent } from "../src/native/contracts"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; +import type { AppSettingsSnapshot } from "./app-settings"; import type { RecordingPrefs } from "./ipc/handlers"; import type { SttStatusEvent, @@ -119,8 +121,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"); @@ -131,13 +133,20 @@ contextBridge.exposeInMainWorld("electronAPI", { setRecordingPrefs: (prefs: Partial) => { return ipcRenderer.invoke("set-recording-prefs", prefs); }, + getAppSettings: () => ipcRenderer.invoke("get-app-settings") as Promise, + setProjectAppearanceDefaults: (defaults: ProjectAppearanceDefaults) => + ipcRenderer.invoke("set-project-appearance-defaults", defaults) as Promise, + resetProjectAppearanceDefaults: () => + ipcRenderer.invoke("reset-project-appearance-defaults") as Promise, + resetRecordingSetup: () => + ipcRenderer.invoke("reset-recording-setup") as Promise, onRecordingPrefsChanged: (callback: (prefs: RecordingPrefs) => void) => { const listener = (_event: unknown, prefs: RecordingPrefs) => callback(prefs); 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-settings.test.ts b/electron/recording-settings.test.ts index 49a1202f1..8471f77cd 100644 --- a/electron/recording-settings.test.ts +++ b/electron/recording-settings.test.ts @@ -1,8 +1,6 @@ -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"; @@ -33,31 +31,17 @@ describe("recording settings", () => { for (const enabled of [false, true]) { saveAutoZoomEnabled(dir, enabled); expect(loadAutoZoomEnabled(dir)).toBe(enabled); - expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ + expect(JSON.parse(readFileSync(file, "utf8"))).toMatchObject({ futurePreference: "keep", autoZoomEnabled: enabled, }); } }); - it("loads the disabled preference in a separate Node process", () => { + it("loads the disabled preference through a fresh store instance", () => { 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"); + expect(loadAutoZoomEnabled(dir)).toBe(false); }); it("rejects invalid writes and reports a failed disk write", () => { diff --git a/electron/recording-settings.ts b/electron/recording-settings.ts index 25370cdab..01303e197 100644 --- a/electron/recording-settings.ts +++ b/electron/recording-settings.ts @@ -1,38 +1,22 @@ -import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { AppSettingsStore, type RecordingPreferences } from "./app-settings"; -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. */ +/** Back-compatible auto-zoom reader used by existing callers and tests. */ export function loadAutoZoomEnabled(userData: string): boolean { - const value = readSettings(userData).autoZoomEnabled; - return typeof value === "boolean" ? value : true; + return new AppSettingsStore(userData).getSnapshot().recording.autoZoomEnabled; } -/** Save only this durable preference; device selection remains session-only. */ +/** Back-compatible auto-zoom writer; the canonical store owns every recording preference. */ 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 }); - } + new AppSettingsStore(userData).setRecordingPreferences({ autoZoomEnabled: enabled }); +} + +export function loadRecordingPreferences(userData: string): RecordingPreferences { + return new AppSettingsStore(userData).getSnapshot().recording; +} + +export function saveRecordingPreferences( + userData: string, + patch: Partial, +): RecordingPreferences { + return new AppSettingsStore(userData).setRecordingPreferences(patch).recording; } 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/App.tsx b/src/App.tsx index aa7619d26..163632f11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,11 @@ const ProviderSettingsDialog = lazy(() => default: module.ProviderSettingsDialog, })), ); +const AppSettingsDialog = lazy(() => + import("./components/ai-edition/AppSettings").then((module) => ({ + default: module.AppSettingsDialog, + })), +); export default function App() { const [windowType, setWindowType] = useState( @@ -146,6 +151,7 @@ export default function App() { + diff --git a/src/cli/CliRecordRunner.tsx b/src/cli/CliRecordRunner.tsx index f588caa4c..a3c232472 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,24 @@ export function CliRecordRunner() { requestRef.current = request; const source = await pickSource(request); - await window.electronAPI.selectSource(source); + await window.electronAPI.selectSource(source, { persist: false }); window.electronAPI.cliLog("info", `Recording source: ${source.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 +189,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 +197,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 +227,7 @@ export function CliRecordRunner() { }, [ requestReady, recorder.microphoneEnabled, + recorder.webcamEnabled, recorder.systemAudioEnabled, recorder.cursorCaptureMode, ]); diff --git a/src/components/ai-edition/AppSettings.test.tsx b/src/components/ai-edition/AppSettings.test.tsx new file mode 100644 index 000000000..8ae433736 --- /dev/null +++ b/src/components/ai-edition/AppSettings.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { DEFAULT_PROJECT_APPEARANCE } from "@/lib/projectDefaults"; +import { AppSettingsDialog } from "./AppSettings"; + +const project = vi.hoisted(() => ({ + document: null as ReturnType | null, +})); +vi.mock("@/lib/ai-edition/store/projectStore", () => ({ + useProjectStore: (selector: (state: typeof project) => unknown) => selector(project), +})); +const microphoneHook = vi.hoisted(() => ({ + enabled: [] as boolean[], +})); +vi.mock("@/hooks/useMicrophoneDevices", () => ({ + useMicrophoneDevices: (enabled: boolean) => { + microphoneHook.enabled.push(enabled); + return { + devices: [{ deviceId: "mic-live", label: "Live microphone", groupId: "g" }], + selectedDeviceId: "mic-live", + setSelectedDeviceId: vi.fn(), + }; + }, +})); +vi.mock("@/hooks/useCameraDevices", () => ({ + useCameraDevices: () => ({ + devices: [{ deviceId: "cam-live", label: "Live camera", groupId: "g" }], + selectedDeviceId: "cam-live", + setSelectedDeviceId: vi.fn(), + }), +})); +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string, vars?: Record) => + vars ? `${key}:${Object.values(vars).join(":")}` : key, +})); + +const recording = { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled: false, + camDeviceId: null, + camDeviceName: null, + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay" as const, + autoZoomEnabled: true, +}; +const snapshot = { + recording, + lastSource: null, + appearance: { version: 1 as const, custom: false, defaults: DEFAULT_PROJECT_APPEARANCE }, +}; + +function OpenSettings() { + const { openDialog, closeDialog } = useEditorDialogActions(); + return ( + <> + + + + ); +} + +function renderSettings() { + render( + + + + , + ); + fireEvent.click(screen.getByText("open settings")); +} + +describe("AppSettings", () => { + beforeEach(() => { + microphoneHook.enabled = []; + project.document = createEmptyDocument({ projectId: "proj_settings", title: "Settings" }); + window.electronAPI = { + getAppSettings: vi.fn(async () => snapshot), + setRecordingPrefs: vi.fn(async (value) => ({ ...recording, ...value })), + setProjectAppearanceDefaults: vi.fn(async (defaults) => ({ + ...snapshot, + appearance: { version: 1 as const, custom: true, defaults }, + })), + resetProjectAppearanceDefaults: vi.fn(async () => snapshot), + resetRecordingSetup: vi.fn(async () => snapshot), + } as unknown as typeof window.electronAPI; + }); + + it("does not enable microphone enumeration until the microphone is on", async () => { + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + expect(microphoneHook.enabled.at(-1)).toBe(false); + fireEvent.click(screen.getByLabelText("appSettings.microphone")); + expect(microphoneHook.enabled.at(-1)).toBe(true); + }); + + it("loads settings and saves edited recording toggles", async () => { + renderSettings(); + expect(await screen.findByTestId("app-settings-dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + fireEvent.click(screen.getByText("appSettings.saveRecording")); + await waitFor(() => + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( + expect.objectContaining({ systemAudioEnabled: true }), + ), + ); + expect(await screen.findByRole("status")).toHaveTextContent("appSettings.saved"); + }); + + it("shows a failed save and never reports Saved", async () => { + vi.mocked(window.electronAPI.setRecordingPrefs).mockRejectedValueOnce(new Error("disk full")); + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByText("appSettings.saveRecording")); + expect(await screen.findByRole("alert")).toHaveTextContent("disk full"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("shows a load failure without rendering stale settings", async () => { + vi.mocked(window.electronAPI.getAppSettings).mockRejectedValueOnce(new Error("read failed")); + renderSettings(); + expect(await screen.findByRole("alert")).toHaveTextContent("read failed"); + expect(screen.queryByText("appSettings.saveRecording")).not.toBeInTheDocument(); + }); + + it("does not let a stale save overwrite a newer local edit", async () => { + let finishSave!: (value: typeof recording) => void; + vi.mocked(window.electronAPI.setRecordingPrefs).mockImplementation( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + fireEvent.click(screen.getByText("appSettings.saveRecording")); + fireEvent.click(screen.getByLabelText("appSettings.systemAudio")); + expect(screen.getByLabelText("appSettings.systemAudio")).not.toBeChecked(); + finishSave({ ...recording, systemAudioEnabled: true }); + await waitFor(() => + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith( + expect.objectContaining({ systemAudioEnabled: true }), + ), + ); + expect(screen.getByLabelText("appSettings.systemAudio")).not.toBeChecked(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("clears loaded settings when a later open fails to load", async () => { + vi.mocked(window.electronAPI.getAppSettings) + .mockResolvedValueOnce(snapshot) + .mockRejectedValueOnce(new Error("read failed")); + renderSettings(); + await screen.findByText("appSettings.saveRecording"); + fireEvent.click(screen.getByText("close settings")); + fireEvent.click(screen.getByText("open settings")); + expect(await screen.findByRole("alert")).toHaveTextContent("read failed"); + expect(screen.queryByText("appSettings.saveRecording")).not.toBeInTheDocument(); + }); + + it("copies the current look and exposes both reset actions", async () => { + renderSettings(); + await screen.findByTestId("app-settings-dialog"); + fireEvent.click(screen.getByText("appSettings.useCurrentAppearance")); + await waitFor(() => + expect(window.electronAPI.setProjectAppearanceDefaults).toHaveBeenCalledWith( + expect.objectContaining({ padding: 50, aspectRatio: "16:9" }), + ), + ); + fireEvent.click(screen.getByText("appSettings.resetAppearance")); + await waitFor(() => + expect(window.electronAPI.resetProjectAppearanceDefaults).toHaveBeenCalled(), + ); + fireEvent.click(screen.getByText("appSettings.resetRecording")); + await waitFor(() => expect(window.electronAPI.resetRecordingSetup).toHaveBeenCalled()); + }); +}); diff --git a/src/components/ai-edition/AppSettings.tsx b/src/components/ai-edition/AppSettings.tsx new file mode 100644 index 000000000..6e260b91f --- /dev/null +++ b/src/components/ai-edition/AppSettings.tsx @@ -0,0 +1,300 @@ +import { Camera, Loader2, Mic, Monitor, MousePointer2, RotateCcw, Save } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useCameraDevices } from "@/hooks/useCameraDevices"; +import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; +import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { projectAppearanceFromEditorSettings } from "@/lib/projectDefaults"; +import type { AppSettingsSnapshot, RecordingPreferences } from "../../../electron/app-settings"; +import { ModalShell } from "./Modals"; +import styles from "./NewEditorShell.module.css"; + +type Status = "idle" | "loading" | "saving" | "saved" | "error"; + +function AppSettings({ open, onClose }: { open: boolean; onClose: () => void }) { + const t = useScopedT("editor"); + const document = useProjectStore((state) => state.document); + const [snapshot, setSnapshot] = useState(null); + const [recording, setRecording] = useState(null); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const generationRef = useRef(0); + + useEffect(() => { + if (!open) { + generationRef.current += 1; + return; + } + const generation = ++generationRef.current; + setStatus("loading"); + setError(null); + setSnapshot(null); + setRecording(null); + void window.electronAPI + .getAppSettings() + .then((value) => { + if (generation !== generationRef.current) return; + setSnapshot(value); + setRecording(value.recording); + setStatus("idle"); + }) + .catch((cause) => { + if (generation !== generationRef.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + setStatus("error"); + }); + }, [open]); + + const microphones = useMicrophoneDevices( + open && Boolean(recording?.micEnabled), + recording?.micDeviceId ?? undefined, + recording?.micDeviceName ?? undefined, + ); + const cameras = useCameraDevices( + open, + recording?.camDeviceId ?? undefined, + recording?.camDeviceName ?? undefined, + ); + + const run = async (work: () => Promise) => { + const generation = ++generationRef.current; + setStatus("saving"); + setError(null); + try { + const next = await work(); + if (generation !== generationRef.current) return; + setSnapshot(next); + setRecording(next.recording); + setStatus("saved"); + } catch (cause) { + if (generation !== generationRef.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + setStatus("error"); + } + }; + + const saveRecording = () => { + if (!recording) return; + void run(async () => { + const saved = await window.electronAPI.setRecordingPrefs(recording); + const next = await window.electronAPI.getAppSettings(); + return { ...next, recording: saved }; + }); + }; + const editRecording = (patch: Partial) => { + generationRef.current += 1; + setRecording((current) => (current ? { ...current, ...patch } : current)); + setStatus("idle"); + setError(null); + }; + + return ( + +
+ {status === "loading" ? ( +
+ {t("appSettings.loading")} +
+ ) : recording && snapshot ? ( + <> +
+

+ {t("appSettings.recordingTitle")} +

+ editRecording({ systemAudioEnabled: value })} + /> + editRecording({ micEnabled: value })} + /> + + editRecording({ camEnabled: value })} + /> + + + editRecording({ + cursorCaptureMode: value ? "editable-overlay" : "system", + }) + } + /> + editRecording({ autoZoomEnabled: value })} + /> +

+ {t("appSettings.lastSource", { + source: snapshot.lastSource?.name ?? t("appSettings.noSource"), + })} +

+
+ + +
+
+
+

+ {t("appSettings.appearanceTitle")} +

+

+ {snapshot.appearance.custom + ? t("appSettings.customAppearance") + : t("appSettings.factoryAppearance")} +

+

+ {t("appSettings.appearanceSummary", { + aspect: snapshot.appearance.defaults.aspectRatio, + padding: snapshot.appearance.defaults.padding, + radius: snapshot.appearance.defaults.borderRadius, + cursor: snapshot.appearance.defaults.cursorTheme, + camera: snapshot.appearance.defaults.webcamLayoutPreset, + })} +

+
+ + +
+
+ + ) : null} + {status === "saved" ?

{t("appSettings.saved")}

: null} + {error ?

{t("appSettings.saveFailed", { error })}

: null} +
+
+ ); +} + +function SettingToggle({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; +}) { + return ( + + ); +} + +export function AppSettingsDialog() { + const section = useEditorDialogSection(); + const { closeDialog } = useEditorDialogActions(); + return ; +} diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index fabfc3acb..596ea0c67 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -1547,6 +1547,7 @@ export function NewEditorShell() { save: () => void handleSave(), export: handleExport, openSettings: handleOpenSettings, + openAppSettings: () => openDialog("settings"), renameProject: handleRenameProject, toggleChat: () => setChatOpen((v) => !v), openProviderSettings: () => openDialog("providers"), diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 6f20c3ebc..63508dd53 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -54,6 +54,7 @@ function TopBar({ mode }: { mode: EditorMode }) { save: noop, export: noop, openSettings: noop, + openAppSettings: noop, renameProject: noop, toggleChat: noop, openProviderSettings: () => openDialog("providers"), diff --git a/src/components/ai-edition/v4/EditorTopBar.test.tsx b/src/components/ai-edition/v4/EditorTopBar.test.tsx index 087c3cc38..f904d88ec 100644 --- a/src/components/ai-edition/v4/EditorTopBar.test.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.test.tsx @@ -19,15 +19,16 @@ import { EditorTopBar } from "./EditorTopBar"; const noop = () => {}; -function renderTopBar(projectTitle: string | null) { +function renderTopBar(projectTitle: string | null, mode: "media" | "edit" | "rec" = "edit") { const onRename = vi.fn(); const onShowAbout = vi.fn(); const onCheckForUpdates = vi.fn(); const onOpenSettings = vi.fn(); + const onOpenAppSettings = vi.fn(); const onOpenProviderSettings = vi.fn(); render( , ); - return { onRename, onShowAbout, onCheckForUpdates, onOpenSettings, onOpenProviderSettings }; + return { + onRename, + onShowAbout, + onCheckForUpdates, + onOpenSettings, + onOpenAppSettings, + onOpenProviderSettings, + }; } /** The menu reads two separate channels, and they answer different questions: `getAppInfo` for @@ -163,17 +172,25 @@ describe("AppMenu", () => { expect(trigger.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/); }); - it("opens on click and offers shortcuts, AI settings and about", () => { + it("opens on click and offers app settings, shortcuts, AI settings and about", () => { renderTopBar("Demo Project"); fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); expect(screen.getByRole("menu")).toBeInTheDocument(); // Exact names: the translator echoes keys, and both settings rows are labelled with a // `…title` key, so a /title/ match would hit two items and pin neither. expect(screen.getByRole("menuitem", { name: "title" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "appSettings.title" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: "providerSettings.title" })).toBeInTheDocument(); expect(screen.getByRole("menuitem", { name: /actions\.about/ })).toBeInTheDocument(); }); + it.each(["media", "edit", "rec"] as const)("opens app settings from %s mode", (mode) => { + const { onOpenAppSettings } = renderTopBar("Demo Project", mode); + fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "appSettings.title" })); + expect(onOpenAppSettings).toHaveBeenCalledTimes(1); + }); + // Issue #420: the AI dialog used to be openable only from the chat panel, which mounts in // Edit mode with the panel expanded. The row is unconditional here — its dialog is mounted // in App.tsx, above every mode — so the menu does not lie in Media and Rec. diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx index 0fa4ce90e..ea00e541d 100644 --- a/src/components/ai-edition/v4/EditorTopBar.tsx +++ b/src/components/ai-edition/v4/EditorTopBar.tsx @@ -10,6 +10,7 @@ import { PanelLeft, RefreshCw, Save, + Settings, Sparkles, Sun, } from "lucide-react"; @@ -28,6 +29,7 @@ export interface TopBarActions { save: () => void; export: () => void; openSettings: () => void; + openAppSettings: () => void; renameProject: (title: string) => void; toggleChat: () => void; openProviderSettings: () => void; @@ -397,6 +399,15 @@ function AppMenu({ actions }: { actions: TopBarActions }) { {open ? (
+