mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
fix(settings): say when the microphone is blocked and where to grant it
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user