diff --git a/electron/build/entitlements.mac.inherit.plist b/electron/build/entitlements.mac.inherit.plist index 7491bffe..807481f5 100644 --- a/electron/build/entitlements.mac.inherit.plist +++ b/electron/build/entitlements.mac.inherit.plist @@ -16,5 +16,8 @@ com.apple.security.inherit + + com.apple.security.device.audio-input + diff --git a/electron/build/entitlements.mac.plist b/electron/build/entitlements.mac.plist index 57f69cd7..1a2d3f3e 100644 --- a/electron/build/entitlements.mac.plist +++ b/electron/build/entitlements.mac.plist @@ -16,6 +16,9 @@ com.apple.security.inherit + + com.apple.security.device.audio-input + keychain-access-groups diff --git a/electron/package.json b/electron/package.json index 8affe10c..44e4a0a6 100644 --- a/electron/package.json +++ b/electron/package.json @@ -62,7 +62,8 @@ "hardenedRuntime": true, "notarize": false, "extendInfo": { - "NSFaceIDUsageDescription": "OpenSwarm uses Touch ID to sign you in to websites with passkeys." + "NSFaceIDUsageDescription": "OpenSwarm uses Touch ID to sign you in to websites with passkeys.", + "NSMicrophoneUsageDescription": "OpenSwarm uses the microphone for voice dictation." }, "provisioningProfile": "build/embedded.provisionprofile", "entitlements": "build/entitlements.mac.plist", diff --git a/electron/preload.js b/electron/preload.js index 1d30ad75..a4eb1689 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -93,6 +93,7 @@ contextBridge.exposeInMainWorld('openswarm', { setVoiceHotkey: (combo) => ipcRenderer.send('voice:set-hotkey', combo), voiceHoldCapable: () => ipcRenderer.invoke('voice:hold-capable'), voiceRequestHoldPermission: () => ipcRenderer.invoke('voice:request-hold-permission'), + voiceRequestMicAccess: () => ipcRenderer.invoke('voice:request-mic-access'), haptic: (pattern) => ipcRenderer.invoke('haptic:perform', pattern), // Native-tap hold relay: real global key-down/key-up for the voice combo, focus-independent. onVoiceHold: (onDown, onUp) => { diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index f913da3b..3e555a77 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -198,6 +198,17 @@ function installVoiceHotkey(getMainWindow) { } return tapProven; }); + // Fires the real TCC mic prompt BEFORE the first capture: with the entitlement present but no + // prior grant, getUserMedia would still fail once and burn the user's first dictation attempt. + ipcMain.handle('voice:request-mic-access', async () => { + if (process.platform !== 'darwin') return true; + try { + if (systemPreferences.getMediaAccessStatus('microphone') === 'granted') return true; + return await systemPreferences.askForMediaAccess('microphone'); + } catch (_) { + return false; + } + }); } module.exports = { installVoiceHotkey }; diff --git a/frontend/src/shared/voice/useVoiceDictation.ts b/frontend/src/shared/voice/useVoiceDictation.ts index e2028a31..0aa36472 100644 --- a/frontend/src/shared/voice/useVoiceDictation.ts +++ b/frontend/src/shared/voice/useVoiceDictation.ts @@ -105,6 +105,9 @@ export function useVoiceDictation() { if (!window.openswarm?.voiceTranscribe) { setError('desktop-only'); return; } // no Electron bridge = web build setError(null); try { + // Fire the OS mic prompt through the main process first: a packaged hardened-runtime build denies renderer getUserMedia outright until TCC granted (the prod dictation-dead cause, ENG-103). + const micOk = await (window.openswarm as any)?.voiceRequestMicAccess?.() ?? true; + if (micOk === false) { setError('mic-denied'); return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } }); const ctx = new AudioContext({ sampleRate: VOICE_SAMPLE_RATE }); const source = ctx.createMediaStreamSource(stream);