try_attach_device was rejecting any device that did not report KEY_A or KEY_R — a leftover heuristic from the whisper-overlay seed. A user whose binding was anything else (Ctrl+Shift+D is a common default) would see no hotkey events from that device even though it supports the key. Replace the hard-coded check with device_supports_combo(supported, combo), a pure helper that reads the configured trigger key code from the HotkeyCombo snapshot. Snapshot is taken from hotkey_rx.borrow() before opening the device; an unconfigured or shutting-down listener short-circuits to a non-attach. Four regression tests in linux::tests cover: supported+D → attach, unsupported → reject, no reported keys → reject, and the explicit non-A/non-R case that demonstrates the bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
414 lines
15 KiB
Rust
414 lines
15 KiB
Rust
//! Linux evdev-based global hotkey listener.
|
|
//!
|
|
//! Reads raw input events from `/dev/input/event*` devices. Works on both
|
|
//! X11 and Wayland because it operates at the kernel level, bypassing the
|
|
//! display server entirely.
|
|
//!
|
|
//! Key patterns stolen from oddlama/whisper-overlay:
|
|
//! - Device hotplug via `notify` watching `/dev/input/`
|
|
//! - Retry loop for udev permission propagation on new devices
|
|
//! - Per-device async event streams
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use evdev::{AttributeSetRef, Device, InputEventKind, Key};
|
|
use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher};
|
|
use tokio::sync::{mpsc, watch, Mutex};
|
|
|
|
use crate::HotkeyCombo;
|
|
|
|
/// Events emitted by the hotkey listener.
|
|
#[derive(Debug, Clone)]
|
|
pub enum HotkeyEvent {
|
|
/// The configured hotkey was pressed.
|
|
Pressed,
|
|
/// The configured hotkey was released (useful for push-to-talk).
|
|
Released,
|
|
}
|
|
|
|
/// Manages evdev device listeners and hotplug detection.
|
|
pub struct EvdevHotkeyListener {
|
|
/// Send a new hotkey config to all listener tasks.
|
|
hotkey_tx: watch::Sender<Option<HotkeyCombo>>,
|
|
/// Signals all tasks to shut down.
|
|
shutdown_tx: mpsc::Sender<()>,
|
|
}
|
|
|
|
impl EvdevHotkeyListener {
|
|
/// Start the hotkey listener. Returns the listener handle and a receiver
|
|
/// for hotkey events.
|
|
///
|
|
/// The listener spawns:
|
|
/// 1. One async task per input device that has the target key
|
|
/// 2. A watcher task that detects new devices via inotify on `/dev/input/`
|
|
pub fn start(combo: HotkeyCombo, event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
|
|
let (hotkey_tx, hotkey_rx) = watch::channel(Some(combo));
|
|
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
|
|
|
let tracked = Arc::new(Mutex::new(HashSet::<PathBuf>::new()));
|
|
|
|
// Spawn initial device listeners
|
|
let hotkey_rx_clone = hotkey_rx.clone();
|
|
let event_tx_clone = event_tx.clone();
|
|
let tracked_clone = tracked.clone();
|
|
tokio::spawn(async move {
|
|
scan_and_attach(&hotkey_rx_clone, &event_tx_clone, &tracked_clone).await;
|
|
});
|
|
|
|
// Spawn hotplug watcher
|
|
let hotkey_rx_hotplug = hotkey_rx.clone();
|
|
let event_tx_hotplug = event_tx.clone();
|
|
let tracked_hotplug = tracked.clone();
|
|
tokio::spawn(async move {
|
|
let (notify_tx, mut notify_rx) = mpsc::channel::<PathBuf>(32);
|
|
|
|
// notify watcher runs on a blocking thread internally.
|
|
// If inotify itself is unavailable (rare: minimal containers,
|
|
// some BSDs misconfigured as Linux) we degrade to "no
|
|
// hotplug detection" rather than panicking the task — the
|
|
// initial scan_and_attach pass above still picks up all
|
|
// devices that exist at startup.
|
|
let _watcher = {
|
|
let notify_tx = notify_tx.clone();
|
|
let watcher = recommended_watcher(move |res: Result<notify::Event, _>| {
|
|
if let Ok(event) = res {
|
|
if matches!(event.kind, EventKind::Create(_)) {
|
|
for path in event.paths {
|
|
if is_event_device(&path) {
|
|
let _ = notify_tx.blocking_send(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
match watcher {
|
|
Ok(mut w) => {
|
|
match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
|
|
Ok(()) => Some(w),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"[kon-hotkey] cannot watch /dev/input ({e}); \
|
|
hotplug detection disabled, devices present \
|
|
at startup still work",
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"[kon-hotkey] cannot create inotify watcher ({e}); \
|
|
hotplug detection disabled",
|
|
);
|
|
None
|
|
}
|
|
}
|
|
};
|
|
|
|
loop {
|
|
tokio::select! {
|
|
Some(path) = notify_rx.recv() => {
|
|
// Retry opening with backoff — udev permissions propagate
|
|
// asynchronously after device creation (whisper-overlay pattern)
|
|
let hotkey_rx = hotkey_rx_hotplug.clone();
|
|
let event_tx = event_tx_hotplug.clone();
|
|
let tracked = tracked_hotplug.clone();
|
|
tokio::spawn(async move {
|
|
for attempt in 0..5 {
|
|
if attempt > 0 {
|
|
tokio::time::sleep(
|
|
std::time::Duration::from_secs(1)
|
|
).await;
|
|
}
|
|
if try_attach_device(
|
|
&path, &hotkey_rx, &event_tx, &tracked,
|
|
).await {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
_ = shutdown_rx.recv() => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
hotkey_tx,
|
|
shutdown_tx,
|
|
}
|
|
}
|
|
|
|
/// Update the hotkey combination. All device listeners pick up the
|
|
/// change via the watch channel.
|
|
pub fn set_hotkey(&self, combo: HotkeyCombo) {
|
|
let _ = self.hotkey_tx.send(Some(combo));
|
|
}
|
|
|
|
/// Stop all listeners and clean up.
|
|
pub async fn stop(&self) {
|
|
let _ = self.hotkey_tx.send(None);
|
|
let _ = self.shutdown_tx.send(()).await;
|
|
}
|
|
}
|
|
|
|
/// Check whether the user has access to evdev devices.
|
|
pub fn check_access() -> Result<(), String> {
|
|
let input_dir = Path::new("/dev/input");
|
|
if !input_dir.exists() {
|
|
return Err("/dev/input does not exist".to_string());
|
|
}
|
|
|
|
// Try to open any event device
|
|
let entries =
|
|
std::fs::read_dir(input_dir).map_err(|e| format!("Cannot read /dev/input: {e}"))?;
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if is_event_device(&path) {
|
|
match Device::open(&path) {
|
|
Ok(_) => return Ok(()),
|
|
Err(e) => {
|
|
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
|
return Err(format!(
|
|
"Permission denied reading {}. \
|
|
Add your user to the 'input' group: \
|
|
sudo usermod -aG input $USER \
|
|
(then log out and back in)",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Err("No input devices found in /dev/input".to_string())
|
|
}
|
|
|
|
/// Scan all `/dev/input/event*` devices and attach listeners to any
|
|
/// that support the target key.
|
|
async fn scan_and_attach(
|
|
hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
|
|
event_tx: &mpsc::Sender<HotkeyEvent>,
|
|
tracked: &Arc<Mutex<HashSet<PathBuf>>>,
|
|
) {
|
|
let input_dir = Path::new("/dev/input");
|
|
let entries = match std::fs::read_dir(input_dir) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
log::error!("Cannot read /dev/input: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if is_event_device(&path) {
|
|
try_attach_device(&path, hotkey_rx, event_tx, tracked).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Try to open a device and start listening if it supports the target key.
|
|
/// Returns true if the device was successfully attached.
|
|
async fn try_attach_device(
|
|
path: &Path,
|
|
hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
|
|
event_tx: &mpsc::Sender<HotkeyEvent>,
|
|
tracked: &Arc<Mutex<HashSet<PathBuf>>>,
|
|
) -> bool {
|
|
let mut tracked_set = tracked.lock().await;
|
|
if tracked_set.contains(path) {
|
|
return true;
|
|
}
|
|
|
|
let Some(combo) = hotkey_rx.borrow().clone() else {
|
|
// Listener is unconfigured or shutting down.
|
|
return false;
|
|
};
|
|
|
|
let device = match Device::open(path) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
log::debug!("Cannot open {}: {e}", path.display());
|
|
return false;
|
|
}
|
|
};
|
|
|
|
if !device_supports_combo(device.supported_keys(), &combo) {
|
|
return false;
|
|
}
|
|
|
|
let device_name = device.name().unwrap_or("unknown").to_string();
|
|
log::info!(
|
|
"Attached hotkey listener to: {} ({})",
|
|
device_name,
|
|
path.display()
|
|
);
|
|
|
|
tracked_set.insert(path.to_path_buf());
|
|
drop(tracked_set);
|
|
|
|
// Spawn a listener task for this device
|
|
let hotkey_rx = hotkey_rx.clone();
|
|
let event_tx = event_tx.clone();
|
|
let path_owned = path.to_path_buf();
|
|
let tracked = tracked.clone();
|
|
|
|
tokio::spawn(async move {
|
|
if let Err(e) = device_listener(device, hotkey_rx, event_tx).await {
|
|
log::warn!("Device listener for {} ended: {e}", path_owned.display());
|
|
}
|
|
// Remove from tracked set so hotplug can re-attach if reconnected
|
|
tracked.lock().await.remove(&path_owned);
|
|
});
|
|
|
|
true
|
|
}
|
|
|
|
/// Listen for events on a single device. Tracks modifier state and fires
|
|
/// hotkey events when the combo matches.
|
|
async fn device_listener(
|
|
device: Device,
|
|
mut hotkey_rx: watch::Receiver<Option<HotkeyCombo>>,
|
|
event_tx: mpsc::Sender<HotkeyEvent>,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
let mut stream = device.into_event_stream()?;
|
|
|
|
// Track modifier state
|
|
let mut ctrl_held = false;
|
|
let mut shift_held = false;
|
|
let mut alt_held = false;
|
|
let mut super_held = false;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
result = stream.next_event() => {
|
|
let event = result?;
|
|
|
|
if let InputEventKind::Key(key) = event.kind() {
|
|
let pressed = event.value() == 1; // 1 = press, 0 = release, 2 = repeat
|
|
let released = event.value() == 0;
|
|
|
|
// Update modifier state
|
|
match key {
|
|
Key::KEY_LEFTCTRL | Key::KEY_RIGHTCTRL => {
|
|
ctrl_held = pressed || (!released && ctrl_held);
|
|
}
|
|
Key::KEY_LEFTSHIFT | Key::KEY_RIGHTSHIFT => {
|
|
shift_held = pressed || (!released && shift_held);
|
|
}
|
|
Key::KEY_LEFTALT | Key::KEY_RIGHTALT => {
|
|
alt_held = pressed || (!released && alt_held);
|
|
}
|
|
Key::KEY_LEFTMETA | Key::KEY_RIGHTMETA => {
|
|
super_held = pressed || (!released && super_held);
|
|
}
|
|
trigger_key => {
|
|
let combo = hotkey_rx.borrow().clone();
|
|
if let Some(ref combo) = combo {
|
|
let code = trigger_key.code();
|
|
if code == combo.key_code
|
|
&& ctrl_held == combo.ctrl
|
|
&& shift_held == combo.shift
|
|
&& alt_held == combo.alt
|
|
&& super_held == combo.super_key
|
|
{
|
|
if pressed {
|
|
let _ = event_tx.send(HotkeyEvent::Pressed).await;
|
|
} else if released {
|
|
let _ = event_tx.send(HotkeyEvent::Released).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ = hotkey_rx.changed() => {
|
|
// Hotkey config changed — if set to None, shut down
|
|
if hotkey_rx.borrow().is_none() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn is_event_device(path: &Path) -> bool {
|
|
path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map_or(false, |n| n.starts_with("event"))
|
|
}
|
|
|
|
/// Return true when the device's reported key set includes the combo's
|
|
/// configured trigger key. A device that reports no keys at all (for
|
|
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
|
|
fn device_supports_combo(
|
|
supported: Option<&AttributeSetRef<Key>>,
|
|
combo: &HotkeyCombo,
|
|
) -> bool {
|
|
supported.map_or(false, |keys| keys.contains(Key::new(combo.key_code)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use evdev::AttributeSet;
|
|
|
|
fn combo_for(key_code: u16) -> HotkeyCombo {
|
|
HotkeyCombo {
|
|
ctrl: false,
|
|
shift: false,
|
|
alt: false,
|
|
super_key: false,
|
|
key_code,
|
|
label: "test".to_string(),
|
|
}
|
|
}
|
|
|
|
const KEY_D: u16 = 32;
|
|
|
|
#[test]
|
|
fn attaches_when_device_supports_configured_trigger() {
|
|
let mut keys = AttributeSet::<Key>::new();
|
|
keys.insert(Key::KEY_D);
|
|
assert!(device_supports_combo(Some(&keys), &combo_for(KEY_D)));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_when_device_lacks_configured_trigger() {
|
|
let mut keys = AttributeSet::<Key>::new();
|
|
keys.insert(Key::KEY_A);
|
|
assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_when_device_reports_no_keys() {
|
|
assert!(!device_supports_combo(None, &combo_for(KEY_D)));
|
|
}
|
|
|
|
// Regression for RB-12: the original filter hard-coded KEY_A || KEY_R
|
|
// and would drop a keyboard bound to any other trigger — for example
|
|
// a user's Ctrl+Shift+D binding on a keyboard that (hypothetically)
|
|
// reports only KEY_D — even though the device clearly supports it.
|
|
#[test]
|
|
fn attaches_for_non_a_non_r_trigger() {
|
|
let mut keys = AttributeSet::<Key>::new();
|
|
keys.insert(Key::KEY_D);
|
|
assert!(device_supports_combo(Some(&keys), &combo_for(KEY_D)));
|
|
|
|
// And conversely, a device that only supports KEY_R is correctly
|
|
// rejected when the binding is KEY_D — the old implementation
|
|
// would have incorrectly attached.
|
|
let mut keys = AttributeSet::<Key>::new();
|
|
keys.insert(Key::KEY_R);
|
|
assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
|
|
}
|
|
}
|