fix(settings): say when the microphone is blocked and where to grant it

This commit is contained in:
Jinjing
2026-09-14 15:06:32 -07:00
parent d6285bd5a7
commit 117f9d70e6
3 changed files with 408 additions and 24 deletions
@@ -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<MediaStream>): 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<void> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root.render(
<VoiceMicrophoneSetting voiceSettings={voiceSettings} onUpdateVoiceSettings={() => {}} />
)
})
}
async function clickAllowAccess(): Promise<void> {
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')
})
})
@@ -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<VoiceSettings>) => 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<VoiceMicrophoneDevice[]>([])
const [devicesKnown, setDevicesKnown] = useState(false)
const [accessPending, setAccessPending] = useState(false)
const mountedRef = useRef(true)
const [accessError, setAccessError] = useState<string | null>(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<void> => {
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<void> => {
// 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<void> => {
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<ReturnType<typeof window.api.developerPermissions.request>>
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({
</p>
{showAccessHint && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.VoiceMicrophoneSetting.accessHint',
'Allow microphone access to list input devices.'
)}
</p>
{accessError ? (
<p className="text-xs text-destructive" role="alert">
{accessError}
</p>
) : (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.VoiceMicrophoneSetting.accessHint',
'Allow microphone access to list input devices.'
)}
</p>
)}
<Button
variant="outline"
size="sm"
+6 -1
View File
@@ -11622,7 +11622,12 @@
"label": "Microphone",
"description": "Input device used for voice dictation. System default follows the OS microphone setting.",
"accessHint": "Allow microphone access to list input devices.",
"allowAccess": "Allow access"
"allowAccess": "Allow access",
"noMicrophoneFound": "No microphone was found. Connect one, then try again.",
"openFailedDetail": "Could not open the microphone. {{value0}}",
"openFailed": "Could not open the microphone.",
"openedSystemSettings": "Opened macOS Privacy & Security. Grant microphone access, then try again.",
"permissionDenied": "Microphone access is blocked. Grant it in your system settings, then try again."
},
"TerminalTccAttributionNotice": {
"body": "The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.",