From eb94511e019cfc7cd323c22eaa7a68537a079a14 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Wed, 27 May 2026 15:25:57 -0500 Subject: [PATCH 1/4] feat(audio): warn and disable recording when no input device detected (#980) Add useAudioInputDevices hook that enumerates audioinput devices and listens for `devicechange` events so plugging/unplugging a mic updates the UI live. When no device is present, the recorder button is disabled, its tooltip reads "No microphone detected", and an inline warning row appears below the button. Includes a `window.__forceNoAudioInput` debug flag for QA on machines with a working mic. --- .../src/CodexCellEditor/TextCellEditor.tsx | 34 +++++- .../hooks/useAudioInputDevices.ts | 102 ++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts diff --git a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx index e8f9b7483..b44492935 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx @@ -22,6 +22,7 @@ import { WhisperTranscriptionClient } from "./WhisperTranscriptionClient"; import AudioWaveformWithTranscription from "./AudioWaveformWithTranscription"; import { AudioValidationBadge } from "./AudioValidationBadge"; import { useAudioValidationStatus } from "./hooks/useAudioValidationStatus"; +import { useAudioInputDevices } from "./hooks/useAudioInputDevices"; import SourceTextDisplay from "./SourceTextDisplay"; import { AudioHistoryViewer } from "./AudioHistoryViewer"; import { useMessageHandler } from "./hooks/useCentralizedMessageDispatcher"; @@ -532,6 +533,18 @@ const CellEditor: React.FC = ({ undefined, }; + // Microphone detection — drives the disabled state and warning under the + // recorder. Updates live via `devicechange`, so unplugging or plugging in + // a mic flips the UI immediately. See `hooks/useAudioInputDevices.ts` for + // the debug flag used to simulate "no microphone" on dev machines. + const { + hasAudioInput, + isChecking: isCheckingAudioDevices, + isSupported: isAudioDeviceApiSupported, + } = useAudioInputDevices(); + const noMicDetected = + isAudioDeviceApiSupported && !isCheckingAudioDevices && !hasAudioInput; + const centerEditor = useCallback(() => { const el = cellEditorRef.current; if (!el) return; @@ -5680,6 +5693,8 @@ const CellEditor: React.FC = ({ : "idle"; const recorderTitle = isCellLocked ? "Cannot record: cell is locked" + : noMicDetected + ? "No microphone detected" : isRecording ? "Stop Recording" : countdown !== null @@ -5699,7 +5714,9 @@ const CellEditor: React.FC = ({ ? stopRecording : startRecording } - disabled={isCellLocked} + disabled={ + isCellLocked || noMicDetected + } title={recorderTitle} /> @@ -5770,6 +5787,21 @@ const CellEditor: React.FC = ({ )} + {noMicDetected && ( + + + No microphone detected. Connect an + input device to record. + + )} {hint && ( (true); + const [isChecking, setIsChecking] = useState(isSupported); + + useEffect(() => { + if (!isSupported) { + setIsChecking(false); + return; + } + + let cancelled = false; + + const checkDevices = async () => { + try { + if (window.__forceNoAudioInput) { + if (!cancelled) { + setHasAudioInput(false); + setIsChecking(false); + } + return; + } + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter((d) => d.kind === "audioinput"); + if (!cancelled) { + setHasAudioInput(audioInputs.length > 0); + setIsChecking(false); + } + } catch (err) { + console.warn("useAudioInputDevices: enumerateDevices failed", err); + if (!cancelled) { + // On enumeration failure, fall back to "assume present" so + // we don't disable recording on a machine that may well + // have a working mic. The actual `getUserMedia` call will + // surface a clearer error if recording is then attempted. + setHasAudioInput(true); + setIsChecking(false); + } + } + }; + + checkDevices(); + + const handleDeviceChange = () => { + checkDevices(); + }; + + navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); + + return () => { + cancelled = true; + navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); + }; + }, [isSupported]); + + return { hasAudioInput, isChecking, isSupported }; +} From e426514b7a7bf9af9cccefcbd381e70bac47748e Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Sat, 30 May 2026 15:09:56 -0500 Subject: [PATCH 2/4] polish(audio): style no-mic warning and add device-detection tests (#980) Visual polish for the no-microphone state: - Add an `unavailable` prop to RecorderCircle that's distinct from `disabled` so the locked-cell visual (faded primary mic) is preserved while the no-mic state gets a solid mid-grey circle with a white MicOff (slashed) icon and no pulse animation. - Replace the inline AlertTriangle hint with a ShadCN Alert box matching the existing yellow-warning pattern used in the source importer forms. Constrained to `w-fit` so it hugs its text and centers under the mic. Hook robustness: - Capture `navigator.mediaDevices` at effect mount so the cleanup path removes its listener from the original reference, even if the global is swapped out later (tests, hot-reload, polyfills). Tests: - 5 unit tests for useAudioInputDevices covering presence, absence, live `devicechange` hot-plug, the `__forceNoAudioInput` debug flag, and graceful fallback when `enumerateDevices` is unavailable. - 1 integration test asserting the record button disables, the warning renders, and `getUserMedia` is never reached when no device exists. --- .../src/CodexCellEditor/TextCellEditor.tsx | 25 ++-- ...llEditor.saveWorkflow.integration.test.tsx | 68 ++++++++++ .../__tests___/useAudioInputDevices.test.ts | 118 ++++++++++++++++++ .../components/RecorderCircle.tsx | 40 ++++-- .../hooks/useAudioInputDevices.ts | 10 +- 5 files changed, 234 insertions(+), 27 deletions(-) create mode 100644 webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts diff --git a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx index b44492935..047f0052f 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx @@ -5714,9 +5714,8 @@ const CellEditor: React.FC = ({ ? stopRecording : startRecording } - disabled={ - isCellLocked || noMicDetected - } + disabled={isCellLocked} + unavailable={noMicDetected} title={recorderTitle} /> @@ -5788,19 +5787,13 @@ const CellEditor: React.FC = ({ )} {noMicDetected && ( - - - No microphone detected. Connect an - input device to record. - + + + + No microphone detected. Connect + an input device to record. + + )} {hint && ( diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx index d83558a3a..caf351dce 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx @@ -984,6 +984,74 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { (window as any).AudioContext = OriginalAudioContext; }); + it("no microphone detected: disables record button, shows warning, and skips getUserMedia", async () => { + sessionStorage.setItem("preferred-editor-tab", "audio"); + + const props = { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + editHistory: mockTranslationUnits[0].editHistory, + cellIndex: 0, + cellType: CodexCellTypes.TEXT, + contentBeingUpdated: { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + cellChanged: false, + }, + setContentBeingUpdated: vi.fn(), + handleCloseEditor: vi.fn(), + handleSaveHtml: vi.fn(), + textDirection: "ltr" as const, + cellLabel: "Test Label", + cellTimestamps: { startTime: 0, endTime: 5 }, + cellIsChild: false, + openCellById: vi.fn(), + cell: mockTranslationUnits[0], + isSaving: false, + saveError: false, + saveRetryCount: 0, + footnoteOffset: 1, + audioAttachments: { "cell-1": "none" as const }, + }; + + // Mock an unplugged-mic environment: `enumerateDevices` returns no + // audioinput entries. `getUserMedia` is still mocked so we can verify + // the disabled button doesn't reach it on click. + const getUserMediaSpy = vi.fn(); + const fakeMediaDevices = new EventTarget() as EventTarget & { + enumerateDevices: ReturnType; + getUserMedia: ReturnType; + }; + fakeMediaDevices.enumerateDevices = vi.fn().mockResolvedValue([]); + fakeMediaDevices.getUserMedia = getUserMediaSpy; + (navigator as any).mediaDevices = fakeMediaDevices; + + render( + + + + + + + + ); + + // The record button's title flips to the new state and it disables. + const startBtn = await screen.findByRole("button", { + name: /No microphone detected/i, + }); + expect(startBtn.hasAttribute("disabled")).toBe(true); + + // The inline warning row is rendered alongside the disabled button. + expect( + await screen.findByText(/Connect an input device to record/i) + ).toBeTruthy(); + + // Clicking the disabled button must not attempt to open a stream. + fireEvent.click(startBtn); + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + it("locked cell: should disable Start Recording and not call getUserMedia", async () => { sessionStorage.setItem("preferred-editor-tab", "audio"); diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts new file mode 100644 index 000000000..659648d1f --- /dev/null +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { useAudioInputDevices } from "../hooks/useAudioInputDevices"; + +/** + * Tests for the microphone detection hook used by the audio recorder. + * + * We back the mock with a real `EventTarget` so `devicechange` listeners + * can be exercised via genuine `dispatchEvent` calls — the same path the + * browser uses when a mic is plugged in or unplugged. + */ + +type FakeMediaDevices = EventTarget & { + enumerateDevices: ReturnType; +}; + +function createFakeMediaDevices(devices: MediaDeviceInfo[]): FakeMediaDevices { + const target = new EventTarget() as FakeMediaDevices; + target.enumerateDevices = vi.fn().mockResolvedValue(devices); + return target; +} + +function audioInputDevice(label = "Mock Mic"): MediaDeviceInfo { + return { + deviceId: `mock-${label}`, + groupId: "group-1", + kind: "audioinput", + label, + toJSON: () => ({}), + } as MediaDeviceInfo; +} + +function videoInputDevice(): MediaDeviceInfo { + return { + deviceId: "mock-cam", + groupId: "group-2", + kind: "videoinput", + label: "Mock Cam", + toJSON: () => ({}), + } as MediaDeviceInfo; +} + +describe("useAudioInputDevices", () => { + const originalMediaDevices = (navigator as any).mediaDevices; + + beforeEach(() => { + delete (window as any).__forceNoAudioInput; + }); + + afterEach(() => { + // Always restore — some tests delete mediaDevices entirely. + if (originalMediaDevices) { + (navigator as any).mediaDevices = originalMediaDevices; + } else { + delete (navigator as any).mediaDevices; + } + }); + + it("reports hasAudioInput=true when at least one audioinput device exists", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.hasAudioInput).toBe(true); + expect(result.current.isSupported).toBe(true); + }); + + it("reports hasAudioInput=false when only non-audio devices are present", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([videoInputDevice()]); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.hasAudioInput).toBe(false); + }); + + it("re-checks on devicechange when a mic is plugged in mid-session", async () => { + const fake = createFakeMediaDevices([]); + (navigator as any).mediaDevices = fake; + + const { result } = renderHook(() => useAudioInputDevices()); + + // Initial state: no audio input + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.hasAudioInput).toBe(false); + + // Simulate plugging in a mic: next enumerate returns a device, + // then the browser fires `devicechange`. + fake.enumerateDevices.mockResolvedValueOnce([audioInputDevice("Plugged-in Mic")]); + act(() => { + fake.dispatchEvent(new Event("devicechange")); + }); + + await waitFor(() => expect(result.current.hasAudioInput).toBe(true)); + }); + + it("honors window.__forceNoAudioInput even when a real mic is enumerated", async () => { + (window as any).__forceNoAudioInput = true; + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.isChecking).toBe(false)); + expect(result.current.hasAudioInput).toBe(false); + }); + + it("falls back to isSupported=false when enumerateDevices is missing", () => { + (navigator as any).mediaDevices = {} as MediaDevices; + + const { result } = renderHook(() => useAudioInputDevices()); + + expect(result.current.isSupported).toBe(false); + // Conservative default: assume present so we never false-positive disable. + expect(result.current.hasAudioInput).toBe(true); + expect(result.current.isChecking).toBe(false); + }); +}); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx index b10d58195..3076e8907 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/RecorderCircle.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Mic, Square } from "lucide-react"; +import { Mic, MicOff, Square } from "lucide-react"; import { Button } from "../../components/ui/button"; import { cn } from "../../lib/utils"; @@ -17,6 +17,11 @@ import { cn } from "../../lib/utils"; * `RecorderWaveform.tsx` as a single-canvas scrolling waveform rendered * below the button. * + * The `unavailable` prop is distinct from `disabled`: it specifically marks + * "no microphone present" and swaps to a grey, slashed-mic, no-pulse visual + * so the user reads it as a hardware issue rather than a temporary lock. + * `disabled` alone (e.g. locked cell) keeps the normal mic + faded primary. + * * Geometry: * - Outer container is 128x128 so the ring-pulse halo can scale past the * 96x96 button edge without being clipped. @@ -33,6 +38,9 @@ export interface RecorderCircleProps { countdown: number | null; onClick: () => void; disabled?: boolean; + /** True when no microphone is connected. Implies disabled and swaps to + * the slashed-mic + neutral-grey + no-pulse visual treatment. */ + unavailable?: boolean; title?: string; } @@ -41,11 +49,14 @@ export const RecorderCircle: React.FC = ({ countdown, onClick, disabled = false, + unavailable = false, title, }) => { const isRecording = state === "recording"; const isCountdown = state === "countdown"; const isIdle = state === "idle"; + const isUnavailable = unavailable; + const effectiveDisabled = disabled || isUnavailable; return (
= ({ > {/* Ring-pulse halo for idle ("mic ready") and recording. Skipped during countdown so the per-digit animation isn't competing with - a continuous expanding ring. The parent's overflow-visible lets + a continuous expanding ring. Also skipped when unavailable — + a static "no input" state shouldn't be drawing attention with + a continuous animation. The parent's overflow-visible lets the halo expand past the button edge. */} - {!isCountdown && ( + {!isCountdown && !isUnavailable && (
); diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts index b6566f0b9..1e0cf8c5e 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts @@ -54,6 +54,10 @@ export function useAudioInputDevices(): UseAudioInputDevicesResult { return; } + // Capture the `mediaDevices` reference at mount so the cleanup path + // can always remove its own listener — even if something later swaps + // `navigator.mediaDevices` (tests, mocks, hot-reload). + const mediaDevices = navigator.mediaDevices; let cancelled = false; const checkDevices = async () => { @@ -65,7 +69,7 @@ export function useAudioInputDevices(): UseAudioInputDevicesResult { } return; } - const devices = await navigator.mediaDevices.enumerateDevices(); + const devices = await mediaDevices.enumerateDevices(); const audioInputs = devices.filter((d) => d.kind === "audioinput"); if (!cancelled) { setHasAudioInput(audioInputs.length > 0); @@ -90,11 +94,11 @@ export function useAudioInputDevices(): UseAudioInputDevicesResult { checkDevices(); }; - navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange); + mediaDevices.addEventListener("devicechange", handleDeviceChange); return () => { cancelled = true; - navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange); + mediaDevices.removeEventListener("devicechange", handleDeviceChange); }; }, [isSupported]); From d95c04dec030aede2487c47fea5dabfdfbd2d899 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Tue, 2 Jun 2026 15:32:37 -0500 Subject: [PATCH 3/4] fix(audio): no-mic vs permission-denied warnings and auto-start guard (#980) - Suppress countdown/recording on auto-start when mic unavailable - Center alert icon/text - add FORCE_STATE_FOR_REVIEW for testing - Add hook + integration tests Attempted but still needing more work - Detect mic permission-denied and detect no-device mic (separately) Still haven't gotten either of these to work. --- package-lock.json | 54 +++-- .../src/CodexCellEditor/TextCellEditor.tsx | 60 ++++-- ...llEditor.saveWorkflow.integration.test.tsx | 195 ++++++++++++++++-- .../__tests___/useAudioInputDevices.test.ts | 136 +++++++++--- .../hooks/useAudioInputDevices.ts | 187 ++++++++++++----- 5 files changed, 491 insertions(+), 141 deletions(-) diff --git a/package-lock.json b/package-lock.json index 459d3b3db..d0808e094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2305,6 +2305,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.609.0.tgz", "integrity": "sha512-0bNPAyPdkWkS9EGB2A9BZDkBNrnVCBzk5lYRezoT4K3/gi9w1DTYH5tuRdwaTZdxW19U1mq7CV0YJJARKO1L9Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2358,6 +2359,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.609.0.tgz", "integrity": "sha512-A0B3sDKFoFlGo8RYRjDBWHXpbgirer2bZBkCIzhSPHc1vOFHt/m2NcUoE2xnBKXJFrptL1xDkvo1P+XYp/BfcQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4982,7 +4984,6 @@ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5025,7 +5026,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5066,7 +5066,6 @@ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -5084,7 +5083,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5151,7 +5149,6 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -5166,7 +5163,6 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -5260,7 +5256,6 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5271,7 +5266,6 @@ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -6017,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -6053,6 +6048,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6065,6 +6061,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6089,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -6132,6 +6130,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6255,6 +6254,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6267,6 +6267,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6282,6 +6283,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6418,7 +6420,6 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -8275,6 +8276,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8472,6 +8474,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -9976,6 +9979,7 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10502,6 +10506,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -10934,6 +10939,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11821,8 +11827,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", @@ -12508,7 +12513,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1400418.tgz", "integrity": "sha512-U8j75zDOXF8IP3o0Cgb7K4tFA9uUHEOru2Wx64+EUqL4LNOh9dRe1i8WKR1k3mSpjcCe3aIkTDvEwq0YkI4hfw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "7.0.0", @@ -12820,6 +12826,7 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -13050,6 +13057,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -14421,7 +14429,6 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -16386,7 +16393,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -16829,7 +16835,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -16866,7 +16871,6 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -19645,6 +19649,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21575,7 +21580,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -21586,8 +21590,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -22691,6 +22694,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24213,7 +24217,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/turndown": { "version": "7.2.4", @@ -24284,6 +24289,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -27232,6 +27238,7 @@ "integrity": "sha512-SyrSVpygEdPzvgpapVZRQCy8XIOecadp56bPQewpfSfo9ypB6wdOUkx13NBu2ANDlUAtJX7KaLJpTtywVHNlVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^22.2.0", "@wdio/config": "8.46.0", @@ -27312,6 +27319,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -27361,6 +27369,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -27437,6 +27446,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -27681,6 +27691,7 @@ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -27791,8 +27802,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", diff --git a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx index 37fbf46ef..a88ad0a8d 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx @@ -534,17 +534,20 @@ const CellEditor: React.FC = ({ undefined, }; - // Microphone detection — drives the disabled state and warning under the - // recorder. Updates live via `devicechange`, so unplugging or plugging in - // a mic flips the UI immediately. See `hooks/useAudioInputDevices.ts` for - // the debug flag used to simulate "no microphone" on dev machines. - const { - hasAudioInput, - isChecking: isCheckingAudioDevices, - isSupported: isAudioDeviceApiSupported, - } = useAudioInputDevices(); - const noMicDetected = - isAudioDeviceApiSupported && !isCheckingAudioDevices && !hasAudioInput; + // Microphone availability — drives the disabled state and warning under + // the recorder. Reacts live to both `devicechange` and permission changes + // so the UI stays in sync without a reload. See + // `hooks/useAudioInputDevices.ts` (top of file) for the in-code constant + // reviewers can flip to simulate "no microphone" / "permission denied" + // on machines with a working mic. + const { noMicDetected, micPermissionDenied, micUnavailable } = useAudioInputDevices(); + // Mirror `micUnavailable` into a ref so guards inside callbacks (and + // setTimeout-deferred work like the auto-start path) can read the + // latest value without becoming stale closures. + const micUnavailableRef = useRef(micUnavailable); + useEffect(() => { + micUnavailableRef.current = micUnavailable; + }, [micUnavailable]); const centerEditor = useCallback(() => { const el = cellEditorRef.current; @@ -2765,6 +2768,19 @@ const CellEditor: React.FC = ({ return; } + // Prevent recording — and importantly, the visible pre-record + // countdown — when no mic is available or permission is denied. + // Read from the ref so the auto-start path (deferred via setTimeout) + // sees the latest value rather than its captured render-time copy. + if (micUnavailableRef.current) { + setRecordingStatus( + micPermissionDenied + ? "Microphone access denied" + : "No microphone detected" + ); + return; + } + // If already recording, counting down, or in the warmup window, do // nothing. `isStartingRecording` covers both the user-configured 0s // debounce path and the async getUserMedia warmup. @@ -5720,6 +5736,8 @@ const CellEditor: React.FC = ({ : "idle"; const recorderTitle = isCellLocked ? "Cannot record: cell is locked" + : micPermissionDenied + ? "Microphone access denied" : noMicDetected ? "No microphone detected" : isRecording @@ -5742,7 +5760,7 @@ const CellEditor: React.FC = ({ : startRecording } disabled={isCellLocked} - unavailable={noMicDetected} + unavailable={micUnavailable} title={recorderTitle} /> @@ -5813,12 +5831,20 @@ const CellEditor: React.FC = ({
)} - {noMicDetected && ( - - + {micUnavailable && ( + // Override the Alert's default absolute-icon layout so the + // AlertCircle and text share a single flex row with proper + // vertical centering. The `!` (important) prefixes are needed + // because the base Alert variant pins the SVG with + // `[&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4`. + + - No microphone detected. Connect - an input device to record. + {micPermissionDenied + ? "Microphone access denied. Enable microphone permissions in your system settings to record." + : "No microphone detected. Connect an input device to record."} )} diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx index caf351dce..89f9e2de0 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx @@ -984,6 +984,50 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { (window as any).AudioContext = OriginalAudioContext; }); + /** + * Helpers for setting up mock mic detection environments. The hook checks + * both `mediaDevices.enumerateDevices()` (for hardware presence) and + * `permissions.query({ name: "microphone" })` (for permission state), + * subscribing to `devicechange` and PermissionStatus `change` events for + * live updates. Both need EventTarget backing so `addEventListener` + * doesn't blow up. + */ + function mockMicEnvironment(opts: { + audioInputs: number; + permission: "granted" | "denied" | "prompt" | "missing"; + getUserMediaSpy?: ReturnType; + }) { + const fakeMediaDevices = new EventTarget() as EventTarget & { + enumerateDevices: ReturnType; + getUserMedia: ReturnType; + }; + fakeMediaDevices.enumerateDevices = vi.fn().mockResolvedValue( + Array.from({ length: opts.audioInputs }, (_, i) => ({ + deviceId: `mic-${i}`, + groupId: "g", + kind: "audioinput", + label: `Mic ${i}`, + toJSON: () => ({}), + })) as MediaDeviceInfo[] + ); + fakeMediaDevices.getUserMedia = + opts.getUserMediaSpy ?? + vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }); + (navigator as any).mediaDevices = fakeMediaDevices; + + if (opts.permission === "missing") { + delete (navigator as any).permissions; + } else { + const status = new EventTarget() as EventTarget & { + state: "granted" | "denied" | "prompt"; + }; + status.state = opts.permission; + (navigator as any).permissions = { + query: vi.fn().mockResolvedValue(status), + }; + } + } + it("no microphone detected: disables record button, shows warning, and skips getUserMedia", async () => { sessionStorage.setItem("preferred-editor-tab", "audio"); @@ -1014,17 +1058,12 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { audioAttachments: { "cell-1": "none" as const }, }; - // Mock an unplugged-mic environment: `enumerateDevices` returns no - // audioinput entries. `getUserMedia` is still mocked so we can verify - // the disabled button doesn't reach it on click. const getUserMediaSpy = vi.fn(); - const fakeMediaDevices = new EventTarget() as EventTarget & { - enumerateDevices: ReturnType; - getUserMedia: ReturnType; - }; - fakeMediaDevices.enumerateDevices = vi.fn().mockResolvedValue([]); - fakeMediaDevices.getUserMedia = getUserMediaSpy; - (navigator as any).mediaDevices = fakeMediaDevices; + mockMicEnvironment({ + audioInputs: 0, + permission: "granted", + getUserMediaSpy, + }); render( @@ -1036,22 +1075,150 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { ); - // The record button's title flips to the new state and it disables. const startBtn = await screen.findByRole("button", { name: /No microphone detected/i, }); expect(startBtn.hasAttribute("disabled")).toBe(true); - - // The inline warning row is rendered alongside the disabled button. expect( await screen.findByText(/Connect an input device to record/i) ).toBeTruthy(); - // Clicking the disabled button must not attempt to open a stream. fireEvent.click(startBtn); expect(getUserMediaSpy).not.toHaveBeenCalled(); }); + it("permission denied: shows distinct warning text and disables record button", async () => { + sessionStorage.setItem("preferred-editor-tab", "audio"); + + const props = { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + editHistory: mockTranslationUnits[0].editHistory, + cellIndex: 0, + cellType: CodexCellTypes.TEXT, + contentBeingUpdated: { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + cellChanged: false, + }, + setContentBeingUpdated: vi.fn(), + handleCloseEditor: vi.fn(), + handleSaveHtml: vi.fn(), + textDirection: "ltr" as const, + cellLabel: "Test Label", + cellTimestamps: { startTime: 0, endTime: 5 }, + cellIsChild: false, + openCellById: vi.fn(), + cell: mockTranslationUnits[0], + isSaving: false, + saveError: false, + saveRetryCount: 0, + footnoteOffset: 1, + audioAttachments: { "cell-1": "none" as const }, + }; + + // Mic exists but permission denied — different state from no-device. + const getUserMediaSpy = vi.fn(); + mockMicEnvironment({ + audioInputs: 1, + permission: "denied", + getUserMediaSpy, + }); + + render( + + + + + + + + ); + + const startBtn = await screen.findByRole("button", { + name: /Microphone access denied/i, + }); + expect(startBtn.hasAttribute("disabled")).toBe(true); + // Permission-specific copy is shown, not the no-device copy. + expect( + await screen.findByText(/Enable microphone permissions/i) + ).toBeTruthy(); + expect( + screen.queryByText(/Connect an input device/i) + ).toBeNull(); + + fireEvent.click(startBtn); + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + + it("auto-start with no mic: does not display countdown or call getUserMedia", async () => { + // Simulate the cell-list mic-button auto-start handoff: a session + // flag is set before the editor opens. When the mic is unavailable + // the countdown must not appear and getUserMedia must not be called. + sessionStorage.setItem("preferred-editor-tab", "audio"); + sessionStorage.setItem("start-audio-recording-cell-1", "1"); + // Default to 3s countdown so we'd see digits if the suppression failed. + (window as any).__recordingCountdownSeconds = 3; + + const getUserMediaSpy = vi.fn(); + mockMicEnvironment({ + audioInputs: 0, + permission: "granted", + getUserMediaSpy, + }); + + const props = { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + editHistory: mockTranslationUnits[0].editHistory, + cellIndex: 0, + cellType: CodexCellTypes.TEXT, + contentBeingUpdated: { + cellMarkers: ["cell-1"], + cellContent: "

Test content

", + cellChanged: false, + }, + setContentBeingUpdated: vi.fn(), + handleCloseEditor: vi.fn(), + handleSaveHtml: vi.fn(), + textDirection: "ltr" as const, + cellLabel: "Test Label", + cellTimestamps: { startTime: 0, endTime: 5 }, + cellIsChild: false, + openCellById: vi.fn(), + cell: mockTranslationUnits[0], + isSaving: false, + saveError: false, + saveRetryCount: 0, + footnoteOffset: 1, + audioAttachments: { "cell-1": "none" as const }, + }; + + render( + + + + + + + + ); + + // Wait for the auto-start setTimeout (300ms) to fire and any + // subsequent renders to settle. + await screen.findByText(/Connect an input device to record/i); + await new Promise((r) => setTimeout(r, 400)); + + // No countdown digits should ever have rendered. + expect(screen.queryByText(/^[123]$/)).toBeNull(); + // The button must reflect the unavailable state, never the countdown. + expect( + screen.queryByRole("button", { name: /Starting in/i }) + ).toBeNull(); + // And of course the stream API must never have been touched. + expect(getUserMediaSpy).not.toHaveBeenCalled(); + }); + it("locked cell: should disable Start Recording and not call getUserMedia", async () => { sessionStorage.setItem("preferred-editor-tab", "audio"); diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts index 659648d1f..203f1ff4d 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts @@ -3,23 +3,50 @@ import { renderHook, waitFor, act } from "@testing-library/react"; import { useAudioInputDevices } from "../hooks/useAudioInputDevices"; /** - * Tests for the microphone detection hook used by the audio recorder. + * Tests for the microphone availability hook used by the audio recorder. * - * We back the mock with a real `EventTarget` so `devicechange` listeners - * can be exercised via genuine `dispatchEvent` calls — the same path the - * browser uses when a mic is plugged in or unplugged. + * Backed by real EventTargets so `devicechange` and PermissionStatus + * `change` events can be exercised via genuine `dispatchEvent` calls — + * the same path the browser uses on hot-plug or permission change. */ type FakeMediaDevices = EventTarget & { enumerateDevices: ReturnType; }; +type FakePermissionStatus = EventTarget & { state: "granted" | "denied" | "prompt" }; +type FakePermissions = { + query: ReturnType; +}; + function createFakeMediaDevices(devices: MediaDeviceInfo[]): FakeMediaDevices { const target = new EventTarget() as FakeMediaDevices; target.enumerateDevices = vi.fn().mockResolvedValue(devices); return target; } +function createFakePermissionStatus( + state: "granted" | "denied" | "prompt" +): FakePermissionStatus { + const status = new EventTarget() as FakePermissionStatus; + status.state = state; + return status; +} + +function createFakePermissions(status: FakePermissionStatus | null): FakePermissions { + return { + query: vi.fn().mockImplementation(async ({ name }: { name: string }) => { + if (name !== "microphone") { + throw new Error(`unsupported permission name: ${name}`); + } + if (!status) { + throw new Error("permission query not supported"); + } + return status; + }), + }; +} + function audioInputDevice(label = "Mock Mic"): MediaDeviceInfo { return { deviceId: `mock-${label}`, @@ -42,77 +69,122 @@ function videoInputDevice(): MediaDeviceInfo { describe("useAudioInputDevices", () => { const originalMediaDevices = (navigator as any).mediaDevices; - - beforeEach(() => { - delete (window as any).__forceNoAudioInput; - }); + const originalPermissions = (navigator as any).permissions; afterEach(() => { - // Always restore — some tests delete mediaDevices entirely. if (originalMediaDevices) { (navigator as any).mediaDevices = originalMediaDevices; } else { delete (navigator as any).mediaDevices; } + if (originalPermissions) { + (navigator as any).permissions = originalPermissions; + } else { + delete (navigator as any).permissions; + } }); - it("reports hasAudioInput=true when at least one audioinput device exists", async () => { + it("reports availability=available when a mic exists and permission is granted", async () => { (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); const { result } = renderHook(() => useAudioInputDevices()); - await waitFor(() => expect(result.current.isChecking).toBe(false)); - expect(result.current.hasAudioInput).toBe(true); - expect(result.current.isSupported).toBe(true); + await waitFor(() => expect(result.current.availability).toBe("available")); + expect(result.current.micUnavailable).toBe(false); + expect(result.current.noMicDetected).toBe(false); + expect(result.current.micPermissionDenied).toBe(false); }); - it("reports hasAudioInput=false when only non-audio devices are present", async () => { + it("reports no-device when only non-audio devices are present", async () => { (navigator as any).mediaDevices = createFakeMediaDevices([videoInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); const { result } = renderHook(() => useAudioInputDevices()); - await waitFor(() => expect(result.current.isChecking).toBe(false)); - expect(result.current.hasAudioInput).toBe(false); + await waitFor(() => expect(result.current.availability).toBe("no-device")); + expect(result.current.noMicDetected).toBe(true); + expect(result.current.micPermissionDenied).toBe(false); + expect(result.current.micUnavailable).toBe(true); }); - it("re-checks on devicechange when a mic is plugged in mid-session", async () => { + it("reports permission-denied even when a mic IS enumerated", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("denied") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => + expect(result.current.availability).toBe("permission-denied") + ); + expect(result.current.micPermissionDenied).toBe(true); + expect(result.current.noMicDetected).toBe(false); + expect(result.current.micUnavailable).toBe(true); + }); + + it("re-evaluates on devicechange when a mic is plugged in mid-session", async () => { const fake = createFakeMediaDevices([]); (navigator as any).mediaDevices = fake; + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); const { result } = renderHook(() => useAudioInputDevices()); - // Initial state: no audio input - await waitFor(() => expect(result.current.isChecking).toBe(false)); - expect(result.current.hasAudioInput).toBe(false); + await waitFor(() => expect(result.current.availability).toBe("no-device")); - // Simulate plugging in a mic: next enumerate returns a device, - // then the browser fires `devicechange`. fake.enumerateDevices.mockResolvedValueOnce([audioInputDevice("Plugged-in Mic")]); act(() => { fake.dispatchEvent(new Event("devicechange")); }); - await waitFor(() => expect(result.current.hasAudioInput).toBe(true)); + await waitFor(() => expect(result.current.availability).toBe("available")); }); - it("honors window.__forceNoAudioInput even when a real mic is enumerated", async () => { - (window as any).__forceNoAudioInput = true; + it("re-evaluates when microphone permission changes mid-session", async () => { (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + const status = createFakePermissionStatus("granted"); + (navigator as any).permissions = createFakePermissions(status); const { result } = renderHook(() => useAudioInputDevices()); - await waitFor(() => expect(result.current.isChecking).toBe(false)); - expect(result.current.hasAudioInput).toBe(false); + await waitFor(() => expect(result.current.availability).toBe("available")); + + // User revokes mic access via system settings. + status.state = "denied"; + act(() => { + status.dispatchEvent(new Event("change")); + }); + + await waitFor(() => + expect(result.current.availability).toBe("permission-denied") + ); }); - it("falls back to isSupported=false when enumerateDevices is missing", () => { + it("falls back to unsupported when enumerateDevices is missing", () => { (navigator as any).mediaDevices = {} as MediaDevices; const { result } = renderHook(() => useAudioInputDevices()); - expect(result.current.isSupported).toBe(false); - // Conservative default: assume present so we never false-positive disable. - expect(result.current.hasAudioInput).toBe(true); - expect(result.current.isChecking).toBe(false); + expect(result.current.availability).toBe("unsupported"); + // Treated as "assume available" so we never false-positive disable. + expect(result.current.micUnavailable).toBe(false); + }); + + it("treats missing permissions API as 'assume granted' (no false denial)", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + // No `navigator.permissions` at all. + delete (navigator as any).permissions; + + const { result } = renderHook(() => useAudioInputDevices()); + + await waitFor(() => expect(result.current.availability).toBe("available")); + expect(result.current.micPermissionDenied).toBe(false); }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts index 1e0cf8c5e..cbf1b8ac4 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts @@ -1,41 +1,97 @@ import { useEffect, useState } from "react"; /** - * Detects whether the user has at least one audio input device (microphone) - * available. Listens for `devicechange` events so the result updates live when - * a microphone is plugged in or unplugged — no need to reopen the audio tab. + * Detects whether the user can record audio right now. Reports separately + * for two different failure modes: + * - "no-device" — there is no audioinput hardware enumerated. The user + * needs to plug in / connect a microphone. + * - "permission-denied" — a device exists but the OS / browser blocks + * access. The user needs to grant microphone access in system settings. + * - "available" — at least one device exists and permission is not blocked. + * - "checking" — initial detection still in flight. + * - "unsupported" — the necessary APIs aren't available. Treated as + * "assume available" by callers so we never false-positive disable on a + * working machine. * - * Behavior notes: - * - When `navigator.mediaDevices.enumerateDevices` is unavailable (very old - * browsers, restricted contexts), `isSupported` returns `false` and the - * caller should treat the result as "unknown" rather than "no device". - * We default `hasAudioInput` to `true` in that case so we never - * false-positive disable recording on a working machine. - * - Devices show up in `enumerateDevices()` even when the user hasn't yet - * granted microphone permission (their labels are blank, but the entries - * still count). That's exactly what we want: we're checking for hardware - * presence, not permission state. + * Reacts live to two signals so the UI stays in sync without a reload: + * - `devicechange` on `navigator.mediaDevices` (mic plugged / unplugged) + * - `change` on the `microphone` PermissionStatus (user grants/revokes + * access via the address bar or system settings) * - * Debug-only override: - * Set `window.__forceNoAudioInput = true` in the webview devtools to - * simulate "no microphone detected" without unplugging hardware. Useful for - * QA / styling work on machines that always have a working mic. Safe to - * leave in — it does nothing unless explicitly set. + * Devices typically show up in `enumerateDevices()` even before the user has + * granted permission (their labels are blank, but the entries still count). + * That means device count alone can't tell us if access is *blocked* — only + * the permissions API can. We query both independently. + * + * ───────────────────────────────────────────────────────────────────────── + * REVIEWER NOTE — testing without unplugging your microphone: + * + * Edit the `FORCE_STATE_FOR_REVIEW` constant below to force a specific + * state regardless of real hardware. Rebuild the webview + * (`pnpm run build:CodexCellEditor`) and reload the extension host to + * see the corresponding UI. **Set back to `null` before committing.** + * + * FORCE_STATE_FOR_REVIEW = "no-device" // grey slashed-mic + "no microphone detected" alert + * FORCE_STATE_FOR_REVIEW = "permission-denied" // grey slashed-mic + "access denied" alert + * FORCE_STATE_FOR_REVIEW = null // real detection (default) + * ───────────────────────────────────────────────────────────────────────── */ + +/** Override real detection for visual review. MUST be `null` in committed code. */ +const FORCE_STATE_FOR_REVIEW: MicAvailability | null = 'permission-denied';//null; + +export type MicAvailability = + | "available" + | "checking" + | "no-device" + | "permission-denied" + | "unsupported"; + export interface UseAudioInputDevicesResult { - /** True when at least one `audioinput` device exists. Defaults to `true` - * while still checking and when the API is unsupported. */ - hasAudioInput: boolean; - /** True during the initial enumeration (before we've heard back once). */ - isChecking: boolean; - /** False when `navigator.mediaDevices.enumerateDevices` isn't available. */ - isSupported: boolean; + /** High-level state for branching UI. */ + availability: MicAvailability; + /** True when the user can't record (no device or permission denied). */ + micUnavailable: boolean; + /** True specifically when no audioinput hardware exists. */ + noMicDetected: boolean; + /** True specifically when permission is denied for an existing mic. */ + micPermissionDenied: boolean; } -declare global { - interface Window { - /** DEV/QA only: force `useAudioInputDevices` to report no microphone. */ - __forceNoAudioInput?: boolean; +type PermissionState = "granted" | "denied" | "prompt" | "unknown"; + +async function queryMicPermission(): Promise { + try { + if (typeof navigator === "undefined" || !navigator.permissions?.query) { + return "unknown"; + } + // `microphone` isn't in TS's PermissionName union in all lib versions. + const status = (await navigator.permissions.query({ + name: "microphone" as PermissionName, + })) as PermissionStatus; + return status.state as PermissionState; + } catch { + // Some environments (Safari, restricted contexts) throw for + // unsupported permission names. Treat as "we don't know". + return "unknown"; + } +} + +async function subscribeToMicPermission( + onChange: (state: PermissionState) => void +): Promise<(() => void) | null> { + try { + if (typeof navigator === "undefined" || !navigator.permissions?.query) { + return null; + } + const status = (await navigator.permissions.query({ + name: "microphone" as PermissionName, + })) as PermissionStatus; + const handler = () => onChange(status.state as PermissionState); + status.addEventListener("change", handler); + return () => status.removeEventListener("change", handler); + } catch { + return null; } } @@ -45,62 +101,81 @@ export function useAudioInputDevices(): UseAudioInputDevicesResult { !!navigator.mediaDevices && typeof navigator.mediaDevices.enumerateDevices === "function"; - const [hasAudioInput, setHasAudioInput] = useState(true); - const [isChecking, setIsChecking] = useState(isSupported); + const [availability, setAvailability] = useState( + isSupported ? "checking" : "unsupported" + ); useEffect(() => { + if (FORCE_STATE_FOR_REVIEW !== null) { + setAvailability(FORCE_STATE_FOR_REVIEW); + return; + } + if (!isSupported) { - setIsChecking(false); + setAvailability("unsupported"); return; } - // Capture the `mediaDevices` reference at mount so the cleanup path - // can always remove its own listener — even if something later swaps - // `navigator.mediaDevices` (tests, mocks, hot-reload). + // Capture `mediaDevices` at mount so the cleanup path can remove its + // listener even if the global is later swapped (tests, hot-reload). const mediaDevices = navigator.mediaDevices; let cancelled = false; - const checkDevices = async () => { + const evaluate = async () => { try { - if (window.__forceNoAudioInput) { - if (!cancelled) { - setHasAudioInput(false); - setIsChecking(false); - } + const [devices, permission] = await Promise.all([ + mediaDevices.enumerateDevices(), + queryMicPermission(), + ]); + if (cancelled) return; + + if (permission === "denied") { + setAvailability("permission-denied"); return; } - const devices = await mediaDevices.enumerateDevices(); const audioInputs = devices.filter((d) => d.kind === "audioinput"); - if (!cancelled) { - setHasAudioInput(audioInputs.length > 0); - setIsChecking(false); - } + setAvailability(audioInputs.length > 0 ? "available" : "no-device"); } catch (err) { - console.warn("useAudioInputDevices: enumerateDevices failed", err); + console.warn("useAudioInputDevices: detection failed", err); if (!cancelled) { - // On enumeration failure, fall back to "assume present" so + // On detection failure, fall back to "assume present" so // we don't disable recording on a machine that may well // have a working mic. The actual `getUserMedia` call will // surface a clearer error if recording is then attempted. - setHasAudioInput(true); - setIsChecking(false); + setAvailability("available"); } } }; - checkDevices(); - - const handleDeviceChange = () => { - checkDevices(); - }; + evaluate(); + const handleDeviceChange = () => evaluate(); mediaDevices.addEventListener("devicechange", handleDeviceChange); + let unsubscribePermission: (() => void) | null = null; + subscribeToMicPermission(() => evaluate()).then((unsub) => { + if (cancelled) { + unsub?.(); + return; + } + unsubscribePermission = unsub; + }); + return () => { cancelled = true; mediaDevices.removeEventListener("devicechange", handleDeviceChange); + unsubscribePermission?.(); }; }, [isSupported]); - return { hasAudioInput, isChecking, isSupported }; + const noMicDetected = availability === "no-device"; + const micPermissionDenied = availability === "permission-denied"; + const micUnavailable = noMicDetected || micPermissionDenied; + + return { + availability, + micUnavailable, + noMicDetected, + micPermissionDenied, + }; } From 2c8b784cd7a859a11dce05241c5f89b67b11ee91 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Tue, 2 Jun 2026 15:51:22 -0500 Subject: [PATCH 4/4] fix(audio): detect OS-level mic denial via getUserMedia error classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium's Permissions API only reports browser-level state, not OS-level permission. On macOS, for example, denying microphone access in System Settings still returns "granted" from navigator.permissions.query — so the hook never flipped to permission-denied without an actual record attempt. Expose `reportRecorderError(err)` from useAudioInputDevices and call it from startActualRecording's catch block. The hook classifies common error names (NotAllowedError, NotFoundError, etc.) and pins availability to the matching state. A successful getUserMedia clears the override so the UI recovers automatically after the user fixes permissions and retries. --- .../src/CodexCellEditor/TextCellEditor.tsx | 30 +++++- .../__tests___/useAudioInputDevices.test.ts | 100 ++++++++++++++++++ .../hooks/useAudioInputDevices.ts | 79 ++++++++++++-- 3 files changed, 199 insertions(+), 10 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx index a88ad0a8d..ea402a2c4 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx @@ -540,7 +540,8 @@ const CellEditor: React.FC = ({ // `hooks/useAudioInputDevices.ts` (top of file) for the in-code constant // reviewers can flip to simulate "no microphone" / "permission denied" // on machines with a working mic. - const { noMicDetected, micPermissionDenied, micUnavailable } = useAudioInputDevices(); + const { noMicDetected, micPermissionDenied, micUnavailable, reportRecorderError } = + useAudioInputDevices(); // Mirror `micUnavailable` into a ref so guards inside callbacks (and // setTimeout-deferred work like the auto-start path) can read the // latest value without becoming stale closures. @@ -548,6 +549,14 @@ const CellEditor: React.FC = ({ useEffect(() => { micUnavailableRef.current = micUnavailable; }, [micUnavailable]); + // Ref for the runtime classifier so `startActualRecording`'s deps stay + // stable. `startActualRecording` is wrapped in `useCallback([audioUrl])` + // and changing its deps to include a new function on every render would + // ripple through several effects. + const reportRecorderErrorRef = useRef(reportRecorderError); + useEffect(() => { + reportRecorderErrorRef.current = reportRecorderError; + }, [reportRecorderError]); const centerEditor = useCallback(() => { const el = cellEditorRef.current; @@ -789,6 +798,12 @@ const CellEditor: React.FC = ({ }, }); + // Successful stream acquisition is the ground-truth signal that + // mic access works. Clear any previous runtime-reported denial so + // the UI recovers without waiting for a (potentially unreliable) + // OS-level permission event. + reportRecorderErrorRef.current?.(null); + const mediaRecorderOptions: MediaRecorderOptions = {}; try { if (typeof MediaRecorder !== "undefined") { @@ -862,7 +877,18 @@ const CellEditor: React.FC = ({ recorder.start(); setMediaRecorder(recorder); } catch (err) { - setRecordingStatus("Microphone access denied"); + // Promote the raw error to a typed availability state so the UI + // flips to the right warning (grey slashed mic + "Access denied" + // alert for `NotAllowedError`, "No microphone detected" for + // `NotFoundError`). The hook ignores unrecognized error names so + // transient failures don't accidentally disable recording. + reportRecorderErrorRef.current?.(err); + const errName = (err as { name?: string } | null)?.name; + const statusMessage = + errName === "NotFoundError" || errName === "DevicesNotFoundError" + ? "No microphone detected" + : "Microphone access denied"; + setRecordingStatus(statusMessage); console.error("Error accessing microphone:", err); setCountdown(null); setIsStartingRecording(false); diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts index 203f1ff4d..cdb7aba1f 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useAudioInputDevices.test.ts @@ -187,4 +187,104 @@ describe("useAudioInputDevices", () => { await waitFor(() => expect(result.current.availability).toBe("available")); expect(result.current.micPermissionDenied).toBe(false); }); + + describe("reportRecorderError (runtime override)", () => { + it("flips to permission-denied on NotAllowedError even when Permissions API reports granted", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + // This is the macOS "denied at OS level" case: passive APIs + // report granted, but getUserMedia throws. + const err = Object.assign(new Error("denied"), { + name: "NotAllowedError", + }); + act(() => { + result.current.reportRecorderError(err); + }); + + expect(result.current.availability).toBe("permission-denied"); + expect(result.current.micPermissionDenied).toBe(true); + }); + + it("flips to no-device on NotFoundError even when enumeration reported a device", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + const err = Object.assign(new Error("missing"), { name: "NotFoundError" }); + act(() => { + result.current.reportRecorderError(err); + }); + + expect(result.current.availability).toBe("no-device"); + }); + + it("clears the runtime override when called with null (e.g. after a successful retry)", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + act(() => { + result.current.reportRecorderError( + Object.assign(new Error(), { name: "NotAllowedError" }) + ); + }); + expect(result.current.availability).toBe("permission-denied"); + + act(() => { + result.current.reportRecorderError(null); + }); + expect(result.current.availability).toBe("available"); + }); + + it("ignores unrecognized error names so transient failures don't disable recording", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + act(() => { + result.current.reportRecorderError( + Object.assign(new Error("weird"), { name: "AbortError" }) + ); + }); + + expect(result.current.availability).toBe("available"); + }); + + it("runtime override outranks the passive Permissions API state", async () => { + (navigator as any).mediaDevices = createFakeMediaDevices([audioInputDevice()]); + // Passive layer says granted... + (navigator as any).permissions = createFakePermissions( + createFakePermissionStatus("granted") + ); + + const { result } = renderHook(() => useAudioInputDevices()); + await waitFor(() => expect(result.current.availability).toBe("available")); + + // ...but runtime got denied. Runtime wins. + act(() => { + result.current.reportRecorderError( + Object.assign(new Error(), { name: "NotAllowedError" }) + ); + }); + expect(result.current.availability).toBe("permission-denied"); + }); + }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts index cbf1b8ac4..25f790c83 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useAudioInputDevices.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; /** * Detects whether the user can record audio right now. Reports separately @@ -13,15 +13,21 @@ import { useEffect, useState } from "react"; * "assume available" by callers so we never false-positive disable on a * working machine. * - * Reacts live to two signals so the UI stays in sync without a reload: + * Reacts live to three signals so the UI stays in sync without a reload: * - `devicechange` on `navigator.mediaDevices` (mic plugged / unplugged) * - `change` on the `microphone` PermissionStatus (user grants/revokes - * access via the address bar or system settings) + * access via the address bar or browser-level settings) + * - Runtime reports from the recorder via `reportRecorderError(err)` — the + * only reliable way to observe an OS-level block on macOS / Linux (the + * Permissions API only reflects browser-level state, not OS settings, + * so a user who denies in System Settings will still see `"granted"` + * from `navigator.permissions.query`; we only learn the real state + * when `getUserMedia()` throws `NotAllowedError`). * * Devices typically show up in `enumerateDevices()` even before the user has * granted permission (their labels are blank, but the entries still count). * That means device count alone can't tell us if access is *blocked* — only - * the permissions API can. We query both independently. + * the permissions API or a real `getUserMedia()` attempt can. * * ───────────────────────────────────────────────────────────────────────── * REVIEWER NOTE — testing without unplugging your microphone: @@ -38,7 +44,7 @@ import { useEffect, useState } from "react"; */ /** Override real detection for visual review. MUST be `null` in committed code. */ -const FORCE_STATE_FOR_REVIEW: MicAvailability | null = 'permission-denied';//null; +const FORCE_STATE_FOR_REVIEW: MicAvailability | null = null; export type MicAvailability = | "available" @@ -56,6 +62,15 @@ export interface UseAudioInputDevicesResult { noMicDetected: boolean; /** True specifically when permission is denied for an existing mic. */ micPermissionDenied: boolean; + /** + * Call from the recorder's `getUserMedia` catch block with the thrown + * error. The hook classifies the error name (`NotAllowedError`, + * `NotFoundError`, etc.) and updates `availability` accordingly. This + * is the only reliable path for catching OS-level mic blocks where the + * Permissions API misreports. Pass `null` to clear a runtime-set state + * (e.g. after a successful `getUserMedia` call). + */ + reportRecorderError: (err: unknown | null) => void; } type PermissionState = "granted" | "denied" | "prompt" | "unknown"; @@ -95,6 +110,31 @@ async function subscribeToMicPermission( } } +/** + * Map a `getUserMedia` rejection to a `MicAvailability` value. Returns `null` + * for transient or unknown errors so the caller leaves the state untouched. + * + * The `name` property is set by the browser per the WebRTC spec and is the + * supported way to distinguish these cases. Older browsers used different + * names (e.g. `DevicesNotFoundError`), so we accept the most common aliases. + */ +function classifyRecorderError(err: unknown): MicAvailability | null { + if (!err || typeof err !== "object") return null; + const name = (err as { name?: string }).name; + switch (name) { + case "NotAllowedError": + case "PermissionDeniedError": // Legacy Chromium alias. + case "SecurityError": + return "permission-denied"; + case "NotFoundError": + case "DevicesNotFoundError": // Legacy alias. + case "OverconstrainedError": + return "no-device"; + default: + return null; + } +} + export function useAudioInputDevices(): UseAudioInputDevicesResult { const isSupported = typeof navigator !== "undefined" && @@ -104,6 +144,12 @@ export function useAudioInputDevices(): UseAudioInputDevicesResult { const [availability, setAvailability] = useState( isSupported ? "checking" : "unsupported" ); + // When the recorder reports an error, we pin the state until cleared. + // Passive signals (`devicechange`, permission `change`) won't override it, + // because on macOS those signals are unreliable for OS-level mic blocks. + // The recorder calls `reportRecorderError(null)` after a successful + // `getUserMedia` to release the pin. + const [runtimeOverride, setRuntimeOverride] = useState(null); useEffect(() => { if (FORCE_STATE_FOR_REVIEW !== null) { @@ -168,14 +214,31 @@ export function useAudioInputDevices(): UseAudioInputDevicesResult { }; }, [isSupported]); - const noMicDetected = availability === "no-device"; - const micPermissionDenied = availability === "permission-denied"; + const reportRecorderError = useCallback((err: unknown | null) => { + if (err === null) { + setRuntimeOverride(null); + return; + } + const classified = classifyRecorderError(err); + if (classified) setRuntimeOverride(classified); + }, []); + + // Runtime override (from a real `getUserMedia` failure) takes precedence + // because it reflects ground truth, not Chromium's stale view of OS + // permissions. The dev-only `FORCE_STATE_FOR_REVIEW` still wins above + // everything for visual testing. + const effectiveAvailability: MicAvailability = + FORCE_STATE_FOR_REVIEW ?? runtimeOverride ?? availability; + + const noMicDetected = effectiveAvailability === "no-device"; + const micPermissionDenied = effectiveAvailability === "permission-denied"; const micUnavailable = noMicDetected || micPermissionDenied; return { - availability, + availability: effectiveAvailability, micUnavailable, noMicDetected, micPermissionDenied, + reportRecorderError, }; }