diff --git a/src/components/ai-edition/v4/RecStage.test.tsx b/src/components/ai-edition/v4/RecStage.test.tsx index 47ac87e05..7ba2d33c3 100644 --- a/src/components/ai-edition/v4/RecStage.test.tsx +++ b/src/components/ai-edition/v4/RecStage.test.tsx @@ -121,6 +121,14 @@ function stubCameras(devices: Array>) { }); } +/** The OS listing these devices, for the microphone check. */ +function stubMediaDevices(devices: Array>) { + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices: vi.fn(async () => devices) }, + }); +} + function renderRecStage() { const onStartRecording = vi.fn(); const view = render( @@ -267,6 +275,64 @@ describe("RecStage controls", () => { expect(toast.error).not.toHaveBeenCalled(); }); + // The microphone row stored "on" with no input at all, beside its own "No microphone + // found", and the take after a Restart never started (#995). + it("keeps the microphone off, and never stores it on, when the OS lists no input", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "videoinput", deviceId: "cam-1" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + fireEvent.click(pill("rec.microphone")); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("rec.noMicrophoneFound")); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(pill("rec.microphone")).toHaveAttribute("aria-pressed", "false"); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + }); + + it("turns a listed microphone on, and stores it", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + + await waitFor(() => expect(pill("rec.microphone")).toHaveAttribute("aria-pressed", "true")); + expect(setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: true }); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("never turns the microphone on after Start recording, when its check ends late", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); + const { onStartRecording } = renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + fireEvent.click(screen.getByRole("button", { name: /rec\.startRecording/ })); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onStartRecording).toHaveBeenCalled(); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + }); + + it("lets a second click during the microphone check take the first one back", async () => { + const { getRecordingPrefs, setRecordingPrefs } = stubRecordingPrefs({ micEnabled: false }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); + renderRecStage(); + await waitFor(() => expect(getRecordingPrefs).toHaveBeenCalled()); + + fireEvent.click(pill("rec.microphone")); + fireEvent.click(pill("rec.microphone")); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(pill("rec.microphone")).toHaveAttribute("aria-pressed", "false"); + expect(setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + }); + it("runs one camera check for a double click", async () => { const { getRecordingPrefs } = stubRecordingPrefs({ camEnabled: false }); stubCameras([]); @@ -447,6 +513,7 @@ describe("RecStage controls", () => { it("ties microphone discovery to the toggle so off then on requests a retry", async () => { stubRecordingPrefs({ micEnabled: true }); + stubMediaDevices([{ kind: "audioinput", deviceId: "mic-1" }]); renderRecStage(); await screen.findByText("rec.noMicrophoneFound"); const toggle = pill("rec.microphone"); diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 5888be49f..9a87698f8 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -23,6 +23,7 @@ import { useEditableCursorAvailable } from "@/hooks/useEditableCursorAvailable"; import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "@/hooks/usePortalOwnsSource"; import { canTurnCameraOn } from "@/lib/cameraAvailability"; +import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import styles from "./EditorShellV4.module.css"; @@ -149,6 +150,31 @@ export function RecStage({ }); }; + // The same check as the HUD toggle: no input listed, never stored as on (#995). + // A second click while the check runs takes the first one back: the last click wins. + const micTurningOn = useRef(false); + const toggleMicrophone = () => { + if (prefs.micEnabled) { + updatePrefs({ micEnabled: false }); + return; + } + if (micTurningOn.current) { + micTurningOn.current = false; + return; + } + micTurningOn.current = true; + void canTurnMicrophoneOn(t) + .then((ok) => { + const wanted = micTurningOn.current; + micTurningOn.current = false; + if (ok && wanted) updatePrefs({ micEnabled: true }); + }) + .catch((err) => { + micTurningOn.current = false; + console.warn("[rec-stage] microphone check failed:", err); + }); + }; + const micDevices = useMicrophoneDevices( prefs.micEnabled, prefs.micDeviceId ?? undefined, @@ -438,7 +464,7 @@ export function RecStage({ className={`${styles.recToggleBtn}${prefs.micEnabled ? ` ${styles.on}` : ""}`} aria-labelledby={rowLabelId("microphone")} aria-pressed={prefs.micEnabled} - onClick={() => updatePrefs({ micEnabled: !prefs.micEnabled })} + onClick={toggleMicrophone} > {prefs.micEnabled ? t("rec.on") : t("rec.off")} @@ -570,7 +596,15 @@ export function RecStage({ {t("rec.cancel")} ) : null} - diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 9036bf774..688a05aa6 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -1606,10 +1606,36 @@ describe("LaunchWindow device buttons", () => { fireEvent.click(await screen.findByTestId("launch-microphone-button")); - expect(recorderState.value.setMicrophoneEnabled).toHaveBeenCalledWith(true); + await waitFor(() => { + expect(recorderState.value.setMicrophoneEnabled).toHaveBeenCalledWith(true); + }); expect(screen.queryByTestId("hud-device-settings")).not.toBeInTheDocument(); }); + // The camera toggle refuses a camera the OS does not list (#967); the microphone let a Mac + // with no input turn it on, and the take after a Restart never started (#995). + it("keeps the microphone off, and never stores it on, when the OS lists no input (#995)", async () => { + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { + enumerateDevices: vi.fn(async () => [{ kind: "videoinput", deviceId: "cam-1" }]), + }, + }); + try { + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-microphone-button")); + + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: false }); + }); + expect(recorderState.value.setMicrophoneEnabled).not.toHaveBeenCalled(); + expect(window.electronAPI.setRecordingPrefs).not.toHaveBeenCalledWith({ micEnabled: true }); + } finally { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + }); + it("turns the microphone off with a single click when it is already on", async () => { recorderState.value.microphoneEnabled = true; @@ -1647,8 +1673,10 @@ describe("LaunchWindow device buttons", () => { fireEvent.click(await screen.findByTestId("launch-microphone-button")); + await waitFor(() => { + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: true }); + }); expect(recorderState.value.setMicrophoneEnabled).toHaveBeenCalledWith(true); - expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: true }); }); it("persists turning the microphone off", async () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 715a941b1..4a310e07d 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { offersEditableCursor } from "@/lib/editableCursorAvailability"; +import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; import { canRecordMicrophone } from "@/utils/platformUtils"; @@ -78,6 +79,8 @@ export function LaunchWindow() { // The update-check label is shared with the app menu and the tray, which read it from // `common`. A second copy under `launch` drifted from it in en and ar before it ever shipped. const tCommon = useScopedT("common"); + // The microphone check's toast is worded where the camera check's is: in `editor`. + const tEditor = useScopedT("editor"); const { locale, setLocale, @@ -891,12 +894,33 @@ export function LaunchWindow() { persistRecordingPrefs({ cursorCaptureMode: next }); }, [controlsLocked, cursorCaptureMode, persistRecordingPrefs, setCursorCaptureMode]); + // Refuses a microphone the OS does not list, as the camera toggle does (#995). A second + // click while the check runs takes the first one back: the last click wins. + const micTurningOn = useRef(false); + // A take that starts while the check runs was decided without the microphone: keep it so. + useEffect(() => { + if (controlsLocked) micTurningOn.current = false; + }, [controlsLocked]); const toggleMicrophone = useCallback(() => { if (controlsLocked) return; - const next = !microphoneEnabled; - setMicrophoneEnabled(next); - persistRecordingPrefs({ micEnabled: next }); - }, [controlsLocked, microphoneEnabled, persistRecordingPrefs, setMicrophoneEnabled]); + if (microphoneEnabled) { + setMicrophoneEnabled(false); + persistRecordingPrefs({ micEnabled: false }); + return; + } + if (micTurningOn.current) { + micTurningOn.current = false; + return; + } + micTurningOn.current = true; + void canTurnMicrophoneOn(tEditor).then((ok) => { + const wanted = micTurningOn.current; + micTurningOn.current = false; + if (!ok || !wanted) return; + setMicrophoneEnabled(true); + persistRecordingPrefs({ micEnabled: true }); + }); + }, [controlsLocked, microphoneEnabled, persistRecordingPrefs, setMicrophoneEnabled, tEditor]); const toggleWebcam = useCallback(() => { if (controlsLocked) return; diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index 89158eb72..2fc860d77 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -140,6 +140,39 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(view.result.current.recording).toBe(true); }); + // A Mac with no audio input recorded the first take with the microphone "on", and the + // take after a Restart never started (#995). A stored "on" must not reach the helper. + it("never asks the helper for a microphone the OS does not list, on Restart either", async () => { + api.setRecordingPrefs = vi.fn(async () => undefined); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { enumerateDevices: vi.fn(async () => []) }, + }); + try { + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + expect(view.result.current.recording).toBe(true); + + await act(async () => { + await view.result.current.restartRecording(); + }); + await settle(); + + expect(api.startNativeMacRecording).toHaveBeenCalledTimes(2); + for (const [request] of api.startNativeMacRecording.mock.calls) { + expect(request.audio.microphone.enabled).toBe(false); + } + expect(api.setRecordingPrefs).toHaveBeenCalledWith({ micEnabled: false }); + } finally { + Reflect.deleteProperty(navigator, "mediaDevices"); + } + }); + // macOS 13 and 14 have no `captureMicrophone`: a saved "mic on" would ask for the // microphone and record a take without it (#700). it("does not apply a saved microphone on macOS 14", async () => { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c20170c15..5feea0ace 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { mixAudioTracks, nativeMicrophoneGain } from "@/lib/audioMix"; import { canTurnCameraOn } from "@/lib/cameraAvailability"; +import { shouldRecordMicrophone } from "@/lib/microphoneAvailability"; import { type NativeLinuxRecordingRequest, portalOwnsSourceSelection, @@ -1179,6 +1180,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startNativeWindowsRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, + recordMicrophone: boolean, countdownRunToken?: number, ) => { try { @@ -1245,7 +1247,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { enabled: systemAudioEnabled, }, microphone: { - enabled: microphoneEnabled, + enabled: recordMicrophone, deviceId: microphoneDeviceId, deviceName: microphoneDeviceName, // Boosted only when the mic has to sit over system audio. @@ -1308,6 +1310,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startNativeMacRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, + recordMicrophone: boolean, countdownRunToken?: number, ) => { try { @@ -1406,7 +1409,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { enabled: systemAudioEnabled, }, microphone: { - enabled: microphoneEnabled, + enabled: recordMicrophone, deviceId: microphoneDeviceId, deviceName: microphoneDeviceName, // Boosted only over system audio, like the Windows request above. @@ -1494,7 +1497,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { * is the one negotiated at the start, so a divergence in audio or cursor * settings would record something the second call never asked for. */ - const buildNativeLinuxRequest = (recordingId?: number): NativeLinuxRecordingRequest => ({ + const buildNativeLinuxRequest = ( + recordMicrophone: boolean, + recordingId?: number, + ): NativeLinuxRecordingRequest => ({ ...(recordingId === undefined ? {} : { recordingId }), video: { // No bitrate on purpose. TARGET_WIDTH/HEIGHT are the app's 4K ceiling, @@ -1507,7 +1513,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { audio: { system: { enabled: systemAudioEnabled }, microphone: { - enabled: microphoneEnabled, + enabled: recordMicrophone, // The device LABEL, not the id. Chromium's deviceId is an opaque // per-origin hash that means nothing to PipeWire, whereas on a // PipeWire system the label IS the node's `node.description` — which @@ -1524,6 +1530,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); const startNativeLinuxRecordingIfAvailable = async ( + recordMicrophone: boolean, countdownRunToken?: number, preparedRecordingId?: number | null, ) => { @@ -1580,7 +1587,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const result = await window.electronAPI.startNativeLinuxRecording( - buildNativeLinuxRequest(activeRecordingId), + buildNativeLinuxRequest(recordMicrophone, activeRecordingId), ); if (!result.success || !result.recordingId) { throw new Error(result.error ?? "Native Linux capture failed."); @@ -1695,7 +1702,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (portalOwnsSource) { try { const prepared = await window.electronAPI.prepareNativeLinuxRecording( - buildNativeLinuxRequest(), + buildNativeLinuxRequest(await shouldRecordMicrophone(microphoneEnabled)), ); if (prepared.success && typeof prepared.recordingId === "number") { preparedRecordingId = prepared.recordingId; @@ -1785,6 +1792,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); return; } + // Resolved once, for whichever path records the take (#995). + const recordMicrophone = await shouldRecordMicrophone(microphoneEnabled); // BEFORE THE SOURCE GATE, on purpose. On Wayland the portal raises its // own picker and is the only thing that can choose a source, so there @@ -1793,7 +1802,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // question this platform never asks the app. It returns false when the // native helper is missing, and the browser fallback below does need a // source, so the gate still guards the path that uses one. - if (await startNativeLinuxRecordingIfAvailable(countdownRunToken, preparedRecordingId)) { + if ( + await startNativeLinuxRecordingIfAvailable( + recordMicrophone, + countdownRunToken, + preparedRecordingId, + ) + ) { return; } @@ -1803,10 +1818,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { + if ( + await startNativeWindowsRecordingIfAvailable( + selectedSource, + recordMicrophone, + countdownRunToken, + ) + ) { return; } - if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { + if ( + await startNativeMacRecordingIfAvailable( + selectedSource, + recordMicrophone, + countdownRunToken, + ) + ) { return; } @@ -1867,7 +1894,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } as unknown as MediaStreamConstraints); })(); - const micCapture: Promise = microphoneEnabled + const micCapture: Promise = recordMicrophone ? (async () => { try { return await navigator.mediaDevices.getUserMedia({ diff --git a/src/lib/microphoneAvailability.ts b/src/lib/microphoneAvailability.ts new file mode 100644 index 000000000..b7efe1ba1 --- /dev/null +++ b/src/lib/microphoneAvailability.ts @@ -0,0 +1,59 @@ +import { toast } from "sonner"; + +/** + * Whether the OS lists any audio input. A Mac with no microphone still records a take + * that asks for one, then fails the next take in the same picker session (#995). A failed + * enumeration answers `true`: not knowing is not "none". + */ +async function hasMicrophoneDevice(): Promise { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.some((device) => device.kind === "audioinput"); + } catch { + return true; + } +} + +/** The toggles store nothing on failure, so this clears an "on" left by an input now gone. */ +function forgetMicrophone() { + void window.electronAPI?.setRecordingPrefs?.({ micEnabled: false }).catch((error) => { + console.warn("Failed to persist the microphone preference:", error); + }); +} + +let pendingCheck: Promise | null = null; + +/** + * Whether the microphone may be switched on, with a toast saying why not: the camera's + * `canTurnCameraOn`, for the HUD toggle and the editor's Record mode alike. A call made + * while one is pending, a double click, shares its answer and its one toast. + * + * @param t The `editor` namespace translator. + */ +export function canTurnMicrophoneOn(t: (key: string) => string): Promise { + pendingCheck ??= hasMicrophoneDevice() + .then((found) => { + if (!found) { + toast.error(t("rec.noMicrophoneFound")); + forgetMicrophone(); + } + return found; + }) + .finally(() => { + pendingCheck = null; + }); + return pendingCheck; +} + +/** + * Whether a take records the microphone: switched on, and an input listed. A stored "on" + * the toggle never checked (an older build, another Mac, an input unplugged since) must + * not reach the capture helper (#995), and is cleared so the toggles show it off. + */ +export async function shouldRecordMicrophone(enabled: boolean): Promise { + if (!enabled) return false; + if (await hasMicrophoneDevice()) return true; + console.warn("No microphone is listed; recording without it."); + forgetMicrophone(); + return false; +}