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..4bdd5dfc8b9 --- /dev/null +++ b/src/renderer/src/components/settings/VoiceMicrophoneSetting.test.tsx @@ -0,0 +1,310 @@ +// @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 { getDefaultVoiceSettings } from '../../../../shared/constants' +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: VoiceSettings = { + ...getDefaultVoiceSettings(), + enabled: true +} + +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(settings: VoiceSettings = voiceSettings): 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('never renders a literal "undefined" when the error message is absent', async () => { + installMediaDevices(async () => { + throw { name: 'AbortError', message: undefined } + }) + + await renderSetting() + await clickAllowAccess() + + expect(alertText()).toBe('Could not open the microphone.') + }) + + it('shows the plain hint until something actually fails', async () => { + installMediaDevices(async () => ({ getTracks: () => [] })) + + 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('uses a generic stream when the saved microphone is stale', async () => { + const getUserMedia = vi.fn(async () => ({ getTracks: () => [] })) + installMediaDevices(getUserMedia) + + await renderSetting({ + ...voiceSettings, + microphoneDeviceId: 'unplugged-mic', + microphoneDeviceLabel: 'Old headset' + }) + await clickAllowAccess() + + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }) + }) + + it('classifies browser-shaped permission errors without requiring Error identity', async () => { + installMediaDevices(async () => { + throw { name: 'NotAllowedError', message: 'Permission denied' } + }) + + await renderSetting() + await clickAllowAccess() + + expect(window.api.developerPermissions.request).toHaveBeenCalledWith({ id: 'microphone' }) + }) + + 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: () => [] } + }) + 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..36a74474477 100644 --- a/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx +++ b/src/renderer/src/components/settings/VoiceMicrophoneSetting.tsx @@ -1,4 +1,5 @@ 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' @@ -9,13 +10,57 @@ import { 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 } +function readMediaDeviceError(error: unknown): { name: string; message?: string } { + if (!error || typeof error !== 'object') { + return { name: '' } + } + // Why: an own `name`/`message` key can hold undefined/null; String() would + // turn that into the literal "undefined" and render it to the user. + const name = 'name' in error ? String(error.name ?? '') : '' + const message = 'message' in error ? String(error.message ?? '').trim() || undefined : undefined + return { name, message } +} + +function isMicrophonePermissionDenied(error: unknown): boolean { + const { name } = readMediaDeviceError(error) + return name === 'NotAllowedError' || name === 'SecurityError' +} + +function microphoneAccessErrorMessage(error: unknown): string { + const { name, message } = readMediaDeviceError(error) + 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.' + ) + } + return message + ? translate( + 'auto.components.settings.VoiceMicrophoneSetting.openFailedDetail', + 'Could not open the microphone. {{value0}}', + { value0: message } + ) + : translate( + 'auto.components.settings.VoiceMicrophoneSetting.openFailed', + 'Could not open the microphone.' + ) +} + function sameDeviceList( a: readonly VoiceMicrophoneDevice[], b: readonly VoiceMicrophoneDevice[] @@ -36,18 +81,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 +104,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 +122,84 @@ 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. + // A generic stream grants discovery even when the saved device is stale. + const openStreamAndRefreshDevices = useCallback(async (): Promise => { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + stream.getTracks().forEach((track) => track.stop()) + await refreshDevices() + }, [refreshDevices]) + 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 +236,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.' + )} +

+ )}