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.'
+ )}
+
+ )}