fix: resolve macOS mic permission on first click in voice mode

On macOS, the first getUserMedia({audio:true}) call hits TCC permission
status 'not-determined' — the OS prompt appears but the in-flight call
rejects, is silently swallowed, and the UI snaps back to idle. Second
click works because permission is already granted.

Fix: add voice:ensureMicAccess IPC channel (mirroring the existing
meeting:checkScreenPermission pattern) that calls
systemPreferences.askForMediaAccess('microphone') before getUserMedia,
so the same first click proceeds once the user grants access.

Also fixes a secondary bug: on the failure path, the code only called
setState('idle'), leaking the WebSocket that connectWs() had already
opened. Now calls stopAudioCapture() for proper cleanup.
This commit is contained in:
Prakhar Pandey 2026-06-10 21:06:03 +05:30
parent c48ef5ac0c
commit 7f70275c70
3 changed files with 47 additions and 2 deletions

View file

@ -944,6 +944,24 @@ export function setupIpcHandlers() {
'voice:synthesize': async (_event, args) => {
return voice.synthesizeSpeech(args.text);
},
'voice:ensureMicAccess': async () => {
if (process.platform !== 'darwin') return { granted: true };
const status = systemPreferences.getMediaAccessStatus('microphone');
console.log('[voice] Microphone permission status:', status);
if (status === 'granted') return { granted: true };
// 'not-determined' shows the native TCC prompt and resolves once the
// user responds; 'denied'/'restricted' resolve false without prompting.
// Awaiting this here means the triggering mic click proceeds to
// getUserMedia only after permission is settled — fixing the first
// click silently failing while the prompt was still up.
try {
const granted = await systemPreferences.askForMediaAccess('microphone');
console.log('[voice] Microphone permission after prompt:', granted);
return { granted };
} catch {
return { granted: false };
}
},
// Live-note handlers
'live-note:run': async (_event, args) => {
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);

View file

@ -151,6 +151,20 @@ export function useVoiceMode() {
analytics.voiceInputStarted();
posthog.people.set_once({ has_used_voice: true });
// Settle the OS-level microphone permission before capturing. On the
// first-ever use (macOS) the permission is 'not-determined'; calling
// getUserMedia directly would reject while the native prompt is up,
// making the first mic click silently do nothing. Resolving it here
// lets this same click proceed once the user grants access.
const mic = await window.ipc
.invoke('voice:ensureMicAccess', null)
.catch(() => ({ granted: true }));
if (!mic.granted) {
console.error('Microphone access denied');
stopAudioCapture();
return;
}
// Kick off mic + WebSocket in parallel, don't await WebSocket
const [stream] = await Promise.all([
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
@ -161,7 +175,10 @@ export function useVoiceMode() {
]);
if (!stream) {
setState('idle');
// connectWs() may have already opened a socket — tear everything
// down (close WS, reset buffers, state) rather than only resetting
// state, which would leak the socket into the next attempt.
stopAudioCapture();
return;
}
@ -192,7 +209,7 @@ export function useVoiceMode() {
source.connect(processor);
processor.connect(audioCtx.destination);
}, [state, connectWs]);
}, [state, connectWs, stopAudioCapture]);
/** Stop recording and return the full transcript (finalized + any current interim) */
const submit = useCallback((): string => {

View file

@ -702,6 +702,16 @@ const ipcSchemas = {
mimeType: z.string(),
}),
},
// Ensures the OS-level microphone permission is settled before capturing.
// On first-ever use (macOS) the permission is 'not-determined'; resolving
// the native prompt up front prevents the in-flight getUserMedia from
// rejecting on the first mic click.
'voice:ensureMicAccess': {
req: z.null(),
res: z.object({
granted: z.boolean(),
}),
},
'meeting:checkScreenPermission': {
req: z.null(),
res: z.object({