diff --git a/apps/whispering/docs/assets/global-passthrough-ui.png b/apps/whispering/docs/assets/global-passthrough-ui.png new file mode 100644 index 0000000000..e5d02f3a89 Binary files /dev/null and b/apps/whispering/docs/assets/global-passthrough-ui.png differ diff --git a/apps/whispering/specs/20260416T000000-global-shortcut-passthrough.md b/apps/whispering/specs/20260416T000000-global-shortcut-passthrough.md new file mode 100644 index 0000000000..260c75a663 --- /dev/null +++ b/apps/whispering/specs/20260416T000000-global-shortcut-passthrough.md @@ -0,0 +1,69 @@ +# Global Shortcut Passthrough (Non-consuming Hotkeys) + +## Problem +Currently, global shortcuts in Whispering are **exclusive and consuming**. This is because `tauri-plugin-global-shortcut` (and the underlying OS APIs like `RegisterHotKey` on Windows) intercepts the keyboard event and prevents it from propagating to other applications. This prevents users from using the same hotkey for both Whispering and another app (e.g., muting Discord while toggling Whispering). + +## Proposed Solution: The "Unregister-Simulate-Re-register" Loop +Since OS-level hotkeys are inherently exclusive, we will implement a "re-play" mechanism: +1. When a global shortcut is triggered, Whispering executes its internal command. +2. If "Passthrough" is enabled for that shortcut: + - Whispering **unregisters** the global hotkey. + - Whispering **simulates** the same key combination using the `enigo` crate (already used in the project). + - Whispering **re-registers** the global hotkey after a short delay (to ensure it doesn't catch its own simulated input). + +## Implementation Details + +### Wave 1: Rust Backend (Tauri Commands) +- **File:** `apps/whispering/src-tauri/src/lib.rs` +- **Task:** Implement `simulate_accelerator` command. +- **Logic:** + - Parse the Electron-style accelerator string (e.g., `Control+Shift+D`). + - Use `enigo` to simulate the key sequence: + 1. Press modifiers. + 2. Click the main key. + 3. Release modifiers. +- **Verification:** Create a small test script to verify `enigo` correctly simulates keys that other apps can hear. + +### Wave 2: State and Persistence +- **File:** `apps/whispering/src/lib/state/device-config.svelte.ts` +- **Task:** Add a single global `.passthrough` setting for all global shortcuts. +- **Key Pattern:** `shortcuts.global.passthrough`. +- **Default:** `false` (opt-in). + +### Wave 3: Service Layer Logic +- **File:** `apps/whispering/src/lib/services/desktop/global-shortcut-manager.ts` +- **Task:** Update the `register` method to support passthrough logic. +- **Logic:** + - The callback provided to `tauriRegister` should check the passthrough setting. + - If enabled: + ```typescript + async (event) => { + // 1. Execute original callback + callback(event.state); + + // 2. Perform Passthrough + if (passthroughEnabled) { + // Check if we are currently simulating keys to avoid infinite loops + const isSimulating = await tauriInvoke('get_is_simulating'); + if (isSimulating) return; + + await unregister(accelerator); + await invoke('simulate_accelerator', { accelerator }); + // Immediately re-register; the simulation flag protects us from self-triggering + await register({ accelerator, callback, on, passthrough: true }); + } + } + ``` + +### Wave 4: UI Integration +- **File:** `apps/whispering/src/routes/(app)/(config)/settings/shortcuts/global/+page.svelte` +- **Task:** Add a centralized "Global Passthrough" toggle switch. +- **UX:** + - Title: "Global Passthrough" + - Description: "Allows other applications to receive global shortcuts at the same time. This works by temporarily unregistering the hotkey, simulating the press, and re-registering." + - Behavior: When toggled, trigger `syncGlobalShortcutsWithSettings()` to apply the change to all registered shortcuts immediately. + +## Risks & Considerations +- **Infinite Loops:** If re-registration happens too quickly, Whispering might catch its own simulated input. A ~100ms delay is usually sufficient. +- **macOS Permissions:** `enigo` requires "Accessibility" permissions on macOS to simulate key presses. Whispering already requests this for its "Write Text" feature, so it should be fine. +- **Timing:** Different OSs have different event propagation speeds; the "unregister/re-register" window needs to be robust. diff --git a/apps/whispering/src-tauri/src/lib.rs b/apps/whispering/src-tauri/src/lib.rs index 8a85baaf8d..ba5454f620 100644 --- a/apps/whispering/src-tauri/src/lib.rs +++ b/apps/whispering/src-tauri/src/lib.rs @@ -152,6 +152,8 @@ pub async fn run() { let builder = builder.invoke_handler(tauri::generate_handler![ write_text, simulate_enter_keystroke, + simulate_accelerator, + get_is_simulating, // Audio recorder commands get_current_recording_id, enumerate_recording_devices, @@ -195,6 +197,7 @@ pub async fn run() { }); } +use std::sync::atomic::Ordering; use enigo::{Direction, Enigo, Key, Keyboard, Settings}; use tauri_plugin_clipboard_manager::ClipboardExt; @@ -276,3 +279,129 @@ async fn simulate_enter_keystroke() -> Result<(), String> { Ok(()) } + +/// Simulates a keyboard accelerator (shortcut) +/// +/// This is used for "passthrough" global shortcuts where Whispering needs to +/// re-emit the key combination so other applications can receive it. +#[tauri::command] +async fn simulate_accelerator( + state: tauri::State<'_, AppData>, + accelerator: String, +) -> Result<(), String> { + state.is_simulating.store(true, Ordering::SeqCst); + + let result = async { + let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?; + + let parts: Vec<&str> = accelerator.split('+').collect(); + let mut modifiers = Vec::new(); + + for i in 0..parts.len() - 1 { + let m = parts[i]; + let key = match m { + "Command" | "Cmd" | "Meta" => Key::Meta, + "Control" | "Ctrl" => Key::Control, + "Alt" | "Option" => Key::Alt, + "Shift" => Key::Shift, + "Super" => Key::Meta, + "CommandOrControl" | "CmdOrCtrl" => { + + #[cfg(target_os = "macos")] + { + Key::Meta + } + #[cfg(not(target_os = "macos"))] + { + Key::Control + } + } + _ => continue, + }; + modifiers.push(key); + } + + let last_part = parts.last().ok_or("Invalid accelerator")?; + let main_key = match *last_part { + "Enter" | "Return" => Key::Return, + "Tab" => Key::Tab, + "Space" => Key::Space, + "Backspace" => Key::Backspace, + "Delete" => Key::Delete, + "Insert" => Key::Insert, + "Home" => Key::Home, + "End" => Key::End, + "PageUp" => Key::PageUp, + "PageDown" => Key::PageDown, + "Up" => Key::UpArrow, + "Down" => Key::DownArrow, + "Left" => Key::LeftArrow, + "Right" => Key::RightArrow, + "Escape" | "Esc" => Key::Escape, + "F1" => Key::F1, + "F2" => Key::F2, + "F3" => Key::F3, + "F4" => Key::F4, + "F5" => Key::F5, + "F6" => Key::F6, + "F7" => Key::F7, + "F8" => Key::F8, + "F9" => Key::F9, + "F10" => Key::F10, + "F11" => Key::F11, + "F12" => Key::F12, + "Plus" => Key::Unicode('+'), + ";" => Key::Unicode(';'), + "'" => Key::Unicode('\''), + "," => Key::Unicode(','), + "." => Key::Unicode('.'), + "/" => Key::Unicode('/'), + "\\" => Key::Unicode('\\'), + "[" => Key::Unicode('['), + "]" => Key::Unicode(']'), + "`" => Key::Unicode('`'), + "-" => Key::Unicode('-'), + "=" => Key::Unicode('='), + _ if last_part.len() == 1 => { + let c = last_part.chars().next().unwrap(); + Key::Unicode(c.to_ascii_lowercase()) + } + _ => return Err(format!("Unsupported key: {}", last_part)), + }; + + // 1. Press all modifiers + for &m in &modifiers { + enigo + .key(m, Direction::Press) + .map_err(|e| format!("Failed to press modifier: {}", e))?; + } + + // 2. Click main key + enigo + .key(main_key, Direction::Click) + .map_err(|e| format!("Failed to click main key: {}", e))?; + + // 3. Release all modifiers in reverse order + for &m in modifiers.iter().rev() { + enigo + .key(m, Direction::Release) + .map_err(|e| format!("Failed to release modifier: {}", e))?; + } + + Ok(()) + } + .await; + + // Small sleep to ensure the OS has processed the simulated events + // before we reset the flag. + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + state.is_simulating.store(false, Ordering::SeqCst); + + result +} + +/// Returns whether a keyboard simulation is currently in progress +#[tauri::command] +async fn get_is_simulating(state: tauri::State<'_, AppData>) -> Result { + Ok(state.is_simulating.load(Ordering::SeqCst)) +} diff --git a/apps/whispering/src-tauri/src/recorder/commands.rs b/apps/whispering/src-tauri/src/recorder/commands.rs index 3e5412719d..61f1ca2c19 100644 --- a/apps/whispering/src-tauri/src/recorder/commands.rs +++ b/apps/whispering/src-tauri/src/recorder/commands.rs @@ -1,18 +1,21 @@ use crate::recorder::recorder::{AudioRecording, RecorderState, Result}; use std::path::PathBuf; use std::sync::Mutex; +use std::sync::atomic::AtomicBool; use tauri::State; use log::{debug, info}; /// Application state containing the recorder pub struct AppData { pub recorder: Mutex, + pub is_simulating: AtomicBool, } impl AppData { pub fn new() -> Self { Self { recorder: Mutex::new(RecorderState::new()), + is_simulating: AtomicBool::new(false), } } } diff --git a/apps/whispering/src/lib/query/desktop/shortcuts.test.ts b/apps/whispering/src/lib/query/desktop/shortcuts.test.ts new file mode 100644 index 0000000000..ac12709de6 --- /dev/null +++ b/apps/whispering/src/lib/query/desktop/shortcuts.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, mock } from 'bun:test'; + +// Mocks must be defined before the module under test is loaded +mock.module('$app/environment', () => ({ + browser: true, + dev: true, +})); + +// Mock deviceConfig +const mockDeviceConfig = { + get: mock((key: string) => { + if (key === 'shortcuts.global.passthrough') { + return true; + } + return false; + }), + set: mock(() => {}), +}; + +// Mock desktopServices +const mockGlobalShortcutManager = { + register: mock(() => Promise.resolve({ data: undefined })), +}; + +const mockDesktopServices = { + globalShortcutManager: mockGlobalShortcutManager, +}; + +// Mock commandCallbacks +const mockCommandCallbacks = { + toggleManualRecording: () => {}, +}; + +mock.module('$lib/state/device-config.svelte', () => ({ + deviceConfig: mockDeviceConfig, +})); + +mock.module('$lib/services/desktop', () => ({ + desktopServices: mockDesktopServices, +})); + +mock.module('$lib/commands', () => ({ + commandCallbacks: mockCommandCallbacks, +})); + +mock.module('$lib/constants/platform', () => ({ + IS_MACOS: false, +})); + +// Now load the module under test +const { globalShortcuts } = require('./shortcuts.ts'); + +describe('globalShortcuts', () => { + describe('registerCommand', () => { + it('should use the global passthrough setting when registering a command', async () => { + const command = { + id: 'toggleManualRecording', + title: 'Toggle Manual Recording', + on: ['Pressed'], + } as any; + + await globalShortcuts.registerCommand({ + command, + accelerator: 'Control+Shift+;' as any, + }); + + expect(mockDeviceConfig.get).toHaveBeenCalledWith('shortcuts.global.passthrough'); + expect(mockGlobalShortcutManager.register).toHaveBeenCalledWith( + expect.objectContaining({ + passthrough: true, + }), + ); + }); + + it('should respect false value for global passthrough', async () => { + mockDeviceConfig.get.mockImplementation((key: string) => { + if (key === 'shortcuts.global.passthrough') { + return false; + } + return true; + }); + + const command = { + id: 'toggleManualRecording', + title: 'Toggle Manual Recording', + on: ['Pressed'], + } as any; + + await globalShortcuts.registerCommand({ + command, + accelerator: 'Control+Shift+;' as any, + }); + + expect(mockGlobalShortcutManager.register).toHaveBeenCalledWith( + expect.objectContaining({ + passthrough: false, + }), + ); + }); + }); +}); diff --git a/apps/whispering/src/lib/query/desktop/shortcuts.ts b/apps/whispering/src/lib/query/desktop/shortcuts.ts index fafa4d44b5..1e58d56daf 100644 --- a/apps/whispering/src/lib/query/desktop/shortcuts.ts +++ b/apps/whispering/src/lib/query/desktop/shortcuts.ts @@ -3,6 +3,7 @@ import { IS_MACOS } from '$lib/constants/platform'; import { defineMutation } from '$lib/query/client'; import { desktopServices } from '$lib/services/desktop'; import type { Accelerator } from '$lib/services/desktop/global-shortcut-manager'; +import { deviceConfig } from '$lib/state/device-config.svelte'; /** * Global shortcuts - desktop-only, require Tauri. @@ -26,10 +27,12 @@ export const globalShortcuts = { 'CommandOrControl', IS_MACOS ? 'Command' : 'Control', ) as Accelerator; + const passthrough = deviceConfig.get('shortcuts.global.passthrough'); return desktopServices.globalShortcutManager.register({ accelerator, callback: commandCallbacks[command.id], on: command.on, + passthrough, }); }, }), diff --git a/apps/whispering/src/lib/services/desktop/global-shortcut-manager.ts b/apps/whispering/src/lib/services/desktop/global-shortcut-manager.ts index cd9b142d15..ba779bbc86 100644 --- a/apps/whispering/src/lib/services/desktop/global-shortcut-manager.ts +++ b/apps/whispering/src/lib/services/desktop/global-shortcut-manager.ts @@ -4,6 +4,7 @@ import { unregister as tauriUnregister, unregisterAll as tauriUnregisterAll, } from '@tauri-apps/plugin-global-shortcut'; +import { invoke as tauriInvoke } from '@tauri-apps/api/core'; import * as os from '@tauri-apps/plugin-os'; import type { Brand } from 'wellcrafted/brand'; import { @@ -97,10 +98,12 @@ export const GlobalShortcutManagerLive = { accelerator, callback, on, + passthrough, }: { accelerator: Accelerator; callback: (state: ShortcutEventState) => void; on: ShortcutEventState[]; + passthrough?: boolean; }): Promise< Result > { @@ -114,9 +117,30 @@ export const GlobalShortcutManagerLive = { const { error: registerError } = await tryAsync({ try: () => - tauriRegister(accelerator, (event) => { + tauriRegister(accelerator, async (event) => { if (on.includes(event.state)) { + // Check if we are currently simulating keys to avoid infinite loops + const isSimulating = await tauriInvoke( + 'get_is_simulating', + ); + if (isSimulating) return; + callback(event.state); + + if (passthrough) { + // Perform passthrough: Unregister -> Simulate -> Re-register + // We re-register immediately after simulation starts/ends + // The get_is_simulating flag will protect us from the event + // that arrives while the keys are being simulated. + await GlobalShortcutManagerLive.unregister(accelerator); + await tauriInvoke('simulate_accelerator', { accelerator }); + await GlobalShortcutManagerLive.register({ + accelerator, + callback, + on, + passthrough, + }); + } } }), catch: (error) => diff --git a/apps/whispering/src/lib/state/device-config.svelte.ts b/apps/whispering/src/lib/state/device-config.svelte.ts index cc81fcdedb..519de7be46 100644 --- a/apps/whispering/src/lib/state/device-config.svelte.ts +++ b/apps/whispering/src/lib/state/device-config.svelte.ts @@ -89,6 +89,7 @@ const DEVICE_DEFINITIONS = { ), // ── Global OS shortcuts (device-specific, never synced) ─────────── + 'shortcuts.global.passthrough': defineEntry(type('boolean'), false), 'shortcuts.global.toggleManualRecording': defineEntry( type('string | null'), `${CommandOrControl}+Shift+;` as string | null, diff --git a/apps/whispering/src/routes/(app)/(config)/settings/shortcuts/global/+page.svelte b/apps/whispering/src/routes/(app)/(config)/settings/shortcuts/global/+page.svelte index c366a86305..215bb52f5f 100644 --- a/apps/whispering/src/routes/(app)/(config)/settings/shortcuts/global/+page.svelte +++ b/apps/whispering/src/routes/(app)/(config)/settings/shortcuts/global/+page.svelte @@ -1,16 +1,26 @@ Global Shortcuts - Whispering @@ -56,7 +66,38 @@ - +
+ + + + Settings + + Additional global shortcut behavior settings. + + + + { + deviceConfig.set('shortcuts.global.passthrough', checked); + void syncGlobalShortcutsWithSettings(); + }} + class="shrink-0" + /> +
+ + Global Passthrough + + + Allows other applications to receive global shortcuts at the + same time. + +
+
+
+
+
{:else} diff --git a/apps/whispering/tsconfig.json b/apps/whispering/tsconfig.json index be5787547f..086ec037f8 100644 --- a/apps/whispering/tsconfig.json +++ b/apps/whispering/tsconfig.json @@ -2,7 +2,11 @@ "extends": ["../../tsconfig.base.json", "./.svelte-kit/tsconfig.json"], "compilerOptions": { "checkJs": true, - "types": ["bun-types"] + "types": ["bun-types"], + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } } // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files