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
6 changes: 6 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,12 @@ pub fn run() {
if let Some(main) = app.get_webview_window("main") {
platform::style_main_window(&main);
}
// The panel takes keyboard focus; the pill must never steal it.
for (label, focusable) in [(pill::LABEL, false), (tray::PANEL_LABEL, true)] {
if let Some(window) = app.get_webview_window(label) {
platform::float_over_fullscreen(&window, focusable);
}
}

// Shortcut registration waits on the main thread, so it can't run
// here before the event loop starts.
Expand Down
10 changes: 10 additions & 0 deletions desktop/src-tauri/src/platform/linux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ pub fn permission_settings_url(_kind: PermissionKind) -> Option<&'static str> {

pub fn style_main_window(_window: &WebviewWindow) {}

/// Nothing extra is needed here today: `alwaysOnTop` is enough for the windows
/// SpeakType shows. Exclusive full screen apps and some Wayland compositors do
/// cover floating windows, so this is where that would be handled.
pub fn float_over_fullscreen(_window: &WebviewWindow, _focusable: bool) {}

/// Showing a window here doesn't give it focus, so ask for it.
pub fn focus_panel(window: &WebviewWindow) {
let _ = window.set_focus();
}

/// Single-modifier hotkeys aren't supported here yet.
pub const MODIFIER_HOTKEYS: &[&str] = &[];

Expand Down
14 changes: 14 additions & 0 deletions desktop/src-tauri/src/platform/macos/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ const RETRY_INTERVAL: Duration = Duration::from_secs(2);
/// creation fails, so it retries until the user grants it, and it starts over
/// if the tap's port is ever invalidated.
fn run_tap() {
let mut reported_failure = false;
loop {
let mask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN);
// SAFETY: the arguments are valid CGEventTapLocation, placement and
Expand All @@ -205,10 +206,23 @@ fn run_tap() {
)
};
if !port.is_null() {
if reported_failure {
eprintln!("[hotkey] Accessibility granted, listening again");
reported_failure = false;
}
// SAFETY: CGEventTapCreate follows the Create rule, so the port is
// ours to release, which the wrapper does when dropped.
let port = unsafe { CFMachPort::wrap_under_create_rule(port) };
run_until_invalidated(&port);
} else if !reported_failure {
// Said once, not every retry. macOS caches the answer per process,
// so granting Accessibility while the app runs usually needs a
// restart before this clears.
eprintln!(
"[hotkey] couldn't create the event tap: Accessibility permission is missing. \
Grant it, then restart SpeakType."
);
reported_failure = true;
}
thread::sleep(RETRY_INTERVAL);
}
Expand Down
133 changes: 132 additions & 1 deletion desktop/src-tauri/src/platform/macos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@
mod hotkey;
mod permissions;

use std::{path::Path, process::Command};
use std::{path::Path, process::Command, sync::OnceLock};

use enigo::{Enigo, Key};
use objc2::{
class,
ffi::object_setClass,
msg_send,
runtime::{AnyClass, AnyObject, Bool, ClassBuilder, Sel},
sel,
};
use tauri::{TitleBarStyle, WebviewWindow};

pub use hotkey::MODIFIER_HOTKEYS;
Expand Down Expand Up @@ -67,3 +74,127 @@ pub fn style_main_window(window: &WebviewWindow) {
let _ = window.set_title_bar_style(TitleBarStyle::Overlay);
let _ = window.set_title("");
}

/// `NSWindowCollectionBehaviorCanJoinAllSpaces`: show on every regular Space.
const CAN_JOIN_ALL_SPACES: usize = 1 << 0;
/// `NSWindowCollectionBehaviorFullScreenAuxiliary`: also show inside another
/// app's full screen Space. Without this a window is hidden the moment any app
/// goes full screen, however high its level is.
const FULL_SCREEN_AUXILIARY: usize = 1 << 8;
/// `NSStatusWindowLevel`, the level the menu bar's own items sit at. Tauri's
/// `alwaysOnTop` only reaches `NSFloatingWindowLevel`, which macOS keeps out of
/// another app's full screen Space however its collection behavior is set.
const STATUS_WINDOW_LEVEL: isize = 25;

/// `NSWindowStyleMaskNonactivatingPanel`: the panel can take clicks and keys
/// without activating SpeakType, so showing it never pulls the user out of the
/// app, or the Space, they are in.
const NONACTIVATING_PANEL: usize = 1 << 7;

/// Gives the menu bar panel keyboard focus once it is on screen.
///
/// Nothing to do here: `show()` already sends `makeKeyAndOrderFront:`, and a
/// nonactivating panel becomes key without activating SpeakType. Activating it
/// (what Tauri's `set_focus` does) would pull the user to whichever Space the
/// main window is on and dismiss the full screen app's menu bar.
pub fn focus_panel(_window: &WebviewWindow) {}

/// Answers for the `canBecomeKeyWindow` and `canBecomeMainWindow` overrides
/// below. tao answers both from its `focusable` ivar, which belongs to the
/// window class these windows no longer have.
extern "C" fn yes(_this: &AnyObject, _cmd: Sel) -> Bool {
Bool::YES
}

extern "C" fn no(_this: &AnyObject, _cmd: Sel) -> Bool {
Bool::NO
}

