mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -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",
|
"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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -835,6 +847,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "extended"
|
name = "extended"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -950,6 +974,12 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "funty"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futf"
|
name = "futf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1846,6 +1876,24 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "kuchikiki"
|
name = "kuchikiki"
|
||||||
version = "0.8.8-speedreader"
|
version = "0.8.8-speedreader"
|
||||||
@@ -2164,6 +2212,18 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
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]]
|
[[package]]
|
||||||
name = "nodrop"
|
name = "nodrop"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -2306,9 +2366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"libc",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2319,13 +2377,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
"libc",
|
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-io-surface",
|
"objc2-io-surface",
|
||||||
"objc2-metal",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2412,17 +2467,6 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-osa-kit"
|
name = "objc2-osa-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3016,6 +3060,12 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "radium"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -3132,23 +3182,6 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -4213,6 +4246,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tap"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -5081,8 +5120,8 @@ dependencies = [
|
|||||||
"coreaudio-sys",
|
"coreaudio-sys",
|
||||||
"cpal",
|
"cpal",
|
||||||
"hound",
|
"hound",
|
||||||
|
"keytap",
|
||||||
"objc",
|
"objc",
|
||||||
"rdev",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
"screencapturekit",
|
"screencapturekit",
|
||||||
@@ -5966,6 +6005,15 @@ dependencies = [
|
|||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wyz"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"tap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11"
|
name = "x11"
|
||||||
version = "2.21.0"
|
version = "2.21.0"
|
||||||
|
|||||||
@@ -52,19 +52,15 @@ webkit2gtk = "2.0"
|
|||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2.0"
|
tauri-plugin-updater = "2.0"
|
||||||
tauri-plugin-process = "2.0"
|
tauri-plugin-process = "2.0"
|
||||||
# Pinned to our fork of upstream main past PR #147 — the released 0.5.3
|
# Observe-only global keyboard tap. Covers macOS, Windows, and Linux
|
||||||
# crate crashes on macOS 14+ when listen() runs on a background thread
|
# (evdev) with left/right modifier fidelity, which is the hard
|
||||||
# because rdev's convert() calls TSMGetInputSourceProperty off the main
|
# requirement the chord engine needs. keytap is our own crate
|
||||||
# queue. The main branch adds `set_is_main_thread(false)` to skip that
|
# (jamiepine/keytap), published to crates.io, that replaces the
|
||||||
# path (used at hotkey_monitor.rs:184).
|
# abandoned Narsil/rdev we previously pinned via git — same capability
|
||||||
#
|
# surface, clean shutdown via Drop, no `set_is_main_thread(false)`
|
||||||
# We pin to jamiepine/rdev instead of Narsil/rdev for supply-chain
|
# dance required (the Sonoma-crashing layout-translation path simply
|
||||||
# durability: upstream has shipped no release since 2023-06 (crates.io
|
# isn't called).
|
||||||
# still lists 0.5.3), so an eventual 0.5.4 carrying this fix is unlikely,
|
keytap = "0.2"
|
||||||
# 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" }
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
# 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
|
//! Thin adapter from `keytap::chord::ChordMatcher` to Tauri events. keytap
|
||||||
//! CGEventTap on macOS / `SetWindowsHookEx` on Windows / `XRecord` on Linux).
|
//! owns the OS event tap + the chord state machine (Momentary vs Toggle,
|
||||||
//! Feeds raw key events into a private `Chord` state machine and translates
|
//! longest-match resolution, sticky-toggle semantics); this module's only
|
||||||
//! its effects into Tauri events + window show/hide calls.
|
//! job is:
|
||||||
//!
|
//!
|
||||||
//! Left- and right-hand modifier variants are deliberately kept distinct.
|
//! 1. Build a `ChordMatcher` from the user's saved PTT + Toggle chords.
|
||||||
//! Defaults bind to right-hand Cmd + right-hand Option so that the usual
|
//! 2. Translate `ChordEvent` → voicebox's [`Effect`] on a dispatcher
|
||||||
//! left-hand shortcuts — Cmd+Option+I to open devtools, Cmd+Option+Esc for
|
//! thread.
|
||||||
//! force-quit, etc. — continue to work untouched.
|
//! 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::collections::{HashMap, HashSet};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::thread;
|
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 tauri::{AppHandle, Emitter, Manager};
|
||||||
|
|
||||||
use crate::focus_capture;
|
use crate::focus_capture;
|
||||||
use crate::DICTATE_WINDOW_LABEL;
|
use crate::DICTATE_WINDOW_LABEL;
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Chord state machine
|
// Public types
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/// Semantic action a chord can be bound to. `PushToTalk` = hold chord to
|
/// Semantic action a chord can be bound to. `PushToTalk` = hold chord to
|
||||||
@@ -33,191 +50,186 @@ pub enum ChordAction {
|
|||||||
ToggleToTalk,
|
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.
|
/// translate these into UI / recorder calls.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Effect {
|
pub enum Effect {
|
||||||
StartRecording(ChordAction),
|
StartRecording(ChordAction),
|
||||||
StopRecording(ChordAction),
|
StopRecording(ChordAction),
|
||||||
/// Emitted when a push-to-talk chord is "upgraded" into the toggle chord
|
/// Emitted when a push-to-talk chord is "upgraded" into the toggle
|
||||||
/// mid-hold — hosts may want to discard the captured audio and restart
|
/// chord mid-hold — hosts may want to discard the captured audio and
|
||||||
/// so the transition moment isn't in the recording.
|
/// restart so the transition moment isn't in the recording.
|
||||||
RestartRecording(ChordAction),
|
RestartRecording(ChordAction),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
/// Chord key sets from capture settings. Both actions use the same
|
||||||
enum KeyEvent {
|
/// `HashSet<Key>` shape so callers don't need to know about keytap's
|
||||||
Down(Key),
|
/// `Chord` type.
|
||||||
Up(Key),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
enum Match {
|
|
||||||
None,
|
|
||||||
Partial,
|
|
||||||
Hit(ChordAction),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Bindings = HashMap<ChordAction, HashSet<Key>>;
|
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
|
// Monitor
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
pub struct HotkeyMonitor {
|
pub struct HotkeyMonitor {
|
||||||
chord: Arc<Mutex<Chord>>,
|
app: AppHandle,
|
||||||
|
active: Option<Active>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Active {
|
||||||
|
dispatcher: JoinHandle<()>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HotkeyMonitor {
|
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 {
|
pub fn spawn(app: AppHandle, bindings: Bindings) -> Self {
|
||||||
let chord = Arc::new(Mutex::new(Chord::new(bindings)));
|
let mut m = Self { app, active: None };
|
||||||
let chord_for_thread = chord.clone();
|
m.apply(bindings);
|
||||||
let app_for_thread = app.clone();
|
m
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_bindings(&self, bindings: Bindings) {
|
/// Swap in a fresh set of chord bindings. Tears down the existing
|
||||||
if let Ok(mut chord) = self.chord.lock() {
|
/// `ChordMatcher` (which stops keytap's chord worker thread and
|
||||||
chord.update_bindings(bindings);
|
/// 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) {
|
fn apply_effect(app: &AppHandle, effect: Effect) {
|
||||||
match effect {
|
match effect {
|
||||||
Effect::StartRecording(_) => {
|
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
|
//! On macOS 10.15+, creating a CGEventTap that observes keyboard events
|
||||||
//! requires the host process to be listed under System Settings → Privacy &
|
//! requires the host process to be listed under System Settings → Privacy &
|
||||||
//! Security → Input Monitoring. Without that trust, `rdev::listen` returns
|
//! Security → Input Monitoring. Without that trust, keytap's `Tap` returns
|
||||||
//! immediately and no key events ever flow through the chord engine.
|
//! a permission error and no key events ever flow through the chord engine.
|
||||||
//!
|
//!
|
||||||
//! The relevant TCC pair lives in IOKit, mirroring `AXIsProcessTrusted` /
|
//! The relevant TCC pair lives in IOKit, mirroring `AXIsProcessTrusted` /
|
||||||
//! `AXIsProcessTrustedWithOptions` on the Accessibility side:
|
//! `AXIsProcessTrustedWithOptions` on the Accessibility side:
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
//!
|
//!
|
||||||
//! `enable_hotkey` calls `request` on first invocation so the prompt fires
|
//! `enable_hotkey` calls `request` on first invocation so the prompt fires
|
||||||
//! from a deterministic, user-initiated point (the Captures toggle) instead
|
//! 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,
|
//! Windows / Linux don't gate keyboard taps behind a TCC-style permission,
|
||||||
//! so those branches return `true`.
|
//! 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
|
//! The frontend captures keypresses through the browser keyboard API (which
|
||||||
//! exposes `event.code` like `"MetaRight"`, `"AltRight"`, `"Space"`, `"KeyA"`)
|
//! exposes `event.code` like `"MetaRight"`, `"AltRight"`, `"Space"`, `"KeyA"`)
|
||||||
//! and stores chords in capture_settings as JSON arrays of canonical names.
|
//! 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.
|
//! variants the chord engine actually matches against.
|
||||||
//!
|
//!
|
||||||
//! Names follow the rdev variant identifiers exactly (`"MetaRight"`,
|
//! Input strings follow the W3C `KeyboardEvent.code` identifiers exactly —
|
||||||
//! `"AltGr"`, `"KeyA"`, …) with one alias: the browser reports right-Option
|
//! `"MetaRight"`, `"AltRight"`, `"KeyA"`, `"Digit0"`, `"ArrowUp"`, … —
|
||||||
//! as `"AltRight"` while rdev calls it `"AltGr"`. Both map to the same key.
|
//! 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
|
/// names that don't have a corresponding variant — the command surface
|
||||||
/// rejects those so we never silently drop keys from a chord.
|
/// rejects those so we never silently drop keys from a chord.
|
||||||
pub fn key_from_str(name: &str) -> Option<Key> {
|
pub fn key_from_str(name: &str) -> Option<Key> {
|
||||||
Some(match name {
|
Some(match name {
|
||||||
// Modifiers — left/right distinction matters for chord defaults.
|
// Modifiers — left/right distinction matters for chord defaults.
|
||||||
"Alt" | "AltLeft" => Key::Alt,
|
"AltLeft" | "Alt" => Key::AltLeft,
|
||||||
"AltGr" | "AltRight" => Key::AltGr,
|
"AltRight" | "AltGr" => Key::AltRight,
|
||||||
"ControlLeft" => Key::ControlLeft,
|
"ControlLeft" => Key::ControlLeft,
|
||||||
"ControlRight" => Key::ControlRight,
|
"ControlRight" => Key::ControlRight,
|
||||||
"MetaLeft" => Key::MetaLeft,
|
"MetaLeft" => Key::MetaLeft,
|
||||||
@@ -27,12 +30,11 @@ pub fn key_from_str(name: &str) -> Option<Key> {
|
|||||||
"ShiftLeft" => Key::ShiftLeft,
|
"ShiftLeft" => Key::ShiftLeft,
|
||||||
"ShiftRight" => Key::ShiftRight,
|
"ShiftRight" => Key::ShiftRight,
|
||||||
"CapsLock" => Key::CapsLock,
|
"CapsLock" => Key::CapsLock,
|
||||||
"Function" => Key::Function,
|
|
||||||
|
|
||||||
// Whitespace / navigation
|
// Whitespace / navigation
|
||||||
"Space" => Key::Space,
|
"Space" => Key::Space,
|
||||||
"Tab" => Key::Tab,
|
"Tab" => Key::Tab,
|
||||||
"Return" | "Enter" => Key::Return,
|
"Enter" | "Return" => Key::Enter,
|
||||||
"Backspace" => Key::Backspace,
|
"Backspace" => Key::Backspace,
|
||||||
"Delete" => Key::Delete,
|
"Delete" => Key::Delete,
|
||||||
"Escape" => Key::Escape,
|
"Escape" => Key::Escape,
|
||||||
@@ -41,10 +43,10 @@ pub fn key_from_str(name: &str) -> Option<Key> {
|
|||||||
"End" => Key::End,
|
"End" => Key::End,
|
||||||
"PageUp" => Key::PageUp,
|
"PageUp" => Key::PageUp,
|
||||||
"PageDown" => Key::PageDown,
|
"PageDown" => Key::PageDown,
|
||||||
"ArrowUp" | "UpArrow" => Key::UpArrow,
|
"ArrowUp" | "UpArrow" => Key::ArrowUp,
|
||||||
"ArrowDown" | "DownArrow" => Key::DownArrow,
|
"ArrowDown" | "DownArrow" => Key::ArrowDown,
|
||||||
"ArrowLeft" | "LeftArrow" => Key::LeftArrow,
|
"ArrowLeft" | "LeftArrow" => Key::ArrowLeft,
|
||||||
"ArrowRight" | "RightArrow" => Key::RightArrow,
|
"ArrowRight" | "RightArrow" => Key::ArrowRight,
|
||||||
|
|
||||||
// Function row
|
// Function row
|
||||||
"F1" => Key::F1, "F2" => Key::F2, "F3" => Key::F3, "F4" => Key::F4,
|
"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,
|
"F9" => Key::F9, "F10" => Key::F10, "F11" => Key::F11, "F12" => Key::F12,
|
||||||
|
|
||||||
// Digits
|
// Digits
|
||||||
"Digit0" | "Num0" => Key::Num0,
|
"Digit0" | "Num0" => Key::Digit0,
|
||||||
"Digit1" | "Num1" => Key::Num1,
|
"Digit1" | "Num1" => Key::Digit1,
|
||||||
"Digit2" | "Num2" => Key::Num2,
|
"Digit2" | "Num2" => Key::Digit2,
|
||||||
"Digit3" | "Num3" => Key::Num3,
|
"Digit3" | "Num3" => Key::Digit3,
|
||||||
"Digit4" | "Num4" => Key::Num4,
|
"Digit4" | "Num4" => Key::Digit4,
|
||||||
"Digit5" | "Num5" => Key::Num5,
|
"Digit5" | "Num5" => Key::Digit5,
|
||||||
"Digit6" | "Num6" => Key::Num6,
|
"Digit6" | "Num6" => Key::Digit6,
|
||||||
"Digit7" | "Num7" => Key::Num7,
|
"Digit7" | "Num7" => Key::Digit7,
|
||||||
"Digit8" | "Num8" => Key::Num8,
|
"Digit8" | "Num8" => Key::Digit8,
|
||||||
"Digit9" | "Num9" => Key::Num9,
|
"Digit9" | "Num9" => Key::Digit9,
|
||||||
|
|
||||||
// Letters — browser uses "KeyA" style which already matches rdev.
|
// Letters — browser emits "KeyA"; keytap uses the bare letter.
|
||||||
"KeyA" => Key::KeyA, "KeyB" => Key::KeyB, "KeyC" => Key::KeyC,
|
"KeyA" => Key::A, "KeyB" => Key::B, "KeyC" => Key::C,
|
||||||
"KeyD" => Key::KeyD, "KeyE" => Key::KeyE, "KeyF" => Key::KeyF,
|
"KeyD" => Key::D, "KeyE" => Key::E, "KeyF" => Key::F,
|
||||||
"KeyG" => Key::KeyG, "KeyH" => Key::KeyH, "KeyI" => Key::KeyI,
|
"KeyG" => Key::G, "KeyH" => Key::H, "KeyI" => Key::I,
|
||||||
"KeyJ" => Key::KeyJ, "KeyK" => Key::KeyK, "KeyL" => Key::KeyL,
|
"KeyJ" => Key::J, "KeyK" => Key::K, "KeyL" => Key::L,
|
||||||
"KeyM" => Key::KeyM, "KeyN" => Key::KeyN, "KeyO" => Key::KeyO,
|
"KeyM" => Key::M, "KeyN" => Key::N, "KeyO" => Key::O,
|
||||||
"KeyP" => Key::KeyP, "KeyQ" => Key::KeyQ, "KeyR" => Key::KeyR,
|
"KeyP" => Key::P, "KeyQ" => Key::Q, "KeyR" => Key::R,
|
||||||
"KeyS" => Key::KeyS, "KeyT" => Key::KeyT, "KeyU" => Key::KeyU,
|
"KeyS" => Key::S, "KeyT" => Key::T, "KeyU" => Key::U,
|
||||||
"KeyV" => Key::KeyV, "KeyW" => Key::KeyW, "KeyX" => Key::KeyX,
|
"KeyV" => Key::V, "KeyW" => Key::W, "KeyX" => Key::X,
|
||||||
"KeyY" => Key::KeyY, "KeyZ" => Key::KeyZ,
|
"KeyY" => Key::Y, "KeyZ" => Key::Z,
|
||||||
|
|
||||||
// Punctuation / symbols
|
// Punctuation / symbols
|
||||||
"Backquote" | "BackQuote" => Key::BackQuote,
|
"Backquote" | "BackQuote" => Key::Backtick,
|
||||||
"Minus" => Key::Minus,
|
"Minus" => Key::Minus,
|
||||||
"Equal" => Key::Equal,
|
"Equal" => Key::Equal,
|
||||||
"BracketLeft" | "LeftBracket" => Key::LeftBracket,
|
"BracketLeft" | "LeftBracket" => Key::BracketLeft,
|
||||||
"BracketRight" | "RightBracket" => Key::RightBracket,
|
"BracketRight" | "RightBracket" => Key::BracketRight,
|
||||||
"Semicolon" | "SemiColon" => Key::SemiColon,
|
"Semicolon" | "SemiColon" => Key::Semicolon,
|
||||||
"Quote" => Key::Quote,
|
"Quote" => Key::Quote,
|
||||||
"Backslash" | "BackSlash" => Key::BackSlash,
|
"Backslash" | "BackSlash" => Key::Backslash,
|
||||||
"Comma" => Key::Comma,
|
"Comma" => Key::Comma,
|
||||||
"Period" | "Dot" => Key::Dot,
|
"Period" | "Dot" => Key::Period,
|
||||||
"Slash" => Key::Slash,
|
"Slash" => Key::Slash,
|
||||||
|
|
||||||
_ => return None,
|
_ => 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
|
/// what triggers the macOS Input Monitoring TCC prompt, so a fresh-install
|
||||||
/// user who never enables the hotkey never sees the prompt.
|
/// user who never enables the hotkey never sees the prompt.
|
||||||
///
|
///
|
||||||
/// Once spawned, the monitor stays alive for the rest of the process: rdev's
|
/// Disabling the hotkey clears the monitor's internal `ChordMatcher` so
|
||||||
/// `listen` blocks forever and offers no stop signal. "Disable" therefore
|
/// keytap's event tap is released while Tauri still owns this `HotkeyState`
|
||||||
/// swaps the chord engine to empty bindings (matches nothing, fires nothing)
|
/// for the rest of the process. A subsequent enable re-arms without
|
||||||
/// rather than tearing down the CGEventTap.
|
/// re-prompting for the Input Monitoring permission.
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct HotkeyState {
|
pub struct HotkeyState {
|
||||||
@@ -879,7 +879,7 @@ fn build_chord_bindings(
|
|||||||
toggle_to_talk: &[String],
|
toggle_to_talk: &[String],
|
||||||
) -> Result<hotkey_monitor::Bindings, String> {
|
) -> Result<hotkey_monitor::Bindings, String> {
|
||||||
use hotkey_monitor::{Bindings, ChordAction};
|
use hotkey_monitor::{Bindings, ChordAction};
|
||||||
use rdev::Key;
|
use keytap::Key;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
fn build_chord(name: &str, names: &[String]) -> Result<HashSet<Key>, String> {
|
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.
|
/// is true) and from the settings toggle.
|
||||||
///
|
///
|
||||||
/// On macOS this is the call that triggers the "Voicebox would like to receive
|
/// 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`.
|
/// the CGEventTap inside `HotkeyMonitor::spawn`.
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
#[command]
|
#[command]
|
||||||
@@ -923,15 +923,15 @@ fn enable_hotkey(
|
|||||||
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
|
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
|
||||||
|
|
||||||
// Fire the Input Monitoring TCC prompt explicitly from the user's
|
// 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
|
// CGEventTap creation. Two reasons: (1) the prompt timing becomes
|
||||||
// deterministic — it appears in response to a click instead of as a
|
// deterministic — it appears in response to a click instead of as a
|
||||||
// mysterious side-effect of "the app started"; (2) on subsequent
|
// mysterious side-effect of "the app started"; (2) on subsequent
|
||||||
// launches we can short-circuit the spawn entirely if the user
|
// 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
|
// The call returns the current grant state; we ignore it because
|
||||||
// rdev::listen will surface its own error via stderr, and the
|
// keytap surfaces its own error via stderr, and the settings UI
|
||||||
// settings UI polls `check_input_monitoring_permission` separately.
|
// polls `check_input_monitoring_permission` separately.
|
||||||
let _ = input_monitoring::request();
|
let _ = input_monitoring::request();
|
||||||
|
|
||||||
// The dictate pill webview must exist before the first chord fires so it
|
// 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())?;
|
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),
|
Some(monitor) => monitor.update_bindings(bindings),
|
||||||
None => {
|
None => {
|
||||||
*slot = Some(hotkey_monitor::HotkeyMonitor::spawn(app, bindings));
|
*slot = Some(hotkey_monitor::HotkeyMonitor::spawn(app, bindings));
|
||||||
@@ -953,16 +953,15 @@ fn enable_hotkey(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quiet the global hotkey by swapping the chord engine to empty bindings.
|
/// Quiet the global hotkey. Tears down the `ChordMatcher` (which stops
|
||||||
/// The CGEventTap stays alive (rdev::listen has no stop) but the chord state
|
/// keytap's chord worker and closes the OS event tap) but keeps the
|
||||||
/// machine matches nothing, so no `dictate:*` events fire and the dictate
|
/// `HotkeyMonitor` handle around so a subsequent `enable_hotkey` re-arms
|
||||||
/// pill never shows. A subsequent `enable_hotkey` call re-arms it without
|
/// without re-prompting for Input Monitoring permission.
|
||||||
/// re-prompting for permission.
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
#[command]
|
#[command]
|
||||||
fn disable_hotkey(state: State<'_, HotkeyState>) -> Result<(), String> {
|
fn disable_hotkey(state: State<'_, HotkeyState>) -> Result<(), String> {
|
||||||
let slot = state.monitor.lock().map_err(|e| e.to_string())?;
|
let mut slot = state.monitor.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(monitor) = slot.as_ref() {
|
if let Some(monitor) = slot.as_mut() {
|
||||||
monitor.update_bindings(hotkey_monitor::Bindings::new());
|
monitor.update_bindings(hotkey_monitor::Bindings::new());
|
||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// this can only happen if the frontend races; the next `enable_hotkey` will
|
||||||
/// pick up the saved chords.
|
/// 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
|
/// picker UI can surface "this key isn't supported" instead of silently
|
||||||
/// dropping it from the chord.
|
/// dropping it from the chord.
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
@@ -985,8 +984,8 @@ fn update_chord_bindings(
|
|||||||
toggle_to_talk: Vec<String>,
|
toggle_to_talk: Vec<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
|
let bindings = build_chord_bindings(&push_to_talk, &toggle_to_talk)?;
|
||||||
let slot = state.monitor.lock().map_err(|e| e.to_string())?;
|
let mut slot = state.monitor.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(monitor) = slot.as_ref() {
|
if let Some(monitor) = slot.as_mut() {
|
||||||
monitor.update_bindings(bindings);
|
monitor.update_bindings(bindings);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user