Skip to content

fix(hooks): pass through OS mouse events when no Logitech is connected#185

Open
hughesyadaddy wants to merge 1 commit into
TomBadash:masterfrom
hughesyadaddy:fix/hooks-passthrough-when-no-logitech
Open

fix(hooks): pass through OS mouse events when no Logitech is connected#185
hughesyadaddy wants to merge 1 commit into
TomBadash:masterfrom
hughesyadaddy:fix/hooks-passthrough-when-no-logitech

Conversation

@hughesyadaddy
Copy link
Copy Markdown
Contributor

Summary

Mouser is a Logitech-mouse remapper, but the macOS CGEventTap and the Windows WH_MOUSE_LL hook are both global — they see events from every input device the OS knows about. Without a "is there even a Logitech attached?" gate Mouser will happily:

  • swallow an xbutton click from a generic USB mouse and route it through whatever action the user mapped (e.g. browser_back),
  • invert a trackpad scroll because the user enabled the invert_vscroll toggle for their Logitech wheel,
  • run a gesture-detection swipe over events from a mouse Mouser cannot see over HID++.

The practical user-facing failure is the KVM scenario: the user switches their KVM to another host, the Logitech is fully detached from this machine, but Mouser keeps running on this side and silently remaps whatever other mouse is plugged in. The same failure mode bites users in the disconnect window between sleep and HID++ reconnect.

Fix

Add BaseMouseHook._should_intercept_events() that returns True only when self._connected_device is not None. That flag flips under both HID++ connect / disconnect on macOS / Windows and under Linux evdev attach / release, so the contract is platform-uniform.

Both the macOS event-tap callback and the Windows LL-hook callback early-return the original event when the gate is closed — immediately after the existing injected-event filter and before any blocking / remapping / dispatching code. The Linux hook is already gated by construction (its evdev hook only attaches once a Logitech source device has been resolved), but consults the same helper defensively.

Behavior under each state

State Hook behavior
No Logitech ever connected pass-through
KVM points away from this host (Logitech disconnects) next event passes through
KVM points back to this host (Logitech reconnects) next event is intercepted
Logitech connected normally unchanged

The gate is evaluated per-event against live state, so transitions are instant — no event-loop tick required.

Test plan

  • BaseMouseHookRuntimeStateTests gains three cases pinning the default False, the flip on _on_hid_connect, and the flip back on _on_hid_disconnect.
  • MacOSPassthroughWhenNoDeviceTests exercises the real MouseHook._event_tap_callback and pins the user-visible failure modes:
    • scroll passes through when no Logitech,
    • xbutton passes through when no Logitech,
    • intercept resumes on the very next event after _on_hid_connect.
  • MacOSTrackpadScrollFilterTests._make_hook updated to assign a stub _connected_device so the existing cases still exercise the path they mean to.
  • Full pytest tests/ -q: 501 passed, 1 skipped, 170 subtests passed.

Why no opt-out?

Mouser's purpose statement in the README is unambiguously about Logitech mice. A user without a Logitech bound to the host gets no value from the OS-level remap pipeline running anyway, and the previous behavior was a silent correctness bug for every KVM / multi-host user. If a future use case needs "remap any mouse" the gate can be relaxed behind an explicit setting, but the default has to be "stay out of the way".

Mouser is a Logitech-mouse remapper. The macOS CGEventTap and the
Windows WH_MOUSE_LL hook are both *global* -- they see events from every
input device the OS knows about -- so without a "is there even a Logitech
attached?" gate Mouser would happily intercept and remap an xbutton click
from a generic USB mouse, swallow a trackpad scroll, or run a swipe
through its gesture detector when the user switched their KVM to another
host. That last failure mode is the practical bug: with the KVM pointing
elsewhere the Logitech is fully disconnected from this machine, but
Mouser keeps remapping whatever other mouse the user has plugged in.

Add ``BaseMouseHook._should_intercept_events()`` that returns True only
when ``self._connected_device is not None`` -- the same flag that flips
under HID++ connect / disconnect (and under Linux evdev attach /
release). Both event-tap callbacks early-return the original event
when the gate is closed, immediately after the existing injected-event
filter and before any blocking / remapping / dispatching runs. The
Linux hook is naturally gated because its evdev hook only attaches once
a Logitech source device has been resolved.

Behavior under the four states:

* No Logitech ever connected      -> pass-through.
* KVM points away from this host  -> HID disconnects -> next event passes through.
* KVM points back to this host    -> HID reconnects  -> next event is intercepted.
* Logitech connected normally     -> unchanged.

Tests
-----
- ``test_should_intercept_events_defaults_to_false`` pins the cold-start
  behavior on a fresh BaseMouseHook.
- ``test_should_intercept_events_flips_on_hid_connect`` /
  ``..._flips_off_on_hid_disconnect`` pin the transition contract.
- ``MacOSPassthroughWhenNoDeviceTests`` covers the user-visible failure
  modes end-to-end against the real ``MouseHook._event_tap_callback``:
  scroll passes through when no device, xbutton passes through when no
  device, and intercept resumes on the very next event after
  ``_on_hid_connect`` fires.
- ``MacOSTrackpadScrollFilterTests`` updates its ``_make_hook`` helper
  to pin a stub ``_connected_device`` so the existing trackpad-filter
  cases exercise the path they actually mean to, instead of falling
  through the new gate.
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.

1 participant