Skip to content

fix: restore cross-app hotkey via HID tap + active tap options#66

Merged
quiet-node merged 3 commits intomainfrom
worktree-snazzy-churning-shamir
Apr 8, 2026
Merged

fix: restore cross-app hotkey via HID tap + active tap options#66
quiet-node merged 3 commits intomainfrom
worktree-snazzy-churning-shamir

Conversation

@quiet-node
Copy link
Copy Markdown
Owner

Summary

  • Fixes the double-tap Control hotkey silently stopping after switching focus to any other app
  • Root cause was two independent CGEventTap configuration mistakes in activator.rs, both required to fix

What broke and why

CGEventTapLocation::Session (was the main culprit)
Session-level taps sit above the macOS window server routing layer. Since macOS 15 Sequoia, the window server applies focus-based filtering: a Session-level tap only receives events while the tap's own process has focus. Switching to any other app produces zero callbacks with no errors. Fixed by switching to CGEventTapLocation::HID, which operates before the window server layer and receives all events regardless of focus — the same approach used by Karabiner-Elements and BetterTouchTool.

CGEventTapOptions::ListenOnly (secondary issue)
ListenOnly taps are killed by macOS secure input mode. When iTerm's "Secure Keyboard Entry" is enabled (or any password field is focused), macOS sends TapDisabledByUserInput and stops all event delivery. Fixed by switching to CGEventTapOptions::Default. We still return CallbackResult::Keep in the callback so no events are blocked.

Other changes

  • Added TapExitReason enum to distinguish intentional stop, creation failure, and macOS-disabled tap — enabling correct retry behavior
  • Rewrote run_loop_with_retry to loop while active, retry immediately on TapDied, back off on CreationFailed
  • Added TapDisabledByTimeout / TapDisabledByUserInput handling in callback (these sentinel values cannot be in the event mask bitmask — causes a shift-left overflow panic)
  • Added Input Monitoring permission check/request via IOKit FFI (IOHIDCheckAccess / IOHIDRequestAccess)
  • Added Input Monitoring as a third step in the onboarding flow
  • Updated CLAUDE.md with a "DO NOT CHANGE" warning documenting both required tap settings

Test plan

  • Double-tap Control summons/dismisses the overlay while Thuki has focus
  • Double-tap Control summons/dismisses the overlay after switching focus to another app (Safari, Finder, etc.)
  • Double-tap Control works with iTerm "Secure Keyboard Entry" enabled
  • Onboarding flow shows all three permission steps in order (Accessibility, Input Monitoring, Screen Recording)
  • bun run test:all passes with 100% coverage
  • bun run validate-build completes with zero warnings and zero errors

🤖 Generated with Claude Code

quiet-node and others added 2 commits April 7, 2026 22:42
The double-tap Control hotkey stopped working whenever focus left
Thuki or its launch terminal. Two independent bugs combined to
cause this.

Bug 1: CGEventTapLocation::Session filtered by window server focus
Since macOS 15 Sequoia, Session-level taps only receive events
while the tap process (or its parent terminal) has focus. Switching
to any other app silently stops all event delivery with no error.
Fixed by using CGEventTapLocation::HID, which operates before the
window server routing layer and receives all keypress events
regardless of which app is active.

Bug 2: CGEventTapOptions::ListenOnly disabled by secure input mode
ListenOnly taps are disabled by macOS secure input mode, which
activates when iTerm has "Secure Keyboard Entry" enabled or any
password field is focused. macOS sends TapDisabledByUserInput and
halts delivery. Fixed by using CGEventTapOptions::Default (active
tap). We return CallbackResult::Keep so no events are blocked.

Additional changes:
- Add Input Monitoring (kTCCServiceListenEvent) as a required
  permission alongside Accessibility and Screen Recording. The
  IOHIDCheckAccess/IOHIDRequestAccess FFI and three new Tauri
  commands wire it into the existing permission infrastructure.
- Add Input Monitoring as step 2 in OnboardingView (between
  Accessibility and Screen Recording, no restart required).
- Improve tap retry logic: distinguish tap-death (reinstall
  immediately) from creation failure (wait + limited retries).
- Handle TapDisabledByTimeout/TapDisabledByUserInput in callback
  by stopping the run loop to trigger an immediate reinstall.
- Document the HID + Default tap requirement in CLAUDE.md so these
  settings are never accidentally reverted.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node quiet-node force-pushed the worktree-snazzy-churning-shamir branch from a8fb8d8 to 1b21c1e Compare April 8, 2026 03:43
@quiet-node
Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. The doc comment on is_input_monitoring_granted says the permission is required for "a CGEventTap at Session level" — but this PR's central change is switching the tap away from Session to HID level. The comment describes the old, broken behavior and directly contradicts the "DO NOT CHANGE" guidance added to CLAUDE.md in this same PR.

///
/// Input Monitoring (`kTCCServiceListenEvent`) is required for a CGEventTap at
/// Session level to receive keyboard events from other applications. Without it,
/// the tap only sees events generated within the Thuki process itself, making
/// the double-tap hotkey invisible when the user's focus is elsewhere.
///
/// Unlike Screen Recording, Input Monitoring does not require a process restart

The comment should say HID level, not Session level (or omit the tap-level qualification entirely, since the function is not tap-level-specific).

🤖 Generated with Claude Code

If this code review was useful, please react with 👍. Otherwise, react with 👎.

…nted doc

The doc comment incorrectly stated the permission was required for a
CGEventTap at Session level. The tap runs at HID level since the fix in
this branch. Updated to reflect the actual implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node quiet-node merged commit 8c7f2cd into main Apr 8, 2026
3 checks passed
@quiet-node quiet-node deleted the worktree-snazzy-churning-shamir branch April 8, 2026 04:08
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