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.
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.
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 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-onlyChannelSink). - Panics are caught. Every C→Rust boundary is wrapped in
catch_unwindand a panic is treated asPass(fail-open). A buggy sink can never brick input. - The sink is
Send + Sync: on Linux it is shared across per-device threads.
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
uinputwrite — 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.
tracingrecords only event types and counts — never keycodes, characters, or coordinates. - No disk, no network.
inputsnever 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/uinputwrite; 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 Linuxuinputdevice-name prefix) so they are filtered out before reaching the sink. Injection from other processes is delivered, flaggedEvent::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
DeviceFilterand the sink.
- macOS: needs Accessibility (
AXIsProcessTrustedplus 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. PollListener::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 paireduinputdevice 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 (aninotifywatch on/dev/input), and a kernel buffer overflow (SYN_DROPPED) resyncs cleanly rather than scrambling state. Theinputs::linuxmodule ships a recommendedudevrule and a non-mutating permission probe for installers.
inject—inject::Injectorsynthesizes raw input primitives (key / button down-up, scroll, relative motion) viaCGEventPost/SendInput/uinput. Events carry theInjectionSignature, so a same-process listener filters them out, and their signs match the listener's, so they round-trip.keycode— interop with thekeycodecrate: totalKeyCode → KeyMappingCodeand fallible reverse, reaching USB-HID / evdev / platform code tables.serde—Serialize/Deserializefor 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.
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).
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.