feat(capture): swap the rdev fork for keytap 0.2, delete local chord state machine

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) <[email protected]>
This commit is contained in:
James Pine
2026-04-23 20:13:23 -07:00
co-authored by Claude Opus 4.7
parent 1ca7895ffb
commit c6114b69bc
6 changed files with 342 additions and 357 deletions
+82 -34
View File
@@ -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"
+9 -13
View File
@@ -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
+185 -245
View File
@@ -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<Key>` shape so callers don't need to know about keytap's
/// `Chord` type.
pub type Bindings = HashMap<ChordAction, HashSet<Key>>;
/// 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<Key>,
active_recording_action: Option<ChordAction>,
}
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<Effect> {
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<Effect> {
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<Mutex<Chord>>,
app: AppHandle,
active: Option<Active>,
}
struct Active {
dispatcher: JoinHandle<()>,
shutdown: Arc<AtomicBool>,
}
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<ChordMatcher<ChordAction>, 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<ChordAction>,
shutdown: Arc<AtomicBool>,
) {
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<ChordAction>,
event: ChordEvent<ChordAction>,
) {
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 12.
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<Key> {
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));
}
}
+3 -3
View File
@@ -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`.
+43 -41
View File
@@ -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<Key> {
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<Key> {
"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<Key> {
"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<Key> {
"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,
+20 -21
View File
@@ -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<hotkey_monitor::Bindings, String> {
use hotkey_monitor::{Bindings, ChordAction};
use rdev::Key;
use keytap::Key;
use std::collections::HashSet;
fn build_chord(name: &str, names: &[String]) -> Result<HashSet<Key>, 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<String>,
) -> 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(())