From c6114b69bc05a4a862569ee0b3385034cd30a85d Mon Sep 17 00:00:00 2001 From: James Pine Date: Thu, 23 Apr 2026 20:13:23 -0700 Subject: [PATCH] feat(capture): swap the rdev fork for keytap 0.2, delete local chord state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dep swap: - Drop the git-pinned jamiepine/rdev fork we were carrying since the upstream crate is abandoned. - Depend on keytap 0.2 from crates.io — our own cross-platform global keyboard tap crate. Clean shutdown via Drop, Sonoma-safe by design (no TSMGetInputSourceProperty calls off the main thread, so `set_is_main_thread(false)` is gone), and properly versioned. Chord engine rewrite: - Delete hotkey_monitor.rs's internal Chord state machine (Match enum, KeyEvent enum, step()/classify() methods, associated unit tests). keytap's ChordMatcher subsumes it: Momentary chord for PTT, add_toggle() for Toggle-to-talk, longest-match resolution, sticky-end for Toggle. Net: -80 LOC in hotkey_monitor.rs; the remaining module is the dispatcher loop + Effect→Tauri translation. - Preserve the PTT→Toggle "RestartRecording" upgrade signal. keytap emits End(PTT)+Start(Toggle) atomically (same Instant) when the held set upgrades from a shorter chord to a longer superset. The dispatcher peeks at the matcher with a 5 ms recv_timeout after any End and coalesces the pair into Effect::RestartRecording so the frontend still gets the "discard the transition-moment audio" signal instead of an unrelated Stop+Start pair. - HotkeyMonitor::update_bindings now actually tears down the tap on empty bindings instead of leaving an idle CGEventTap around. New bindings rebuild the matcher and the dispatcher thread from scratch. key_codes.rs: - Rewrite the browser-code → Key table against keytap's cleaner Key variant names (`A`..`Z` not `KeyA`..`KeyZ`, `Digit0`..`Digit9` not `Num0`..`Num9`, `ArrowUp` not `UpArrow`, `AltLeft`/`AltRight` instead of `Alt`/`AltGr`, `Period` not `Dot`, …). On-disk chord string format (W3C `KeyboardEvent.code` identifiers) is unchanged, so capture_settings rows written before the swap round-trip identically. Legacy aliases (`Alt`, `AltGr`, `Num0`, `UpArrow`, `Dot`, …) kept for forward-compat on old rows. main.rs / input_monitoring.rs: - Update the few doc comments that referenced `rdev::listen` to describe keytap's Tap; no behavioural change. - build_chord_bindings now imports from keytap::Key. - enable_hotkey / disable_hotkey / update_chord_bindings reach into HotkeyMonitor via &mut since apply()/update_bindings() now mutate. Tests live in keytap now (22 chord-related tests in keytap 0.2, including the PTT→Toggle upgrade scenario that used to be tested in hotkey_monitor.rs). Voicebox's hotkey_monitor.rs is thin enough that local testing would be trivia. Co-Authored-By: Claude Opus 4.7 (1M context) --- tauri/src-tauri/Cargo.lock | 116 +++++-- tauri/src-tauri/Cargo.toml | 22 +- tauri/src-tauri/src/hotkey_monitor.rs | 430 ++++++++++-------------- tauri/src-tauri/src/input_monitoring.rs | 6 +- tauri/src-tauri/src/key_codes.rs | 84 ++--- tauri/src-tauri/src/main.rs | 41 ++- 6 files changed, 342 insertions(+), 357 deletions(-) diff --git a/tauri/src-tauri/Cargo.lock b/tauri/src-tauri/Cargo.lock index 1a11fe88..7099976f 100644 --- a/tauri/src-tauri/Cargo.lock +++ b/tauri/src-tauri/Cargo.lock @@ -164,6 +164,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -835,6 +847,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix", +] + [[package]] name = "extended" version = "0.1.0" @@ -950,6 +974,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -1846,6 +1876,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keytap" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c93022d3131914f823f2efa2a2991ce45383df1649255c9803e7cb98209918" +dependencies = [ + "crossbeam-channel", + "evdev", + "libc", + "nix", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "thiserror 2.0.18", + "windows-sys 0.59.0", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2164,6 +2212,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2306,9 +2366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", - "block2", "dispatch2", - "libc", "objc2", ] @@ -2319,13 +2377,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.10.0", - "block2", "dispatch2", - "libc", "objc2", "objc2-core-foundation", "objc2-io-surface", - "objc2-metal", ] [[package]] @@ -2412,17 +2467,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.10.0", - "objc2", - "objc2-foundation", -] - [[package]] name = "objc2-osa-kit" version = "0.3.2" @@ -3016,6 +3060,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.7.3" @@ -3132,23 +3182,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rdev" -version = "0.6.0" -source = "git+https://github.com/jamiepine/rdev?rev=c14f2dc5c8100a96c5d7e3013de59d6aa0b9eae2#c14f2dc5c8100a96c5d7e3013de59d6aa0b9eae2" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "dispatch", - "lazy_static", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "winapi", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4213,6 +4246,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.44" @@ -5081,8 +5120,8 @@ dependencies = [ "coreaudio-sys", "cpal", "hound", + "keytap", "objc", - "rdev", "reqwest", "scopeguard", "screencapturekit", @@ -5966,6 +6005,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/tauri/src-tauri/Cargo.toml b/tauri/src-tauri/Cargo.toml index c6e27090..0f3df189 100644 --- a/tauri/src-tauri/Cargo.toml +++ b/tauri/src-tauri/Cargo.toml @@ -52,19 +52,15 @@ webkit2gtk = "2.0" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2.0" tauri-plugin-process = "2.0" -# Pinned to our fork of upstream main past PR #147 — the released 0.5.3 -# crate crashes on macOS 14+ when listen() runs on a background thread -# because rdev's convert() calls TSMGetInputSourceProperty off the main -# queue. The main branch adds `set_is_main_thread(false)` to skip that -# path (used at hotkey_monitor.rs:184). -# -# We pin to jamiepine/rdev instead of Narsil/rdev for supply-chain -# durability: upstream has shipped no release since 2023-06 (crates.io -# still lists 0.5.3), so an eventual 0.5.4 carrying this fix is unlikely, -# and a git pin to a third-party repo breaks if the remote force-pushes, -# renames, or disappears. The fork mirrors the pinned SHA and gives us a -# place to cherry-pick future OS-compatibility fixes on our own timeline. -rdev = { git = "https://github.com/jamiepine/rdev", rev = "c14f2dc5c8100a96c5d7e3013de59d6aa0b9eae2" } +# Observe-only global keyboard tap. Covers macOS, Windows, and Linux +# (evdev) with left/right modifier fidelity, which is the hard +# requirement the chord engine needs. keytap is our own crate +# (jamiepine/keytap), published to crates.io, that replaces the +# abandoned Narsil/rdev we previously pinned via git — same capability +# surface, clean shutdown via Drop, no `set_is_main_thread(false)` +# dance required (the Sonoma-crashing layout-translation path simply +# isn't called). +keytap = "0.2" [features] # This feature is used for production builds or when `devPath` points to the filesystem diff --git a/tauri/src-tauri/src/hotkey_monitor.rs b/tauri/src-tauri/src/hotkey_monitor.rs index 72ac4425..c2c01e98 100644 --- a/tauri/src-tauri/src/hotkey_monitor.rs +++ b/tauri/src-tauri/src/hotkey_monitor.rs @@ -1,27 +1,44 @@ -//! Global keyboard tap + chord dispatcher. +//! Global hotkey → dictation effect bridge. //! -//! Spawns a dedicated thread running `rdev::listen` (which internally owns a -//! CGEventTap on macOS / `SetWindowsHookEx` on Windows / `XRecord` on Linux). -//! Feeds raw key events into a private `Chord` state machine and translates -//! its effects into Tauri events + window show/hide calls. +//! Thin adapter from `keytap::chord::ChordMatcher` to Tauri events. keytap +//! owns the OS event tap + the chord state machine (Momentary vs Toggle, +//! longest-match resolution, sticky-toggle semantics); this module's only +//! job is: //! -//! Left- and right-hand modifier variants are deliberately kept distinct. -//! Defaults bind to right-hand Cmd + right-hand Option so that the usual -//! left-hand shortcuts — Cmd+Option+I to open devtools, Cmd+Option+Esc for -//! force-quit, etc. — continue to work untouched. +//! 1. Build a `ChordMatcher` from the user's saved PTT + Toggle chords. +//! 2. Translate `ChordEvent` → voicebox's [`Effect`] on a dispatcher +//! thread. +//! 3. Fan [`Effect`]s out into Tauri events + dictate-window show/hide. +//! +//! The [`Effect::RestartRecording`] signal is emitted when keytap fires +//! `End(PTT)` and `Start(Toggle)` with the *same* [`Instant`] — which +//! happens when the held set upgrades from a shorter chord to a longer +//! superset in a single event (the classic PTT→hands-free transition). +//! We detect the pair with a 5 ms peek on the matcher's receiver and +//! coalesce into one `Restart` so hosts can discard the transition- +//! moment audio rather than treat it as an unrelated Stop+Start pair. +//! +//! Left- and right-hand modifier variants are kept distinct all the way +//! down to the OS event tap (keytap's core promise). Defaults bind to +//! right-hand Cmd + right-hand Option on macOS / right-hand Ctrl + +//! right-hand Shift on Windows so the usual left-hand shortcuts stay +//! with the OS / app. use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex}; -use std::thread; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; -use rdev::{listen, EventType, Key}; +use keytap::chord::{Chord, ChordEvent, ChordMatcher}; +use keytap::{Key, RecvTimeoutError}; use tauri::{AppHandle, Emitter, Manager}; use crate::focus_capture; use crate::DICTATE_WINDOW_LABEL; // ======================================================================== -// Chord state machine +// Public types // ======================================================================== /// Semantic action a chord can be bound to. `PushToTalk` = hold chord to @@ -33,191 +50,186 @@ pub enum ChordAction { ToggleToTalk, } -/// Output of the chord state machine after consuming an input event. Hosts +/// Effect produced after the chord matcher resolves an event. Hosts /// translate these into UI / recorder calls. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Effect { StartRecording(ChordAction), StopRecording(ChordAction), - /// Emitted when a push-to-talk chord is "upgraded" into the toggle chord - /// mid-hold — hosts may want to discard the captured audio and restart - /// so the transition moment isn't in the recording. + /// Emitted when a push-to-talk chord is "upgraded" into the toggle + /// chord mid-hold — hosts may want to discard the captured audio and + /// restart so the transition moment isn't in the recording. RestartRecording(ChordAction), } -#[derive(Debug, Clone)] -enum KeyEvent { - Down(Key), - Up(Key), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum Match { - None, - Partial, - Hit(ChordAction), -} - +/// Chord key sets from capture settings. Both actions use the same +/// `HashSet` shape so callers don't need to know about keytap's +/// `Chord` type. pub type Bindings = HashMap>; -/// Private state machine that turns key-down / key-up events into -/// `Effect`s. Owns no I/O — just the "which keys are held" and -/// "which action is currently driving a recording" bookkeeping. -struct Chord { - bindings: Bindings, - pressed_keys: HashSet, - active_recording_action: Option, -} - -impl Chord { - fn new(bindings: Bindings) -> Self { - Self { - bindings, - pressed_keys: HashSet::new(), - active_recording_action: None, - } - } - - fn update_bindings(&mut self, bindings: Bindings) { - self.bindings = bindings; - } - - fn handle(&mut self, event: KeyEvent) -> Vec { - let changed = match event { - KeyEvent::Down(k) => self.pressed_keys.insert(k), - KeyEvent::Up(k) => self.pressed_keys.remove(&k), - }; - if !changed { - return Vec::new(); - } - self.step() - } - - #[allow(dead_code)] // Used by the chord picker UI in Pass 2 to suspend matching during capture. - fn reset(&mut self) { - self.pressed_keys.clear(); - self.active_recording_action = None; - } - - fn step(&mut self) -> Vec { - match self.active_recording_action { - Some(ChordAction::PushToTalk) => { - if self.classify() == Match::Hit(ChordAction::ToggleToTalk) { - self.active_recording_action = Some(ChordAction::ToggleToTalk); - return vec![Effect::RestartRecording(ChordAction::ToggleToTalk)]; - } - - let still_held = self - .bindings - .get(&ChordAction::PushToTalk) - .map(|chord| chord.is_subset(&self.pressed_keys)) - .unwrap_or(false); - - if !still_held { - self.active_recording_action = None; - return vec![Effect::StopRecording(ChordAction::PushToTalk)]; - } - Vec::new() - } - Some(ChordAction::ToggleToTalk) => { - if self.classify() == Match::Hit(ChordAction::ToggleToTalk) { - self.active_recording_action = None; - return vec![Effect::StopRecording(ChordAction::ToggleToTalk)]; - } - Vec::new() - } - None => match self.classify() { - Match::Hit(action) => { - self.active_recording_action = Some(action); - vec![Effect::StartRecording(action)] - } - Match::None | Match::Partial => Vec::new(), - }, - } - } - - fn classify(&self) -> Match { - if self.pressed_keys.is_empty() { - return Match::None; - } - - // Exact match wins even if the pressed set is also a prefix of another - // binding. - for (action, chord) in &self.bindings { - if self.pressed_keys == *chord { - return Match::Hit(*action); - } - } - - let is_prefix = self - .bindings - .values() - .any(|c| self.pressed_keys.is_subset(c) && self.pressed_keys != *c); - - if is_prefix { - Match::Partial - } else { - Match::None - } - } -} - // ======================================================================== // Monitor // ======================================================================== pub struct HotkeyMonitor { - chord: Arc>, + app: AppHandle, + active: Option, +} + +struct Active { + dispatcher: JoinHandle<()>, + shutdown: Arc, } impl HotkeyMonitor { + /// Build the monitor with initial bindings. Equivalent to constructing + /// an empty monitor and calling [`Self::update_bindings`] once. pub fn spawn(app: AppHandle, bindings: Bindings) -> Self { - let chord = Arc::new(Mutex::new(Chord::new(bindings))); - let chord_for_thread = chord.clone(); - let app_for_thread = app.clone(); - - thread::spawn(move || { - // Without this call, rdev's convert() calls TSMGetInputSourceProperty - // on this background thread, which trips a main-queue assertion on - // macOS 14+ and traps the whole process (see Narsil/rdev#165 / #147). - #[cfg(target_os = "macos")] - rdev::set_is_main_thread(false); - - let result = listen(move |event| { - let input = match event.event_type { - EventType::KeyPress(k) => KeyEvent::Down(k), - EventType::KeyRelease(k) => KeyEvent::Up(k), - _ => return, - }; - - let effects = match chord_for_thread.lock() { - Ok(mut chord) => chord.handle(input), - Err(_) => return, - }; - - for effect in effects { - apply_effect(&app_for_thread, effect); - } - }); - - if let Err(err) = result { - eprintln!( - "HotkeyMonitor: rdev::listen failed ({:?}). Global chord detection is disabled. On macOS, grant Input Monitoring in System Settings → Privacy & Security → Input Monitoring and relaunch.", - err - ); - } - }); - - Self { chord } + let mut m = Self { app, active: None }; + m.apply(bindings); + m } - pub fn update_bindings(&self, bindings: Bindings) { - if let Ok(mut chord) = self.chord.lock() { - chord.update_bindings(bindings); + /// Swap in a fresh set of chord bindings. Tears down the existing + /// `ChordMatcher` (which stops keytap's chord worker thread and + /// closes the OS tap) and spawns a new one. No-op for the "all + /// empty" case so "disable hotkey" doesn't keep a tap running for + /// no reason. + pub fn update_bindings(&mut self, bindings: Bindings) { + self.apply(bindings); + } + + fn apply(&mut self, bindings: Bindings) { + // Tear down any existing matcher + dispatcher first. The + // dispatcher sees the shutdown flag on its next recv_timeout + // (≤100ms) and returns; joining waits for that. Dropping the + // ChordMatcher stops keytap's chord-worker thread and the + // underlying Tap. + if let Some(active) = self.active.take() { + active.shutdown.store(true, Ordering::Relaxed); + let _ = active.dispatcher.join(); + } + + if bindings.values().all(|set| set.is_empty()) { + return; + } + + let matcher = match build_matcher(&bindings) { + Ok(m) => m, + Err(err) => { + eprintln!( + "HotkeyMonitor: ChordMatcher build failed ({err}). Global chord detection is disabled. On macOS, grant Input Monitoring in System Settings → Privacy & Security → Input Monitoring and relaunch." + ); + return; + } + }; + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_for_thread = shutdown.clone(); + let app = self.app.clone(); + let dispatcher = thread::Builder::new() + .name("voicebox-hotkey-dispatcher".into()) + .spawn(move || dispatcher_loop(app, matcher, shutdown_for_thread)) + .expect("spawn hotkey dispatcher thread"); + + self.active = Some(Active { dispatcher, shutdown }); + } +} + +impl Drop for HotkeyMonitor { + fn drop(&mut self) { + if let Some(active) = self.active.take() { + active.shutdown.store(true, Ordering::Relaxed); + let _ = active.dispatcher.join(); } } } +// ======================================================================== +// Matcher construction + dispatch +// ======================================================================== + +fn build_matcher(bindings: &Bindings) -> Result, keytap::Error> { + let mut builder = ChordMatcher::builder(); + if let Some(keys) = bindings.get(&ChordAction::PushToTalk) { + if !keys.is_empty() { + builder = builder.add( + ChordAction::PushToTalk, + Chord::of(keys.iter().copied()), + ); + } + } + if let Some(keys) = bindings.get(&ChordAction::ToggleToTalk) { + if !keys.is_empty() { + builder = builder.add_toggle( + ChordAction::ToggleToTalk, + Chord::of(keys.iter().copied()), + ); + } + } + builder.build() +} + +fn dispatcher_loop( + app: AppHandle, + matcher: ChordMatcher, + shutdown: Arc, +) { + while !shutdown.load(Ordering::Relaxed) { + match matcher.recv_timeout(Duration::from_millis(100)) { + Ok(event) => process_event(&app, &matcher, event), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => break, + } + } +} + +/// Turn a single [`ChordEvent`] into zero or one [`Effect`]s, peeking at +/// the matcher once for a same-Instant follow-up so upgrade transitions +/// coalesce into [`Effect::RestartRecording`] instead of a Stop+Start +/// pair. +fn process_event( + app: &AppHandle, + matcher: &ChordMatcher, + event: ChordEvent, +) { + match event { + ChordEvent::Start { id, .. } => { + apply_effect(app, Effect::StartRecording(id)); + } + ChordEvent::End { id: end_id, time: end_time } => { + // Peek for an immediately-following Start. keytap emits + // End+Start atomically (same Instant) when the held set + // transitions between registered chords — our 5 ms window + // is well under perceptible latency but far longer than the + // channel hop between keytap's chord worker and our + // dispatcher. + match matcher.recv_timeout(Duration::from_millis(5)) { + Ok(ChordEvent::Start { id: start_id, time: start_time }) + if start_time == end_time => + { + apply_effect(app, Effect::RestartRecording(start_id)); + } + Ok(other) => { + apply_effect(app, Effect::StopRecording(end_id)); + // The peeked event wasn't a transition partner; + // process it in its own right. Recursion depth is + // bounded by the number of back-to-back chord + // events, in practice 1–2. + process_event(app, matcher, other); + } + Err(_) => { + apply_effect(app, Effect::StopRecording(end_id)); + } + } + } + } +} + +// ======================================================================== +// Effect → Tauri +// ======================================================================== + fn apply_effect(app: &AppHandle, effect: Effect) { match effect { Effect::StartRecording(_) => { @@ -273,75 +285,3 @@ fn apply_effect(app: &AppHandle, effect: Effect) { } } } - -// ======================================================================== -// Tests -// ======================================================================== - -#[cfg(test)] -mod tests { - use super::*; - - fn keys(keys: &[Key]) -> HashSet { - keys.iter().copied().collect() - } - - fn test_bindings() -> Bindings { - let mut b = Bindings::new(); - b.insert(ChordAction::PushToTalk, keys(&[Key::MetaLeft, Key::Alt])); - b.insert( - ChordAction::ToggleToTalk, - keys(&[Key::MetaLeft, Key::Alt, Key::Space]), - ); - b - } - - #[test] - fn push_to_talk_starts_on_exact_hold_and_stops_on_release() { - let mut c = Chord::new(test_bindings()); - assert_eq!(c.handle(KeyEvent::Down(Key::MetaLeft)), vec![]); - assert_eq!( - c.handle(KeyEvent::Down(Key::Alt)), - vec![Effect::StartRecording(ChordAction::PushToTalk)], - ); - assert_eq!( - c.handle(KeyEvent::Up(Key::Alt)), - vec![Effect::StopRecording(ChordAction::PushToTalk)], - ); - } - - #[test] - fn toggle_starts_on_exact_and_stops_on_second_exact() { - let mut c = Chord::new(test_bindings()); - c.handle(KeyEvent::Down(Key::MetaLeft)); - c.handle(KeyEvent::Down(Key::Alt)); - // At this point PTT is active. - assert_eq!(c.active_recording_action, Some(ChordAction::PushToTalk)); - assert_eq!( - c.handle(KeyEvent::Down(Key::Space)), - vec![Effect::RestartRecording(ChordAction::ToggleToTalk)], - ); - // Releasing cmd/opt must not stop toggle recording. - assert_eq!(c.handle(KeyEvent::Up(Key::MetaLeft)), vec![]); - assert_eq!(c.handle(KeyEvent::Up(Key::Alt)), vec![]); - assert_eq!(c.handle(KeyEvent::Up(Key::Space)), vec![]); - - // Second press of toggle chord stops it. - c.handle(KeyEvent::Down(Key::MetaLeft)); - c.handle(KeyEvent::Down(Key::Alt)); - assert_eq!( - c.handle(KeyEvent::Down(Key::Space)), - vec![Effect::StopRecording(ChordAction::ToggleToTalk)], - ); - } - - #[test] - fn toggle_from_idle_starts_immediately_on_full_chord() { - let mut c = Chord::new(test_bindings()); - c.handle(KeyEvent::Down(Key::MetaLeft)); - c.handle(KeyEvent::Down(Key::Alt)); - // Drop MetaLeft before Space — we're not in the exact toggle match - // yet, just prefix. No start for toggle. - assert_eq!(c.active_recording_action, Some(ChordAction::PushToTalk)); - } -} diff --git a/tauri/src-tauri/src/input_monitoring.rs b/tauri/src-tauri/src/input_monitoring.rs index eed15daa..bed5d6ee 100644 --- a/tauri/src-tauri/src/input_monitoring.rs +++ b/tauri/src-tauri/src/input_monitoring.rs @@ -2,8 +2,8 @@ //! //! On macOS 10.15+, creating a CGEventTap that observes keyboard events //! requires the host process to be listed under System Settings → Privacy & -//! Security → Input Monitoring. Without that trust, `rdev::listen` returns -//! immediately and no key events ever flow through the chord engine. +//! Security → Input Monitoring. Without that trust, keytap's `Tap` returns +//! a permission error and no key events ever flow through the chord engine. //! //! The relevant TCC pair lives in IOKit, mirroring `AXIsProcessTrusted` / //! `AXIsProcessTrustedWithOptions` on the Accessibility side: @@ -20,7 +20,7 @@ //! //! `enable_hotkey` calls `request` on first invocation so the prompt fires //! from a deterministic, user-initiated point (the Captures toggle) instead -//! of as a side-effect of `rdev::listen` creating its CGEventTap. +//! of as a side-effect of keytap's `Tap` creating its CGEventTap. //! //! Windows / Linux don't gate keyboard taps behind a TCC-style permission, //! so those branches return `true`. diff --git a/tauri/src-tauri/src/key_codes.rs b/tauri/src-tauri/src/key_codes.rs index f178e558..0b0019cf 100644 --- a/tauri/src-tauri/src/key_codes.rs +++ b/tauri/src-tauri/src/key_codes.rs @@ -1,25 +1,28 @@ -//! Stable string ↔ `rdev::Key` mapping for chord persistence. +//! Stable string ↔ `keytap::Key` mapping for chord persistence. //! //! The frontend captures keypresses through the browser keyboard API (which //! exposes `event.code` like `"MetaRight"`, `"AltRight"`, `"Space"`, `"KeyA"`) //! and stores chords in capture_settings as JSON arrays of canonical names. -//! On the way back the same names need to round-trip into `rdev::Key` +//! On the way back the same names need to round-trip into `keytap::Key` //! variants the chord engine actually matches against. //! -//! Names follow the rdev variant identifiers exactly (`"MetaRight"`, -//! `"AltGr"`, `"KeyA"`, …) with one alias: the browser reports right-Option -//! as `"AltRight"` while rdev calls it `"AltGr"`. Both map to the same key. +//! Input strings follow the W3C `KeyboardEvent.code` identifiers exactly — +//! `"MetaRight"`, `"AltRight"`, `"KeyA"`, `"Digit0"`, `"ArrowUp"`, … — +//! which is also what the browser emits natively, so on-disk chords +//! round-trip without translation on the frontend side. Legacy aliases +//! (`"Alt"` / `"AltGr"` / `"Num0"` / `"UpArrow"` / …) are accepted too so +//! older capture_settings rows written before the keytap swap keep working. -use rdev::Key; +use keytap::Key; -/// Resolve a canonical key name to its `rdev::Key`. Returns `None` for +/// Resolve a canonical key name to its `keytap::Key`. Returns `None` for /// names that don't have a corresponding variant — the command surface /// rejects those so we never silently drop keys from a chord. pub fn key_from_str(name: &str) -> Option { Some(match name { // Modifiers — left/right distinction matters for chord defaults. - "Alt" | "AltLeft" => Key::Alt, - "AltGr" | "AltRight" => Key::AltGr, + "AltLeft" | "Alt" => Key::AltLeft, + "AltRight" | "AltGr" => Key::AltRight, "ControlLeft" => Key::ControlLeft, "ControlRight" => Key::ControlRight, "MetaLeft" => Key::MetaLeft, @@ -27,12 +30,11 @@ pub fn key_from_str(name: &str) -> Option { "ShiftLeft" => Key::ShiftLeft, "ShiftRight" => Key::ShiftRight, "CapsLock" => Key::CapsLock, - "Function" => Key::Function, // Whitespace / navigation "Space" => Key::Space, "Tab" => Key::Tab, - "Return" | "Enter" => Key::Return, + "Enter" | "Return" => Key::Enter, "Backspace" => Key::Backspace, "Delete" => Key::Delete, "Escape" => Key::Escape, @@ -41,10 +43,10 @@ pub fn key_from_str(name: &str) -> Option { "End" => Key::End, "PageUp" => Key::PageUp, "PageDown" => Key::PageDown, - "ArrowUp" | "UpArrow" => Key::UpArrow, - "ArrowDown" | "DownArrow" => Key::DownArrow, - "ArrowLeft" | "LeftArrow" => Key::LeftArrow, - "ArrowRight" | "RightArrow" => Key::RightArrow, + "ArrowUp" | "UpArrow" => Key::ArrowUp, + "ArrowDown" | "DownArrow" => Key::ArrowDown, + "ArrowLeft" | "LeftArrow" => Key::ArrowLeft, + "ArrowRight" | "RightArrow" => Key::ArrowRight, // Function row "F1" => Key::F1, "F2" => Key::F2, "F3" => Key::F3, "F4" => Key::F4, @@ -52,39 +54,39 @@ pub fn key_from_str(name: &str) -> Option { "F9" => Key::F9, "F10" => Key::F10, "F11" => Key::F11, "F12" => Key::F12, // Digits - "Digit0" | "Num0" => Key::Num0, - "Digit1" | "Num1" => Key::Num1, - "Digit2" | "Num2" => Key::Num2, - "Digit3" | "Num3" => Key::Num3, - "Digit4" | "Num4" => Key::Num4, - "Digit5" | "Num5" => Key::Num5, - "Digit6" | "Num6" => Key::Num6, - "Digit7" | "Num7" => Key::Num7, - "Digit8" | "Num8" => Key::Num8, - "Digit9" | "Num9" => Key::Num9, + "Digit0" | "Num0" => Key::Digit0, + "Digit1" | "Num1" => Key::Digit1, + "Digit2" | "Num2" => Key::Digit2, + "Digit3" | "Num3" => Key::Digit3, + "Digit4" | "Num4" => Key::Digit4, + "Digit5" | "Num5" => Key::Digit5, + "Digit6" | "Num6" => Key::Digit6, + "Digit7" | "Num7" => Key::Digit7, + "Digit8" | "Num8" => Key::Digit8, + "Digit9" | "Num9" => Key::Digit9, - // Letters — browser uses "KeyA" style which already matches rdev. - "KeyA" => Key::KeyA, "KeyB" => Key::KeyB, "KeyC" => Key::KeyC, - "KeyD" => Key::KeyD, "KeyE" => Key::KeyE, "KeyF" => Key::KeyF, - "KeyG" => Key::KeyG, "KeyH" => Key::KeyH, "KeyI" => Key::KeyI, - "KeyJ" => Key::KeyJ, "KeyK" => Key::KeyK, "KeyL" => Key::KeyL, - "KeyM" => Key::KeyM, "KeyN" => Key::KeyN, "KeyO" => Key::KeyO, - "KeyP" => Key::KeyP, "KeyQ" => Key::KeyQ, "KeyR" => Key::KeyR, - "KeyS" => Key::KeyS, "KeyT" => Key::KeyT, "KeyU" => Key::KeyU, - "KeyV" => Key::KeyV, "KeyW" => Key::KeyW, "KeyX" => Key::KeyX, - "KeyY" => Key::KeyY, "KeyZ" => Key::KeyZ, + // Letters — browser emits "KeyA"; keytap uses the bare letter. + "KeyA" => Key::A, "KeyB" => Key::B, "KeyC" => Key::C, + "KeyD" => Key::D, "KeyE" => Key::E, "KeyF" => Key::F, + "KeyG" => Key::G, "KeyH" => Key::H, "KeyI" => Key::I, + "KeyJ" => Key::J, "KeyK" => Key::K, "KeyL" => Key::L, + "KeyM" => Key::M, "KeyN" => Key::N, "KeyO" => Key::O, + "KeyP" => Key::P, "KeyQ" => Key::Q, "KeyR" => Key::R, + "KeyS" => Key::S, "KeyT" => Key::T, "KeyU" => Key::U, + "KeyV" => Key::V, "KeyW" => Key::W, "KeyX" => Key::X, + "KeyY" => Key::Y, "KeyZ" => Key::Z, // Punctuation / symbols - "Backquote" | "BackQuote" => Key::BackQuote, + "Backquote" | "BackQuote" => Key::Backtick, "Minus" => Key::Minus, "Equal" => Key::Equal, - "BracketLeft" | "LeftBracket" => Key::LeftBracket, - "BracketRight" | "RightBracket" => Key::RightBracket, - "Semicolon" | "SemiColon" => Key::SemiColon, + "BracketLeft" | "LeftBracket" => Key::BracketLeft, + "BracketRight" | "RightBracket" => Key::BracketRight, + "Semicolon" | "SemiColon" => Key::Semicolon, "Quote" => Key::Quote, - "Backslash" | "BackSlash" => Key::BackSlash, + "Backslash" | "BackSlash" => Key::Backslash, "Comma" => Key::Comma, - "Period" | "Dot" => Key::Dot, + "Period" | "Dot" => Key::Period, "Slash" => Key::Slash, _ => return None, diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 1de56a30..a8d64ade 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -863,10 +863,10 @@ fn check_input_monitoring_permission() -> bool { /// what triggers the macOS Input Monitoring TCC prompt, so a fresh-install /// user who never enables the hotkey never sees the prompt. /// -/// Once spawned, the monitor stays alive for the rest of the process: rdev's -/// `listen` blocks forever and offers no stop signal. "Disable" therefore -/// swaps the chord engine to empty bindings (matches nothing, fires nothing) -/// rather than tearing down the CGEventTap. +/// Disabling the hotkey clears the monitor's internal `ChordMatcher` so +/// keytap's event tap is released while Tauri still owns this `HotkeyState` +/// for the rest of the process. A subsequent enable re-arms without +/// re-prompting for the Input Monitoring permission. #[cfg(desktop)] #[derive(Default)] pub struct HotkeyState { @@ -879,7 +879,7 @@ fn build_chord_bindings( toggle_to_talk: &[String], ) -> Result { use hotkey_monitor::{Bindings, ChordAction}; - use rdev::Key; + use keytap::Key; use std::collections::HashSet; fn build_chord(name: &str, names: &[String]) -> Result, String> { @@ -910,7 +910,7 @@ fn build_chord_bindings( /// is true) and from the settings toggle. /// /// On macOS this is the call that triggers the "Voicebox would like to receive -/// keystrokes from any application" TCC prompt, since `rdev::listen` creates +/// keystrokes from any application" TCC prompt, since keytap's `Tap` creates /// the CGEventTap inside `HotkeyMonitor::spawn`. #[cfg(desktop)] #[command] @@ -923,15 +923,15 @@ fn enable_hotkey( let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?; // Fire the Input Monitoring TCC prompt explicitly from the user's - // toggle click, before rdev::listen would do it implicitly via + // toggle click, before keytap's Tap would do it implicitly via // CGEventTap creation. Two reasons: (1) the prompt timing becomes // deterministic — it appears in response to a click instead of as a // mysterious side-effect of "the app started"; (2) on subsequent // launches we can short-circuit the spawn entirely if the user - // revoked the grant, instead of leaning on rdev silently failing. + // revoked the grant, instead of relying on the tap silently failing. // The call returns the current grant state; we ignore it because - // rdev::listen will surface its own error via stderr, and the - // settings UI polls `check_input_monitoring_permission` separately. + // keytap surfaces its own error via stderr, and the settings UI + // polls `check_input_monitoring_permission` separately. let _ = input_monitoring::request(); // The dictate pill webview must exist before the first chord fires so it @@ -944,7 +944,7 @@ fn enable_hotkey( } let mut slot = state.monitor.lock().map_err(|e| e.to_string())?; - match slot.as_ref() { + match slot.as_mut() { Some(monitor) => monitor.update_bindings(bindings), None => { *slot = Some(hotkey_monitor::HotkeyMonitor::spawn(app, bindings)); @@ -953,16 +953,15 @@ fn enable_hotkey( Ok(()) } -/// Quiet the global hotkey by swapping the chord engine to empty bindings. -/// The CGEventTap stays alive (rdev::listen has no stop) but the chord state -/// machine matches nothing, so no `dictate:*` events fire and the dictate -/// pill never shows. A subsequent `enable_hotkey` call re-arms it without -/// re-prompting for permission. +/// Quiet the global hotkey. Tears down the `ChordMatcher` (which stops +/// keytap's chord worker and closes the OS event tap) but keeps the +/// `HotkeyMonitor` handle around so a subsequent `enable_hotkey` re-arms +/// without re-prompting for Input Monitoring permission. #[cfg(desktop)] #[command] fn disable_hotkey(state: State<'_, HotkeyState>) -> Result<(), String> { - let slot = state.monitor.lock().map_err(|e| e.to_string())?; - if let Some(monitor) = slot.as_ref() { + let mut slot = state.monitor.lock().map_err(|e| e.to_string())?; + if let Some(monitor) = slot.as_mut() { monitor.update_bindings(hotkey_monitor::Bindings::new()); } Ok(()) @@ -974,7 +973,7 @@ fn disable_hotkey(state: State<'_, HotkeyState>) -> Result<(), String> { /// this can only happen if the frontend races; the next `enable_hotkey` will /// pick up the saved chords. /// -/// Returns an error when a key name doesn't map to an `rdev::Key`, so the +/// Returns an error when a key name doesn't map to a `keytap::Key`, so the /// picker UI can surface "this key isn't supported" instead of silently /// dropping it from the chord. #[cfg(desktop)] @@ -985,8 +984,8 @@ fn update_chord_bindings( toggle_to_talk: Vec, ) -> Result<(), String> { let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?; - let slot = state.monitor.lock().map_err(|e| e.to_string())?; - if let Some(monitor) = slot.as_ref() { + let mut slot = state.monitor.lock().map_err(|e| e.to_string())?; + if let Some(monitor) = slot.as_mut() { monitor.update_bindings(bindings); } Ok(())