diff --git a/bun.lock b/bun.lock index d00ce99..1af0ccd 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "packages/desktop": { "name": "@bluemacaw/desktop", - "version": "0.0.0", + "version": "0.1.4", "dependencies": { "@ai-sdk/assemblyai": "^2.0.33", "@ai-sdk/azure": "^3.0.64", @@ -32,6 +32,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-sql": "^2.0.0", @@ -70,7 +71,7 @@ }, "packages/landing": { "name": "@bluemacaw/landing", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@fontsource-variable/nunito": "^5.2.7", "@radix-ui/react-separator": "^1.1.8", @@ -527,6 +528,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A=="], + "@tauri-apps/plugin-autostart": ["@tauri-apps/plugin-autostart@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], "@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="], diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6921454..206f21a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "^2.0.0", "@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-sql": "^2.0.0", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 277de08..d86d324 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -220,6 +220,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -364,6 +375,7 @@ dependencies = [ "sqlx", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", "tauri-plugin-global-shortcut", "tauri-plugin-sql", @@ -1067,13 +1079,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -1084,7 +1116,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1232,7 +1264,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -3836,6 +3868,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5021,7 +5064,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -5071,7 +5114,7 @@ checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -5141,6 +5184,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-clipboard-manager" version = "2.3.2" @@ -5215,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5751,7 +5808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.4", @@ -6984,6 +7041,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -7145,7 +7211,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 44912bf..9d69daf 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } +tauri-plugin-autostart = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-sql = { version = "2", features = ["sqlite"] } diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 37d68c5..2298d68 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -29,6 +29,10 @@ "store:allow-save", "updater:default", "updater:allow-check", - "updater:allow-download-and-install" + "updater:allow-download-and-install", + "autostart:default", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled" ] } diff --git a/packages/desktop/src-tauri/src/commands.rs b/packages/desktop/src-tauri/src/commands.rs index 0d100c2..f78dca9 100644 --- a/packages/desktop/src-tauri/src/commands.rs +++ b/packages/desktop/src-tauri/src/commands.rs @@ -8,7 +8,9 @@ use crate::paste::Paster; use crate::platform::is_wayland_session; use crate::secrets::Vault; use crate::shortcut::HotkeyCombo; -use crate::shortcut::parse::{format_combo, parse_combo}; +use crate::shortcut::parse::{ + format_combo, parse_combo, parse_combo_permissive, parse_double_tap, parse_modifiers_only, +}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, State}; @@ -16,7 +18,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use uuid::Uuid; #[cfg(target_os = "macos")] -use crate::shortcut::{ShortcutManager, macos_fn::MacOsFnTap}; +use crate::shortcut::{ShortcutManager, macos_chord::MacOsChordTap, macos_fn::MacOsFnTap}; /// Application state shared across Tauri commands. /// @@ -48,6 +50,13 @@ pub struct AppState { /// combo just unregisters the global shortcut, the dormant tap stays. #[cfg(target_os = "macos")] pub fn_tap: Mutex>>, + /// macOS chord tap — handles modifier-only chord shortcuts and + /// double-tap-modifier shortcuts. Lazily initialized the first time + /// the user picks such a combo. Same v1 lifecycle caveat as + /// `fn_tap`: tap thread survives the app lifetime, switching + /// shortcuts just drops the binding. + #[cfg(target_os = "macos")] + pub chord_tap: Mutex>>, } /// Platform identifier emitted to the JS side. The webview keys per-OS @@ -327,17 +336,29 @@ pub fn list_audio_input_devices() -> Vec { } /// Routes the raw `combo` string the JS side sends into the existing typed -/// [`HotkeyCombo`] enum. The special case-insensitive marker `"Fn"` becomes -/// [`HotkeyCombo::Fn`] (routed to the macOS `CGEventTap` backend); -/// everything else is [`HotkeyCombo::Standard`] (routed to -/// `tauri-plugin-global-shortcut`). The JS contract — a single `combo` -/// string — is unchanged. -fn parse_combo_input(input: &str) -> HotkeyCombo { - if input.trim().eq_ignore_ascii_case("fn") { - HotkeyCombo::Fn - } else { - HotkeyCombo::Standard { combo: input.to_string() } +/// [`HotkeyCombo`] enum. Recognises three macOS-specific markers before +/// falling back to the standard combo path: +/// +/// * `"Fn"` (case-insensitive) → [`HotkeyCombo::Fn`] +/// * `"DoubleTap+"` → [`HotkeyCombo::DoubleTap`] +/// * two-or-more bare modifier names like `"Cmd+Opt"` → +/// [`HotkeyCombo::ModifiersOnly`] +/// +/// Anything else falls through to [`HotkeyCombo::Standard`] and goes +/// through `tauri-plugin-global-shortcut`. The JS contract — a single +/// `combo` string — is unchanged. +fn parse_combo_input(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.eq_ignore_ascii_case("fn") { + return Ok(HotkeyCombo::Fn); + } + if let Some(modifier) = parse_double_tap(trimmed).map_err(|e| e.to_string())? { + return Ok(HotkeyCombo::DoubleTap { modifier }); } + if let Some(mods) = parse_modifiers_only(trimmed).map_err(|e| e.to_string())? { + return Ok(HotkeyCombo::ModifiersOnly { mods: mods.0 }); + } + Ok(HotkeyCombo::Standard { combo: input.to_string() }) } #[tauri::command] @@ -346,9 +367,25 @@ pub fn register_hotkey( state: State<'_, AppState>, combo: String, ) -> Result { - let parsed = parse_combo_input(&combo); + let parsed = parse_combo_input(&combo)?; let combo_str = match parsed { HotkeyCombo::Fn => return register_fn_hotkey(&app, &state), + HotkeyCombo::ModifiersOnly { mods } => { + return register_chord_hotkey( + &app, + &state, + HotkeyCombo::ModifiersOnly { mods }, + format!("modifiers-only(0x{mods:x})"), + ); + } + HotkeyCombo::DoubleTap { modifier } => { + return register_chord_hotkey( + &app, + &state, + HotkeyCombo::DoubleTap { modifier }, + format!("double-tap(0x{modifier:x})"), + ); + } HotkeyCombo::Standard { combo } => combo, }; let shortcut = parse_combo(&combo_str).map_err(|e| e.to_string())?; @@ -417,6 +454,63 @@ fn register_fn_hotkey( Err("Fn key shortcut is only supported on macOS".to_string()) } +#[cfg(target_os = "macos")] +fn register_chord_hotkey( + app: &AppHandle, + state: &State<'_, AppState>, + combo: HotkeyCombo, + label: String, +) -> Result { + // Drop any plugin-managed standard shortcut so we don't double-fire + // from two backends, mirroring `register_fn_hotkey`. + { + let mut current = state.current_hotkey.lock().map_err(|e| e.to_string())?; + if let Some(prev) = current.take() { + let _ = app.global_shortcut().unregister(prev); + } + } + // Drop any prior chord tap (the tap's run loop survives, but the + // binding is replaced). Each new chord-mode picks gets a fresh + // `MacOsChordTap` because the mode is baked in at construction. + let mut tap_slot = state.chord_tap.lock().map_err(|e| e.to_string())?; + let app_clone = app.clone(); + let tap = Arc::new( + MacOsChordTap::new( + move || { + let _ = app_clone.emit(EVT_SHORTCUT_TOGGLE, ()); + }, + &combo, + ) + .map_err(|e| e.to_string())?, + ); + tap.register(combo).map_err(|e| match e { + crate::shortcut::ShortcutError::InputMonitoringRequired => format!( + "{ERR_INPUT_MONITORING_REQUIRED} grant bluemacaw in System Settings → Privacy & \ + Security → Input Monitoring, then quit and reopen the app" + ), + crate::shortcut::ShortcutError::AccessibilityRequired => format!( + "{ERR_ACCESSIBILITY_REQUIRED} grant bluemacaw in System Settings → Privacy & Security \ + → Accessibility, then try again" + ), + other => other.to_string(), + })?; + *tap_slot = Some(tap); + Ok(label) +} + +#[cfg(not(target_os = "macos"))] +fn register_chord_hotkey( + _app: &AppHandle, + _state: &State<'_, AppState>, + _combo: HotkeyCombo, + _label: String, +) -> Result { + Err( + "Modifier-only chord and double-tap shortcuts are only supported on macOS in this version" + .to_string(), + ) +} + #[tauri::command] pub fn unregister_hotkey( app: AppHandle, @@ -440,16 +534,20 @@ pub fn unregister_hotkey( /// Unlike `register_hotkey`, the Fn-key path is intentionally NOT supported /// here — the cancel hotkey is meant to be distinct from the toggle and /// only one Fn-tap can ever be installed, so allowing `"Fn"` would either -/// collide with the toggle path or silently no-op. The combo parser -/// (`parse_combo`) still requires at least one modifier, so we don't risk -/// swallowing bare keys like Esc system-wide. +/// collide with the toggle path or silently no-op. +/// +/// The permissive combo parser is used so a bare key like `"Esc"` is +/// accepted. That's only safe because the JS side registers the cancel +/// hotkey *while a recording is in flight* and unregisters it again the +/// moment the recording ends — Esc is not globally swallowed during +/// idle / transcribing / error states. #[tauri::command] pub fn register_cancel_hotkey( app: AppHandle, state: State<'_, AppState>, combo: String, ) -> Result { - let shortcut = parse_combo(&combo).map_err(|e| e.to_string())?; + let shortcut = parse_combo_permissive(&combo).map_err(|e| e.to_string())?; let mut current = state .current_cancel_hotkey .lock() @@ -486,6 +584,16 @@ pub fn unregister_cancel_hotkey( Ok(()) } +/// Parse-only validation of a cancel-hotkey combo. Used by Settings / +/// onboarding to surface a parse error before the user commits, without +/// actually registering the shortcut globally — registration is the +/// recording loop's job. +#[tauri::command] +pub fn validate_cancel_hotkey(combo: String) -> Result { + let shortcut = parse_combo_permissive(&combo).map_err(|e| e.to_string())?; + Ok(format_combo(&shortcut)) +} + /// Read `defaults read com.apple.HIToolbox AppleFnUsageType`. /// Returns `None` when the key has never been set (factory default). /// 0 = Do Nothing, 1 = Change Input Source, 2 = Show Emoji & Symbols, 3 = Start Dictation. @@ -652,23 +760,23 @@ mod tests { #[test] fn parse_combo_input_recognises_fn_marker_exact() { - assert_eq!(parse_combo_input("Fn"), HotkeyCombo::Fn); + assert_eq!(parse_combo_input("Fn").unwrap(), HotkeyCombo::Fn); } #[test] fn parse_combo_input_recognises_fn_marker_lowercase() { - assert_eq!(parse_combo_input("fn"), HotkeyCombo::Fn); + assert_eq!(parse_combo_input("fn").unwrap(), HotkeyCombo::Fn); } #[test] fn parse_combo_input_recognises_fn_marker_with_whitespace_and_caps() { - assert_eq!(parse_combo_input(" FN "), HotkeyCombo::Fn); + assert_eq!(parse_combo_input(" FN ").unwrap(), HotkeyCombo::Fn); } #[test] fn parse_combo_input_treats_standard_combo_as_standard() { assert_eq!( - parse_combo_input("Cmd+Shift+Space"), + parse_combo_input("Cmd+Shift+Space").unwrap(), HotkeyCombo::Standard { combo: "Cmd+Shift+Space".to_string(), }, @@ -681,10 +789,51 @@ mod tests { // downstream handles whitespace. This pins the verbatim-passthrough // contract so a future refactor doesn't silently start trimming. assert_eq!( - parse_combo_input(" Ctrl+Alt+A "), + parse_combo_input(" Ctrl+Alt+A ").unwrap(), HotkeyCombo::Standard { combo: " Ctrl+Alt+A ".to_string(), }, ); } + + #[test] + fn parse_combo_input_recognises_modifier_only_chord() { + // Two-modifier chord like Cmd+Opt routes to the macOS chord + // tap rather than `tauri-plugin-global-shortcut`. + use crate::shortcut::parse::ModifierSet; + let parsed = parse_combo_input("Cmd+Opt").unwrap(); + match parsed { + HotkeyCombo::ModifiersOnly { mods } => { + assert!(mods & ModifierSet::CMD != 0); + assert!(mods & ModifierSet::ALT != 0); + assert!(mods & ModifierSet::CTRL == 0); + assert!(mods & ModifierSet::SHIFT == 0); + } + other => panic!("expected ModifiersOnly, got {other:?}"), + } + } + + #[test] + fn parse_combo_input_recognises_double_tap() { + use crate::shortcut::parse::ModifierSet; + assert_eq!( + parse_combo_input("DoubleTap+Cmd").unwrap(), + HotkeyCombo::DoubleTap { + modifier: ModifierSet::CMD, + }, + ); + } + + #[test] + fn parse_combo_input_single_modifier_falls_through_to_standard() { + // A single bare modifier like "Cmd" is not a chord and not a + // double-tap marker, so it should fall through to the standard + // path (where `parse_combo` will reject it as `NoKey`). + assert_eq!( + parse_combo_input("Cmd").unwrap(), + HotkeyCombo::Standard { + combo: "Cmd".to_string(), + }, + ); + } } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index f0d5677..5226602 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -34,6 +34,18 @@ pub fn run() { env_logger::init(); tauri::Builder::default() + .plugin(tauri_plugin_autostart::init( + // `AppleScript` registers a Login Item via osascript on macOS. + // The alternative (`LaunchAgent`) writes a plist to + // ~/Library/LaunchAgents which survives app uninstall — the + // Login Item path is cleaner and matches what the user sees + // in System Settings → General → Login Items. + tauri_plugin_autostart::MacosLauncher::AppleScript, + // No CLI args needed on relaunch; bluemacaw boots the same + // tray-resident process whether the user clicked the dock + // icon or the OS auto-launched it. + None, + )) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin( @@ -65,6 +77,7 @@ pub fn run() { commands::unregister_hotkey, commands::register_cancel_hotkey, commands::unregister_cancel_hotkey, + commands::validate_cancel_hotkey, commands::get_fn_usage_type, commands::set_fn_usage_type, commands::get_platform_info, @@ -83,6 +96,8 @@ pub fn run() { current_cancel_hotkey: Mutex::new(None), #[cfg(target_os = "macos")] fn_tap: Mutex::new(None), + #[cfg(target_os = "macos")] + chord_tap: Mutex::new(None), }; app.manage(app_state); diff --git a/packages/desktop/src-tauri/src/shortcut/macos_chord.rs b/packages/desktop/src-tauri/src/shortcut/macos_chord.rs new file mode 100644 index 0000000..fd9031b --- /dev/null +++ b/packages/desktop/src-tauri/src/shortcut/macos_chord.rs @@ -0,0 +1,532 @@ +#![cfg(target_os = "macos")] + +//! macOS modifier-chord shortcut backend. +//! +//! Covers two related shortcut surfaces that `tauri-plugin-global-shortcut` +//! cannot express because its `RegisterHotKey`/`global-hotkey` +//! foundation requires every binding to terminate in a non-modifier +//! key: +//! +//! * **Modifier-only chord** — e.g. Cmd+Opt held together with no +//! other key. Fires once on the press edge when the exact +//! modifier set becomes held; the chord is "armed" again once any +//! of those modifiers is released, so holding indefinitely yields +//! exactly one event per press. +//! * **Double-tap modifier** — e.g. tap Cmd twice within ~350ms. +//! The tap tracks the press/release sequence of a single modifier +//! and fires when it sees `down → up → down` inside the window +//! without any other modifier or non-modifier key intervening. +//! +//! Both surfaces share the same `CGEventTap` plumbing as +//! [`super::macos_fn::MacOsFnTap`] — the tap gates on the **Input +//! Monitoring** TCC bucket (`kTCCServiceListenEvent`), surfaced as +//! [`super::ShortcutError::InputMonitoringRequired`] when the kernel +//! refuses to install it. +//! +//! The chord tap also subscribes to `KeyDown` events so it can +//! invalidate the in-flight chord/double-tap state when the user +//! presses an additional non-modifier key (Cmd+S during a Cmd+Opt +//! chord, for example). + +use super::parse::ModifierSet; +use super::{HotkeyCombo, ShortcutError, ShortcutManager}; +use core_foundation::runloop::{kCFRunLoopCommonModes, CFRunLoop}; +use core_graphics::event::{ + CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, + CGEventType, +}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +/// CGEventFlags for each macOS modifier bit, mirrored from +/// `kCGEventFlagMask*`. Stable across macOS versions. +const FLAG_CMD: u64 = CGEventFlags::CGEventFlagCommand.bits(); +const FLAG_SHIFT: u64 = CGEventFlags::CGEventFlagShift.bits(); +const FLAG_ALT: u64 = CGEventFlags::CGEventFlagAlternate.bits(); +const FLAG_CTRL: u64 = CGEventFlags::CGEventFlagControl.bits(); + +/// Window inside which a press-release-press sequence still counts as +/// a double-tap. Matches the macOS dictation gesture's tolerance. +const DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(350); + +#[derive(Debug, Clone, Copy)] +enum ChordMode { + /// Fire when the exact modifier set becomes held, exactly once + /// until any of those modifiers is released. + ModifiersOnly { mods: u8 }, + /// Fire on the second press of the named modifier when it occurs + /// inside [`DOUBLE_TAP_WINDOW`] of the first release. + DoubleTap { modifier: u8 }, +} + +pub struct MacOsChordTap { + on_toggle: Arc, + mode: ChordMode, + state: Arc>, +} + +// Manual `Debug` impl because the `on_toggle` callback is a trait +// object that can't derive `Debug`. The tests `unwrap_err()` a +// `Result`, which requires the Ok variant to be +// `Debug`; skipping the callback in the output is harmless. +impl std::fmt::Debug for MacOsChordTap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MacOsChordTap") + .field("mode", &self.mode) + .field("on_toggle", &"") + .finish() + } +} + +struct TapState { + thread: Option>, +} + +impl MacOsChordTap { + pub fn new(on_toggle: F, mode_combo: &HotkeyCombo) -> Result { + let mode = match mode_combo { + HotkeyCombo::ModifiersOnly { mods } => ChordMode::ModifiersOnly { mods: *mods }, + HotkeyCombo::DoubleTap { modifier } => ChordMode::DoubleTap { modifier: *modifier }, + _ => { + return Err(ShortcutError::Backend( + "MacOsChordTap only handles ModifiersOnly / DoubleTap combos".into(), + )); + } + }; + Ok(Self { + on_toggle: Arc::new(on_toggle), + mode, + state: Arc::new(Mutex::new(TapState { thread: None })), + }) + } + + fn start(&self) -> Result<(), ShortcutError> { + if self.state.lock().unwrap().thread.is_some() { + return Ok(()); + } + let on_toggle = self.on_toggle.clone(); + let mode = self.mode; + let (tx, rx) = std::sync::mpsc::channel::>(); + + let handle = thread::spawn(move || { + // Mutable per-tap state: the chord arm flag for the + // modifier-only path, and the press/release timestamps + // for the double-tap path. + let arm = Arc::new(Mutex::new(ChordRuntime::new())); + let arm_for_cb = arm.clone(); + let on_toggle_for_cb = on_toggle.clone(); + + log::info!("chord tap: creating CGEventTap (mode={mode:?})"); + let tap_result = CGEventTap::new( + CGEventTapLocation::HID, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + vec![CGEventType::FlagsChanged, CGEventType::KeyDown], + move |_proxy, etype, event| { + let flags = event.get_flags().bits(); + let mut runtime = arm_for_cb.lock().unwrap(); + match etype { + CGEventType::FlagsChanged => { + handle_flags_changed( + &mut runtime, + mode, + flags, + on_toggle_for_cb.as_ref(), + ); + } + CGEventType::KeyDown => { + // Any non-modifier press resets the chord + // detection so a Cmd+Opt+S chord doesn't + // also fire the Cmd+Opt modifier-only + // chord, and a Cmd+S keypress doesn't get + // misread as the second tap of a Cmd + // double-tap. + runtime.invalidate(); + } + _ => {} + } + None + }, + ); + + match tap_result { + Ok(tap) => { + let loop_source = match tap.mach_port.create_runloop_source(0) { + Ok(src) => src, + Err(()) => { + let _ = tx.send(Err(ShortcutError::Backend( + "failed to create CFRunLoopSource for chord CGEventTap".into(), + ))); + return; + } + }; + let current = CFRunLoop::get_current(); + current.add_source(&loop_source, unsafe { kCFRunLoopCommonModes }); + tap.enable(); + log::info!("chord tap: enabled"); + let _ = tx.send(Ok(())); + CFRunLoop::run_current(); + } + Err(()) => { + log::error!( + "chord CGEventTap creation returned null — Input Monitoring permission \ + almost certainly missing (kTCCServiceListenEvent)" + ); + let _ = tx.send(Err(ShortcutError::InputMonitoringRequired)); + } + } + }); + + let setup = rx + .recv() + .map_err(|_| ShortcutError::Backend("chord tap thread terminated before setup".into()))?; + setup?; + self.state.lock().unwrap().thread = Some(handle); + Ok(()) + } +} + +impl ShortcutManager for MacOsChordTap { + fn register(&self, combo: HotkeyCombo) -> Result<(), ShortcutError> { + match combo { + HotkeyCombo::ModifiersOnly { mods } => match self.mode { + ChordMode::ModifiersOnly { mods: m } if m == mods => self.start(), + _ => Err(ShortcutError::Backend( + "chord tap was constructed for a different mode".into(), + )), + }, + HotkeyCombo::DoubleTap { modifier } => match self.mode { + ChordMode::DoubleTap { modifier: m } if m == modifier => self.start(), + _ => Err(ShortcutError::Backend( + "chord tap was constructed for a different mode".into(), + )), + }, + _ => Err(ShortcutError::Backend( + "MacOsChordTap only handles ModifiersOnly / DoubleTap combos".into(), + )), + } + } + + fn unregister(&self) -> Result<(), ShortcutError> { + // Same v1 caveat as `MacOsFnTap`: the tap thread owns its run + // loop and we can't cleanly stop a CFRunLoop in v1. Switching + // shortcuts unregisters the binding from the JS side; the tap + // stays alive and dormant until process exit. + Ok(()) + } +} + +/// Tap-thread-local mutable state. +struct ChordRuntime { + /// For ModifiersOnly: true once we've fired for the current + /// "all required mods held" episode. Reset when any required mod + /// is released. + chord_armed: bool, + /// For DoubleTap: timestamp of the most recent clean release of + /// the tracked modifier. `None` until we've seen at least one + /// complete press-release pair. + last_release: Option, + /// For DoubleTap: was the modifier "exactly held alone" on the + /// previous flags snapshot? Used to detect press/release edges. + last_held: bool, + /// Set when the in-flight chord/double-tap sequence has been + /// invalidated (non-modifier KeyDown, or a non-target modifier + /// got mixed in). Cleared only when the user returns to "nothing + /// held". + poisoned: bool, +} + +impl ChordRuntime { + fn new() -> Self { + Self { + chord_armed: false, + last_release: None, + last_held: false, + poisoned: false, + } + } + fn invalidate(&mut self) { + // Mark the chord as already-fired so a held chord doesn't + // immediately refire after a non-modifier KeyDown rejoins + // the same modifier set. + self.chord_armed = true; + self.poisoned = true; + self.last_release = None; + } +} + +fn flags_to_modifier_set(flags: u64) -> u8 { + let mut out = 0u8; + if (flags & FLAG_CMD) != 0 { + out |= ModifierSet::CMD; + } + if (flags & FLAG_CTRL) != 0 { + out |= ModifierSet::CTRL; + } + if (flags & FLAG_ALT) != 0 { + out |= ModifierSet::ALT; + } + if (flags & FLAG_SHIFT) != 0 { + out |= ModifierSet::SHIFT; + } + out +} + +fn handle_flags_changed( + runtime: &mut ChordRuntime, + mode: ChordMode, + flags: u64, + on_toggle: &F, +) where + F: Fn() + ?Sized, +{ + let current = flags_to_modifier_set(flags); + match mode { + ChordMode::ModifiersOnly { mods } => { + let exact_match = current == mods; + if exact_match && !runtime.chord_armed { + runtime.chord_armed = true; + log::info!("chord tap: modifier-only chord fired (mods=0x{mods:x})"); + on_toggle(); + } + // Disarm when ANY required modifier is released, so the + // next time the user assembles the full set we fire + // again. + if !exact_match && (current & mods) != mods { + runtime.chord_armed = false; + } + } + ChordMode::DoubleTap { modifier } => { + let exactly_target = current == modifier; + let press = exactly_target && !runtime.last_held; + let release = runtime.last_held && !exactly_target; + + // Poison the in-flight pair when any modifier state + // includes something OTHER than the target alone — e.g. + // Cmd+Shift. Prevents "Cmd → Cmd+Shift → Cmd → Cmd" from + // being misread as a Cmd double-tap. + if current != 0 && current != modifier { + runtime.poisoned = true; + runtime.last_release = None; + } + + if press && !runtime.poisoned { + if let Some(last) = runtime.last_release { + if last.elapsed() <= DOUBLE_TAP_WINDOW { + log::info!( + "chord tap: double-tap fired (modifier=0x{modifier:x})" + ); + on_toggle(); + runtime.last_release = None; + runtime.last_held = exactly_target; + return; + } + } + // First press of a new pair or a stale prior release + // beyond the window — reset the candidate timestamp + // so the *next* clean release seeds the pair. + runtime.last_release = None; + } + + if release { + // Only a clean release (everything off) is a valid + // candidate for the "first tap" of a future pair. + if !runtime.poisoned && current == 0 { + runtime.last_release = Some(Instant::now()); + } else { + runtime.last_release = None; + } + } + + // Clear poison only when the user has fully let go of + // everything — this keeps a corrupted sequence corrupted + // until they restart with a clean slate. + if current == 0 { + runtime.poisoned = false; + } + + runtime.last_held = exactly_target; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flag_constants_align_with_cgevent_constants() { + // Sanity: the bit values we hard-code match what the + // core-graphics crate exposes. + assert_eq!(FLAG_CMD, CGEventFlags::CGEventFlagCommand.bits()); + assert_eq!(FLAG_SHIFT, CGEventFlags::CGEventFlagShift.bits()); + assert_eq!(FLAG_ALT, CGEventFlags::CGEventFlagAlternate.bits()); + assert_eq!(FLAG_CTRL, CGEventFlags::CGEventFlagControl.bits()); + } + + #[test] + fn flags_to_modifier_set_maps_each_bit() { + assert_eq!(flags_to_modifier_set(0), 0); + assert_eq!(flags_to_modifier_set(FLAG_CMD), ModifierSet::CMD); + assert_eq!(flags_to_modifier_set(FLAG_CMD | FLAG_ALT), ModifierSet::CMD | ModifierSet::ALT); + } + + #[test] + fn rejects_construction_for_non_chord_combo() { + let err = MacOsChordTap::new(|| {}, &HotkeyCombo::Fn).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("ModifiersOnly / DoubleTap"), "got: {msg}"); + } + + #[test] + fn rejects_construction_for_standard_combo() { + let err = MacOsChordTap::new( + || {}, + &HotkeyCombo::Standard { + combo: "Cmd+Shift+Space".into(), + }, + ) + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("ModifiersOnly / DoubleTap"), "got: {msg}"); + } + + #[test] + fn modifier_only_chord_fires_once_per_press_episode() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::ModifiersOnly { mods: ModifierSet::CMD | ModifierSet::ALT }; + let mut rt = ChordRuntime::new(); + + // Press Cmd+Opt simultaneously — fires once. + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_ALT, &*cb); + assert_eq!(*count.lock().unwrap(), 1); + + // Same flags arriving again (e.g. another FlagsChanged tick + // with the same modifiers held) must not refire. + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_ALT, &*cb); + assert_eq!(*count.lock().unwrap(), 1); + + // Release one modifier, then re-press: rearm + fire again. + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_ALT, &*cb); + assert_eq!(*count.lock().unwrap(), 2); + } + + #[test] + fn modifier_only_chord_ignores_extra_modifier() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::ModifiersOnly { mods: ModifierSet::CMD | ModifierSet::ALT }; + let mut rt = ChordRuntime::new(); + + // Cmd+Opt+Shift held — NOT an exact match, so don't fire. + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_ALT | FLAG_SHIFT, &*cb); + assert_eq!(*count.lock().unwrap(), 0); + } + + #[test] + fn modifier_only_chord_does_not_fire_for_subset() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::ModifiersOnly { mods: ModifierSet::CMD | ModifierSet::ALT }; + let mut rt = ChordRuntime::new(); + + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + assert_eq!(*count.lock().unwrap(), 0); + } + + #[test] + fn double_tap_does_not_fire_on_a_single_press_release() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::DoubleTap { modifier: ModifierSet::CMD }; + let mut rt = ChordRuntime::new(); + + // Press Cmd, release Cmd. + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + handle_flags_changed(&mut rt, mode, 0, &*cb); + assert_eq!(*count.lock().unwrap(), 0); + } + + #[test] + fn double_tap_fires_when_two_presses_arrive_in_quick_succession() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::DoubleTap { modifier: ModifierSet::CMD }; + let mut rt = ChordRuntime::new(); + + // Tap 1 + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + handle_flags_changed(&mut rt, mode, 0, &*cb); + // Tap 2 (the test uses real time but the window is 350ms, + // and these calls run inside microseconds of each other). + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + assert_eq!(*count.lock().unwrap(), 1); + } + + #[test] + fn double_tap_poisons_when_other_modifier_is_added_between_taps() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::DoubleTap { modifier: ModifierSet::CMD }; + let mut rt = ChordRuntime::new(); + + // Tap 1: Cmd down, Cmd up + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + handle_flags_changed(&mut rt, mode, 0, &*cb); + // User starts a Cmd+Shift combo: Shift down (no Cmd) — this + // shouldn't poison since Cmd isn't held; but next, Cmd added + // — Cmd+Shift held — this is not "exactly Cmd", so poison. + handle_flags_changed(&mut rt, mode, FLAG_SHIFT, &*cb); + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_SHIFT, &*cb); + // Now release Shift — Cmd alone. That's a "press" of Cmd but + // it's poisoned, so no fire. + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + assert_eq!(*count.lock().unwrap(), 0); + } + + #[test] + fn invalidate_blocks_in_flight_modifier_chord() { + let count = Arc::new(Mutex::new(0u32)); + let count_c = count.clone(); + let cb: Arc = Arc::new(move || { + *count_c.lock().unwrap() += 1; + }); + let mode = ChordMode::ModifiersOnly { mods: ModifierSet::CMD | ModifierSet::ALT }; + let mut rt = ChordRuntime::new(); + + // A non-modifier KeyDown arrived (e.g. user pressed S), so + // the in-flight chord is invalidated. + rt.invalidate(); + // User now holds Cmd+Opt exactly — must NOT fire because the + // chord was poisoned by the earlier non-modifier press. + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_ALT, &*cb); + assert_eq!(*count.lock().unwrap(), 0); + + // Once the user releases at least one of the required mods, + // the chord disarms and a fresh press fires. + handle_flags_changed(&mut rt, mode, FLAG_CMD, &*cb); + handle_flags_changed(&mut rt, mode, FLAG_CMD | FLAG_ALT, &*cb); + assert_eq!(*count.lock().unwrap(), 1); + } +} diff --git a/packages/desktop/src-tauri/src/shortcut/macos_fn.rs b/packages/desktop/src-tauri/src/shortcut/macos_fn.rs index 0eebaa9..83d21b0 100644 --- a/packages/desktop/src-tauri/src/shortcut/macos_fn.rs +++ b/packages/desktop/src-tauri/src/shortcut/macos_fn.rs @@ -150,7 +150,9 @@ impl ShortcutManager for MacOsFnTap { fn register(&self, combo: HotkeyCombo) -> Result<(), ShortcutError> { match combo { HotkeyCombo::Fn => self.start(), - HotkeyCombo::Standard { .. } => Err(ShortcutError::Backend( + HotkeyCombo::Standard { .. } + | HotkeyCombo::ModifiersOnly { .. } + | HotkeyCombo::DoubleTap { .. } => Err(ShortcutError::Backend( "MacOsFnTap only supports Fn combos".into(), )), } diff --git a/packages/desktop/src-tauri/src/shortcut/mod.rs b/packages/desktop/src-tauri/src/shortcut/mod.rs index d4337f4..9b2bd00 100644 --- a/packages/desktop/src-tauri/src/shortcut/mod.rs +++ b/packages/desktop/src-tauri/src/shortcut/mod.rs @@ -2,19 +2,32 @@ use serde::{Deserialize, Serialize}; pub mod parse; +#[cfg(target_os = "macos")] +pub mod macos_chord; #[cfg(target_os = "macos")] pub mod macos_fn; /// Hotkey combination supported by the [`ShortcutManager`] surface. /// -/// `Fn` is macOS-only and routed to a `CGEventTap`-based backend -/// because `tauri-plugin-global-shortcut` cannot observe the -/// secondary-fn modifier on its own. Everything else is a string -/// like `"Ctrl+Shift+Space"` understood by the plugin. +/// Variants beyond `Standard` are routed to a `CGEventTap`-based +/// backend because `tauri-plugin-global-shortcut` cannot observe +/// either the secondary-fn modifier or pure modifier transitions — +/// the plugin's `RegisterHotKey`/`global-hotkey` foundation requires +/// a non-modifier key on every binding. The chord/double-tap paths +/// are macOS-only in v1; non-macOS platforms surface a clear error. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum HotkeyCombo { Fn, + /// Modifier-only chord such as Cmd+Opt — fires when the exact + /// modifier set becomes held and nothing else has been pressed. + /// The `mods` u8 follows the bit layout in + /// [`parse::ModifierSet`]. + ModifiersOnly { mods: u8 }, + /// Double-tap of a single modifier within a short window + /// (~350ms). Same bit layout as `mods` above, but exactly one + /// bit set. + DoubleTap { modifier: u8 }, Standard { combo: String }, } diff --git a/packages/desktop/src-tauri/src/shortcut/parse.rs b/packages/desktop/src-tauri/src/shortcut/parse.rs index eab91cb..2f47cdf 100644 --- a/packages/desktop/src-tauri/src/shortcut/parse.rs +++ b/packages/desktop/src-tauri/src/shortcut/parse.rs @@ -10,9 +10,115 @@ pub enum ParseError { NoKey, #[error("unknown key: {0}")] UnknownKey(String), + #[error("double-tap combo needs exactly one modifier (got: {0})")] + DoubleTapNeedsOneModifier(String), +} + +/// Modifier bitset shared by the modifier-only and double-tap combo +/// variants. The flags mirror `tauri_plugin_global_shortcut::Modifiers` +/// for the standard combo path but live in a plain `u8` so the variant +/// can be `Copy` and serialize cleanly across the IPC boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ModifierSet(pub u8); + +impl ModifierSet { + pub const CMD: u8 = 1 << 0; + pub const CTRL: u8 = 1 << 1; + pub const ALT: u8 = 1 << 2; + pub const SHIFT: u8 = 1 << 3; + + pub fn is_empty(self) -> bool { + self.0 == 0 + } + pub fn contains(self, flag: u8) -> bool { + (self.0 & flag) != 0 + } + pub fn count(self) -> u32 { + self.0.count_ones() + } +} + +/// Try to parse a modifier-only combo string (e.g. `"Cmd+Opt"`). +/// Returns `Ok(Some(...))` on success, `Ok(None)` when the input has a +/// non-modifier component (i.e. it's a normal combo and should go +/// through `parse_combo`), and `Err` when the parts that ARE present +/// are individually unrecognisable. +pub fn parse_modifiers_only(input: &str) -> Result, ParseError> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseError::Empty); + } + let mut mods = ModifierSet::default(); + for raw in trimmed.split('+') { + let part = raw.trim(); + if part.is_empty() { + continue; + } + match part.to_ascii_lowercase().as_str() { + "cmd" | "command" | "meta" | "super" | "win" | "windows" => mods.0 |= ModifierSet::CMD, + "ctrl" | "control" => mods.0 |= ModifierSet::CTRL, + "alt" | "option" | "opt" => mods.0 |= ModifierSet::ALT, + "shift" => mods.0 |= ModifierSet::SHIFT, + // A non-modifier component means this isn't a modifier-only + // combo — bail with Ok(None) so the caller falls through to + // the standard-combo path. + _ => return Ok(None), + } + } + if mods.count() < 2 { + // A "modifier-only chord" with only one modifier would collide + // with regular usage of that key (e.g. Cmd held while typing + // text), so we require at least two distinct modifiers. The + // single-modifier shortcut surface is double-tap, not chord. + return Ok(None); + } + Ok(Some(mods)) +} + +/// Try to parse a double-tap combo string (e.g. `"DoubleTap+Cmd"`). +/// Returns `Ok(Some(modifier))` on success, `Ok(None)` if the input +/// doesn't start with the `DoubleTap` marker, and `Err` if it does but +/// the trailing modifier is missing or invalid. +pub fn parse_double_tap(input: &str) -> Result, ParseError> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ParseError::Empty); + } + let mut parts = trimmed.split('+').map(|p| p.trim()).filter(|p| !p.is_empty()); + let Some(first) = parts.next() else { + return Err(ParseError::Empty); + }; + if !first.eq_ignore_ascii_case("doubletap") && !first.eq_ignore_ascii_case("double-tap") { + return Ok(None); + } + let rest: Vec<&str> = parts.collect(); + if rest.len() != 1 { + return Err(ParseError::DoubleTapNeedsOneModifier(trimmed.to_string())); + } + let mod_flag = match rest[0].to_ascii_lowercase().as_str() { + "cmd" | "command" | "meta" | "super" | "win" | "windows" => ModifierSet::CMD, + "ctrl" | "control" => ModifierSet::CTRL, + "alt" | "option" | "opt" => ModifierSet::ALT, + "shift" => ModifierSet::SHIFT, + _ => return Err(ParseError::DoubleTapNeedsOneModifier(trimmed.to_string())), + }; + Ok(Some(mod_flag)) } pub fn parse_combo(input: &str) -> Result { + parse_combo_inner(input, /* require_modifier */ true) +} + +/// Variant of [`parse_combo`] that accepts bare keys (e.g. `"Esc"`). +/// Used for the cancel-recording hotkey, which is only ever registered +/// while a recording is in flight — so consuming a bare key globally is +/// scoped to that short window and won't block other apps' Esc presses +/// during normal use. +pub fn parse_combo_permissive(input: &str) -> Result { + parse_combo_inner(input, /* require_modifier */ false) +} + +fn parse_combo_inner(input: &str, require_modifier: bool) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { return Err(ParseError::Empty); @@ -47,10 +153,11 @@ pub fn parse_combo(input: &str) -> Result { } return Err(ParseError::NoKey); }; - if mods.is_empty() { + if require_modifier && mods.is_empty() { return Err(ParseError::NoModifier); } - Ok(Shortcut::new(Some(mods), k)) + let mods_opt = if mods.is_empty() { None } else { Some(mods) }; + Ok(Shortcut::new(mods_opt, k)) } pub fn format_combo(shortcut: &Shortcut) -> String { @@ -177,6 +284,37 @@ mod tests { assert_eq!(parse_combo("A"), Err(ParseError::NoModifier)); } + #[test] + fn parse_permissive_accepts_bare_key() { + // The cancel-recording hotkey runs through the permissive parser + // because we only register it while a recording is in flight, so + // a bare Esc binding is safe — other apps still see Esc when no + // recording is active. + let s = parse_combo_permissive("Esc").unwrap(); + assert!(s.mods.is_empty()); + assert_eq!(s.key, Code::Escape); + } + + #[test] + fn parse_permissive_still_rejects_empty_input() { + assert_eq!(parse_combo_permissive(""), Err(ParseError::Empty)); + } + + #[test] + fn parse_permissive_still_requires_a_key() { + // Modifier-only combos go through a different code path entirely + // (see HotkeyCombo::ModifiersOnly) so the permissive parser still + // demands at least one non-modifier key. + assert_eq!(parse_combo_permissive("Cmd"), Err(ParseError::NoKey)); + } + + #[test] + fn parse_permissive_accepts_combo_with_modifier() { + let s = parse_combo_permissive("Cmd+Esc").unwrap(); + assert_eq!(s.mods, Modifiers::SUPER); + assert_eq!(s.key, Code::Escape); + } + #[test] fn parse_rejects_unknown_key() { assert!(matches!( @@ -252,4 +390,93 @@ mod tests { let formatted = format_combo(&s); assert_eq!(formatted, "Cmd+Ctrl+Alt+Shift+A"); } + + // ---- Modifier-only chord parser ---------------------------------- + + #[test] + fn modifiers_only_accepts_two_modifiers() { + let mods = parse_modifiers_only("Cmd+Opt").unwrap().unwrap(); + assert!(mods.contains(ModifierSet::CMD)); + assert!(mods.contains(ModifierSet::ALT)); + assert!(!mods.contains(ModifierSet::CTRL)); + assert!(!mods.contains(ModifierSet::SHIFT)); + } + + #[test] + fn modifiers_only_is_case_insensitive() { + let a = parse_modifiers_only("cmd+option").unwrap().unwrap(); + let b = parse_modifiers_only("CMD+OPT").unwrap().unwrap(); + let c = parse_modifiers_only("Command+Alt").unwrap().unwrap(); + assert_eq!(a.0, b.0); + assert_eq!(a.0, c.0); + } + + #[test] + fn modifiers_only_rejects_single_modifier() { + // A bare `Cmd` would collide with normal usage of Cmd; the + // single-modifier surface is double-tap, not a chord. The + // parser returns Ok(None) so the caller falls through. + assert_eq!(parse_modifiers_only("Cmd").unwrap(), None); + } + + #[test] + fn modifiers_only_returns_none_for_normal_combo() { + // Standard combos contain a non-modifier component, so the + // modifier-only parser punts back to the caller. + assert_eq!(parse_modifiers_only("Cmd+Shift+Space").unwrap(), None); + } + + #[test] + fn modifiers_only_rejects_empty_input() { + assert_eq!(parse_modifiers_only("").unwrap_err(), ParseError::Empty); + } + + #[test] + fn modifiers_only_accepts_three_modifiers() { + let mods = parse_modifiers_only("Cmd+Ctrl+Opt").unwrap().unwrap(); + assert_eq!(mods.count(), 3); + assert!(mods.contains(ModifierSet::CMD)); + assert!(mods.contains(ModifierSet::CTRL)); + assert!(mods.contains(ModifierSet::ALT)); + } + + // ---- Double-tap parser ------------------------------------------- + + #[test] + fn double_tap_accepts_each_modifier() { + assert_eq!(parse_double_tap("DoubleTap+Cmd").unwrap(), Some(ModifierSet::CMD)); + assert_eq!(parse_double_tap("DoubleTap+Ctrl").unwrap(), Some(ModifierSet::CTRL)); + assert_eq!(parse_double_tap("DoubleTap+Opt").unwrap(), Some(ModifierSet::ALT)); + assert_eq!(parse_double_tap("DoubleTap+Shift").unwrap(), Some(ModifierSet::SHIFT)); + } + + #[test] + fn double_tap_is_case_insensitive() { + assert_eq!(parse_double_tap("doubletap+cmd").unwrap(), Some(ModifierSet::CMD)); + assert_eq!(parse_double_tap("double-tap+CMD").unwrap(), Some(ModifierSet::CMD)); + } + + #[test] + fn double_tap_returns_none_for_non_double_tap_input() { + assert_eq!(parse_double_tap("Cmd+Space").unwrap(), None); + assert_eq!(parse_double_tap("Fn").unwrap(), None); + } + + #[test] + fn double_tap_rejects_multi_modifier() { + // Double-tap is by definition a single modifier pressed twice; + // a two-modifier value is a user input error. + assert!(matches!( + parse_double_tap("DoubleTap+Cmd+Opt").unwrap_err(), + ParseError::DoubleTapNeedsOneModifier(_), + )); + } + + #[test] + fn double_tap_rejects_missing_modifier() { + assert!(matches!( + parse_double_tap("DoubleTap").unwrap_err(), + ParseError::DoubleTapNeedsOneModifier(_), + )); + } } diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 2cb1b92..dc53af2 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -1,5 +1,4 @@ import { - getCancelHotkeyCombo, getHistoryLastSweep, getHotkeyCombo, getRetentionDays, @@ -26,16 +25,11 @@ export default function App() { console.error('initial registerHotkey failed', e); } })(); - void (async () => { - try { - const cancelCombo = await getCancelHotkeyCombo(); - await vox.registerCancelHotkey(cancelCombo); - } catch (e) { - // Cancel-hotkey registration must never block recording. Log - // and continue — the overlay's Cancel button still works. - console.error('initial registerCancelHotkey failed', e); - } - })(); + // The cancel hotkey is intentionally NOT registered here. Its + // lifecycle is bound to the recording state (`useHotkeyRecording`): + // registered on recording start, unregistered when recording ends. + // That keeps bare-key bindings like Esc from being globally + // swallowed outside the recording window. }, [isMain]); useEffect(() => { diff --git a/packages/desktop/src/components/HotkeyInput.test.tsx b/packages/desktop/src/components/HotkeyInput.test.tsx index 7a5ea47..69695b0 100644 --- a/packages/desktop/src/components/HotkeyInput.test.tsx +++ b/packages/desktop/src/components/HotkeyInput.test.tsx @@ -80,4 +80,211 @@ describe('HotkeyInput', () => { render( {}} />); expect(screen.queryByRole('button', { name: /use fn/i })).not.toBeInTheDocument(); }); + + // ---- Modifier-only chord capture --------------------------------- + + it('captures a modifier-only chord (Cmd+Opt) on macOS', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + // Press Cmd, then Opt (both modifiers held). + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Alt', + code: 'AltLeft', + metaKey: true, + altKey: true, + }), + ); + // Release both — the order of release shouldn't matter. + window.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'Meta', + code: 'MetaLeft', + metaKey: false, + altKey: true, + }), + ); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', code: 'AltLeft' })); + }); + expect(onChange).toHaveBeenCalledWith('Cmd+Alt'); + }); + + it('emits chord parts in stable Cmd+Ctrl+Alt+Shift order', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + // Press in deliberately wrong order: Shift, Cmd, Ctrl. + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Shift', + code: 'ShiftLeft', + shiftKey: true, + }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Meta', + code: 'MetaLeft', + metaKey: true, + shiftKey: true, + }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Control', + code: 'ControlLeft', + ctrlKey: true, + metaKey: true, + shiftKey: true, + }), + ); + // Release all. + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift', code: 'ShiftLeft' })); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft' })); + window.dispatchEvent( + new KeyboardEvent('keyup', { key: 'Control', code: 'ControlLeft' }), + ); + }); + expect(onChange).toHaveBeenCalledWith('Cmd+Ctrl+Shift'); + }); + + it('drops a single-modifier press without committing on macOS', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft' })); + }); + // Single Cmd tap is held back as a possible double-tap + // candidate; nothing should commit yet. + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not offer chord captures off macOS', () => { + vi.mocked(usePlatform).mockReturnValue({ os: 'windows', isWayland: false }); + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Control', + code: 'ControlLeft', + ctrlKey: true, + }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Alt', + code: 'AltLeft', + ctrlKey: true, + altKey: true, + }), + ); + window.dispatchEvent( + new KeyboardEvent('keyup', { key: 'Control', code: 'ControlLeft' }), + ); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', code: 'AltLeft' })); + }); + // Modifier-only chord is macOS-only; off-platform the press is + // dropped silently rather than emitting an unsupported combo. + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not offer chord captures when allowChord=false', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Alt', + code: 'AltLeft', + metaKey: true, + altKey: true, + }), + ); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft' })); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', code: 'AltLeft' })); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + // ---- Double-tap modifier capture -------------------------------- + + it('captures a double-tap of a single modifier on macOS', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + // First tap of Cmd + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft' })); + // Second tap of Cmd, immediately after + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + }); + expect(onChange).toHaveBeenCalledWith('DoubleTap+Cmd'); + }); + + it('does not fire double-tap when a different modifier is pressed second', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', code: 'MetaLeft' })); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Alt', code: 'AltLeft', altKey: true }), + ); + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + // ---- Standard combo still works through the new path ------------ + + it('still captures a Cmd+Opt+S combo (modifier + non-modifier)', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /capture/i })); + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Meta', code: 'MetaLeft', metaKey: true }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Alt', + code: 'AltLeft', + metaKey: true, + altKey: true, + }), + ); + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'S', + code: 'KeyS', + metaKey: true, + altKey: true, + }), + ); + }); + expect(onChange).toHaveBeenCalledWith('Cmd+Alt+S'); + }); }); diff --git a/packages/desktop/src/components/HotkeyInput.tsx b/packages/desktop/src/components/HotkeyInput.tsx index 7c9af06..e03c025 100644 --- a/packages/desktop/src/components/HotkeyInput.tsx +++ b/packages/desktop/src/components/HotkeyInput.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button'; import { usePlatform } from '@/lib/use-platform'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; export interface HotkeyInputProps { value: string; @@ -24,23 +24,56 @@ export interface HotkeyInputProps { * cancel-hotkey command doesn't support the Fn-tap path (one tap per * process; collides with the toggle's Fn binding). */ allowFn?: boolean; + /** Whether a bare key (no modifier, e.g. Esc) is a valid capture. + * The cancel-recording input passes `true` because its global + * registration is scoped to the recording window. The toggle hotkey + * leaves this at `false` so a bare-key registration can't swallow + * other apps' key presses globally. */ + allowBareKey?: boolean; + /** Whether modifier-only chord and double-tap captures are + * offered. Defaults to true. The cancel-hotkey input passes `false` + * — its backend cannot register those surfaces (one chord tap per + * process, owned by the toggle hotkey). */ + allowChord?: boolean; } -function formatFromEvent(e: KeyboardEvent): string | null { - if (e.key === 'Escape') return null; - const isModifierKey = - e.key === 'Shift' || - e.key === 'Control' || - e.key === 'Alt' || - e.key === 'Meta' || - e.key === 'OS'; - if (isModifierKey) return null; +/** Window inside which a press-release-press sequence still counts as + * a double-tap. Matches the Rust-side `DOUBLE_TAP_WINDOW` so capture + * UX and runtime behavior stay aligned. */ +const DOUBLE_TAP_WINDOW_MS = 350; + +type ModifierName = 'Cmd' | 'Ctrl' | 'Alt' | 'Shift'; + +function modifierFromEvent(e: KeyboardEvent): ModifierName | null { + if (e.key === 'Meta' || e.key === 'OS') return 'Cmd'; + if (e.key === 'Control') return 'Ctrl'; + if (e.key === 'Alt') return 'Alt'; + if (e.key === 'Shift') return 'Shift'; + return null; +} + +function isModifierKey(e: KeyboardEvent): boolean { + return modifierFromEvent(e) !== null; +} + +function formatModifierOnly(mods: Set): string { + // Stable order matches the Rust formatter so the round-trip + // through persistence is canonical. + const order: ModifierName[] = ['Cmd', 'Ctrl', 'Alt', 'Shift']; + return order.filter((m) => mods.has(m)).join('+'); +} + +function formatComboFromEvent(e: KeyboardEvent, allowBareKey: boolean): string | null { + if (isModifierKey(e)) return null; + // Esc cancels capture unless the caller has opted into bare keys + // (cancel-hotkey input), in which case Esc is the canonical value. + if (e.key === 'Escape' && !allowBareKey) return null; const parts: string[] = []; if (e.metaKey) parts.push('Cmd'); if (e.ctrlKey) parts.push('Ctrl'); if (e.altKey) parts.push('Alt'); if (e.shiftKey) parts.push('Shift'); - if (parts.length === 0) return null; + if (parts.length === 0 && !allowBareKey) return null; parts.push(keyLabel(e)); return parts.join('+'); } @@ -59,6 +92,35 @@ function keyLabel(e: KeyboardEvent): string { return e.key.toUpperCase(); } +/** Tracker for the chord/double-tap detector. Lives across keydown + + * keyup events while the user is composing a capture. */ +interface ChordTracker { + /** Modifiers currently physically held. */ + currentMods: Set; + /** Every modifier that's been pressed since the last "clean slate" + * (all keys released). Used to commit Cmd+Opt-style chords on + * keyup-of-last-modifier. */ + sequenceMods: Set; + /** True once a non-modifier key has been pressed in the current + * sequence — that path delegates to the standard combo formatter + * and shouldn't fall back to a chord/double-tap on keyup. */ + sawNonModifier: boolean; + /** When the user released their only-held modifier on its own, + * the modifier name and the release timestamp. Used to detect a + * double-tap when the same modifier is pressed again inside + * [`DOUBLE_TAP_WINDOW_MS`]. */ + lastSingleRelease: { mod: ModifierName; at: number } | null; +} + +function freshTracker(): ChordTracker { + return { + currentMods: new Set(), + sequenceMods: new Set(), + sawNonModifier: false, + lastSingleRelease: null, + }; +} + export function HotkeyInput({ value, onChange, @@ -66,6 +128,8 @@ export function HotkeyInput({ onCaptureCancel, onUseFnRequested, allowFn = true, + allowBareKey = false, + allowChord = true, }: HotkeyInputProps) { const [capturing, setCapturing] = useState(false); const platform = usePlatform(); @@ -74,25 +138,101 @@ export function HotkeyInput({ // The cache is warmed at app launch via the onboarding gate, so this // null window is effectively a single tick on first paint. const showFn = allowFn && platform?.os === 'macos'; + const showChord = allowChord && platform?.os === 'macos'; + // Tracker for the chord / double-tap detector. Held in a ref so + // its mutations don't trigger re-renders mid-capture. + const trackerRef = useRef(freshTracker()); useEffect(() => { if (!capturing) return; - function handle(e: KeyboardEvent) { + // Reset chord state every time capture begins so an aborted + // previous capture can't leak partial sequence into the next. + trackerRef.current = freshTracker(); + + function finish(combo: string) { + onChange(combo); + setCapturing(false); + } + + function handleKeyDown(e: KeyboardEvent) { e.preventDefault(); e.stopPropagation(); - if (e.key === 'Escape') { + // Esc handling — same as before. Cancel capture unless the + // caller opted into bare keys (cancel-hotkey input). + if (e.key === 'Escape' && !allowBareKey) { setCapturing(false); onCaptureCancel?.(); return; } - const combo = formatFromEvent(e); + const tracker = trackerRef.current; + const mod = modifierFromEvent(e); + if (mod) { + // Possible double-tap: same modifier was tapped alone + // recently and is now being pressed again. + if ( + showChord && + tracker.lastSingleRelease && + tracker.lastSingleRelease.mod === mod && + Date.now() - tracker.lastSingleRelease.at <= DOUBLE_TAP_WINDOW_MS && + tracker.currentMods.size === 0 + ) { + finish(`DoubleTap+${mod}`); + return; + } + tracker.currentMods.add(mod); + tracker.sequenceMods.add(mod); + return; + } + // Non-modifier key: take the standard-combo path. + tracker.sawNonModifier = true; + const combo = formatComboFromEvent(e, allowBareKey); if (combo === null) return; - onChange(combo); - setCapturing(false); + finish(combo); } - window.addEventListener('keydown', handle, true); - return () => window.removeEventListener('keydown', handle, true); - }, [capturing, onChange, onCaptureCancel]); + + function handleKeyUp(e: KeyboardEvent) { + const tracker = trackerRef.current; + const mod = modifierFromEvent(e); + if (!mod) return; + tracker.currentMods.delete(mod); + if (tracker.currentMods.size !== 0) return; + + // All modifiers released. Decide what (if anything) to + // commit based on what the user pressed. + if (tracker.sawNonModifier) { + // Standard combo path — already committed in keydown + // OR ignored (e.g. modifier alone followed by non-mod + // that didn't form a valid combo). Either way, reset. + trackerRef.current = freshTracker(); + return; + } + if (!showChord) { + // Chord captures aren't available — modifier-only + // sequences are dropped silently so the user can + // retry with a real combo. + trackerRef.current = freshTracker(); + return; + } + if (tracker.sequenceMods.size >= 2) { + // Two or more distinct modifiers pressed without any + // non-modifier — commit as a modifier-only chord. + finish(formatModifierOnly(tracker.sequenceMods)); + return; + } + // Exactly one modifier pressed and released. Stash it as + // a double-tap candidate; the next keydown of the same + // modifier within the window will commit. + tracker.lastSingleRelease = { mod, at: Date.now() }; + tracker.sequenceMods.clear(); + } + + window.addEventListener('keydown', handleKeyDown, true); + window.addEventListener('keyup', handleKeyUp, true); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('keyup', handleKeyUp, true); + }; + }, [capturing, onChange, onCaptureCancel, allowBareKey, showChord]); function toggle() { if (capturing) { @@ -105,28 +245,36 @@ export function HotkeyInput({ } return ( -
- - {capturing ? 'Press a key combo…' : value} - - - {showFn && ( - + {capturing ? 'Press a key combo…' : value} + + + {showFn && ( + + )} +
+ {capturing && showChord && ( +

+ Press a combo with a key (e.g. Cmd+Shift+Space), or hold two modifiers together + (e.g. Cmd+Opt), or double-tap a single modifier. +

)} ); diff --git a/packages/desktop/src/hooks/useHotkeyRecording.test.tsx b/packages/desktop/src/hooks/useHotkeyRecording.test.tsx index 4140fae..af44241 100644 --- a/packages/desktop/src/hooks/useHotkeyRecording.test.tsx +++ b/packages/desktop/src/hooks/useHotkeyRecording.test.tsx @@ -24,6 +24,19 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: listenMock, })); +vi.mock('@/lib/db', () => ({ + getCancelHotkeyCombo: vi.fn(async () => 'Escape'), +})); + +vi.mock('@/lib/invoke', () => ({ + vox: { + registerCancelHotkey: vi.fn(async () => 'Escape'), + unregisterCancelHotkey: vi.fn(async () => undefined), + }, +})); + +import { getCancelHotkeyCombo } from '@/lib/db'; +import { vox } from '@/lib/invoke'; import { EVT_SHORTCUT_CANCEL } from '@/lib/markers'; import type { RecordingDeps, RecordingState } from '@/lib/recording-controller'; import { SHORTCUT_EVENT, useHotkeyRecording } from './useHotkeyRecording'; @@ -39,6 +52,12 @@ function makeDeps(): RecordingDeps { requestMicrophonePermission: vi.fn(async () => 'Granted' as const), }, transcribe: vi.fn(async () => 'hi'), + // Provide DB-backed deps explicitly: mocking `@/lib/db` at the + // module level (needed for the cancel-hotkey lifecycle) wipes out + // the default fallbacks recording-controller would otherwise reach + // for. Passing stubs here keeps the controller path test-isolated. + saveTranscription: vi.fn(async () => undefined), + resolveActiveConfig: vi.fn(async () => null), }; } @@ -48,6 +67,12 @@ function makePublish() { beforeEach(() => { listenMock.mockClear(); + vi.mocked(getCancelHotkeyCombo).mockClear(); + vi.mocked(getCancelHotkeyCombo).mockResolvedValue('Escape'); + vi.mocked(vox.registerCancelHotkey).mockClear(); + vi.mocked(vox.registerCancelHotkey).mockResolvedValue('Escape'); + vi.mocked(vox.unregisterCancelHotkey).mockClear(); + vi.mocked(vox.unregisterCancelHotkey).mockResolvedValue(undefined); }); describe('useHotkeyRecording', () => { @@ -151,6 +176,50 @@ describe('useHotkeyRecording', () => { expect(deps.vox.cancelRecording).not.toHaveBeenCalled(); }); + it('registers the cancel hotkey on recording start and unregisters when the recording ends', async () => { + const deps = makeDeps(); + const { result } = renderHook(() => useHotkeyRecording({ deps, publish: makePublish() })); + + // idle → recording fires registerCancelHotkey with the stored combo + await act(async () => { + fireEvent(); + }); + await waitFor(() => expect(result.current.state.kind).toBe('recording')); + await waitFor(() => expect(vox.registerCancelHotkey).toHaveBeenCalledWith('Escape')); + expect(vox.unregisterCancelHotkey).not.toHaveBeenCalled(); + + // recording → transcribing → idle unregisters the cancel hotkey + await act(async () => { + fireEvent(); + }); + await waitFor(() => expect(result.current.state.kind).toBe('idle')); + await waitFor(() => expect(vox.unregisterCancelHotkey).toHaveBeenCalled()); + }); + + it('unregisters the cancel hotkey when the user cancels mid-recording', async () => { + const deps = makeDeps(); + const { result } = renderHook(() => useHotkeyRecording({ deps, publish: makePublish() })); + await act(async () => { + fireEvent(); + }); + await waitFor(() => expect(result.current.state.kind).toBe('recording')); + await waitFor(() => expect(vox.registerCancelHotkey).toHaveBeenCalled()); + + await act(async () => { + fireEvent(EVT_SHORTCUT_CANCEL); + }); + await waitFor(() => expect(result.current.state.kind).toBe('idle')); + await waitFor(() => expect(vox.unregisterCancelHotkey).toHaveBeenCalled()); + }); + + it('does not register the cancel hotkey while idle', async () => { + renderHook(() => useHotkeyRecording({ deps: makeDeps(), publish: makePublish() })); + // Give any synchronous effects a chance to run. + await waitFor(() => { + expect(vox.registerCancelHotkey).not.toHaveBeenCalled(); + }); + }); + it('ignores events fired during transcribing (no double-stop)', async () => { // Make transcribe slow so we can fire while in transcribing. const deps = makeDeps(); diff --git a/packages/desktop/src/hooks/useHotkeyRecording.ts b/packages/desktop/src/hooks/useHotkeyRecording.ts index 80ff4bb..2bf401b 100644 --- a/packages/desktop/src/hooks/useHotkeyRecording.ts +++ b/packages/desktop/src/hooks/useHotkeyRecording.ts @@ -1,3 +1,4 @@ +import { getCancelHotkeyCombo } from '@/lib/db'; import { vox } from '@/lib/invoke'; import { EVT_SHORTCUT_CANCEL, EVT_SHORTCUT_TOGGLE } from '@/lib/markers'; import { publishRecordingState } from '@/lib/overlay-bridge'; @@ -44,10 +45,44 @@ export function useHotkeyRecording(options: UseHotkeyRecordingOptions = {}): { useEffect(() => { let cancelled = false; + // Track whether the cancel hotkey is currently registered so the + // recording-start path can be idempotent and the recording-end + // path doesn't fire an unnecessary unregister IPC. + let cancelHotkeyActive = false; + + async function registerCancelHotkey() { + if (cancelHotkeyActive) return; + try { + const combo = await getCancelHotkeyCombo(); + await vox.registerCancelHotkey(combo); + cancelHotkeyActive = true; + } catch (e) { + // A failed cancel-hotkey registration must never block + // recording — the overlay's Cancel button still works. + console.error('registerCancelHotkey failed', e); + } + } + + async function unregisterCancelHotkey() { + if (!cancelHotkeyActive) return; + try { + await vox.unregisterCancelHotkey(); + } catch (e) { + console.error('unregisterCancelHotkey failed', e); + } finally { + cancelHotkeyActive = false; + } + } + const applyNext = (next: RecordingState) => { if (cancelled) return; setState(next); void publishRef.current(next); + if (next.kind === 'recording') { + void registerCancelHotkey(); + } else { + void unregisterCancelHotkey(); + } }; const togglePromise = listen(SHORTCUT_EVENT, () => { void toggle(stateRef.current, depsRef.current, applyNext); @@ -59,6 +94,9 @@ export function useHotkeyRecording(options: UseHotkeyRecordingOptions = {}): { cancelled = true; void togglePromise.then((fn) => fn()); void cancelPromise.then((fn) => fn()); + // Best-effort: drop any cancel-hotkey registration we still + // own when the hook tears down (e.g. main window close). + void unregisterCancelHotkey(); }; }, []); diff --git a/packages/desktop/src/lib/autostart.ts b/packages/desktop/src/lib/autostart.ts new file mode 100644 index 0000000..701b29c --- /dev/null +++ b/packages/desktop/src/lib/autostart.ts @@ -0,0 +1,28 @@ +import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart'; + +/** + * Thin async wrapper around `tauri-plugin-autostart`'s frontend API. + * Lives in its own module so test files can mock a single import path + * instead of chasing the plugin's nested exports across every screen + * that exposes the toggle (Settings + Onboarding). + * + * On macOS the plugin uses the `AppleScript` Login Item path (configured + * in `src-tauri/src/lib.rs`); on Windows it writes to + * `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`; on Linux it + * drops a `.desktop` file in `~/.config/autostart`. The contract is the + * same on all three: `enable` makes bluemacaw launch at login, `disable` + * removes the entry, `isEnabled` reports current state. + */ +export const autostart = { + isEnabled: () => isEnabled(), + enable: () => enable(), + disable: () => disable(), + /** Convenience for components that bind a checkbox: dispatch by value. */ + set: async (value: boolean): Promise => { + if (value) { + await enable(); + } else { + await disable(); + } + }, +}; diff --git a/packages/desktop/src/lib/db.ts b/packages/desktop/src/lib/db.ts index 612fc42..ac18d91 100644 --- a/packages/desktop/src/lib/db.ts +++ b/packages/desktop/src/lib/db.ts @@ -20,11 +20,13 @@ const CANCEL_HOTKEY_COMBO_KEY = 'cancel_hotkey_combo'; * onboarding wizard skip step 2 on a subsequent run. */ const HOTKEYS_ONBOARDED_KEY = 'hotkeys_onboarded'; -/** Default cancel hotkey applied when the user has never set one. Cmd+Esc - * is a sensible macOS default — discoverable, not bound to anything else, - * and requires a modifier so the combo parser accepts it (bare Esc would - * be rejected and would also be a hostile thing to register globally). */ -const DEFAULT_CANCEL_HOTKEY = 'Cmd+Esc'; +/** Default cancel hotkey applied when the user has never set one. The + * cancel hotkey is only registered globally while a recording is in + * flight, so a bare Esc is safe — it doesn't intercept other apps' + * Escape presses outside the recording window. Canonical spelling + * matches what `HotkeyInput`'s keydown formatter produces, so the value + * round-trips cleanly through capture → store → re-render. */ +const DEFAULT_CANCEL_HOTKEY = 'Escape'; const FN_USAGE_TYPE_ORIGINAL_KEY = 'fn_usage_type_original'; const RETENTION_DAYS_KEY = 'history_retention_days'; const HISTORY_LAST_SWEEP_KEY = 'history_last_sweep'; diff --git a/packages/desktop/src/lib/invoke.ts b/packages/desktop/src/lib/invoke.ts index 482c4ad..7360bfd 100644 --- a/packages/desktop/src/lib/invoke.ts +++ b/packages/desktop/src/lib/invoke.ts @@ -80,6 +80,14 @@ export const vox = { */ registerCancelHotkey: (combo: string) => invoke('register_cancel_hotkey', { combo }), unregisterCancelHotkey: () => invoke('unregister_cancel_hotkey'), + /** + * Parse-only validation of a cancel-hotkey combo. Used by Settings + * and onboarding to surface a parse error before commit, without + * actually holding the shortcut globally. Registration is the + * recording loop's job — bare keys like Esc shouldn't be swallowed + * outside the recording window. + */ + validateCancelHotkey: (combo: string) => invoke('validate_cancel_hotkey', { combo }), /** macOS only: read the AppleFnUsageType setting (0..3) or null if unset. */ getFnUsageType: () => invoke('get_fn_usage_type'), diff --git a/packages/desktop/src/windows/main/OnboardingScreen.test.tsx b/packages/desktop/src/windows/main/OnboardingScreen.test.tsx index b203940..19461e3 100644 --- a/packages/desktop/src/windows/main/OnboardingScreen.test.tsx +++ b/packages/desktop/src/windows/main/OnboardingScreen.test.tsx @@ -18,6 +18,7 @@ vi.mock('@/lib/invoke', () => ({ unregisterHotkey: vi.fn(), registerCancelHotkey: vi.fn(), unregisterCancelHotkey: vi.fn(), + validateCancelHotkey: vi.fn(), getFnUsageType: vi.fn(), setFnUsageType: vi.fn(), }, @@ -27,6 +28,15 @@ vi.mock('@/lib/onboarding', () => ({ markOnboardingCompleted: vi.fn(async () => undefined), })); +vi.mock('@/lib/autostart', () => ({ + autostart: { + isEnabled: vi.fn(async () => false), + enable: vi.fn(async () => undefined), + disable: vi.fn(async () => undefined), + set: vi.fn(async () => undefined), + }, +})); + vi.mock('@/lib/db', () => ({ getHotkeyCombo: vi.fn(async () => 'Cmd+Shift+Space'), setHotkeyCombo: vi.fn(async () => undefined), @@ -90,14 +100,16 @@ function resetAllMocks() { vi.mocked(vox.unregisterHotkey).mockReset(); vi.mocked(vox.registerCancelHotkey).mockReset(); vi.mocked(vox.unregisterCancelHotkey).mockReset(); + vi.mocked(vox.validateCancelHotkey).mockReset(); vi.mocked(vox.registerHotkey).mockResolvedValue('Cmd+Shift+Space'); - vi.mocked(vox.registerCancelHotkey).mockResolvedValue('Cmd+Esc'); + vi.mocked(vox.registerCancelHotkey).mockResolvedValue('Escape'); + vi.mocked(vox.validateCancelHotkey).mockResolvedValue('Escape'); vi.mocked(db.getHotkeyCombo).mockReset(); vi.mocked(db.getHotkeyCombo).mockResolvedValue('Cmd+Shift+Space'); vi.mocked(db.setHotkeyCombo).mockReset(); vi.mocked(db.setHotkeyCombo).mockResolvedValue(undefined); vi.mocked(db.getCancelHotkeyCombo).mockReset(); - vi.mocked(db.getCancelHotkeyCombo).mockResolvedValue('Cmd+Esc'); + vi.mocked(db.getCancelHotkeyCombo).mockResolvedValue('Escape'); vi.mocked(db.setCancelHotkeyCombo).mockReset(); vi.mocked(db.setCancelHotkeyCombo).mockResolvedValue(undefined); vi.mocked(db.setHotkeysOnboarded).mockReset(); @@ -271,8 +283,11 @@ describe(' — wizard navigation (full flow)', () => { await waitFor(() => expect(db.setHotkeyCombo).toHaveBeenCalledWith('Cmd+Shift+Space')); expect(vox.registerHotkey).toHaveBeenCalledWith('Cmd+Shift+Space'); - expect(db.setCancelHotkeyCombo).toHaveBeenCalledWith('Cmd+Esc'); - expect(vox.registerCancelHotkey).toHaveBeenCalledWith('Cmd+Esc'); + expect(db.setCancelHotkeyCombo).toHaveBeenCalledWith('Escape'); + // Cancel hotkey is validated (parse-only) at onboarding time; its + // global registration happens later, when a recording starts. + expect(vox.validateCancelHotkey).toHaveBeenCalledWith('Escape'); + expect(vox.registerCancelHotkey).not.toHaveBeenCalled(); expect(db.setHotkeysOnboarded).toHaveBeenCalledWith(true); await waitFor(() => diff --git a/packages/desktop/src/windows/main/SettingsRecording.test.tsx b/packages/desktop/src/windows/main/SettingsRecording.test.tsx index 4490937..643e326 100644 --- a/packages/desktop/src/windows/main/SettingsRecording.test.tsx +++ b/packages/desktop/src/windows/main/SettingsRecording.test.tsx @@ -11,9 +11,18 @@ vi.mock('@/lib/invoke', () => ({ unregisterHotkey: vi.fn(), registerCancelHotkey: vi.fn(), unregisterCancelHotkey: vi.fn(), + validateCancelHotkey: vi.fn(), getPlatformInfo: vi.fn(async () => ({ os: 'macos', isWayland: false })), }, })); +vi.mock('@/lib/autostart', () => ({ + autostart: { + isEnabled: vi.fn(async () => false), + enable: vi.fn(async () => undefined), + disable: vi.fn(async () => undefined), + set: vi.fn(async () => undefined), + }, +})); vi.mock('@/lib/db', () => ({ getSelectedMicDeviceId: vi.fn(), setSelectedMicDeviceId: vi.fn(), @@ -23,6 +32,7 @@ vi.mock('@/lib/db', () => ({ setCancelHotkeyCombo: vi.fn(), })); +import { autostart } from '@/lib/autostart'; import { getCancelHotkeyCombo, getHotkeyCombo, @@ -47,11 +57,12 @@ beforeEach(() => { { id: 'builtin', label: 'Built-in', isDefault: true }, ]); voxMock.registerHotkey.mockResolvedValue('Cmd+Shift+Space'); - voxMock.registerCancelHotkey.mockResolvedValue('Cmd+Esc'); + voxMock.registerCancelHotkey.mockResolvedValue('Escape'); voxMock.unregisterCancelHotkey.mockResolvedValue(); + voxMock.validateCancelHotkey.mockResolvedValue('Escape'); getSelectedMicDeviceIdMock.mockResolvedValue(null); getHotkeyComboMock.mockResolvedValue('Cmd+Shift+Space'); - getCancelHotkeyComboMock.mockResolvedValue('Cmd+Esc'); + getCancelHotkeyComboMock.mockResolvedValue('Escape'); setSelectedMicDeviceIdMock.mockResolvedValue(); setHotkeyComboMock.mockResolvedValue(); setCancelHotkeyComboMock.mockResolvedValue(); @@ -121,7 +132,7 @@ describe('SettingsRecording', () => { }); }); - it('persists + registers a new cancel hotkey when the user captures one', async () => { + it('persists + validates a new cancel hotkey when the user captures one', async () => { render(); await waitFor(() => screen.getByLabelText(/microphone/i)); const captureButtons = screen.getAllByRole('button', { name: /capture/i }); @@ -136,7 +147,47 @@ describe('SettingsRecording', () => { ); await waitFor(() => { expect(setCancelHotkeyComboMock).toHaveBeenCalledWith('Cmd+Backspace'); - expect(voxMock.registerCancelHotkey).toHaveBeenCalledWith('Cmd+Backspace'); + // The cancel hotkey is validated (parse-only) here; its actual + // global registration happens later, in `useHotkeyRecording`, + // when a recording starts. + expect(voxMock.validateCancelHotkey).toHaveBeenCalledWith('Cmd+Backspace'); + expect(voxMock.registerCancelHotkey).not.toHaveBeenCalled(); + }); + }); + + it('captures a bare Esc as the cancel hotkey', async () => { + render(); + await waitFor(() => screen.getByLabelText(/microphone/i)); + const captureButtons = screen.getAllByRole('button', { name: /capture/i }); + fireEvent.click(captureButtons[1] as HTMLElement); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape' })); + await waitFor(() => { + expect(setCancelHotkeyComboMock).toHaveBeenCalledWith('Escape'); + expect(voxMock.validateCancelHotkey).toHaveBeenCalledWith('Escape'); + }); + }); + + it('reflects the current autostart state on mount', async () => { + vi.mocked(autostart.isEnabled).mockResolvedValueOnce(true); + render(); + await waitFor(() => { + const toggle = screen.getByTestId('settings-autostart-toggle'); + expect(toggle).toHaveAttribute('data-state', 'checked'); + }); + }); + + it('flips autostart through the plugin when the user toggles the switch', async () => { + vi.mocked(autostart.isEnabled).mockResolvedValueOnce(false); + render(); + await waitFor(() => { + const toggle = screen.getByTestId('settings-autostart-toggle'); + expect(toggle).toHaveAttribute('data-state', 'unchecked'); + }); + const toggle = screen.getByTestId('settings-autostart-toggle'); + fireEvent.click(toggle); + await waitFor(() => { + expect(autostart.set).toHaveBeenCalledWith(true); + expect(toggle).toHaveAttribute('data-state', 'checked'); }); }); }); diff --git a/packages/desktop/src/windows/main/SettingsRecording.tsx b/packages/desktop/src/windows/main/SettingsRecording.tsx index cc73f4e..baf6f4b 100644 --- a/packages/desktop/src/windows/main/SettingsRecording.tsx +++ b/packages/desktop/src/windows/main/SettingsRecording.tsx @@ -2,6 +2,8 @@ import { HotkeyInput } from '@/components/HotkeyInput'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { autostart } from '@/lib/autostart'; import { clearOriginalFnUsageType, getCancelHotkeyCombo, @@ -31,11 +33,14 @@ function fnUsageLabel(value: number): string { export function SettingsRecording() { const hotkeyId = useId(); const cancelHotkeyId = useId(); + const autostartId = useId(); const deviceId = useId(); const [devices, setDevices] = useState([]); const [selectedDevice, setSelectedDevice] = useState(''); const [hotkey, setHotkey] = useState(''); const [cancelHotkey, setCancelHotkey] = useState(''); + const [autostartEnabled, setAutostartEnabled] = useState(false); + const [autostartError, setAutostartError] = useState(null); const [testStatus, setTestStatus] = useState<'idle' | 'recording' | 'playing' | 'error'>( 'idle', ); @@ -71,10 +76,28 @@ export function SettingsRecording() { setSelectedDevice(persistedDevice ?? ''); setHotkey(persistedHotkey); setCancelHotkey(persistedCancelHotkey); + try { + setAutostartEnabled(await autostart.isEnabled()); + } catch (e) { + console.error('autostart.isEnabled failed', e); + } await refreshDevices(); })(); }, [refreshDevices]); + async function handleAutostartToggle(next: boolean) { + const previous = autostartEnabled; + setAutostartEnabled(next); + setAutostartError(null); + try { + await autostart.set(next); + } catch (e) { + console.error('autostart.set failed', e); + setAutostartEnabled(previous); + setAutostartError(e instanceof Error ? e.message : String(e)); + } + } + async function handleDeviceChange(next: string) { setSelectedDevice(next); await setSelectedMicDeviceId(next === '' ? null : next); @@ -183,31 +206,26 @@ export function SettingsRecording() { setCancelHotkey(combo); await setCancelHotkeyCombo(combo); try { - await vox.registerCancelHotkey(combo); + // Validate without registering globally — the cancel hotkey + // only goes live while a recording is in flight, so we don't + // want to hold the binding here. + await vox.validateCancelHotkey(combo); setCancelHotkeyError(null); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error('register_cancel_hotkey failed', e); + console.error('validate_cancel_hotkey failed', e); setCancelHotkeyError(msg); } } - async function handleCancelHotkeyCaptureStart() { - // Free the OS shortcut so the webview can see the keydown the user - // is about to press. Symmetric with the toggle-hotkey path. - try { - await vox.unregisterCancelHotkey(); - } catch (e) { - console.error('unregister_cancel_hotkey failed', e); - } + function handleCancelHotkeyCaptureStart() { + // No-op: the cancel hotkey is not persistently registered, so + // there is nothing to release before the webview captures the + // user's keydown. Kept as a stable prop hook for HotkeyInput. } - async function handleCancelHotkeyCaptureCancel() { - try { - await vox.registerCancelHotkey(cancelHotkey); - } catch (e) { - console.error('restore registerCancelHotkey failed', e); - } + function handleCancelHotkeyCaptureCancel() { + // No-op: nothing to restore. } async function handleTestRecording() { @@ -280,13 +298,19 @@ export function SettingsRecording() {
+

