Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/components/ai-edition/v4/RecStage.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 }));
Expand Down Expand Up @@ -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<Partial<MediaDeviceInfo>>) {
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(
Expand Down Expand Up @@ -221,6 +237,48 @@ 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();
});

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 () => {
Expand Down
18 changes: 17 additions & 1 deletion src/components/ai-edition/v4/RecStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -133,6 +134,21 @@ 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 });
})
.catch((err) => {
console.warn("[rec-stage] camera check failed:", err);
});
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const micDevices = useMicrophoneDevices(
prefs.micEnabled,
prefs.micDeviceId ?? undefined,
Expand Down Expand Up @@ -471,7 +487,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")}
</button>
Expand Down
36 changes: 2 additions & 34 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<boolean> {
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
Expand Down Expand Up @@ -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;
}

Expand Down
61 changes: 61 additions & 0 deletions src/lib/cameraAvailability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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<boolean> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((device) => device.kind === "videoinput");
} catch {
return true;
}
}

let pendingCheck: Promise<boolean> | 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 function canTurnCameraOn(t: (key: string) => string): Promise<boolean> {
pendingCheck ??= checkCameraCanTurnOn(t).finally(() => {
pendingCheck = null;
});
return pendingCheck;
}

async function checkCameraCanTurnOn(t: (key: string) => string): Promise<boolean> {
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;
}
Loading