//! 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 //! //! ## Lifecycle //! //! Every task this module spawns is owned by a //! [`crate::supervisor::SupervisorHandle`] living inside the //! [`EvdevHotkeyListener`]. On `stop()`, the supervisor sends a broadcast //! shutdown signal and awaits every `JoinHandle` with a bounded timeout, //! so a reconfigure cannot leave orphaned listeners alive (which would //! otherwise hold `event_tx` clones forever and emit duplicate events to //! the frontend). See `supervisor.rs` for the supervisor and //! `tests/listener_lifecycle.rs` for the regression tests. use std::collections::HashMap; 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::supervisor::SupervisorHandle; 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, } /// Shared map of attached evdev devices. Keyed by path so attach is /// idempotent. Membership-only marker — the actual `JoinHandle` for each /// device listener task lives in the supervisor. Insert-before-spawn /// under one mutex hold closes the TOCTOU window the previous design had. type TrackedDevices = Arc>>; /// Manages evdev device listeners and hotplug detection. /// /// All spawned tasks are owned by an internal /// [`SupervisorHandle`]. On `stop()` (or `Drop`) every task receives a /// broadcast shutdown signal and is joined with a per-task timeout so a /// reconfigure cannot leak listeners. pub struct EvdevHotkeyListener { /// Send a new hotkey config to all listener tasks. hotkey_tx: watch::Sender>, /// Tracks every spawned task. Cloned into spawn sites so per-device /// retry tasks can register their own children. supervisor: SupervisorHandle, /// Set to `true` once `stop()` has run so `Drop` skips its /// best-effort shutdown signal. stopped: bool, } 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 async fn start(combo: HotkeyCombo, event_tx: mpsc::Sender) -> Self { let (hotkey_tx, hotkey_rx) = watch::channel(Some(combo)); let tracked: TrackedDevices = Arc::new(Mutex::new(HashMap::new())); let supervisor = SupervisorHandle::new(); // Spawn initial scanner. Walks /dev/input once and attaches every // matching device. After it completes the hotplug watcher (below) // is responsible for keeping the attachment set in sync. { let hotkey_rx = hotkey_rx.clone(); let event_tx = event_tx.clone(); let tracked = tracked.clone(); let supervisor_inner = supervisor.clone(); let mut shutdown_rx = supervisor.subscribe(); let scanner_handle = tokio::spawn(async move { tokio::select! { _ = scan_and_attach( &hotkey_rx, &event_tx, &tracked, &supervisor_inner, ) => {} _ = shutdown_rx.recv() => { tracing::debug!( target: "lumotia_hotkey", "scanner received shutdown signal mid-scan" ); } } }); supervisor.register("scanner", scanner_handle).await; } // Spawn hotplug watcher. Hands the supervisor handle through so // it can register retry tasks it spawns. { let hotkey_rx = hotkey_rx.clone(); let event_tx = event_tx.clone(); let tracked = tracked.clone(); let supervisor_inner = supervisor.clone(); let mut shutdown_rx = supervisor.subscribe(); let hotplug_handle = tokio::spawn(async move { run_hotplug_watcher( hotkey_rx, event_tx, tracked, supervisor_inner, &mut shutdown_rx, ) .await; }); supervisor.register("hotplug", hotplug_handle).await; } let task_count = supervisor.task_count().await; tracing::info!( target: "lumotia_hotkey", task_count = task_count, "supervisor started" ); Self { hotkey_tx, supervisor, stopped: false, } } /// 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. /// /// Consumes the listener so it cannot be reused. Awaits every /// supervised task with a per-task timeout (see /// [`SupervisorHandle::shutdown`]); a stuck task is logged and /// detached rather than blocking the caller indefinitely. pub async fn stop(mut self) { // Signal None first so device listeners exit their loop cleanly // without waiting for the broadcast subscription select arm. let _ = self.hotkey_tx.send(None); self.supervisor.shutdown().await; self.stopped = true; } } /// Best-effort shutdown on drop. Async drop isn't available in stable /// Rust, so we only fire the broadcast — we cannot await JoinHandles /// here. Tasks subscribed to the broadcast see the signal and exit /// cooperatively; their JoinHandles detach but the runtime reclaims them /// once they finish. The intended path is always explicit `stop().await` /// before drop. impl Drop for EvdevHotkeyListener { fn drop(&mut self) { if !self.stopped { self.supervisor.signal_shutdown_nonblocking(); let _ = self.hotkey_tx.send(None); } } } /// Hotplug watcher loop. Listens for inotify events on `/dev/input/` /// and dispatches a retry task per new device path. Cooperatively /// shuts down on broadcast. async fn run_hotplug_watcher( hotkey_rx: watch::Receiver>, event_tx: mpsc::Sender, tracked: TrackedDevices, supervisor: SupervisorHandle, shutdown_rx: &mut tokio::sync::broadcast::Receiver<()>, ) { let (notify_tx, mut notify_rx) = mpsc::channel::(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| { 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) => { tracing::warn!( error = %e, "cannot watch /dev/input; hotplug detection disabled, \ devices present at startup still work" ); None } }, Err(e) => { tracing::warn!( error = %e, "cannot create inotify watcher; 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). // The retry task subscribes to the broadcast so it exits // promptly on stop() even if it's mid-backoff. let hotkey_rx = hotkey_rx.clone(); let event_tx = event_tx.clone(); let tracked = tracked.clone(); let supervisor_inner = supervisor.clone(); let mut retry_shutdown_rx = supervisor.subscribe(); let retry_handle = tokio::spawn(async move { for attempt in 0..5 { if attempt > 0 { tokio::select! { _ = tokio::time::sleep( std::time::Duration::from_secs(1) ) => {} _ = retry_shutdown_rx.recv() => return, } } if try_attach_device( &path, &hotkey_rx, &event_tx, &tracked, &supervisor_inner, ).await { break; } } }); supervisor.register("hotplug-retry", retry_handle).await; } _ = shutdown_rx.recv() => break, } } } /// 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>, event_tx: &mpsc::Sender, tracked: &TrackedDevices, supervisor: &SupervisorHandle, ) { let input_dir = Path::new("/dev/input"); let entries = match std::fs::read_dir(input_dir) { Ok(e) => e, Err(e) => { tracing::error!(error = %e, "cannot read /dev/input"); return; } }; for entry in entries.flatten() { let path = entry.path(); if is_event_device(&path) { try_attach_device(&path, hotkey_rx, event_tx, tracked, supervisor).await; } } } /// Try to open a device and start listening if it supports the target key. /// Returns true if the device was successfully attached. /// /// Insert-into-tracked-then-spawn-then-release-mutex makes attachment /// atomic against concurrent hotplug + scan; the previous design's /// remove-after-task-exits window allowed double-attaches. async fn try_attach_device( path: &Path, hotkey_rx: &watch::Receiver>, event_tx: &mpsc::Sender, tracked: &TrackedDevices, supervisor: &SupervisorHandle, ) -> bool { // Hold the mutex across the contains-check, the insert, AND the // spawn registration. This is the TOCTOU fix for Race-extra: the // previous implementation released the mutex before spawning and // before removal, leaving windows where concurrent scan + hotplug // could double-attach the same device. let mut tracked_map = tracked.lock().await; if tracked_map.contains_key(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) => { tracing::debug!(path = %path.display(), error = %e, "cannot open device"); return false; } }; if !device_supports_combo(device.supported_keys(), &combo) { return false; } let device_name = device.name().unwrap_or("unknown").to_string(); tracing::info!( device = %device_name, path = %path.display(), "attached hotkey listener" ); // Insert BEFORE spawning the listener task so a racing caller (the // scanner running concurrently with a hotplug retry, for example) // sees the entry and short-circuits. tracked_map.insert(path.to_path_buf(), ()); // Clone everything the spawned task needs before we release the // mutex so the release point is a single statement. let hotkey_rx_owned = hotkey_rx.clone(); let event_tx_owned = event_tx.clone(); let path_owned = path.to_path_buf(); let tracked_for_cleanup = tracked.clone(); let mut shutdown_rx = supervisor.subscribe(); let listener_handle = tokio::spawn(async move { let listener_fut = device_listener(device, hotkey_rx_owned, event_tx_owned); tokio::select! { res = listener_fut => { if let Err(e) = res { tracing::warn!( path = %path_owned.display(), error = %e, "device listener ended" ); } } _ = shutdown_rx.recv() => { tracing::debug!( target: "lumotia_hotkey", path = %path_owned.display(), "device listener received shutdown signal" ); } } // Remove from tracked set so hotplug can re-attach if reconnected. tracked_for_cleanup.lock().await.remove(&path_owned); }); drop(tracked_map); // Register with the supervisor. This await is brief — it just locks // the supervisor inner Vec and pushes — and happens outside the // tracked-map lock. supervisor.register("device-listener", listener_handle).await; 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>, event_tx: mpsc::Sender, ) -> Result<(), Box> { 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 { let to_send = if pressed { Some(HotkeyEvent::Pressed) } else if released { Some(HotkeyEvent::Released) } else { None }; if let Some(event) = to_send { if event_tx.send(event).await.is_err() { // Receiver was dropped without an // explicit None-on-hotkey-rx // shutdown. Log once and exit so // the listener doesn't spin // sending into a closed channel. tracing::warn!( "hotkey event channel closed; \ listener for device exiting" ); return Ok(()); } } } } } } } } _ = 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()) .is_some_and(|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>, combo: &HotkeyCombo) -> bool { supported.is_some_and(|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::::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::::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::::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::::new(); keys.insert(Key::KEY_R); assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D))); } // TODO(test): Race-extra (TOCTOU on `tracked`) is hard to exercise // without real /dev/input/event* devices + the udev attach race. // The new insert-before-spawn + supervisor-owned-handle design // closes the window by construction; a deterministic test would need // to fake the evdev::Device::open path which the crate doesn't // currently expose. See atomiser finding "Race-extra" for context. }