+ Only registered while a recording is in flight, so bare keys like Esc won't + intercept other apps when you're not recording. +

void handleCancelHotkeyChange(c)} - onCaptureStart={() => void handleCancelHotkeyCaptureStart()} - onCaptureCancel={() => void handleCancelHotkeyCaptureCancel()} + onCaptureStart={handleCancelHotkeyCaptureStart} + onCaptureCancel={handleCancelHotkeyCaptureCancel} allowFn={false} + allowBareKey={true} + allowChord={false} />
{cancelHotkeyError && ( @@ -323,6 +347,30 @@ export function SettingsRecording() {
+
+
+ +

+ Launch bluemacaw automatically into the tray when you sign in. +

+
+ void handleAutostartToggle(v)} + /> +
+ {autostartError && ( +

+ {autostartError} +

+ )}
{testStatus === 'recording' && 'Recording 3s…'} diff --git a/packages/desktop/src/windows/main/onboarding/OnboardingStepHotkeys.tsx b/packages/desktop/src/windows/main/onboarding/OnboardingStepHotkeys.tsx index fef4b4c..d9035d5 100644 --- a/packages/desktop/src/windows/main/onboarding/OnboardingStepHotkeys.tsx +++ b/packages/desktop/src/windows/main/onboarding/OnboardingStepHotkeys.tsx @@ -1,6 +1,8 @@ import { HotkeyInput } from '@/components/HotkeyInput'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { autostart } from '@/lib/autostart'; import { clearOriginalFnUsageType, getCancelHotkeyCombo, @@ -41,12 +43,15 @@ function isFnCombo(combo: string): boolean { export function OnboardingStepHotkeys({ onBack, onNext }: OnboardingStepHotkeysProps) { const hotkeyId = useId(); const cancelHotkeyId = useId(); + const autostartId = useId(); const [hotkey, setHotkey] = useState(''); const [cancelHotkey, setCancelHotkey] = useState(''); + const [autostartEnabled, setAutostartEnabled] = useState(false); const [ready, setReady] = useState(false); const [saving, setSaving] = useState(false); const [hotkeyError, setHotkeyError] = useState(null); const [cancelHotkeyError, setCancelHotkeyError] = useState(null); + const [autostartError, setAutostartError] = useState(null); /** * When non-null, the user clicked "Use Fn" but the macOS * `AppleFnUsageType` setting is something other than 0 ("Do Nothing"). @@ -63,10 +68,34 @@ export function OnboardingStepHotkeys({ onBack, onNext }: OnboardingStepHotkeysP ]); setHotkey(persistedHotkey); setCancelHotkey(persistedCancelHotkey); + // Read current OS autostart state separately — its failure + // mode (e.g. unsupported platform) must not block the rest + // of the form from rendering. + try { + setAutostartEnabled(await autostart.isEnabled()); + } catch (e) { + console.error('autostart.isEnabled failed', e); + } setReady(true); })(); }, []); + async function handleAutostartToggle(next: boolean) { + // Optimistic flip so the switch feels immediate; revert + surface + // an inline error if the OS write fails (e.g. sandbox denies the + // Login Item write or osascript times out on macOS). + const previous = autostartEnabled; + setAutostartEnabled(next); + setAutostartError(null); + try { + await autostart.set(next); + } catch (e) { + console.error('autostart.set failed', e); + setAutostartEnabled(previous); + setAutostartError(e instanceof Error ? e.message : String(e)); + } + } + // Free the OS-level shortcut so the webview can see the keydown the // user is about to press during capture. We re-register the previous // combo if the user cancels, or the new one once they commit (Next). @@ -84,19 +113,12 @@ export function OnboardingStepHotkeys({ onBack, onNext }: OnboardingStepHotkeysP console.error('restore registerHotkey failed', e); } } - async function handleCancelHotkeyCaptureStart() { - try { - await vox.unregisterCancelHotkey(); - } catch (e) { - console.error('unregister_cancel_hotkey failed', e); - } + function handleCancelHotkeyCaptureStart() { + // The cancel hotkey is not persistently registered (its lifecycle + // is bound to the recording window), so nothing to release here. } - async function handleCancelHotkeyCaptureCancel() { - try { - await vox.registerCancelHotkey(cancelHotkey); - } catch (e) { - console.error('restore registerCancelHotkey failed', e); - } + function handleCancelHotkeyCaptureCancel() { + // No-op: nothing to restore. } /** @@ -193,10 +215,12 @@ export function OnboardingStepHotkeys({ onBack, onNext }: OnboardingStepHotkeysP } await setCancelHotkeyCombo(cancelHotkey); try { - await vox.registerCancelHotkey(cancelHotkey); + // Validate parsing only — the recording loop owns the + // actual global registration when a recording starts. + await vox.validateCancelHotkey(cancelHotkey); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error('register_cancel_hotkey failed', e); + console.error('validate_cancel_hotkey failed', e); setCancelHotkeyError(msg); return; } @@ -274,9 +298,11 @@ export function OnboardingStepHotkeys({ onBack, onNext }: OnboardingStepHotkeysP void handleCancelHotkeyCaptureStart()} - onCaptureCancel={() => void handleCancelHotkeyCaptureCancel()} + onCaptureStart={handleCancelHotkeyCaptureStart} + onCaptureCancel={handleCancelHotkeyCaptureCancel} allowFn={false} + allowBareKey={true} + allowChord={false} /> )}
@@ -289,6 +315,31 @@ export function OnboardingStepHotkeys({ onBack, onNext }: OnboardingStepHotkeysP

)} +
+
+ +

+ Launch automatically into the tray when you sign in. You can change this + later in Settings → Recording. +

+
+ void handleAutostartToggle(v)} + /> +
+ {autostartError && ( +

+ {autostartError} +

+ )}

Per-mic selection lives in Settings → Recording after onboarding.