Skip to content

feat(hook): implement Linux evdev+uinput mouse hook#119

Merged
AprilNEA merged 4 commits into
AprilNEA:masterfrom
cserby:story/linux-hook/linux-evdev-hook-support
Jun 5, 2026
Merged

feat(hook): implement Linux evdev+uinput mouse hook#119
AprilNEA merged 4 commits into
AprilNEA:masterfrom
cserby:story/linux-hook/linux-evdev-hook-support

Conversation

@cserby

@cserby cserby commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Enumerates mouse devices via evdev::enumerate() (devices with BTN_LEFT), grabs each exclusively, and re-injects pass-through events via a paired uinput virtual device. Suppress events are consumed without reaching the desktop.
  • 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). The Unsupported stub is now Windows-only.
  • Shutdown uses a per-thread pipe to interrupt blocking poll() cleanly.

Known limitations

  • No hotplug — devices are enumerated at Hook::start only; a mouse plugged in afterwards won't be hooked until restart.
  • No per-app profilesfrontmost_bundle_id() always returns None on Linux (the roadmap item P1.4).

Manual testing (Linux)

Build and run the included smoke-test example with sudo (requires access to /dev/input and /dev/uinput):

cargo build --example print_events -p openlogi-hook
sudo ./target/debug/examples/print_events

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by BTN_LEFT, per-device EVIOCGRAB, 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.rsHookError::NoDeviceFound / HookError::Linux(io::Error) (Linux-only), Hook::start/stop/Drop dispatch to the Linux path
  • Add examples/print_events.rs (new, 51 lines) — manual smoke-test with ctrlc shutdown
  • Update src/tests.rs — Linux error variants in display test, linux_start_does_not_return_unsupported guard assertion
  • Update Cargo.tomlevdev + libc (Linux-only runtime deps), ctrlc (dev-dep for the example)

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Big Pickle (free) (credentials for Anthropic not configured) | 𝕏

Comment thread crates/openlogi-hook/src/linux.rs Outdated
@greptile-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a Linux evdev+uinput mouse hook implementation, complementing the existing macOS CGEventTap path. It enumerates all /dev/input mice with BTN_LEFT, exclusively grabs each device, and re-injects pass-through events via a paired uinput virtual device — with hi-res scroll support, clean shutdown via per-thread pipes, and proper error propagation from start().

  • New linux.rs module: device enumeration, virtual device cloning, event translation (buttons + hi-res/low-res scroll), per-device thread loop with pipe-based shutdown.
  • lib.rs updates: two new Linux-only HookError variants (NoDeviceFound, Linux), conditional Hook/Drop wiring for Linux, and platform-agnostic has_accessibility() returning true on Linux.
  • tests.rs and examples/print_events.rs: new platform tests and a Linux-only smoke-test example with ctrlc.

Confidence Score: 3/5

The 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

Filename Overview
crates/openlogi-hook/src/linux.rs Core Linux implementation; has open defects (spin loop on POLLHUP/POLLERR when device is unplugged, SYN_DROPPED treated identically to SYN_REPORT causing stuck buttons) flagged in prior review rounds that remain unaddressed.
crates/openlogi-hook/src/lib.rs Public API updated cleanly: new Linux-only HookError variants with correct cfg gates, Infallible-based Hook stub for unsupported targets, and platform-aware Drop/stop.
crates/openlogi-hook/src/tests.rs Added Linux-specific tests for Hook::start not returning Unsupported; existing cross-platform tests unchanged and correct.
crates/openlogi-hook/examples/print_events.rs Linux-only smoke test with ctrlc; provides a no-op fallback main for other platforms.
crates/openlogi-hook/Cargo.toml evdev/libc added as Linux-only deps; ctrlc correctly placed in Linux-only dev-deps; no issues.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (6): Last reviewed commit: "fix(hook): additional Linux hook review ..." | Re-trigger Greptile

