diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 757469fa..ddf00328 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -95,6 +95,8 @@ class AppSettings(BaseModel): # None = platform default (Cmd/Ctrl+Shift+D); parts format matches new_agent_shortcut. dictation_shortcut: Optional[str] = None voice_hold_to_talk: bool = True + # Dictation hotkey acts outside the app only when the user opts in (ENG-341). + dictation_works_anywhere: bool = False # Whisper model id from the desktop catalog (electron/voice/whisperModels.js); None = its default. dictation_model: Optional[str] = None # Personal glossary (comma-separated names/jargon) fed to whisper as a decode prompt so "Anthropic" never comes out "and Thropic". diff --git a/electron/preload.js b/electron/preload.js index 15d252ee..941000d0 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -114,6 +114,7 @@ contextBridge.exposeInMainWorld('openswarm', { // True keyboard hold-to-talk needs the native key tap; renderers ask so Settings copy stays honest, // and request triggers the macOS Accessibility prompt when the tap is blocked on permission. setVoiceHotkey: (combo) => ipcRenderer.send('voice:set-hotkey', combo), + setVoiceScope: (anywhere) => ipcRenderer.send('voice:set-scope', anywhere === true), voiceHoldCapable: () => ipcRenderer.invoke('voice:hold-capable'), voiceRequestHoldPermission: () => ipcRenderer.invoke('voice:request-hold-permission'), voiceRequestMicAccess: () => ipcRenderer.invoke('voice:request-mic-access'), diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index ac149c62..7bde5108 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -1,4 +1,4 @@ -const { app, globalShortcut, ipcMain, systemPreferences } = require('electron'); +const { app, BrowserWindow, globalShortcut, ipcMain, systemPreferences } = require('electron'); const { spawn, spawnSync } = require('child_process'); const path = require('path'); const fs = require('fs'); @@ -87,7 +87,11 @@ function uiohookKeycodeFor(key, UiohookKey) { } function installVoiceHotkey(getMainWindow) { + // App-scoped by default (ENG-341): hotkeys act only while an OpenSwarm window is focused, unless + // the user opts into dictate-anywhere in Settings. + let worksAnywhere = false; const send = (channel) => { + if (!worksAnywhere && BrowserWindow.getFocusedWindow() === null) return; const win = getMainWindow(); if (win && !win.isDestroyed()) win.webContents.send(channel); }; @@ -123,6 +127,7 @@ function installVoiceHotkey(getMainWindow) { // Fallback shortcut stays registered while unfocused until the primary tier proves alive. const registerVoiceShortcut = () => { + if (!worksAnywhere) return; if (primaryProven()) return; if (registeredAccel === fallbackCombo.accel) return; unregisterFallbackShortcut(); @@ -287,9 +292,19 @@ function installVoiceHotkey(getMainWindow) { return false; } }; - tryStartNativeTap(); - startFnWatcher(); - registerVoiceShortcut(); + // Arming the native tiers is what raises macOS's Input Monitoring prompt, so a fresh install must + // NOT arm at boot (ENG-341): the prompt fires at first dictation use instead, with context. + let tiersArmed = false; + const armNativeTiers = () => { + if (tiersArmed) return; + tiersArmed = true; + tryStartNativeTap(); + startFnWatcher(); + registerVoiceShortcut(); + }; + try { + if (fs.existsSync(path.join(app.getPath('userData'), 'dictation-used'))) armNativeTiers(); + } catch (_) {} app.on('browser-window-focus', () => { unregisterFallbackShortcut(); pokeFnWatcher(); }); app.on('browser-window-blur', registerVoiceShortcut); @@ -331,16 +346,24 @@ function installVoiceHotkey(getMainWindow) { combo = next; fallbackCombo = combo.special ? parseCombo(LEGACY_COMBO) : combo; if (UiohookKeyRef && !combo.special) tapKeycode = uiohookKeycodeFor(combo.key, UiohookKeyRef); - startFnWatcher(); + if (tiersArmed) startFnWatcher(); unregisterFallbackShortcut(); registerVoiceShortcut(); console.log('[voice] hotkey set to', combo.accel); }); + // Renderer pushes the dictate-anywhere setting on boot and on change. + ipcMain.on('voice:set-scope', (_e, anywhere) => { + worksAnywhere = anywhere === true; + if (!worksAnywhere) unregisterFallbackShortcut(); + else if (tiersArmed && BrowserWindow.getFocusedWindow() === null) registerVoiceShortcut(); + }); + ipcMain.handle('voice:hold-capable', () => tapProven || fnProven); // Settings' "Hold to talk" fires the Accessibility prompt; Input Monitoring has no Electron API, // but a running tap makes macOS list the app in that pane for the user to flip. ipcMain.handle('voice:request-hold-permission', () => { + armNativeTiers(); if (process.platform === 'darwin' && !tapProven) { try { systemPreferences.isTrustedAccessibilityClient(true); } catch (_) {} } @@ -349,6 +372,7 @@ function installVoiceHotkey(getMainWindow) { // 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 () => { + armNativeTiers(); if (process.platform !== 'darwin') return true; try { if (systemPreferences.getMediaAccessStatus('microphone') === 'granted') return true; diff --git a/frontend/src/app/pages/Settings/sections/general/DictationSettings.tsx b/frontend/src/app/pages/Settings/sections/general/DictationSettings.tsx index a6ecbee8..2ef8b7b6 100644 --- a/frontend/src/app/pages/Settings/sections/general/DictationSettings.tsx +++ b/frontend/src/app/pages/Settings/sections/general/DictationSettings.tsx @@ -51,7 +51,7 @@ const DictationSettings: React.FC<{ Dictation shortcut - Works anywhere, even with the app in the background. + Starts dictation while OpenSwarm is focused. {form.dictation_shortcut ? ( @@ -117,6 +117,18 @@ const DictationSettings: React.FC<{ + + + Dictate anywhere + Shortcut also works with OpenSwarm in the background, typing into other apps. + + setForm({ ...form, dictation_works_anywhere: e.target.checked })} + /> + + Haptics diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 68126fd6..bf2ca6fb 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -37,6 +37,7 @@ export interface AppSettings { memory_enabled?: boolean; agent_settings_write_enabled?: boolean; dictation_haptics?: boolean; + dictation_works_anywhere?: boolean; dictation_sound_volume?: number; dictation_disabled_surfaces?: string; anthropic_api_key: string | null; @@ -179,6 +180,7 @@ export const DEFAULT_SETTINGS: AppSettings = { memory_enabled: true, agent_settings_write_enabled: true, dictation_haptics: true, + dictation_works_anywhere: false, dictation_sound_volume: 0.7, dictation_disabled_surfaces: '', anthropic_api_key: null, diff --git a/frontend/src/shared/voice/VoiceDictationContext.tsx b/frontend/src/shared/voice/VoiceDictationContext.tsx index 6ceed60d..3fba4ac5 100644 --- a/frontend/src/shared/voice/VoiceDictationContext.tsx +++ b/frontend/src/shared/voice/VoiceDictationContext.tsx @@ -29,12 +29,19 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode const dictationShortcut = useAppSelector((s) => s.settings.data.dictation_shortcut ?? null); const dictationModel = useAppSelector((s) => s.settings.data.dictation_model ?? null); + const dictationAnywhere = useAppSelector((s) => (s.settings.data as { dictation_works_anywhere?: boolean }).dictation_works_anywhere ?? false); // Push the user's combo to main on boot and on change so every hotkey tier rebinds live. useEffect(() => { const bridge = window as unknown as { openswarm?: { setVoiceHotkey?: (combo: string | null) => void } }; bridge.openswarm?.setVoiceHotkey?.(dictationShortcut); }, [dictationShortcut]); + // Same for the dictate-anywhere scope (ENG-341): app-scoped unless the user opted in. + useEffect(() => { + const bridge = window as unknown as { openswarm?: { setVoiceScope?: (anywhere: boolean) => void } }; + bridge.openswarm?.setVoiceScope?.(dictationAnywhere); + }, [dictationAnywhere]); + // Main boots with the catalog default, so a user who picked something else has to say so on every // launch or dictation quietly runs on the wrong model. useEffect(() => {