From a889d0e6078ff2ebd738c3631e0ea4657dff00e2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 11 Apr 2026 16:30:10 -0600 Subject: [PATCH] feat: add voice input troubleshooting notifications and mic permission checks - Wire up dead voiceInput:micPermission IPC handler via preload API - Add pre-flight mic permission check before starting recording (macOS) - Add pre-flight connection/auth validation in toggleVoiceInput - Show OS notifications for all voice input failure points: shortcut registration, mic denied, no connection, auth missing, transcription HTTP errors, and generic voice input errors - Improve renderer error messages for NotAllowedError/NotFoundError - Forward renderer errors to main process for OS-level notifications --- src/main/index.ts | 139 +++++++++++++++++- src/preload/voice-input-preload.ts | 5 + src/renderer/src/components/VoiceInput.svelte | 21 ++- 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 2694cf1..3fbecf1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -112,7 +112,7 @@ let voiceInputRecording = false // ─── Global Shortcuts ─────────────────────────────────── -const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string): void => { +const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceInputAccel?: string, callAccel?: string): void => { globalShortcut.unregisterAll() // Global shortcut – bring main window to foreground @@ -150,12 +150,46 @@ const registerShortcuts = (globalAccel?: string, spotlightAccel?: string, voiceI toggleVoiceInput() }) log.info(`Voice input shortcut "${voiceInputAccel}" registered: ${ok}`) + if (!ok) { + new Notification({ + title: 'Voice Input', + body: `Could not register shortcut "${voiceInputAccel}". It may be in use by another application.` + }).show() + } } catch (error) { log.warn('Failed to register voice input shortcut:', voiceInputAccel, error) + new Notification({ + title: 'Voice Input', + body: `Failed to register shortcut "${voiceInputAccel}". It may conflict with another application.` + }).show() } } else { log.info(`Voice input shortcut skipped — accel="${voiceInputAccel}", enabled=${CONFIG?.voiceInputEnabled}`) } + + // Call shortcut – open the voice/video call overlay + if (callAccel && CONFIG?.callEnabled !== false) { + try { + const ok = globalShortcut.register(callAccel, () => { + toggleCall() + }) + log.info(`Call shortcut "${callAccel}" registered: ${ok}`) + if (!ok) { + new Notification({ + title: 'Call', + body: `Could not register shortcut "${callAccel}". It may be in use by another application.` + }).show() + } + } catch (error) { + log.warn('Failed to register call shortcut:', callAccel, error) + new Notification({ + title: 'Call', + body: `Failed to register shortcut "${callAccel}". It may conflict with another application.` + }).show() + } + } else { + log.info(`Call shortcut skipped — accel="${callAccel}", enabled=${CONFIG?.callEnabled}`) + } } // ─── Spotlight Window ─────────────────────────────────── @@ -370,6 +404,46 @@ async function toggleVoiceInput(): Promise { return } + // Pre-flight: check microphone permission on macOS + if (process.platform === 'darwin') { + const micStatus = systemPreferences.getMediaAccessStatus('microphone') + if (micStatus !== 'granted') { + const granted = await systemPreferences.askForMediaAccess('microphone') + if (!granted) { + log.warn('Voice input: microphone permission denied') + new Notification({ + title: 'Voice Input', + body: 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.' + }).show() + return + } + } + } + + // Pre-flight: check a connection is configured + try { + const config = await getConfig() + if (!config.defaultConnectionId || config.connections.length === 0) { + log.warn('Voice input: no connection configured') + new Notification({ + title: 'Voice Input', + body: 'No connection configured. Set up a connection in Settings before using voice input.' + }).show() + return + } + const conn = config.connections.find((c) => c.id === config.defaultConnectionId) + if (!conn) { + log.warn('Voice input: default connection not found') + new Notification({ + title: 'Voice Input', + body: 'Default connection not found. Check your connection settings.' + }).show() + return + } + } catch (err: any) { + log.warn('Voice input: config check failed:', err) + } + // Start recording — chime plays concurrently (separate audio output path from mic input) voiceInputRecording = true playChime(true) @@ -390,6 +464,49 @@ async function toggleVoiceInput(): Promise { } } +// ─── Call Shortcut ────────────────────────────────────── + +async function toggleCall(): Promise { + // Pre-flight: check a connection is configured + try { + const config = await getConfig() + if (!config.defaultConnectionId || config.connections.length === 0) { + log.warn('Call: no connection configured') + new Notification({ + title: 'Call', + body: 'No connection configured. Set up a connection in Settings before using the call shortcut.' + }).show() + return + } + const conn = config.connections.find((c) => c.id === config.defaultConnectionId) + if (!conn) { + log.warn('Call: default connection not found') + new Notification({ + title: 'Call', + body: 'Default connection not found. Check your connection settings.' + }).show() + return + } + + let url = conn.url + if (conn.type === 'local' && SERVER_URL) { + url = SERVER_URL + } + if (url.startsWith('http://0.0.0.0')) { + url = url.replace('http://0.0.0.0', 'http://localhost') + } + + sendToRenderer('call', { connectionId: conn.id, url }) + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + mainWindow.focus() + } + } catch (err: any) { + log.warn('Call: config check failed:', err) + } +} + // ─── Windows ──────────────────────────────────────────── function createMainWindow(show = true): void { @@ -924,7 +1041,7 @@ if (!gotTheLock) { CONFIG = await getConfig() updateTray() voiceInputRecording = false - registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut) + registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut) }) // Python/uv @@ -1236,10 +1353,10 @@ if (!gotTheLock) { try { const config = await getConfig() if (!config.defaultConnectionId || config.connections.length === 0) { - throw new Error('No connection configured') + throw new Error('No connection configured. Set up a connection in Settings first.') } const conn = config.connections.find((c) => c.id === config.defaultConnectionId) - if (!conn) throw new Error('Default connection not found') + if (!conn) throw new Error('Default connection not found. Check your connection settings.') let url = conn.url if (conn.type === 'local' && SERVER_URL) { @@ -1274,7 +1391,7 @@ if (!gotTheLock) { } if (!token) { - throw new Error('Not authenticated — open a connection first') + throw new Error('Not authenticated. Open a connection and sign in before using voice input.') } // Build multipart form data manually using Node.js @@ -1306,13 +1423,17 @@ if (!gotTheLock) { if (!response.ok) { const text = await response.text().catch(() => '') - throw new Error(`Transcription failed (${response.status}): ${text}`) + throw new Error(`Transcription failed (HTTP ${response.status}). ${text || 'Check that your server has Speech-to-Text configured.'}`) } const result = await response.json() return result } catch (error: any) { log.error('voiceInput:transcribe failed:', error) + new Notification({ + title: 'Voice Input Failed', + body: error?.message || 'Transcription failed. Check logs for details.' + }).show() throw error } }) @@ -1370,6 +1491,10 @@ if (!gotTheLock) { ipcMain.handle('voiceInput:error', (_event, message: string) => { log.warn('Voice input error:', message) voiceInputRecording = false + new Notification({ + title: 'Voice Input Error', + body: message || 'An unknown error occurred with voice input.' + }).show() }) // Open Terminal @@ -1627,7 +1752,7 @@ if (!gotTheLock) { // Global shortcut - registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut) + registerShortcuts(CONFIG.globalShortcut, CONFIG.spotlightShortcut, CONFIG.voiceInputShortcut, CONFIG.callShortcut) // Enable screen capture session.defaultSession.setDisplayMediaRequestHandler( diff --git a/src/preload/voice-input-preload.ts b/src/preload/voice-input-preload.ts index a39fc3b..93c0b47 100644 --- a/src/preload/voice-input-preload.ts +++ b/src/preload/voice-input-preload.ts @@ -10,6 +10,11 @@ const api = { }) }, + // Request microphone permission (macOS system-level) + checkMicPermission: (): Promise => { + return ipcRenderer.invoke('voiceInput:micPermission') + }, + // Send recorded audio to main process for transcription transcribe: (audioBuffer: ArrayBuffer, token?: string): Promise => { return ipcRenderer.invoke('voiceInput:transcribe', audioBuffer, token) diff --git a/src/renderer/src/components/VoiceInput.svelte b/src/renderer/src/components/VoiceInput.svelte index 7e44e95..09b3bd5 100644 --- a/src/renderer/src/components/VoiceInput.svelte +++ b/src/renderer/src/components/VoiceInput.svelte @@ -72,6 +72,15 @@ await new Promise((r) => setTimeout(r, 500)) try { + // Request system-level mic permission (macOS) before activating the mic + const permStatus = await api?.checkMicPermission() + if (permStatus === 'denied') { + const msg = 'Microphone access denied. Enable it in System Settings → Privacy & Security → Microphone, then restart the app.' + showError(msg) + api?.error(msg) + return + } + mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) audioChunks = [] @@ -96,7 +105,13 @@ mediaRecorder.start(250) timer = setInterval(() => { duration++ }, 1000) } catch (err: any) { - showError(err?.message || 'Mic access failed') + const msg = err?.name === 'NotAllowedError' + ? 'Microphone access denied. Check system permissions.' + : err?.name === 'NotFoundError' + ? 'No microphone found. Connect a microphone and try again.' + : err?.message || 'Mic access failed' + showError(msg) + api?.error(msg) } } @@ -176,7 +191,9 @@ api?.close() } } catch (err: any) { - showError(err?.message || 'Transcription failed') + const msg = err?.message || 'Transcription failed' + showError(msg) + api?.error(msg) } }