--- name: Hotkey crate (Linux evdev) type: architecture-map-page slice: 05-core-storage-hotkey-build last_verified: 2026/05/09 --- # Hotkey crate (Linux evdev) > **Where you are:** [Architecture map](../README.md) → [Core, Storage, Hotkey, Build](README.md) → Hotkey crate (Linux evdev) **Plain English summary.** A global hotkey listener for Linux that reads keypresses straight from `/dev/input/event*` rather than going through a display server. That makes it work on both X11 and Wayland, with no compositor-specific protocol negotiation. macOS and Windows fall back to Tauri's global-shortcut plugin; this crate compiles to a no-op on those platforms. ## At a glance - Crate: `lumotia-hotkey`. - LOC: 632 (`lib.rs` 177, `linux.rs` 426, `stub.rs` 29). - External deps: `tokio 1` (rt + sync + macros + time), `serde 1`, `log 0.4`, plus Linux-only: `evdev 0.12` (with `tokio` feature), `notify 7` (default features off, `macos_fsevent` only), `nix 0.29` (`fs` feature). - Public surface: `HotkeyCombo`, `HotkeyEvent`, `EvdevHotkeyListener`, `check_evdev_access`. Plus the parser `HotkeyCombo::from_tauri_str`. - Consumers: slice 2 (`src-tauri/src/commands/hotkey.rs`) is the only caller in production. The Tauri command holds an `EvdevHotkeyListener` in `tauri::State` and forwards `HotkeyEvent` over `mpsc` to the live-session command. ## Public surface ### `HotkeyCombo` — `crates/hotkey/src/lib.rs:33` Cross-platform shape. Lives in `lib.rs` so both `linux` and `stub` modules can use it. ```rust pub struct HotkeyCombo { pub ctrl: bool, pub shift: bool, pub alt: bool, pub super_key: bool, pub key_code: u16, // evdev key code pub label: String, // user-facing, eg "Ctrl+Shift+R" } impl HotkeyCombo { pub fn from_tauri_str(s: &str) -> Option; } ``` ### `from_tauri_str` parser — `crates/hotkey/src/lib.rs:50` Splits on `+`, accepts `ctrl|control`, `shift`, `alt`, `super|meta|cmd|command`, plus a single trigger key. The key-name → evdev-code mapping at `lib.rs:79` covers A-Z, 0-9, F1-F12, plus arrows, modifiers, and the standard punctuation set. Returns `None` on unmappable input. ### `HotkeyEvent` — `crates/hotkey/src/linux.rs:23` / `stub.rs:11` Defined in both backends: ```rust pub enum HotkeyEvent { Pressed, Released } ``` `Released` matters for push-to-talk. ### `EvdevHotkeyListener` — `crates/hotkey/src/linux.rs:32` ```rust pub struct EvdevHotkeyListener { /* hotkey_tx, shutdown_tx */ } impl EvdevHotkeyListener { pub fn start(combo: HotkeyCombo, event_tx: mpsc::Sender) -> Self; pub fn set_hotkey(&self, combo: HotkeyCombo); pub async fn stop(&self); } ``` `start` spawns: 1. **One async task per input device** that supports the configured trigger key (initial scan). 2. **A watcher task** that listens on `notify` for new files in `/dev/input/` and attaches per-device listeners with a 5-attempt 1-second-backoff loop (udev permissions propagate asynchronously after `inotify CREATE`). `set_hotkey` updates a `tokio::sync::watch::Sender>`. All listener tasks pick up the change; future devices use the new combo. `stop` sends `None` on the watch channel and signals the shutdown channel. Per-device tasks exit on the next `hotkey_rx.changed()` poll. ### `check_evdev_access()` — `crates/hotkey/src/lib.rs:166` Probes whether the current user can read evdev devices. On permission denied returns: ``` Permission denied reading /dev/input/eventN. Add your user to the 'input' group: sudo usermod -aG input $USER (then log out and back in) ``` The hotkey command in slice 2 calls this at startup; the message becomes a non-modal toast on first run. ## Linux backend internals ### Device hotplug — `crates/hotkey/src/linux.rs:64-136` Uses `notify::recommended_watcher` against `/dev/input/`. On inotify `Create(_)` events with a path that starts `event*`, it spawns a 5-attempt retry to attach a per-device listener. Retries are 1-second sleeps; udev permissions propagate over a window of a few hundred milliseconds after device creation. Failure modes: - **`recommended_watcher` returns `Err`** (rare; minimal containers, BSD pretending to be Linux). Logs and degrades to "no hotplug detection" — the initial scan still picks up devices that exist at startup. Non-fatal. - **`watcher.watch("/dev/input")` returns `Err`** (eg `/dev/input` itself missing). Same fallback. ### Per-device listener — `crates/hotkey/src/linux.rs:274` `device.into_event_stream()` gives an async stream of `evdev::InputEvent`. The listener tracks four modifier flags (`ctrl_held`, `shift_held`, `alt_held`, `super_held`) by watching `KEY_LEFT*` and `KEY_RIGHT*` press / release. When the trigger key matches and modifier state matches, sends `HotkeyEvent::Pressed` or `HotkeyEvent::Released` on `event_tx`. ### `device_supports_combo` — `crates/hotkey/src/linux.rs:368` Filters out devices whose reported `EV_KEY` capability does not include the configured trigger. Replaces the RB-12 hard-coded `KEY_A || KEY_R` filter from the original whisper-overlay port. See [Existing in-repo docs](#existing-in-repo-docs). ```rust fn device_supports_combo(supported: Option<&AttributeSetRef>, combo: &HotkeyCombo) -> bool { supported.is_some_and(|keys| keys.contains(Key::new(combo.key_code))) } ``` ### Channel close handling — `crates/hotkey/src/linux.rs:328` When `event_tx.send(...)` fails, the listener exits cleanly: receiver was dropped, no point spinning. Logged at WARN once per device. ## Tests `crates/hotkey/src/linux.rs:372-426`: - `attaches_when_device_supports_configured_trigger` — happy path. - `rejects_when_device_lacks_configured_trigger` — wrong key. - `rejects_when_device_reports_no_keys` — `None` capability set. - `attaches_for_non_a_non_r_trigger` — RB-12 regression: a `Ctrl+Shift+D` binding now attaches to a device that reports KEY_D and rejects a device that only reports KEY_R. ## Watch-outs - **Linux only.** The crate compiles to a no-op (`stub.rs`) on macOS and Windows where Tauri's `plugin-global-shortcut` handles hotkeys natively. Feature parity is maintained across platforms but by two distinct mechanisms — including, for example, hotplug detection (Linux only). - **Requires user in the `input` group.** Documented in the `check_evdev_access` error message. Without group membership, no devices open. - **Modifier tracking is per-device.** A user pressing `Ctrl` on the physical keyboard and the trigger on the laptop's built-in keyboard would not match — modifier state is local to one device's stream. In practice users press all keys on the same keyboard so this is fine, but worth knowing for split keyboard rigs. - **`notify` listens on `/dev/input` non-recursively.** Sub-directories would be missed. None exist today. - **Hotplug retry attempts (5 × 1 s) can stack.** A USB hub that announces 10 keyboards in a burst spawns 10 retry tasks; benign but worth knowing. ## Existing in-repo docs - [`docs/issues/hotkey-linux-device-filter.md`](../../issues/hotkey-linux-device-filter.md) — RB-12. The hard-coded `KEY_A || KEY_R` filter the current `device_supports_combo` replaced. ## See also - [Slice 2 hotkey command](../02-tauri-runtime/README.md) — the only caller. - [Slice 1 preferences (hotkey configuration)](../01-frontend/README.md) — the UI that produces a Tauri-style hotkey string.