diff --git a/tauri/src-tauri/src/focus_capture.rs b/tauri/src-tauri/src/focus_capture.rs index e64b1761..38774087 100644 --- a/tauri/src-tauri/src/focus_capture.rs +++ b/tauri/src-tauri/src/focus_capture.rs @@ -198,6 +198,17 @@ pub fn capture_focus() -> Result { &mut focused as *mut _, ); if err != AX_ERROR_SUCCESS || focused.is_null() { + // Some apps (terminals, some Electron windows) expose no + // system-wide focused element. Rather than drop the dictation, + // fall back to the frontmost app so the transcript still injects + // there via activate + ⌘V. + if let Some(fp) = frontmost_pid() { + return Ok(FocusSnapshot { + pid: fp, + bundle_id: bundle_id_for_pid(fp), + role: None, + }); + } return Err(format!( "No focused element (AXError {}). Verify Accessibility permission is granted and a focused text field exists.", err @@ -213,6 +224,26 @@ pub fn capture_focus() -> Result { return Err(format!("AXUIElementGetPid failed (AXError {})", err)); } + // If the focused element belongs to our OWN process, the dictate pill + // has transiently taken key focus for its WebKit mic capture — the + // system-wide AXFocusedUIElement then resolves to the pill instead of + // the user's real target, which would make us paste into ourselves (a + // no-op) or drop the text entirely. The pill is a non-activating panel + // so it never becomes the frontmost application; remap to the + // frontmost app, which is always the real dictation target. + let our_pid = std::process::id() as Pid; + if pid == our_pid { + if let Some(fp) = frontmost_pid() { + if fp != our_pid { + return Ok(FocusSnapshot { + pid: fp, + bundle_id: bundle_id_for_pid(fp), + role: None, + }); + } + } + } + let role = { let role_attr = cf_string_const("AXRole"); match role_attr { @@ -301,6 +332,28 @@ pub fn activate_pid(pid: i32) -> Result<(), String> { } } +/// PID of the app the user is currently in (NSWorkspace frontmost). Used to +/// skip re-activation when the paste target never lost frontmost status — +/// activating an already-frontmost app is a no-op at best, and on +/// fullscreen Spaces macOS 26's cooperative activation returns NO for it, +/// which previously aborted the whole paste. +#[cfg(target_os = "macos")] +pub fn frontmost_pid() -> Option { + unsafe { + let _pool = AutoreleasePool::new(); + let ws: Id = msg_send![class!(NSWorkspace), sharedWorkspace]; + if ws.is_null() { + return None; + } + let app: Id = msg_send![ws, frontmostApplication]; + if app.is_null() { + return None; + } + let pid: i32 = msg_send![app, processIdentifier]; + Some(pid) + } +} + /// `true` when `NSRunningApplication` responds to /// `yieldActivationToApplication:` — the macOS 14+ discriminator for the /// cooperative-activation APIs. Cached since the answer doesn't change diff --git a/tauri/src-tauri/src/hotkey_monitor.rs b/tauri/src-tauri/src/hotkey_monitor.rs index bb0cbd12..1510ed2c 100644 --- a/tauri/src-tauri/src/hotkey_monitor.rs +++ b/tauri/src-tauri/src/hotkey_monitor.rs @@ -272,6 +272,10 @@ fn apply_effect(app: &AppHandle, effect: Effect) { // it out of whatever app the user was typing in, which is // the opposite of what a dictation overlay should do. let _ = window.show(); + // Order the pill into the currently-active Space (incl. a + // foreign app's fullscreen Space) — see main.rs. + #[cfg(target_os = "macos")] + crate::force_order_front(&window); let payload = serde_json::json!({ "focus": focus }); let _ = window.emit("dictate:start", payload); } diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 0f44ac90..34bb301b 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -57,9 +57,137 @@ fn build_dictate_window(app: &tauri::AppHandle) -> tauri::Result *const objc::runtime::Class; +} + +/// `canBecomeKeyWindow` override for the pill panel. Borderless NSPanels +/// refuse key status by default, and WebKit rejects `getUserMedia` from a +/// document whose window can never become key — so recording silently fails. +/// Returning YES restores capture; the panel stays non-activating, so showing +/// it never steals focus from the app being dictated into. +#[cfg(target_os = "macos")] +extern "C" fn pill_panel_can_become_key( + _this: &objc::runtime::Object, + _sel: objc::runtime::Sel, +) -> objc::runtime::BOOL { + objc::runtime::YES +} + +/// Lazily-registered NSPanel subclass for the dictate pill: key-capable (for +/// WebKit media capture) while remaining a panel (for fullscreen-Space join). +#[cfg(target_os = "macos")] +fn pill_panel_class() -> &'static objc::runtime::Class { + use objc::declare::ClassDecl; + use objc::runtime::{Class, Object, Sel, BOOL}; + use objc::{class, sel, sel_impl}; + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let superclass = class!(NSPanel); + let mut decl = + ClassDecl::new("VoiceboxPillPanel", superclass).expect("register VoiceboxPillPanel"); + unsafe { + decl.add_method( + sel!(canBecomeKeyWindow), + pill_panel_can_become_key as extern "C" fn(&Object, Sel) -> BOOL, + ); + } + decl.register(); + }); + Class::get("VoiceboxPillPanel").expect("VoiceboxPillPanel registered") +} + +/// Convert the dictate pill's NSWindow into a key-capable NSPanel and set the +/// collection behavior + window level required to appear over another app's +/// native macOS fullscreen Space. +/// +/// A regular (Dock-icon) app's plain NSWindow is never admitted to a foreign +/// fullscreen Space regardless of collection-behavior flags or window level; +/// an NSPanel with the same flags is. NSPanel adds no instance variables over +/// NSWindow, so re-classing the live object is safe (the tauri-nspanel plugin +/// uses the same technique). Idempotent via an `isKindOfClass` guard. Runs on +/// the main thread because AppKit window mutation is main-thread-only. +#[cfg(target_os = "macos")] +pub fn apply_fullscreen_overlay_behavior(window: &tauri::WebviewWindow) { + let w = window.clone(); + let dispatched = window.run_on_main_thread(move || { + use objc::runtime::{Object, NO, YES}; + use objc::{class, msg_send, sel, sel_impl}; + + // NSWindowCollectionBehavior bit flags. + const CAN_JOIN_ALL_SPACES: u64 = 1 << 0; + const STATIONARY: u64 = 1 << 4; + const FULL_SCREEN_AUXILIARY: u64 = 1 << 8; // the flag stock Tauri never sets + const NONACTIVATING_PANEL: u64 = 1 << 7; // NSWindowStyleMaskNonactivatingPanel + // NSScreenSaverWindowLevel — floats above fullscreen app content. + const OVERLAY_WINDOW_LEVEL: i64 = 1000; + + let ns_window = match w.ns_window() { + Ok(ptr) => ptr as *mut Object, + Err(e) => { + eprintln!("apply_fullscreen_overlay_behavior: ns_window() failed: {e}"); + return; + } + }; + if ns_window.is_null() { + return; + } + // SAFETY: ns_window is a valid, non-null NSWindow* owned by Tauri for + // the lifetime of the webview window; all selectors are standard AppKit + // calls and we are on the main thread. + unsafe { + let is_panel: objc::runtime::BOOL = + msg_send![ns_window, isKindOfClass: class!(NSPanel)]; + if is_panel == NO { + object_setClass(ns_window, pill_panel_class()); + let style: u64 = msg_send![ns_window, styleMask]; + let _: () = msg_send![ns_window, setStyleMask: style | NONACTIVATING_PANEL]; + let _: () = msg_send![ns_window, setHidesOnDeactivate: NO]; + let _: () = msg_send![ns_window, setBecomesKeyOnlyIfNeeded: YES]; + let _: () = msg_send![ns_window, setFloatingPanel: YES]; + } + let behavior = CAN_JOIN_ALL_SPACES | FULL_SCREEN_AUXILIARY | STATIONARY; + let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; + let _: () = msg_send![ns_window, setLevel: OVERLAY_WINDOW_LEVEL]; + } + }); + if let Err(e) = dispatched { + eprintln!("apply_fullscreen_overlay_behavior: main-thread dispatch failed: {e}"); + } +} + +/// Order the pill front over whatever Space is active. `orderFrontRegardless` +/// works even though the app is inactive (it always is mid-dictation — the +/// user is typing in some other app). Called right after `window.show()`. +#[cfg(target_os = "macos")] +pub fn force_order_front(window: &tauri::WebviewWindow) { + let w = window.clone(); + let _ = window.run_on_main_thread(move || { + use objc::runtime::Object; + use objc::{msg_send, sel, sel_impl}; + if let Ok(ptr) = w.ns_window() { + let ns_window = ptr as *mut Object; + if !ns_window.is_null() { + unsafe { + let _: () = msg_send![ns_window, orderFrontRegardless]; + } + } + } + }); +} + /// Position, undo click-through, and show the dictate pill window. /// /// The hide path parks the window at (-10_000, -10_000) and toggles @@ -118,6 +246,8 @@ pub fn show_dictate_window(app: &tauri::AppHandle) { #[cfg(not(target_os = "linux"))] let _ = window.set_ignore_cursor_events(false); let _ = window.show(); + #[cfg(target_os = "macos")] + force_order_front(&window); } const LEGACY_PORT: u16 = 8000; @@ -1214,6 +1344,7 @@ fn open_input_monitoring_settings(app: tauri::AppHandle) -> Result<(), String> { /// Returns `true` when the paste sequence completed end-to-end. #[command] async fn paste_final_text( + app: tauri::AppHandle, text: String, focus: focus_capture::FocusSnapshot, ) -> Result { @@ -1227,15 +1358,46 @@ async fn paste_final_text( ); } - focus_capture::activate_pid(focus.pid)?; - tokio::time::sleep(std::time::Duration::from_millis(POST_ACTIVATE_SETTLE_MS)).await; + // Only re-activate the target when the user actually left it. When it is + // still frontmost (the common case — the dictate pill is non-activating, + // and in a fullscreen Space the target never loses frontmost), activation + // is a no-op; on macOS 26 fullscreen Spaces `activate` returns NO for an + // already-frontmost app, which would otherwise abort the paste entirely. + #[cfg(target_os = "macos")] + let already_front = focus_capture::frontmost_pid() == Some(focus.pid); + #[cfg(not(target_os = "macos"))] + let already_front = false; + + if !already_front { + focus_capture::activate_pid(focus.pid)?; + tokio::time::sleep(std::time::Duration::from_millis(POST_ACTIVATE_SETTLE_MS)).await; + } let snapshot = clipboard::save_clipboard()?; let after_write = clipboard::write_text(&text)?; + // Order the pill out for the synthetic ⌘V. The pill is a key-capable + // panel (WebKit needs that for getUserMedia), and over a fullscreen Space + // it holds key focus Spotlight-style — the keystroke would land in the + // pill instead of the target app. Hidden it can't swallow keys; restored + // immediately after so the webview never suspends between dictations. + #[cfg(target_os = "macos")] + let pill = app.get_webview_window(DICTATE_WINDOW_LABEL); + #[cfg(target_os = "macos")] + if let Some(ref w) = pill { + let _ = w.hide(); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + } + let paste_result = synthetic_keys::send_paste(); tokio::time::sleep(std::time::Duration::from_millis(PASTE_CONSUME_MS)).await; + #[cfg(target_os = "macos")] + if let Some(ref w) = pill { + let _ = w.show(); + force_order_front(w); + } + let safe_to_restore = matches!( clipboard::current_change_count(), Ok(current) if current == after_write