mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(voice): prevent rdev TSMGetInputSourceProperty crash on macOS 26 (#2735)
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user