Skip to content

fix: make the event monitor self-healing and surface Secure Input - #5

Merged
gentlespoon merged 7 commits into
gentlespoon:mainfrom
lukas-runge:fix/event-tap-reliability
Jun 12, 2026
Merged

fix: make the event monitor self-healing and surface Secure Input#5
gentlespoon merged 7 commits into
gentlespoon:mainfrom
lukas-runge:fix/event-tap-reliability

Conversation

@lukas-runge

@lukas-runge lukas-runge commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Hi @gentlespoon,

after reproducing this a bunch of times I let @claude finally have a look at it. Currently testing it, but seems to work fine. Feel free to also have a look and refine if you have any other ideas on why the frequent event monitor restarts have been necessary since my last PR.

Best regards,
@lukas-runge

Problem

The event monitor regularly died and needed a manual restart via the menu. Separately, sometimes right-click activation kept working while the activation key (Space) appeared dead.

Root causes

1. Tap disabled by macOS, never re-enabled. The tap is an active filter, so every matched event waits on our callback. The callback did synchronous AX IPC on every click system-wide and on every leftMouseDragged event (hundreds/sec), with the default AX messaging timeout of ~6 s per call to a busy app. Once a callback exceeded the tap's time budget, macOS sent tapDisabledByTimeout — which fell into the default: switch case, so the tap stayed dead until a manual restart.

2. Secure Event Input. While any app holds Secure Input (browser password fields, Terminal's Secure Keyboard Entry, password managers), the WindowServer withholds keyboard events from event taps while mouse events keep flowing — exactly the "right-click works, Space doesn't" asymmetry. Not a code bug, but the app gave no indication of it.

Changes (one commit each, in order)

  1. Re-enable the tap when tapDisabledByTimeout / tapDisabledByUserInput arrives in the callback.
  2. Watchdog (5 s): re-enables a disabled tap, tears down an invalidated mach port, and starts the monitor once accessibility trust exists (covers login autostart before permission is granted, silent tapCreate failure, trust loss after re-signing). isMonitoring now reports the real tap state; trust-loss restart no longer runs inside the tap callback.
  3. Cap AX messaging timeout at 250 ms so a single beachballing app can no longer stall the callback past the tap's time budget.
  4. Cut callback load: drop unhandled mouseMoved/keyUp from the event mask; stop polling the AX window position on every drag event (the drag state is recomputed on demand in startSnapping(), so behavior is unchanged).
  5. Remove force unwraps from the snapping flow — frontMostWindow can legitimately be nil and crashed the app before.
  6. Detect Secure Input in the watchdog, log which app holds it, and switch the status bar icon with an explanatory tooltip while it is active.
  7. Remove dead code (unused polling, isSpacePressed, unused private CGS API declarations).

Testing

Manually tested on macOS 15: snapping via Space and right-click (with and without moveOnActivate), Escape cancel, multi-monitor drags, and the Secure Input flow (Terminal → Secure Keyboard Entry → status bar indicator appears within ~5 s, Space blocked while right-click keeps working, everything reverts on release).

🤖 Generated with Claude Code

lukas-runge and others added 7 commits June 11, 2026 17:26
macOS disables a CGEventTap whose callback exceeds its time budget and
delivers tapDisabledByTimeout/tapDisabledByUserInput as pseudo-events.
These fell into the default switch case, so the tap stayed dead until a
manual restart via the menu. Re-enable it right in the callback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Watchdog (5 s) re-enables a disabled tap, tears down an invalidated
  mach port, and starts the monitor once accessibility trust exists.
  This also covers: permission granted after launch (login autostart),
  silent tapCreate failure, and trust loss after re-signing.
- isMonitoring now reports the actual tap state (tapIsEnabled) instead
  of mere existence of the mach port.
- tapCreate failure is logged instead of claiming the monitor started.
- Trust-loss restart no longer runs inside the tap callback (it would
  invalidate the mach port whose callback is executing); it is
  dispatched async to the main queue instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AX calls to an unresponsive app block for up to ~6 s by default. The
tap callback does such calls synchronously, so one beachballing app
could exceed the tap's time budget and get it disabled by macOS.
250 ms is generous for healthy apps; window placement on slow apps is
still covered by the existing verify-and-retry loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drop mouseMoved and keyUp from the event mask: neither is handled,
  yet every such event in the system was routed through the active tap.
- Stop polling the AX window position on every leftMouseDragged event
  (hundreds of sync IPC round-trips per second to the very app that is
  busy handling the drag). startSnapping() already recomputes the drag
  state on demand, so behavior is unchanged.

Together with the AX timeout cap this removes the main trigger for
tapDisabledByTimeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frontMostWindow can legitimately be nil (click on the desktop, AX
lookup failure), but startSnapping still set isSnapping = true and the
subsequent drag/up handlers force-unwrapped it — crashing the app.
startSnapping now aborts cleanly when no window is resolved, and the
drag/up handlers guard instead of unwrapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While any app holds Secure Event Input (browser password fields,
Terminal's Secure Keyboard Entry, password managers), the WindowServer
withholds keyboard events from event taps while mouse events keep
flowing — right-click activation works but the activation key appears
dead. The watchdog now detects the state, logs which app holds it, and
the status bar icon switches with an explanatory tooltip while it is
active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- startPollingWindow/stopPollingWindow, dragCheckTimer and
  windowCoordinatesEnd: the polling was never started and the value
  never read.
- isSpacePressed: never read.
- CGSDisableUpdate/CGSReenableUpdate private-API declarations: never
  called.
- Commented-out DockPosition enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gentlespoon
gentlespoon merged commit e0b4fce into gentlespoon:main Jun 12, 2026
1 check passed
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.

2 participants