Files
Lumotia/docs/architecture-map/05-core-storage-hotkey-build/hotkey-linux-evdev.md
Jake 26c7307607
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — docs, scripts, root config, residuals
Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.

transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
  -> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
  as immutable audit trail). Includes architecture-map references
  to magnotia_core::*, magnotia_storage::*, etc. now pointing at
  lumotia_*; dev-setup.md tracing output examples (lumotia_startup
  target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
  audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
  hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
  crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
  crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
  doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
  ("lumotia_task_sync"); magnotia_locale i18n localStorage key
  renamed + migration shim added; CSS keyframe names
  magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
  system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
  keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
  wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
  to "lumotia era" earlier — restored).

Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
  legacy detection strings in legacy_and_target_paths() so the
  migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
  — migration call sites reference the legacy magnotia keys
  deliberately.
- docs/handovers/ — historical audit trail.

cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:38:03 +01:00

7.2 KiB
Raw Blame History

name, type, slice, last_verified
name type slice last_verified
Hotkey crate (Linux evdev) architecture-map-page 05-core-storage-hotkey-build 2026/05/09

Hotkey crate (Linux evdev)

Where you are: Architecture mapCore, Storage, Hotkey, Build → 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

HotkeyCombocrates/hotkey/src/lib.rs:33

Cross-platform shape. Lives in lib.rs so both linux and stub modules can use it.

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.

HotkeyEventcrates/hotkey/src/linux.rs:23 / stub.rs:11

Defined in both backends:

pub enum HotkeyEvent { Pressed, Released }

Released matters for push-to-talk.

EvdevHotkeyListenercrates/hotkey/src/linux.rs:32

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_combocrates/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.

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_keysNone 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

See also