Comment thread crates/openlogi-hook/examples/print_events.rs
Comment thread crates/openlogi-hook/examples/print_events.rs Outdated
@cserby cserby force-pushed the story/linux-hook/linux-evdev-hook-support branch from 7a3cffa to edcd436 Compare June 4, 2026 09:33

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ea0ba7c is functionally identical to the prior revision; no conflict-resolution artifacts or semantic changes visible
  • Standalone print_events commitedcd436 isolates the smoke-test example behind the feature commit; example content unchanged from the prior review

Pullfrog  | Fix it ➔View workflow run | Using Big Pickle (free) (credentials for Anthropic not configured) | 𝕏

Comment thread crates/openlogi-hook/src/linux.rs Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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-emissiontranslate return value is now matched with a guard that suppresses low-res REL_WHEEL/REL_HWHEEL events when hi-res scroll is active, rather than falling through to PassThrough and re-injecting through uinput. Unit tests added for both axes
  • Fail-fast virtual device creationbuild_virtual_device called eagerly in start() instead of inside device_thread; a failure now prevents the hook from starting rather than silently running without re-injection
  • Fix platform gating on print_events example — added #![cfg(target_os = "linux")] and removed the macOS-specific has_accessibility() check

Pullfrog  | View workflow run | Using Big Pickle (free) (credentials for Anthropic not configured) | 𝕏

@recchia

recchia commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Thanks @cserby — this is great, and going straight to evdev+uinput instead of X11 was the right call: it covers Wayland sessions without compositor-specific code. I'm picking this up from two angles so nothing gets duplicated.

1) Testing #119 on real Linux hardware.
Environment: Ubuntu 24.04, GNOME on Wayland, MX Master 3S and MX Anywhere 3.

Specifically I'll try to confirm/repro the items already flagged in review, since they need real hardware:

  • The Wayland path end-to-end (exclusive grab + Suppress + re-inject under an actual Wayland session, not just X11).
  • The 100% CPU spin on device unplug (POLLHUP/POLLERR not handled in wait_readable) — easy to trigger with a wireless receiver that sleeps/disconnects.
  • The silent-failure path when /dev/uinput is inaccessible or EVIOCGRAB fails — does a Suppress callback then pass everything through with no error surfaced?
  • The low-res scroll companion leak when hi-res axes are active.
  • A clean cross-platform cargo build of the workspace from a Linux host.

I'll report back with logs and the exact udev/permission setup needed to run without sudo.

2) Claiming Phase 0 — HID/device-layer verification on Linux (from the tracking issue #95).
This is out of scope for this PR (it's openlogi-hid, not the hook crate), and it's still open, so I'm taking it to avoid collisions:

  • async-hid/hidpp enumeration of the Bolt receiver + BT-direct/wired devices over hidraw.
  • DPI (0x2201), SmartShift (0x2111/0x2110), and battery read/write.
  • Whether Linux needs report-size handling analogous to the Windows short/long-report channel in feat(hid): dual-handle HID++ channel for Windows short/long reports #73.
  • Documented udev rules for non-root hidraw access to Logitech receivers.

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.

cserby added 3 commits June 5, 2026 19:29
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)
@AprilNEA AprilNEA force-pushed the story/linux-hook/linux-evdev-hook-support branch 2 times, most recently from 05d486c to 0302dcf Compare June 5, 2026 11:43
Comment thread crates/openlogi-hook/src/linux.rs
@AprilNEA AprilNEA force-pushed the story/linux-hook/linux-evdev-hook-support branch from 0302dcf to 5985670 Compare June 5, 2026 11:52
- 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).
@AprilNEA AprilNEA force-pushed the story/linux-hook/linux-evdev-hook-support branch from 5985670 to 0ac5dc8 Compare June 5, 2026 12:08
@AprilNEA AprilNEA merged commit 8b74676 into AprilNEA:master Jun 5, 2026
7 of 8 checks passed
AprilNEA added a commit to cserby/OpenLogi that referenced this pull request Jun 5, 2026
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.)
AprilNEA added a commit that referenced this pull request Jun 5, 2026
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.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants