mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix: resolve macOS mic permission on first click in voice mode (#613)
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:
parent
0f4a693b34
commit
faaefe936f
3 changed files with 47 additions and 2 deletions
|
|
@ -944,6 +944,24 @@ export function setupIpcHandlers() {
|
||||||
'voice:synthesize': async (_event, args) => {
|
'voice:synthesize': async (_event, args) => {
|
||||||
return voice.synthesizeSpeech(args.text);
|
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 handlers
|
||||||
'live-note:run': async (_event, args) => {
|
'live-note:run': async (_event, args) => {
|
||||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,20 @@ export function useVoiceMode() {
|
||||||
analytics.voiceInputStarted();
|
analytics.voiceInputStarted();
|
||||||
posthog.people.set_once({ has_used_voice: true });
|
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
|
// Kick off mic + WebSocket in parallel, don't await WebSocket
|
||||||
const [stream] = await Promise.all([
|
const [stream] = await Promise.all([
|
||||||
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
|
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
|
||||||
|
|
@ -161,7 +175,10 @@ export function useVoiceMode() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!stream) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,7 +209,7 @@ export function useVoiceMode() {
|
||||||
|
|
||||||
source.connect(processor);
|
source.connect(processor);
|
||||||
processor.connect(audioCtx.destination);
|
processor.connect(audioCtx.destination);
|
||||||
}, [state, connectWs]);
|
}, [state, connectWs, stopAudioCapture]);
|
||||||
|
|
||||||
/** Stop recording and return the full transcript (finalized + any current interim) */
|
/** Stop recording and return the full transcript (finalized + any current interim) */
|
||||||
const submit = useCallback((): string => {
|
const submit = useCallback((): string => {
|
||||||
|
|
|
||||||
|
|
@ -702,6 +702,16 @@ const ipcSchemas = {
|
||||||
mimeType: z.string(),
|
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': {
|
'meeting:checkScreenPermission': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue