fix(macos): show dictation pill over fullscreen Spaces and inject into the focused app

The floating dictation pill is an ordinary NSWindow, which macOS never admits
to another app's native fullscreen Space. The pill therefore never appears in
fullscreen, its WKWebView stays occluded, and getUserMedia never resolves, so
recording only starts after leaving fullscreen.

Convert the pill to a key-capable NSPanel (the window class fullscreen Spaces
accept; key-capable so WebKit permits mic capture) with a
CanJoinAllSpaces | FullScreenAuxiliary collection behavior, and order it front
with orderFrontRegardless on every show.

Also fix desktop text injection: capture_focus() read the system-wide focused
element, which resolved to the pill itself while it briefly held key focus.
That dropped the transcript (paste-into-self guard) and let the pill swallow
the user's next keypress. Target the frontmost application instead (always the
real dictation target, since the pill is a non-activating panel), fall back to
it for apps that expose no AX focus (terminals, some Electron windows), and
restore the pill without re-taking key focus after paste so the target keeps
keyboard focus.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Trawadi Hussain Moiz
2026-10-03 19:54:00 +00:00
committed by jamiepine
co-authored by Claude Fable 5
parent 51f49dea19
commit 53537befe9
3 changed files with 221 additions and 2 deletions
+53
View File
@@ -198,6 +198,17 @@ pub fn capture_focus() -> Result<FocusSnapshot, String> {
&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<FocusSnapshot, String> {
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<i32> {
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
+4
View File
@@ -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);
}
+164 -2
View File
@@ -57,9 +57,137 @@ fn build_dictate_window(app: &tauri::AppHandle) -> tauri::Result<tauri::WebviewW
window.set_position(PhysicalPosition::new(x, y))?;
}
// Make the pill able to float over other apps' native fullscreen Spaces.
#[cfg(target_os = "macos")]
apply_fullscreen_overlay_behavior(&window);
Ok(window)
}
/// `object_setClass` — reclass a live object. Not re-exported by `objc`.
#[cfg(target_os = "macos")]
extern "C" {
fn object_setClass(
obj: *mut objc::runtime::Object,
cls: *const objc::runtime::Class,
) -> *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<bool, String> {
@@ -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