diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index b65e8518b..ee37e40f7 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -1729,6 +1729,29 @@ describe("LaunchWindow device buttons", () => { expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); }); + it("does not store the camera as on when it cannot be turned on (#967)", async () => { + recorderState.value.setWebcamEnabled.mockResolvedValueOnce(false); + + renderLaunchWindow(); + fireEvent.click(await screen.findByTestId("launch-webcam-button")); + + await waitFor(() => { + expect(recorderState.value.setWebcamEnabled).toHaveBeenCalledWith(true); + }); + // Let the toggle's then() run before checking what it stored. + await act(async () => undefined); + expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalledWith({ camEnabled: true }); + }); + + it("stores the camera as on once it was turned on", async () => { + renderLaunchWindow(); + fireEvent.click(await screen.findByTestId("launch-webcam-button")); + + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ camEnabled: true }); + }); + }); + it("turns the camera off with a single click when it is already on", async () => { recorderState.value.webcamEnabled = true; diff --git a/src/hooks/useScreenRecorder.noCamera.test.tsx b/src/hooks/useScreenRecorder.noCamera.test.tsx new file mode 100644 index 000000000..cd75f16b8 --- /dev/null +++ b/src/hooks/useScreenRecorder.noCamera.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => ({ + useScopedT: () => (key: string) => key, +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +vi.mock("@/lib/requestCameraAccess", () => ({ + requestCameraAccess: vi.fn(async () => ({ success: true, granted: true, status: "granted" })), +})); + +import { toast } from "sonner"; +import { useScreenRecorder } from "./useScreenRecorder"; + +type ElectronAPI = Window["electronAPI"]; +type RecordingPrefs = Awaited>; + +const CAMERA = { kind: "videoinput", deviceId: "cam-1", label: "FaceTime HD Camera", groupId: "" }; + +function prefs(camEnabled: boolean): RecordingPrefs { + return { + micEnabled: false, + micDeviceId: null, + micDeviceName: null, + camEnabled, + camDeviceId: null, + camDeviceName: null, + camQuality: "2160p", + systemAudioEnabled: false, + cursorCaptureMode: "editable-overlay", + hideDesktopIcons: false, + autoZoomEnabled: true, + }; +} + +let setRecordingPrefs: ReturnType; +let enumerateDevices: ReturnType; +let getUserMedia: ReturnType; + +function stub(stored: RecordingPrefs) { + setRecordingPrefs = vi.fn(async (patch: Partial) => ({ ...stored, ...patch })); + window.electronAPI = { + getRecordingPrefs: vi.fn(async () => stored), + setRecordingPrefs, + onRecordingPrefsChanged: vi.fn(() => () => undefined), + getPlatform: vi.fn(() => "darwin"), + getSelectedSource: vi.fn(async () => null), + } as unknown as ElectronAPI; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices, getUserMedia }, + }); +} + +describe("useScreenRecorder on a machine without a camera (#967)", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + enumerateDevices = vi.fn(async () => []); + getUserMedia = vi.fn(async () => { + throw new DOMException("Requested device not found", "NotFoundError"); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("refuses to turn the camera on when the OS lists none", async () => { + stub(prefs(false)); + const view = renderHook(() => useScreenRecorder()); + await waitFor(() => expect(view.result.current.recordingPrefsLoaded).toBe(true)); + + let ok: boolean | undefined; + await act(async () => { + ok = await view.result.current.setWebcamEnabled(true); + }); + + expect(ok).toBe(false); + expect(view.result.current.webcamEnabled).toBe(false); + expect(toast.error).toHaveBeenCalledWith("recording.cameraNotFound"); + expect(getUserMedia).not.toHaveBeenCalled(); + expect(setRecordingPrefs).toHaveBeenCalledWith({ camEnabled: false }); + }); + + it("still turns a listed camera on", async () => { + enumerateDevices = vi.fn(async () => [CAMERA]); + getUserMedia = vi.fn(async () => ({ getVideoTracks: () => [], getTracks: () => [] })); + stub(prefs(false)); + const view = renderHook(() => useScreenRecorder()); + await waitFor(() => expect(view.result.current.recordingPrefsLoaded).toBe(true)); + + let ok: boolean | undefined; + await act(async () => { + ok = await view.result.current.setWebcamEnabled(true); + }); + + expect(ok).toBe(true); + expect(view.result.current.webcamEnabled).toBe(true); + await waitFor(() => expect(getUserMedia).toHaveBeenCalled()); + expect(setRecordingPrefs).not.toHaveBeenCalled(); + }); + + it("stores the camera as off when a stored 'on' cannot open it", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + stub(prefs(true)); + const view = renderHook(() => useScreenRecorder()); + + await waitFor(() => { + expect(setRecordingPrefs).toHaveBeenCalledWith({ camEnabled: false }); + }); + expect(view.result.current.webcamEnabled).toBe(false); + expect(toast.error).toHaveBeenCalledWith("recording.cameraNotFound"); + }); + + // A saved camera name makes NotFoundError a "wait for the identity" case. In the + // HUD, useCameraHudSync clears the identity once the list comes back empty, and + // the acquire that follows takes the failure path. + it("stores the camera as off once a restored camera identity is cleared", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + stub({ ...prefs(true), camDeviceId: "cam-1", camDeviceName: "FaceTime HD Camera" }); + const view = renderHook(() => useScreenRecorder()); + await waitFor(() => expect(getUserMedia).toHaveBeenCalled()); + expect(view.result.current.webcamEnabled).toBe(true); + expect(setRecordingPrefs).not.toHaveBeenCalled(); + + act(() => { + view.result.current.setWebcamDeviceId(undefined); + view.result.current.setWebcamDeviceName(undefined); + }); + + await waitFor(() => { + expect(setRecordingPrefs).toHaveBeenCalledWith({ camEnabled: false }); + }); + expect(view.result.current.webcamEnabled).toBe(false); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ae86f3c7d..915fcf959 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -156,6 +156,21 @@ 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 @@ -487,6 +502,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { 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); + }); + return false; + } + setWebcamEnabledState(true); return true; }, @@ -536,6 +561,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!cancelled) { console.warn("Failed to get webcam access:", cameraError); setWebcamEnabledState(false); + // The stored preference has to follow, or the editor's Record mode + // keeps reading "On" from it while the HUD shows the camera off (#967). + void window.electronAPI?.setRecordingPrefs?.({ camEnabled: false }).catch((error) => { + console.warn("Failed to persist the camera preference:", error); + }); const isDeviceError = cameraError instanceof DOMException && [