From e32f4deb92a5de15e5fa3e59da5395d700cf5efb Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 11:10:11 +0200 Subject: [PATCH 1/2] fix(record): keep the Camera row off when no camera can be opened Record mode's Camera row wrote camEnabled straight to the shared recording prefs, while the check #967 added (camera access, then a camera the OS lists) lived inside the HUD's setWebcamEnabled. With no camera, the row turned On and saved camEnabled: true beside its own "No camera found". The check moves to canTurnCameraOn in src/lib/cameraAvailability.ts, unchanged, and both toggles go through it: the row now stays Off with the HUD's toast. Fixes #998. --- .../ai-edition/v4/RecStage.test.tsx | 44 ++++++++++++++++ src/components/ai-edition/v4/RecStage.tsx | 14 +++++- src/hooks/useScreenRecorder.ts | 36 +------------ src/lib/cameraAvailability.ts | 50 +++++++++++++++++++ 4 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 src/lib/cameraAvailability.ts diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index b79308df3..99f7165d9 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { RecStage } from "./RecStage"; @@ -9,6 +10,10 @@ vi.mock("@/contexts/I18nContext", () => ({ useScopedT: () => (key: string) => key, })); +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + // Whether the native helper can leave the system cursor out of the pixels. Its own answer (and // the HUD's) is tested with the function it calls; here it is a switch. const editableCursor = vi.hoisted(() => ({ available: true })); @@ -105,6 +110,17 @@ function stubRecordingPrefs( return { getRecordingPrefs, setRecordingPrefs }; } +/** Camera access granted, and the OS listing these devices. Call after `stubRecordingPrefs`. */ +function stubCameras(devices: Array>) { + Object.assign(window.electronAPI as object, { + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), + }); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices: vi.fn(async () => devices) }, + }); +} + function renderRecStage() { const onStartRecording = vi.fn(); const view = render( @@ -221,6 +237,34 @@ describe("RecStage controls", () => { expect(button).toHaveAttribute("aria-pressed", "false"); }); + // The HUD toggle refuses a camera the OS does not list (#967). This row stored it as on + // anyway, beside its own "No camera found" (#998). + it("keeps the camera off, and never stores it on, when the OS lists no camera", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); + stubCameras([]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.camera")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("recording.cameraNotFound")); + expect(pill("rec.camera")).toHaveAttribute("aria-pressed", "false"); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ camEnabled: true }); + }); + + it("turns a listed camera on, and stores it", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); + stubCameras([{ kind: "videoinput", deviceId: "cam-1", label: "FaceTime HD Camera" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.camera")); + + await waitFor(() => expect(pill("rec.camera")).toHaveAttribute("aria-pressed", "true")); + expect(setRecordingPrefs).toHaveBeenCalledWith({ camEnabled: true }); + expect(toast.error).not.toHaveBeenCalled(); + }); + // A settings file written before the preference existed has no key, and every such // installation has been getting auto-zoom — so absent must read as on, not off. it("defaults on when a stored prefs blob has no autoZoomEnabled key", async () => { diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 658ed6b4d..debe493b1 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -22,6 +22,7 @@ import { useCameraPreviewStream } from "@/hooks/useCameraPreviewStream"; import { useEditableCursorAvailable } from "@/hooks/useEditableCursorAvailable"; import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; +import { canTurnCameraOn } from "@/lib/cameraAvailability"; import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import styles from "./EditorShellV4.module.css"; @@ -133,6 +134,17 @@ export function RecStage({ }); }; + // Through the HUD toggle's own check: a camera that cannot be opened is never stored as on. + const toggleCamera = () => { + if (prefs.camEnabled) { + updatePrefs({ camEnabled: false }); + return; + } + void canTurnCameraOn(t).then((ok) => { + if (ok) updatePrefs({ camEnabled: true }); + }); + }; + const micDevices = useMicrophoneDevices( prefs.micEnabled, prefs.micDeviceId ?? undefined, @@ -471,7 +483,7 @@ export function RecStage({ className={`${styles.recToggleBtn}${prefs.camEnabled ? ` ${styles.on}` : ""}`} aria-labelledby={rowLabelId("camera")} aria-pressed={prefs.camEnabled} - onClick={() => updatePrefs({ camEnabled: !prefs.camEnabled })} + onClick={toggleCamera} > {prefs.camEnabled ? t("rec.on") : t("rec.off")} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 915fcf959..c20170c15 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { mixAudioTracks, nativeMicrophoneGain } from "@/lib/audioMix"; +import { canTurnCameraOn } from "@/lib/cameraAvailability"; import { type NativeLinuxRecordingRequest, portalOwnsSourceSelection, @@ -17,7 +18,6 @@ import { parseWindowHandleFromSourceId, } from "@/lib/nativeWindowsRecording"; import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession"; -import { requestCameraAccess } from "@/lib/requestCameraAccess"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { canRecordMicrophone } from "@/utils/platformUtils"; import { createRecorderHandle, type RecorderHandle } from "./recorderHandle"; @@ -156,21 +156,6 @@ type NativeLinuxRecordingHandle = { webcamOffsetMs: number | null; }; -/** - * Whether the OS lists any camera at all. A Mac with no camera still grants - * camera access, so the permission check alone lets the toggle report success - * for a camera that can never open (#967). A failed enumeration answers `true`: - * not knowing is not "none", and the acquire reports the real failure. - */ -async function hasCameraDevice(): Promise { - try { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.some((device) => device.kind === "videoinput"); - } catch { - return true; - } -} - /** * How far AHEAD of the native screen recording the browser-recorded webcam * started, in whole milliseconds (negative, since the webcam always starts @@ -491,24 +476,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } - const accessResult = await requestCameraAccess(); - if (!accessResult.success) { - toast.error(t("recording.failedCameraAccess")); - return false; - } - - if (!accessResult.granted) { - toast.error(t("recording.cameraBlocked")); - return false; - } - - if (!(await hasCameraDevice())) { - toast.error(t("recording.cameraNotFound")); - // The toggle stores nothing on failure, so clear an "on" left behind by a - // camera that was unplugged while it was in use. - void window.electronAPI?.setRecordingPrefs?.({ camEnabled: false }).catch((error) => { - console.warn("Failed to persist the camera preference:", error); - }); + if (!(await canTurnCameraOn(t))) { return false; } diff --git a/src/lib/cameraAvailability.ts b/src/lib/cameraAvailability.ts new file mode 100644 index 000000000..62d8c5110 --- /dev/null +++ b/src/lib/cameraAvailability.ts @@ -0,0 +1,50 @@ +import { toast } from "sonner"; +import { requestCameraAccess } from "./requestCameraAccess"; + +/** + * Whether the OS lists any camera at all. A Mac with no camera still grants + * camera access, so the permission check alone lets the toggle report success + * for a camera that can never open (#967). A failed enumeration answers `true`: + * not knowing is not "none", and the acquire reports the real failure. + */ +async function hasCameraDevice(): Promise { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some((device) => device.kind === "videoinput"); + } catch { + return true; + } +} + +/** + * Whether the camera may be switched on, with a toast saying why not. + * + * The one check for every surface that turns the camera on (the HUD toggle, and the editor's + * Record mode), so neither stores `camEnabled: true` for a camera the other refuses (#998). + * + * @param t The `editor` namespace translator. + */ +export async function canTurnCameraOn(t: (key: string) => string): Promise { + const accessResult = await requestCameraAccess(); + if (!accessResult.success) { + toast.error(t("recording.failedCameraAccess")); + return false; + } + + if (!accessResult.granted) { + toast.error(t("recording.cameraBlocked")); + return false; + } + + if (!(await hasCameraDevice())) { + toast.error(t("recording.cameraNotFound")); + // The toggles store nothing on failure, so clear an "on" left behind by a + // camera that was unplugged while it was in use. + void window.electronAPI?.setRecordingPrefs?.({ camEnabled: false }).catch((error) => { + console.warn("Failed to persist the camera preference:", error); + }); + return false; + } + + return true; +} From 2ca9db8d66319ce1be047d5ed7bfc78ea0040a38 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Tue, 6 Oct 2026 16:05:11 +0200 Subject: [PATCH 2/2] fix(record): run one camera check for a double click --- src/components/ai-edition/v4/RecStage.test.tsx | 14 ++++++++++++++ src/components/ai-edition/v4/RecStage.tsx | 10 +++++++--- src/lib/cameraAvailability.ts | 13 ++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 99f7165d9..caee37c8e 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -265,6 +265,20 @@ describe("RecStage controls", () => { expect(toast.error).not.toHaveBeenCalled(); }); + it("runs one camera check for a double click", async () => { + const { getRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); + stubCameras([]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.camera")); + fireEvent.click(pill("rec.camera")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("recording.cameraNotFound")); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(window.electronAPI?.requestCameraAccess).toHaveBeenCalledTimes(1); + }); + // A settings file written before the preference existed has no key, and every such // installation has been getting auto-zoom — so absent must read as on, not off. it("defaults on when a stored prefs blob has no autoZoomEnabled key", async () => { diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index debe493b1..5888be49f 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -140,9 +140,13 @@ export function RecStage({ updatePrefs({ camEnabled: false }); return; } - void canTurnCameraOn(t).then((ok) => { - if (ok) updatePrefs({ camEnabled: true }); - }); + void canTurnCameraOn(t) + .then((ok) => { + if (ok) updatePrefs({ camEnabled: true }); + }) + .catch((err) => { + console.warn("[rec-stage] camera check failed:", err); + }); }; const micDevices = useMicrophoneDevices( diff --git a/src/lib/cameraAvailability.ts b/src/lib/cameraAvailability.ts index 62d8c5110..075f62de7 100644 --- a/src/lib/cameraAvailability.ts +++ b/src/lib/cameraAvailability.ts @@ -16,15 +16,26 @@ async function hasCameraDevice(): Promise { } } +let pendingCheck: Promise | null = null; + /** * Whether the camera may be switched on, with a toast saying why not. * * The one check for every surface that turns the camera on (the HUD toggle, and the editor's * Record mode), so neither stores `camEnabled: true` for a camera the other refuses (#998). + * A call made while one is pending, a double click, shares its answer: one access request, + * one toast. * * @param t The `editor` namespace translator. */ -export async function canTurnCameraOn(t: (key: string) => string): Promise { +export function canTurnCameraOn(t: (key: string) => string): Promise { + pendingCheck ??= checkCameraCanTurnOn(t).finally(() => { + pendingCheck = null; + }); + return pendingCheck; +} + +async function checkCameraCanTurnOn(t: (key: string) => string): Promise { const accessResult = await requestCameraAccess(); if (!accessResult.success) { toast.error(t("recording.failedCameraAccess"));