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
67 changes: 67 additions & 0 deletions src/components/ai-edition/v4/RecStage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ function stubCameras(devices: Array<Partial<MediaDeviceInfo>>) {
});
}

/** The OS listing these devices, for the microphone check. */
function stubMediaDevices(devices: Array<Partial<MediaDeviceInfo>>) {
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 @@ -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([]);
Expand Down Expand Up @@ -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");
Expand Down
38 changes: 36 additions & 2 deletions src/components/ai-edition/v4/RecStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")}
</button>
Expand Down Expand Up @@ -570,7 +596,15 @@ export function RecStage({
{t("rec.cancel")}
</button>
) : null}
<button type="button" className={styles.bigRecBtn} onClick={onStartRecording}>
<button
type="button"
className={styles.bigRecBtn}
onClick={() => {
// The take is decided now: a microphone check still running must not turn it on.
micTurningOn.current = false;
onStartRecording();
}}
>
<span className={styles.bigRecDot} aria-hidden />
{t("rec.startRecording")}
</button>
Expand Down
32 changes: 30 additions & 2 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 () => {
Expand Down
32 changes: 28 additions & 4 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setMicrophoneEnabled(true);
persistRecordingPrefs({ micEnabled: true });
});
}, [controlsLocked, microphoneEnabled, persistRecordingPrefs, setMicrophoneEnabled, tEditor]);

const toggleWebcam = useCallback(() => {
if (controlsLocked) return;
Expand Down
33 changes: 33 additions & 0 deletions src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading