AprilNEA/inputs-rs

★ 0Forks 0RustGitHub ↗Compare

README

inputs

Production-grade cross-platform global input listening and suppression for Rust.

inputs turns raw operating-system input into one unified Event stream and lets a caller decide, per event and in real time, whether each event passes through to the focused application or is suppressed. That is the whole job.

Platform Mechanism Suppression
macOS CGEventTap at the HID level yes (with Accessibility)
Windows WH_KEYBOARD_LL + WH_MOUSE_LL yes
Linux (X11 & Wayland) evdev grab + uinput replay yes (with device access)

The Linux backend works identically under X11 and Wayland because it sits below the display server, at the kernel evdev layer — the only portable way to suppress input under Wayland, where compositors expose no general interception API.

What it is not

inputs has no notion of keybindings, gestures, profiles, macros, or business actions. Those belong to a separate layer (a controls-style crate) built on top of this one. inputs only converts device events into a unified model and offers real-time pass/suppress. Keeping that boundary sharp is a deliberate design goal, not an omission.

Example

use inputs::{Listener, Event, EventDisposition, EventKind, KeyCode};

let listener = Listener::builder()
    .start(|event: &Event| {
        if let EventKind::Key(k) = &event.kind {
            if k.code() == Some(KeyCode::Escape) {
                return EventDisposition::Suppress; // eat Escape
            }
        }
        EventDisposition::Pass // everything else passes through
    })
    .expect("failed to start listener");

// … later:
listener.stop();

Runnable examples: cargo run --example print_events, --example suppress_key, --example list_devices.

The real-time contract

The sink callback runs on the OS capture thread and gates system input while it executes (inside the macOS/Windows hook, or the Linux reader thread). Therefore:

  • Be fast — target well under a millisecond. No blocking I/O, no contended locks, no heavy allocation. Hand off real work to another thread via inputs::channel(...) (an observe-only ChannelSink).
  • Panics are caught. Every C→Rust boundary is wrapped in catch_unwind and a panic is treated as Pass (fail-open). A buggy sink can never brick input.
  • The sink is Send + Sync: on Linux it is shared across per-device threads.

Security model

inputs sees all keyboard input, including passwords. Its guarantees:

  • Never lock system input. The top invariant. Any internal failure — a wedged callback, a panic, a revoked permission, an OS that keeps disabling the tap, a failed uinput write — fails open: pass the event, release the hook/grab, or (macOS, where a live HID tap cannot be released in place) terminate the process so the OS reclaims the Mach port and restores input.
  • No event content is ever logged. tracing records only event types and counts — never keycodes, characters, or coordinates.
  • No disk, no network. inputs never persists events and never sends them anywhere. It is a library, not a service.
  • Least permission, never auto-granted. The crate only reads permission status and can surface the OS settings prompt; it never grants on the user's behalf. macOS needs Accessibility; Linux needs /dev/input/* read and (for suppression) /dev/uinput write; Windows low-level hooks need no privacy grant.
  • Self-injection never loops back. Events this crate replays/injects carry a signature (macOS event-source user data, Windows dwExtraInfo, a reserved Linux uinput device-name prefix) so they are filtered out before reaching the sink. Injection from other processes is delivered, flagged Event::injected.
  • No vendor allowlists, no security policy baked in. Which devices to grab and which events to suppress are the caller's decisions, expressed through DeviceFilter and the sink.

Platform notes

  • macOS: needs Accessibility (AXIsProcessTrusted plus a live filtering-tap probe — the trust flag alone goes stale after the user removes the app's row). Two watchdogs bound the HID-tap freeze hazard: a callback stuck past 200 ms and a tap lifecycle stalled past 1.5 s both force process exit. Poll Listener::is_healthy() while running.
  • Windows: hooks run on a dedicated thread with its own message pump, so a wedged caller thread cannot stall input. Windows silently removes a hook whose callback exceeds LowLevelHooksTimeout; the latency contract is the only protection.
  • Linux: one reader thread per selected device. Grabbing (EVIOCGRAB) is required to suppress; a paired uinput device replays passed-through events. Process death releases every grab automatically (the kernel drops the grab when the fd closes), so no external watchdog is needed. Touch surfaces and pointing sticks are never grabbed by default — their streams cannot be faithfully re-injected — so they keep working un-hooked. Devices are picked up as they are plugged in (an inotify watch on /dev/input), and a kernel buffer overflow (SYN_DROPPED) resyncs cleanly rather than scrambling state. The inputs::linux module ships a recommended udev rule and a non-mutating permission probe for installers.

Optional features

  • inject — inject::Injector synthesizes raw input primitives (key / button down-up, scroll, relative motion) via CGEventPost / SendInput / uinput. Events carry the InjectionSignature, so a same-process listener filters them out, and their signs match the listener's, so they round-trip.
  • keycode — interop with the keycode crate: total KeyCode → KeyMappingCode and fallible reverse, reaching USB-HID / evdev / platform code tables.
  • serde — Serialize/Deserialize for the event and key types.
  • tracing (default) — structured logging; disable for a zero-dependency, zero-log build.

The diagnostics module (always available) exposes list_event_taps() to detect input-capture contention on macOS.

Status & compatibility

Pre-1.0 (0.x): the API may change between minor versions. The event model, backend contracts, injection synthesis, Linux hotplug, and keycode interop are in place; Windows raw-input device attribution and a read-only Wayland portal path are landing incrementally (see CHANGELOG.md). MSRV is 1.85 (Rust edition 2024).

License

MIT OR Apache-2.0, at your option. Portions of the platform backends were extracted from AprilNEA/OpenLogi's openlogi-hook crate (same license) — the verified reference implementation. See CHANGELOG.md for the extraction record.

Contributors

AprilNEA

Issues