diff --git a/src/openhuman/voice/dictation_listener.rs b/src/openhuman/voice/dictation_listener.rs index 0da3321d1..677e9f9e5 100644 --- a/src/openhuman/voice/dictation_listener.rs +++ b/src/openhuman/voice/dictation_listener.rs @@ -92,6 +92,14 @@ pub fn publish_transcription(text: String) -> usize { /// Reads the `dictation` config section and registers the global hotkey. /// When the hotkey fires, publishes a `DictationEvent` to the broadcast /// channel that the Socket.IO bridge forwards to all connected clients. +/// +/// **macOS note**: this function is a no-op on macOS. Starting with macOS 26, +/// `TSMGetInputSourceProperty` is enforced to run on the main dispatch queue; +/// rdev's CGEventTap callback fires on a background thread and crashes with +/// `EXC_BREAKPOINT` (`dispatch_assert_queue_fail`) on the first key press. The +/// Tauri host already registers the shortcut via `tauri-plugin-global-shortcut` +/// (main-thread-safe) and emits `dictation://toggle` to the frontend, making +/// the core-side rdev listener redundant on macOS. (#2677) pub async fn start_if_enabled(config: &Config) { if !config.dictation.enabled { log::info!("{LOG_PREFIX} dictation disabled in config, skipping hotkey listener"); @@ -104,13 +112,33 @@ pub async fn start_if_enabled(config: &Config) { return; } + // On macOS the rdev listener must not start: rdev's CGEventTap callback + // calls TSMGetInputSourceProperty off the main thread, crashing with + // EXC_BREAKPOINT on macOS 26 (dispatch_assert_queue_fail). The Tauri host + // handles this shortcut via tauri-plugin-global-shortcut instead. (#2677) + #[cfg(target_os = "macos")] + { + log::info!( + "{LOG_PREFIX} macOS: skipping rdev hotkey listener — \ + handled by Tauri host via tauri-plugin-global-shortcut (issue #2677)" + ); + return; + } + + // Non-macOS: start the rdev-based listener. + #[cfg(not(target_os = "macos"))] + start_rdev_listener(hotkey_str, config).await; +} + +#[cfg(not(target_os = "macos"))] +async fn start_rdev_listener(hotkey_str: String, config: &Config) { // Map DictationActivationMode to our hotkey ActivationMode. let mode = match config.dictation.activation_mode { crate::openhuman::config::DictationActivationMode::Push => ActivationMode::Push, crate::openhuman::config::DictationActivationMode::Toggle => ActivationMode::Tap, }; - // Normalize the hotkey string for rdev (CmdOrCtrl → cmd on macOS, ctrl on others). + // Normalize the hotkey string for rdev (CmdOrCtrl → ctrl on non-macOS). let normalized = normalize_hotkey_for_rdev(&hotkey_str); log::info!( @@ -309,6 +337,39 @@ mod tests { start_if_enabled(&config).await; } + // On macOS the rdev listener must never start — TSMGetInputSourceProperty + // must run on the main dispatch queue; rdev fires its callback on a + // background thread and crashes with EXC_BREAKPOINT on macOS 26. (#2677) + #[cfg(target_os = "macos")] + #[tokio::test] + async fn start_if_enabled_is_noop_on_macos_with_valid_hotkey() { + let mut config = Config::default(); + config.dictation.enabled = true; + config.dictation.hotkey = "ctrl+space".into(); + // Must return without panicking or spawning the rdev listener. + start_if_enabled(&config).await; + // No rdev thread was started: the global handle remains None. + let guard = LISTENER_HANDLE.lock().expect("lock"); + assert!( + guard.is_none(), + "rdev listener must not be started on macOS" + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn start_if_enabled_is_noop_on_macos_with_fn_hotkey() { + let mut config = Config::default(); + config.dictation.enabled = true; + config.dictation.hotkey = "fn".into(); + start_if_enabled(&config).await; + let guard = LISTENER_HANDLE.lock().expect("lock"); + assert!( + guard.is_none(), + "rdev listener must not be started on macOS" + ); + } + #[test] fn normalize_maps_shift_and_alt_verbatim() { let result = normalize_hotkey_for_rdev("Shift+Alt+D"); diff --git a/src/openhuman/voice/server.rs b/src/openhuman/voice/server.rs index c1a177e03..2796a0b55 100644 --- a/src/openhuman/voice/server.rs +++ b/src/openhuman/voice/server.rs @@ -527,10 +527,13 @@ impl HotkeyListenerKind { /// Start the appropriate hotkey listener for the current platform and key. /// -/// On macOS, the Fn/Globe key cannot be detected by `rdev`'s CGEventTap. -/// When the configured hotkey is `"fn"` we fall back to the Swift-based -/// globe listener (`accessibility::globe`) which monitors -/// `NSEvent.flagsChanged` for the `.function` modifier flag. +/// On macOS, the Fn/Globe key is handled by the Swift-based globe listener +/// (`accessibility::globe`) which monitors `NSEvent.flagsChanged`. All other +/// keys return an error on macOS: rdev's CGEventTap callback calls +/// `TSMGetInputSourceProperty` off the main thread; macOS 26 enforces +/// `dispatch_assert_queue(main_queue)` inside that API and kills the process +/// with `EXC_BREAKPOINT` (`dispatch_assert_queue_fail`). Configure +/// `hotkey = "fn"` to avoid this. (#2677) fn start_hotkey_listener( hotkey_str: &str, mode: hotkey::ActivationMode, @@ -547,12 +550,26 @@ fn start_hotkey_listener( if hotkey_str.trim().eq_ignore_ascii_case("fn") { return start_globe_hotkey_listener(mode, server_cancel); } + // rdev calls TSMGetInputSourceProperty off the main thread; macOS 26 + // enforces main-queue-only access and crashes the process. Only the + // Fn/Globe key is safe via the Swift globe listener. (#2677) + return Err(format!( + "voice server hotkey '{}' is not supported on macOS — \ + only 'fn' (Fn/Globe key) is safe. rdev calls \ + TSMGetInputSourceProperty off the main thread on macOS 26, \ + causing EXC_BREAKPOINT. Set hotkey = \"fn\" in voice config \ + (issue #2677).", + hotkey_str + )); } - // Default path: rdev-based listener for all other keys. - let combo = hotkey::parse_hotkey(hotkey_str)?; - let (handle, rx) = hotkey::start_listener(combo, mode)?; - Ok((HotkeyListenerKind::Rdev(handle), rx)) + // Non-macOS: rdev-based listener for all keys. + #[cfg(not(target_os = "macos"))] + { + let combo = hotkey::parse_hotkey(hotkey_str)?; + let (handle, rx) = hotkey::start_listener(combo, mode)?; + Ok((HotkeyListenerKind::Rdev(handle), rx)) + } } /// macOS-only: start the Swift globe listener and bridge FN_DOWN / FN_UP