---
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<Self>;
}
```

### `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<HotkeyEvent>) -> 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<Option<HotkeyCombo>>`. 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<Key>>, 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.
