mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 21:30:39 -07:00
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:
co-authored by
Claude Opus 4.7
parent
1ca7895ffb
commit
c6114b69bc
Generated
+82
-34
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user