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
+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(())