feat(hook): implement Linux evdev+uinput mouse hook#119
Conversation
There was a problem hiding this comment.
Important
Low-res scroll companion events leak through uinput when hi-res is active — see inline comment in linux.rs. Worth addressing before merging.
Reviewed changes — This PR implements a Linux mouse hook using evdev for exclusive device grab + uinput for event re-injection, enabling OpenLogi to intercept and optionally suppress mouse events on Linux.
- Add
src/linux.rs(new, 457 lines) — evdev enumeration filtered byBTN_LEFT, per-deviceEVIOCGRAB, per-device uinput virtual device, event translation (buttons, hi-res/standard fractional scroll), SYN_REPORT-batched re-injection, and pipe-signaled thread shutdown - Extend
src/lib.rs—HookError::NoDeviceFound/HookError::Linux(io::Error)(Linux-only),Hook::start/stop/Dropdispatch to the Linux path - Add
examples/print_events.rs(new, 51 lines) — manual smoke-test withctrlcshutdown - Update
src/tests.rs— Linux error variants in display test,linux_start_does_not_return_unsupportedguard assertion - Update
Cargo.toml—evdev+libc(Linux-only runtime deps),ctrlc(dev-dep for the example)
Big Pickle (free) (credentials for Anthropic not configured) | 𝕏
Greptile SummaryThis PR adds a Linux
Confidence Score: 3/5The new Linux hook path has two open correctness defects in linux.rs that were flagged in prior review rounds and remain unaddressed before merge. When a physical mouse is unplugged, poll() returns with POLLHUP/POLLERR set on the device fd but no POLLIN. wait_readable falls through both checks and immediately loops, spinning the thread at 100% CPU until the process is killed. Separately, the kernel emits SYN_DROPPED (sync code 3) rather than SYN_REPORT (code 0) when the evdev ring buffer overflows; the current code flushes whatever partial events are in pending unconditionally for any EventSummary::Synchronization variant — a BTN_LEFT=1 received before the overflow with BTN_LEFT=0 lost leaves the virtual device with a permanently stuck button that the exclusive grab prevents the physical device from correcting. crates/openlogi-hook/src/linux.rs — specifically wait_readable (POLLHUP/POLLERR handling) and the SYN_DROPPED branch inside device_thread. Important Files Changed
Sequence DiagramsequenceDiagram
participant App
participant Hook as Hook::start()
participant LinuxMod as linux::start()
participant DevThread as device_thread (per mouse)
participant PhysDev as Physical evdev Device
participant VirtDev as uinput Virtual Device
participant Desktop as Desktop (Wayland/X11)
App->>Hook: Hook::start(cb)
Hook->>LinuxMod: linux::start(cb)
LinuxMod->>LinuxMod: find_mouse_devices()
LinuxMod->>LinuxMod: build_virtual_device(device)
LinuxMod->>LinuxMod: create_pipe()
LinuxMod->>DevThread: thread::spawn(device_thread)
LinuxMod-->>App: Ok(HookInner)
loop Event loop
DevThread->>PhysDev: wait_readable(device_fd, stop_fd)
PhysDev->>DevThread: fetch_events()
DevThread->>DevThread: translate(event, hires_scroll)
DevThread->>App: cb(MouseEvent)
App-->>DevThread: EventDisposition
alt PassThrough
DevThread->>DevThread: pending.push(event)
DevThread->>VirtDev: emit on SYN_REPORT
VirtDev->>Desktop: re-injected events
else Suppress
DevThread->>DevThread: drop event
end
end
App->>Hook: hook.stop()
Hook->>LinuxMod: linux::stop(inner)
LinuxMod->>DevThread: signal_pipe wakes poll()
DevThread->>PhysDev: drop device (releases grab)
DevThread-->>LinuxMod: thread joined
Reviews (6): Last reviewed commit: "fix(hook): additional Linux hook review ..." | Re-trigger Greptile |
7a3cffa to
edcd436
Compare
There was a problem hiding this comment.
Important
The hi-res scroll double-emission flagged in review #4426566416 remains unaddressed. The incremental changes (rebase onto master + edcd436 example commit) introduce no new issues, but the open thread at linux.rs:446 still blocks merge.
Reviewed changes — This incremental review covers the two commits pushed since 7a3cffa: a rebase of the feature implementation onto master and extraction of the print_events example into a standalone commit.
- Rebase onto master — the implementation in
ea0ba7cis functionally identical to the prior revision; no conflict-resolution artifacts or semantic changes visible - Standalone
print_eventscommit —edcd436isolates the smoke-test example behind the feature commit; example content unchanged from the prior review
Big Pickle (free) (credentials for Anthropic not configured) | 𝕏
There was a problem hiding this comment.
✅ No new issues found. The hi-res scroll double-emission is cleanly fixed.
Reviewed changes — This incremental review covers a2c6fa2 ("fix(hook): address PR review findings in Linux hook"), which resolves the blocking hi-res scroll issue flagged in the prior Pullfrog review.
- Fix hi-res scroll double-emission —
translatereturn value is now matched with a guard that suppresses low-resREL_WHEEL/REL_HWHEELevents when hi-res scroll is active, rather than falling through toPassThroughand re-injecting through uinput. Unit tests added for both axes - Fail-fast virtual device creation —
build_virtual_devicecalled eagerly instart()instead of insidedevice_thread; a failure now prevents the hook from starting rather than silently running without re-injection - Fix platform gating on
print_eventsexample — added#![cfg(target_os = "linux")]and removed the macOS-specifichas_accessibility()check
Big Pickle (free) (credentials for Anthropic not configured) | 𝕏
|
Thanks @cserby — this is great, and going straight to 1) Testing #119 on real Linux hardware. Specifically I'll try to confirm/repro the items already flagged in review, since they need real hardware:
I'll report back with logs and the exact udev/permission setup needed to run without 2) Claiming Phase 0 — HID/device-layer verification on Linux (from the tracking issue #95).
I'll open it as a separate PR so it doesn't entangle with the hook work here. If anyone's already on Phase 0, give me a shout so we don't double up. |
Enumerate mouse devices via evdev::enumerate() (devices with BTN_LEFT), grab each exclusively, and re-inject pass-through events via a paired uinput virtual device. Events marked Suppress are consumed without reaching the desktop. Shutdown uses a per-thread pipe to interrupt blocking poll() cleanly. Supports fractional scroll via REL_WHEEL_HI_RES / REL_HWHEEL_HI_RES (÷120 per the kernel convention) with automatic fallback to integer REL_WHEEL / REL_HWHEEL on devices that don't expose hi-res axes. HookError gains NoDeviceFound and Linux(io::Error) variants (Linux-only). Hook struct, Drop, start, and stop dispatch to linux::* on Linux, narrowing the Unsupported stub to Windows-only. Known limitations: no hotplug (devices enumerated at Hook::start only); frontmost_bundle_id() always returns None (per-app profiles not yet implemented on Linux).
- Move build_virtual_device() into start() loop so uinput failures surface as HookError::Linux rather than silently killing the thread - Suppress REL_WHEEL/REL_HWHEEL when hires_scroll is active to prevent low-res companion events from doubling scroll distance via uinput - Gate print_events example with #![cfg(target_os = "linux")] so it does not break compilation on macOS/Windows where ctrlc is absent; remove the dead has_accessibility() guard (always true on Linux)
05d486c to
0302dcf
Compare
0302dcf to
5985670
Compare
- create_pipe: use pipe2(O_CLOEXEC) so the stop pipe doesn't leak into any child process the app spawns. - device_thread: skip a device whose exclusive grab fails instead of reading + replaying it — the desktop still sees the un-grabbed device's events, so replaying would duplicate every one. - drop the redundant second SYN_REPORT: emit() already appends one, so the incoming sync event is no longer pushed into the batch. - device_thread: on a uinput emit failure, stop the device loop (dropping the grab restores normal input) rather than clearing `pending` and silently dropping the report's pass-through events. - print_events: gate main on cfg(linux) with a non-linux fallback instead of a crate-level #![cfg(target_os = "linux")], which left an empty crate with no main on macOS/Windows (E0601 under cargo build --all-targets). - start: terminate the spawn closure body with `;` (clippy::semicolon_if_nothing_returned, -D warnings on the Linux CI).
5985670 to
0ac5dc8
Compare
poll() returns immediately and keeps an fd flagged when it has an error condition, so a grabbed device that errors or hangs up (e.g. is unplugged) left wait_readable spinning at 100% CPU — neither POLLIN branch ever fired. Check POLLERR/POLLHUP/POLLNVAL and return, so the caller exits the device thread and releases the grab. (pullfrog review on AprilNEA#119's hook, carried in.)
poll() returns immediately and keeps an fd flagged when it has an error condition, so a grabbed device that errors or hangs up (e.g. is unplugged) left wait_readable spinning at 100% CPU — neither POLLIN branch ever fired. Check POLLERR/POLLHUP/POLLNVAL and return, so the caller exits the device thread and releases the grab. (pullfrog review on #119's hook, carried in.)

Summary
evdev::enumerate()(devices withBTN_LEFT), grabs each exclusively, and re-injects pass-through events via a paireduinputvirtual device.Suppressevents are consumed without reaching the desktop.REL_WHEEL_HI_RES/REL_HWHEEL_HI_RES(÷120 per the kernel convention), with automatic fallback to integerREL_WHEEL/REL_HWHEELon devices that don't expose hi-res axes.HookErrorgainsNoDeviceFoundandLinux(io::Error)variants (Linux-only). TheUnsupportedstub is now Windows-only.poll()cleanly.Known limitations
Hook::startonly; a mouse plugged in afterwards won't be hooked until restart.frontmost_bundle_id()always returnsNoneon Linux (the roadmap item P1.4).Manual testing (Linux)
Build and run the included smoke-test example with
sudo(requires access to/dev/inputand/dev/uinput):Move the mouse, scroll, and click buttons — events should print to stdout and still reach the desktop normally. Ctrl-C shuts down cleanly.
🤖 Generated with Claude Code