/// The `NSPanel` subclass a window of this kind becomes, registered once.
///
/// Both kinds say no to `canBecomeMainWindow`: a panel is never the main
/// window. They differ on key status. The menu bar panel needs it, to be typed
/// into and so that losing it closes the panel. The pill must never take it:
/// it floats above whatever app someone is dictating into, so becoming key
/// would mean swallowing the keystrokes meant for that app.
///
/// Answering these here rather than leaving them to `NSPanel` is deliberate.
/// A borderless nonactivating panel happens to answer no to both on macOS 26,
/// so the pill behaves today either way, but that is an undocumented default
/// rather than anything `"focusable": false` still controls once the window's
/// class has changed.
fn panel_class(focusable: bool) -> Option<&'static AnyClass> {
static KEY: OnceLock<Option<&'static AnyClass>> = OnceLock::new();
static QUIET: OnceLock<Option<&'static AnyClass>> = OnceLock::new();

let (class, name, can_become_key) = if focusable {
(
&KEY,
c"SpeakTypeKeyPanel",
yes as extern "C" fn(_, _) -> Bool,
)
} else {
(
&QUIET,
c"SpeakTypeQuietPanel",
no as extern "C" fn(_, _) -> Bool,
)
};
*class.get_or_init(|| {
let mut builder = ClassBuilder::new(name, class!(NSPanel))?;
// SAFETY: both selectors take no arguments beyond the usual two and
// return BOOL, which is what these functions do.
unsafe {
builder.add_method(sel!(canBecomeKeyWindow), can_become_key);
builder.add_method(sel!(canBecomeMainWindow), no as extern "C" fn(_, _) -> Bool);
}
Some(builder.register())
})
}

/// Lets a floating window (the pill, the menu bar panel) appear over full
/// screen apps, the way the Swift app's `NSPanel` did.
///
/// Three things are needed, and the window is invisible over a full screen app
/// without any one of them:
///
/// - `fullScreenAuxiliary`, which Tauri never sets: `visibleOnAllWorkspaces`
/// only gets as far as `canJoinAllSpaces`, and that covers regular Spaces.
/// - `NSStatusWindowLevel`: `alwaysOnTop` only reaches the floating level.
/// - being an `NSPanel`. A plain `NSWindow` of an inactive app is kept out of
/// another app's full screen Space whatever its flags say, so the window's
/// class is swapped for one of the `NSPanel` subclasses above.
///
/// Changing the class costs the window everything tao put on its own class:
/// `canBecomeKeyWindow` and `canBecomeMainWindow`, which tao answers from a
/// `focusable` ivar, and its `sendEvent:` override, which implements dragging
/// a window by its background. The subclasses answer the first two; the third
/// only matters for windows with drag regions, which these two don't have.
/// `tao::Window::set_focusable` would now abort, since it writes that ivar by
/// name, so nothing may call it on these windows.
///
/// Must run on the main thread, before the window is first shown.
pub fn float_over_fullscreen(window: &WebviewWindow, focusable: bool) {
let Ok(ns_window) = window.ns_window() else {
return;
};
let ns_window = ns_window.cast::<AnyObject>();
// SAFETY: `ns_window()` returns this window's live `NSWindow`. `NSPanel` is
// a subclass of `NSWindow` that declares no extra instance variables, so an
// `NSWindow` can safely be made one in place. Every message below is part
// of `NSPanel`'s public interface, and this runs on the main thread.
unsafe {
object_setClass(ns_window, panel_class(focusable).unwrap_or(class!(NSPanel)));

let style: usize = msg_send![ns_window, styleMask];
let _: () = msg_send![ns_window, setStyleMask: style | NONACTIVATING_PANEL];
// An NSPanel hides itself when its app stops being active, which for
// these two windows is most of the time.
let _: () = msg_send![ns_window, setHidesOnDeactivate: false];

let current: usize = msg_send![ns_window, collectionBehavior];
let behavior = current | CAN_JOIN_ALL_SPACES | FULL_SCREEN_AUXILIARY;
let _: () = msg_send![ns_window, setCollectionBehavior: behavior];
let _: () = msg_send![ns_window, setLevel: STATUS_WINDOW_LEVEL];
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//! - `permissions`, `request_permission`, `permission_settings_url`: the OS
//! permissions dictation needs, for onboarding and Settings.
//! - `style_main_window`: native window chrome tweaks.
//! - `float_over_fullscreen`: keeps a floating window visible over full screen apps.
//! - `focus_panel`: focuses the menu bar panel without disturbing the user's Space.
//! - `NEURAL_ENGINE`, `extract_zip`, `ORT_ACCELERATOR`: speech model acceleration.

use enigo::{Direction, Enigo, Key, Keyboard};
Expand Down
10 changes: 10 additions & 0 deletions desktop/src-tauri/src/platform/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ pub fn permission_settings_url(kind: PermissionKind) -> Option<&'static str> {

pub fn style_main_window(_window: &WebviewWindow) {}

/// Nothing extra is needed here today: `alwaysOnTop` is enough for the windows
/// SpeakType shows. Exclusive full screen apps and some Wayland compositors do
/// cover floating windows, so this is where that would be handled.
pub fn float_over_fullscreen(_window: &WebviewWindow, _focusable: bool) {}

/// Showing a window here doesn't give it focus, so ask for it.
pub fn focus_panel(window: &WebviewWindow) {
let _ = window.set_focus();
}

/// Single-modifier hotkeys aren't supported here yet.
pub const MODIFIER_HOTKEYS: &[&str] = &[];

Expand Down
3 changes: 2 additions & 1 deletion desktop/src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use tauri::{
use crate::{
AppState, LockExt,
dictation::{DictationState, Event},
platform,
};

pub const TRAY_ID: &str = "main";
Expand Down Expand Up @@ -143,7 +144,7 @@ fn toggle_panel(app: &AppHandle, icon: Rect) {
let _ = panel.set_position(position);
}
let _ = panel.show();
let _ = panel.set_focus();
platform::focus_panel(&panel);
let _ = app.emit_to(PANEL_LABEL, "panel-shown", ());
}

Expand Down