diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 60471613..c00ef322 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -877,12 +877,14 @@ interface Window { getAnnouncements: () => Promise; /** Hide the OS cursor before browser capture starts. */ hideOsCursor: () => Promise<{ success: boolean }>; - /** Recording preferences (mic, system audio) */ + /** Recording preferences (mic, system audio, webcam) */ getRecordingPreferences: () => Promise<{ success: boolean; microphoneEnabled: boolean; microphoneDeviceId?: string; systemAudioEnabled: boolean; + webcamEnabled: boolean; + webcamDeviceId?: string; }>; getRecordingAudioLabConfig: () => Promise<{ browserMicrophoneProfile: string; @@ -892,6 +894,8 @@ interface Window { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + webcamEnabled?: boolean; + webcamDeviceId?: string; }) => Promise<{ success: boolean; error?: string }>; /** Countdown timer before recording */ getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index dc1f01d9..93aee878 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -4,6 +4,10 @@ import { hasAppSetting, readAppSettingsStore, writeAppSettingsStore } from "../. import { hideCursor } from "../../cursorHider"; import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; import { COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE } from "../constants"; +import { + createRecordingPreferencesStore, + type RecordingPreferencesPatch, +} from "../settings/recordingPreferencesStore"; import { countdownCancelled, countdownInProgress, @@ -18,6 +22,7 @@ import { parseJsonWithByteOrderMark } from "../utils"; const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE"; const DEFAULT_BROWSER_MICROPHONE_PROFILE = "processed"; +const recordingPreferencesStore = createRecordingPreferencesStore(RECORDINGS_SETTINGS_FILE); const BROWSER_MICROPHONE_PROFILES = new Set([ "processed", "no-agc", @@ -117,8 +122,7 @@ export function registerSettingsHandlers() { // --------------------------------------------------------------------------- ipcMain.handle("get-recording-preferences", async () => { try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); - const parsed = parseJsonWithByteOrderMark>(content); + const parsed = await recordingPreferencesStore.read(); return { success: true, microphoneEnabled: parsed.microphoneEnabled === true, @@ -127,6 +131,9 @@ export function registerSettingsHandlers() { ? parsed.microphoneDeviceId : undefined, systemAudioEnabled: parsed.systemAudioEnabled === true, + webcamEnabled: parsed.webcamEnabled === true, + webcamDeviceId: + typeof parsed.webcamDeviceId === "string" ? parsed.webcamDeviceId : undefined, }; } catch { return { @@ -134,6 +141,8 @@ export function registerSettingsHandlers() { microphoneEnabled: false, microphoneDeviceId: undefined, systemAudioEnabled: false, + webcamEnabled: false, + webcamDeviceId: undefined, }; } }); @@ -142,37 +151,15 @@ export function registerSettingsHandlers() { return getBrowserMicrophoneProfileFromEnv(); }); - ipcMain.handle( - "set-recording-preferences", - async ( - _, - prefs: { - microphoneEnabled?: boolean; - microphoneDeviceId?: string; - systemAudioEnabled?: boolean; - }, - ) => { - try { - let existing: Record = {}; - try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); - existing = parseJsonWithByteOrderMark>(content); - } catch { - // file doesn't exist yet - } - const merged = { ...existing, ...prefs }; - await fs.writeFile( - RECORDINGS_SETTINGS_FILE, - JSON.stringify(merged, null, 2), - "utf-8", - ); - return { success: true }; - } catch (error) { - console.error("Failed to save recording preferences:", error); - return { success: false, error: String(error) }; - } - }, - ); + ipcMain.handle("set-recording-preferences", async (_, prefs: RecordingPreferencesPatch) => { + try { + await recordingPreferencesStore.update(prefs); + return { success: true }; + } catch (error) { + console.error("Failed to save recording preferences:", error); + return { success: false, error: String(error) }; + } + }); ipcMain.handle("get-countdown-delay", async () => { try { diff --git a/electron/ipc/settings/recordingPreferencesStore.test.ts b/electron/ipc/settings/recordingPreferencesStore.test.ts new file mode 100644 index 00000000..9caaeaae --- /dev/null +++ b/electron/ipc/settings/recordingPreferencesStore.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRecordingPreferencesStore } from "./recordingPreferencesStore"; + +vi.mock("electron", () => ({ + app: { + getPath: () => "", + }, +})); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + fs.rm(directory, { + recursive: true, + force: true, + }), + ), + ); +}); + +describe("recording preferences store", () => { + it("preserves concurrent microphone and webcam preference updates", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-preferences-")); + temporaryDirectories.push(directory); + const store = createRecordingPreferencesStore(path.join(directory, "recording.json")); + + await Promise.all([ + store.update({ microphoneEnabled: true }), + store.update({ microphoneDeviceId: "preferred-mic" }), + store.update({ webcamEnabled: true }), + store.update({ webcamDeviceId: "preferred-camera" }), + ]); + + await expect(store.read()).resolves.toEqual({ + microphoneEnabled: true, + microphoneDeviceId: "preferred-mic", + webcamEnabled: true, + webcamDeviceId: "preferred-camera", + }); + }); +}); diff --git a/electron/ipc/settings/recordingPreferencesStore.ts b/electron/ipc/settings/recordingPreferencesStore.ts new file mode 100644 index 00000000..26eced16 --- /dev/null +++ b/electron/ipc/settings/recordingPreferencesStore.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import { parseJsonWithByteOrderMark } from "../utils"; + +export interface RecordingPreferencesPatch { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + webcamEnabled?: boolean; + webcamDeviceId?: string; +} + +export function createRecordingPreferencesStore(filePath: string) { + let operationQueue: Promise = Promise.resolve(); + + const readFile = async (): Promise> => { + try { + const content = await fs.readFile(filePath, "utf-8"); + const parsed = parseJsonWithByteOrderMark(content); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } + }; + + return { + async read(): Promise> { + await operationQueue; + return readFile(); + }, + async update(patch: RecordingPreferencesPatch): Promise { + const operation = operationQueue.then(async () => { + const existing = await readFile(); + await fs.writeFile( + filePath, + JSON.stringify({ ...existing, ...patch }, null, 2), + "utf-8", + ); + }); + operationQueue = operation.catch(() => undefined); + await operation; + }, + }; +} diff --git a/electron/preload.ts b/electron/preload.ts index 5ba1567a..a3037231 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -979,6 +979,8 @@ contextBridge.exposeInMainWorld("electronAPI", { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean; + webcamEnabled?: boolean; + webcamDeviceId?: string; }) => ipcRenderer.invoke("set-recording-preferences", prefs), getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"), setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay), diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 6d17f0a4..45965241 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1533,6 +1533,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setMicrophoneDeviceId(result.microphoneDeviceId); } setSystemAudioEnabled(result.systemAudioEnabled); + setWebcamEnabled(result.webcamEnabled); + if (result.webcamDeviceId) { + setWebcamDeviceId(result.webcamDeviceId); + } } })(); }, []); @@ -1552,6 +1556,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void window.electronAPI.setRecordingPreferences({ systemAudioEnabled: enabled }); }, []); + const persistWebcamEnabled = useCallback((enabled: boolean) => { + setWebcamEnabled(enabled); + void window.electronAPI.setRecordingPreferences({ webcamEnabled: enabled }); + }, []); + + const persistWebcamDeviceId = useCallback((deviceId: string | undefined) => { + setWebcamDeviceId(deviceId); + void window.electronAPI.setRecordingPreferences({ webcamDeviceId: deviceId }); + }, []); + useEffect(() => { let cleanup: (() => void) | undefined; @@ -2427,9 +2441,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { systemAudioEnabled, setSystemAudioEnabled: persistSystemAudioEnabled, webcamEnabled, - setWebcamEnabled, + setWebcamEnabled: persistWebcamEnabled, webcamDeviceId, - setWebcamDeviceId, + setWebcamDeviceId: persistWebcamDeviceId, countdownDelay, setCountdownDelay, };