Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<boolean>('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.
129 changes: 129 additions & 0 deletions apps/whispering/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<bool, String> {
Ok(state.is_simulating.load(Ordering::SeqCst))
}
3 changes: 3 additions & 0 deletions apps/whispering/src-tauri/src/recorder/commands.rs
Original file line number Diff line number Diff line change
@@ -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<RecorderState>,
pub is_simulating: AtomicBool,
}

impl AppData {
pub fn new() -> Self {
Self {
recorder: Mutex::new(RecorderState::new()),
is_simulating: AtomicBool::new(false),
}
}
}
Expand Down
101 changes: 101 additions & 0 deletions apps/whispering/src/lib/query/desktop/shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
);
});
});
});
3 changes: 3 additions & 0 deletions apps/whispering/src/lib/query/desktop/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
});
},
}),
Expand Down
Loading