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);