diff --git a/src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx b/src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx new file mode 100644 index 00000000000..c9a55361281 --- /dev/null +++ b/src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx @@ -0,0 +1,274 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DeveloperPermissionRequestResult } from '../../../../shared/developer-permissions-types' +import type { VoiceSettings } from '../../../../shared/speech-types' + +// Why: repo convention — React only suppresses its act() warning when this global is set. +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const mocks = vi.hoisted(() => ({ toastSuccess: vi.fn(), toastError: vi.fn() })) + +vi.mock('sonner', () => ({ + toast: { success: mocks.toastSuccess, error: mocks.toastError, message: vi.fn() } +})) + +import { VoiceMicrophoneSetting } from './VoiceMicrophoneSetting' + +const voiceSettings = { + enabled: true, + microphoneDeviceId: null, + microphoneDeviceLabel: null +} as VoiceSettings + +function namedError(name: string, message = 'boom'): Error { + const error = new Error(message) + error.name = name + return error +} + +function installMediaDevices(getUserMedia: () => Promise): void { + Object.assign(navigator, { + mediaDevices: { + getUserMedia: vi.fn(getUserMedia), + enumerateDevices: vi.fn(async () => []), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + }) +} + +function installPermissionsApi(result: DeveloperPermissionRequestResult | Error): void { + Object.assign(window, { + api: { + developerPermissions: { + request: vi.fn(async () => { + if (result instanceof Error) { + throw result + } + return result + }) + } + } + }) +} + +let container: HTMLDivElement +let root: Root + +async function renderSetting(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root.render( + {}} /> + ) + }) +} + +async function clickAllowAccess(): Promise { + const button = Array.from(container.querySelectorAll('button')).find( + (candidate) => candidate.textContent === 'Allow access' + ) + if (!button) { + throw new Error('Allow access button not rendered') + } + await act(async () => { + button.click() + }) +} + +function alertText(): string { + return container.querySelector('[role="alert"]')?.textContent ?? '' +} + +describe('VoiceMicrophoneSetting access failures', () => { + beforeEach(() => { + vi.clearAllMocks() + installPermissionsApi({ id: 'microphone', status: 'denied', openedSystemSettings: false }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('routes a denied getUserMedia to the OS permission request and says where to grant it', async () => { + installMediaDevices(async () => { + throw new DOMException('Permission denied', 'NotAllowedError') + }) + + await renderSetting() + await clickAllowAccess() + + expect(window.api.developerPermissions.request).toHaveBeenCalledWith({ id: 'microphone' }) + expect(alertText()).toBe( + 'Microphone access is blocked. Grant it in your system settings, then try again.' + ) + }) + + it('points at Privacy & Security once the request opened it', async () => { + installMediaDevices(async () => { + throw namedError('NotAllowedError') + }) + installPermissionsApi({ id: 'microphone', status: 'denied', openedSystemSettings: true }) + + await renderSetting() + await clickAllowAccess() + + expect(alertText()).toBe( + 'Opened macOS Privacy & Security. Grant microphone access, then try again.' + ) + }) + + it('still reports a block on platforms where the OS request is unsupported', async () => { + installMediaDevices(async () => { + throw namedError('NotAllowedError') + }) + installPermissionsApi({ id: 'microphone', status: 'unsupported', openedSystemSettings: false }) + + await renderSetting() + await clickAllowAccess() + + expect(alertText()).toBe( + 'Microphone access is blocked. Grant it in your system settings, then try again.' + ) + }) + + it('names the missing-hardware case instead of a permission instruction', async () => { + installMediaDevices(async () => { + throw namedError('NotFoundError') + }) + + await renderSetting() + await clickAllowAccess() + + expect(window.api.developerPermissions.request).not.toHaveBeenCalled() + expect(alertText()).toBe('No microphone was found. Connect one, then try again.') + }) + + it('keeps the underlying detail for an unclassified failure', async () => { + installMediaDevices(async () => { + throw namedError('AbortError', 'Could not start audio source') + }) + + await renderSetting() + await clickAllowAccess() + + expect(alertText()).toBe('Could not open the microphone. Could not start audio source') + }) + + it('shows the plain hint until something actually fails', async () => { + installMediaDevices(async () => ({ getTracks: () => [] }) as unknown as MediaStream) + + await renderSetting() + + expect(container.querySelector('[role="alert"]')).toBeNull() + expect(container.textContent).toContain('Allow microphone access to list input devices.') + + await clickAllowAccess() + + expect(container.querySelector('[role="alert"]')).toBeNull() + }) + + it('opens a stream after the OS grant so the device list is not left empty', async () => { + let calls = 0 + let streamOpened = false + const getUserMedia = vi.fn(async () => { + calls += 1 + // Why: the first attempt is what triggers the OS prompt; the grant must re-open a stream, + // because enumerateDevices hides labels until one has been opened in this renderer. + if (calls === 1) { + throw namedError('NotAllowedError') + } + streamOpened = true + return { getTracks: () => [] } as unknown as MediaStream + }) + Object.assign(navigator, { + mediaDevices: { + getUserMedia, + // Why: mirrors the real rule the fix exists for — no labels until a stream has been opened. + enumerateDevices: vi.fn(async () => + streamOpened + ? [{ kind: 'audioinput', deviceId: 'mic-1', label: 'Built-in Microphone' }] + : [] + ), + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + }) + installPermissionsApi({ id: 'microphone', status: 'granted', openedSystemSettings: false }) + + await renderSetting() + await clickAllowAccess() + + expect(getUserMedia).toHaveBeenCalledTimes(2) + expect(mocks.toastSuccess).toHaveBeenCalledTimes(1) + expect(container.querySelector('[role="alert"]')).toBeNull() + // Why: the grant is only useful if the list it unblocks actually fills in — the hint and its + // Allow access button are what the pane shows while no device is known. + expect(container.textContent).not.toContain('Allow microphone access to list input devices.') + }) + + it('keeps a second browser denial classified as a permission error', async () => { + installMediaDevices(async () => { + throw new DOMException('Permission denied', 'NotAllowedError') + }) + installPermissionsApi({ id: 'microphone', status: 'granted', openedSystemSettings: false }) + + await renderSetting() + await clickAllowAccess() + + expect(alertText()).toBe( + 'Microphone access is blocked. Grant it in your system settings, then try again.' + ) + expect(mocks.toastSuccess).not.toHaveBeenCalled() + }) + + it('names the missing-hardware case for the legacy DevicesNotFoundError alias', async () => { + installMediaDevices(async () => { + throw namedError('DevicesNotFoundError') + }) + + await renderSetting() + await clickAllowAccess() + + expect(alertText()).toBe('No microphone was found. Connect one, then try again.') + }) + + it('treats SecurityError as a permission denial, like NotAllowedError', async () => { + installMediaDevices(async () => { + throw namedError('SecurityError') + }) + + await renderSetting() + await clickAllowAccess() + + expect(window.api.developerPermissions.request).toHaveBeenCalledWith({ id: 'microphone' }) + expect(alertText()).toBe( + 'Microphone access is blocked. Grant it in your system settings, then try again.' + ) + }) + + it('reports a failed permission REQUEST as such, with the IPC wrapper stripped', async () => { + installMediaDevices(async () => { + throw namedError('NotAllowedError') + }) + installPermissionsApi( + new Error( + "Error invoking remote method 'developerPermissions:request': Error: xdg-open not found" + ) + ) + + await renderSetting() + await clickAllowAccess() + + // Why: the microphone was never reopened — calling this a microphone-open failure would invert + // the provenance, and the raw transport prefix must never reach the pane. + expect(alertText()).toBe('xdg-open not found') + expect(alertText()).not.toContain('Error invoking remote method') + }) +}) diff --git a/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx b/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx index b5dd245db11..a2388a41485 100644 --- a/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx +++ b/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx @@ -1,21 +1,62 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' import type { VoiceSettings } from '../../../../shared/speech-types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { + buildAudioCaptureConstraints, buildVoiceMicrophoneSelectOptions, listVoiceMicrophoneDevices, microphoneDeviceIdFromSelectValue, type VoiceMicrophoneDevice } from '@/components/dictation/microphone-devices' +import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' +import { extractIpcErrorMessage } from '@/lib/ipc-error' type VoiceMicrophoneSettingProps = { voiceSettings: VoiceSettings onUpdateVoiceSettings: (updates: Partial) => void } +// Why read the DOMException directly: a getUserMedia rejection never crossed IPC, so unwrapping it +// with the IPC reader would assert the wrong provenance. +function isMicrophonePermissionDenied(error: unknown): boolean { + const name = error instanceof Error ? error.name : '' + return name === 'NotAllowedError' || name === 'SecurityError' +} + +function microphoneAccessErrorMessage(error: unknown): string { + const name = error instanceof Error ? error.name : '' + const message = error instanceof Error ? error.message : undefined + if (name === 'NotAllowedError' || name === 'SecurityError') { + return translate( + 'auto.components.settings.VoiceMicrophoneSetting.permissionDenied', + 'Microphone access is blocked. Grant it in your system settings, then try again.' + ) + } + if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { + return translate( + 'auto.components.settings.VoiceMicrophoneSetting.noMicrophoneFound', + 'No microphone was found. Connect one, then try again.' + ) + } + // Why: a getUserMedia rejection never crossed IPC, so the IPC reader would assert the wrong + // provenance — read the DOMException message directly. + const detail = message?.trim() || undefined + return detail + ? translate( + 'auto.components.settings.VoiceMicrophoneSetting.openFailedDetail', + 'Could not open the microphone. {{value0}}', + { value0: detail } + ) + : translate( + 'auto.components.settings.VoiceMicrophoneSetting.openFailed', + 'Could not open the microphone.' + ) +} + function sameDeviceList( a: readonly VoiceMicrophoneDevice[], b: readonly VoiceMicrophoneDevice[] @@ -36,18 +77,12 @@ export function VoiceMicrophoneSetting({ const [devices, setDevices] = useState([]) const [devicesKnown, setDevicesKnown] = useState(false) const [accessPending, setAccessPending] = useState(false) - const mountedRef = useRef(true) + const [accessError, setAccessError] = useState(null) + const mountedRef = useMountedRef() // Why: devicechange fires several times per Bluetooth connect; drop enumerations // that resolve out of order so a stale list cannot land last. const refreshGenerationRef = useRef(0) - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - const refreshDevices = useCallback(async (): Promise => { const generation = refreshGenerationRef.current + 1 refreshGenerationRef.current = generation @@ -65,7 +100,7 @@ export function VoiceMicrophoneSetting({ } setDevicesKnown(next.length > 0) setDevices((current) => (sameDeviceList(current, next) ? current : next)) - }, []) + }, [mountedRef]) // Why: voiceSettings.enabled is a dependency so enabling dictation re-scans — // that toggle is often when mic permission lands and real labels appear. @@ -83,25 +118,89 @@ export function VoiceMicrophoneSetting({ } }, [refreshDevices, voiceSettings.enabled]) - // Why: enumerateDevices hides ids and labels until mic permission is granted, so - // the list stays empty until something opens a stream at least once. + // Why: enumerateDevices hides ids and labels until a stream has been opened in THIS renderer, + // so refreshing alone leaves the list empty even once permission is granted. + const openStreamAndRefreshDevices = useCallback(async (): Promise => { + // Why the preferred device: probing the one the user actually selected surfaces a device-specific + // failure here rather than at dictation time. + const stream = await navigator.mediaDevices.getUserMedia( + buildAudioCaptureConstraints(voiceSettings.microphoneDeviceId) + ) + stream.getTracks().forEach((track) => track.stop()) + await refreshDevices() + }, [refreshDevices, voiceSettings.microphoneDeviceId]) + const requestMicrophoneAccess = useCallback(async (): Promise => { if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { return } setAccessPending(true) + setAccessError(null) try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) - stream.getTracks().forEach((track) => track.stop()) - await refreshDevices() - } catch { - // Denied or unavailable — the hint stays visible so the user can retry. + try { + await openStreamAndRefreshDevices() + return + } catch (error) { + if (!isMicrophonePermissionDenied(error)) { + throw error + } + } + + let result: Awaited> + try { + result = await window.api.developerPermissions.request({ id: 'microphone' }) + } catch (error) { + // Why separate: this one DID cross IPC, so the wrapper must be stripped — and the microphone + // was never reopened, so reporting it as an open failure would invert the provenance. + if (mountedRef.current) { + setAccessError( + extractIpcErrorMessage( + error, + translate( + 'auto.components.settings.VoicePane.ad5d036ecc', + 'Could not request microphone permission. Voice dictation was not enabled.' + ) + ) + ) + } + return + } + if (!mountedRef.current) { + return + } + if (result.status !== 'granted') { + setAccessError( + result.openedSystemSettings + ? translate( + 'auto.components.settings.VoiceMicrophoneSetting.openedSystemSettings', + 'Opened macOS Privacy & Security. Grant microphone access, then try again.' + ) + : translate( + 'auto.components.settings.VoiceMicrophoneSetting.permissionDenied', + 'Microphone access is blocked. Grant it in your system settings, then try again.' + ) + ) + return + } + await openStreamAndRefreshDevices() + if (mountedRef.current) { + toast.success( + translate( + 'auto.components.settings.VoicePane.cd9fe37556', + 'Microphone permission granted' + ) + ) + } + } catch (error) { + if (mountedRef.current) { + setAccessError(microphoneAccessErrorMessage(error)) + } } finally { if (mountedRef.current) { setAccessPending(false) } } - }, [refreshDevices]) + }, [mountedRef, openStreamAndRefreshDevices]) const { options, selectedValue } = useMemo( () => @@ -138,12 +237,18 @@ export function VoiceMicrophoneSetting({

{showAccessHint && (
-

- {translate( - 'auto.components.settings.VoiceMicrophoneSetting.accessHint', - 'Allow microphone access to list input devices.' - )} -

+ {accessError ? ( +

+ {accessError} +

+ ) : ( +

+ {translate( + 'auto.components.settings.VoiceMicrophoneSetting.accessHint', + 'Allow microphone access to list input devices.' + )} +

+ )}