mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Improve microphone permission errors and drop failure reporting (#20801)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications. * fix(settings): say when the microphone is blocked and where to grant it * Use generic stream for microphone permission requests - Request generic audio stream instead of saved device to handle stale device IDs (unplugged microphones). This ensures the initial permission grant succeeds even if the previously saved device is no longer available. - Refactor error handling to not require instanceof checks, supporting errors thrown as plain objects and improving robustness across browsers. - Simplify tests with proper typing and add coverage for stale device and permission error edge cases. * fix type check * minor type fix
This commit is contained in:
@@ -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<Pick<MediaStream, 'getTracks'>>): 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<void> {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<VoiceMicrophoneSetting voiceSettings={settings} 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('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')
|
||||
})
|
||||
})
|
||||
@@ -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<VoiceSettings>) => 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<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 +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<void> => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
await refreshDevices()
|
||||
}, [refreshDevices])
|
||||
|
||||
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 +236,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