Files
voicebox/tauri/src-tauri/src/input_monitoring.rs
T
James PineandClaude Opus 4.7 c6114b69bc 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]>
2026-04-23 20:13:23 -07:00

83 lines
3.6 KiB
Rust

//! Platform permission gate for the global keyboard tap.
//!
//! 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, 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:
//!
//! - `IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)` — read the current
//! grant without prompting. We call this from the Captures settings UI
//! so the row can show "granted" / "missing" without surprising the user.
//! - `IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)` — fire the
//! "Voicebox would like to receive keystrokes from any application"
//! dialog and add Voicebox to the Input Monitoring pane (toggle off).
//! Returns true when access is already granted; otherwise returns false
//! and queues the prompt. The user still has to flip the toggle on; this
//! just gets us into the list.
//!
//! `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 keytap's `Tap` creating its CGEventTap.
//!
//! Windows / Linux don't gate keyboard taps behind a TCC-style permission,
//! so those branches return `true`.
#[cfg(target_os = "macos")]
mod ffi {
use std::os::raw::c_uint;
/// `kIOHIDRequestTypeListenEvent` from `<IOKit/hidsystem/IOHIDLib.h>` —
/// the request-type discriminator for "I want to read keyboard / mouse
/// events created by other processes."
pub const REQUEST_TYPE_LISTEN_EVENT: c_uint = 1;
/// `kIOHIDAccessTypeGranted` from `IOHIDLib.h`. The other values are
/// `Denied = 1` and `Unknown = 2`; we only ever care about the granted
/// case so they don't get their own constants.
pub const ACCESS_TYPE_GRANTED: c_uint = 0;
#[link(name = "IOKit", kind = "framework")]
extern "C" {
/// Returns the current access state as an `IOHIDAccessType` enum
/// (Granted=0, Denied=1, Unknown=2). No prompt side-effect.
///
/// Declared as `c_uint` rather than `bool`: the C signature returns
/// the full enum, and reading a 3-valued enum into Rust's 1-bit
/// `bool` is undefined behaviour that silently inverts our gate.
pub fn IOHIDCheckAccess(request_type: c_uint) -> c_uint;
/// Returns true when access is already granted; otherwise queues
/// the system prompt and returns false synchronously. Safe to call
/// repeatedly — once the entry exists in the Input Monitoring pane
/// macOS won't re-prompt. Real `Boolean` (UInt8) return on the C
/// side, so `bool` here is correct.
pub fn IOHIDRequestAccess(request_type: c_uint) -> bool;
}
}
#[cfg(target_os = "macos")]
pub fn is_trusted() -> bool {
unsafe { ffi::IOHIDCheckAccess(ffi::REQUEST_TYPE_LISTEN_EVENT) == ffi::ACCESS_TYPE_GRANTED }
}
/// Fire the Input Monitoring prompt if not already granted. Returns the
/// current grant state; a `false` here means the prompt was queued and the
/// user needs to flip the toggle in System Settings before key events flow.
#[cfg(target_os = "macos")]
pub fn request() -> bool {
unsafe { ffi::IOHIDRequestAccess(ffi::REQUEST_TYPE_LISTEN_EVENT) }
}
#[cfg(not(target_os = "macos"))]
pub fn is_trusted() -> bool {
true
}
#[cfg(not(target_os = "macos"))]
pub fn request() -> bool {
true
}