Skip to content

Stop CGEvent tap from gating keystrokes in unrelated apps (#328)#337

Merged
FuJacob merged 1 commit into
mainfrom
fix/non-blocking-input-tap
May 28, 2026
Merged

Stop CGEvent tap from gating keystrokes in unrelated apps (#328)#337
FuJacob merged 1 commit into
mainfrom
fix/non-blocking-input-tap

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 28, 2026

Summary

Fixes #328 (Cotabby messing with DaVinci Resolve's Spacebar play/pause). The single active .defaultTap we installed at the head of the system event chain held every keyDown synchronously until our main-actor callback returned, so any contention on the main actor (AX polling, prediction work, SwiftUI updates) translated into observable keystroke delay in every app — even when Cotabby was disabled globally, because the tap callback still ran before the early-exit check.

Replaced with a two-tap design:

  • Observer tap: steady-state .listenOnly at .headInsertEventTap. Sees every keyDown for classification, but the system does not wait on the callback's return, so a slow main actor cannot stall global keystrokes.
  • Accept tap: narrow active .defaultTap at .tailAppendEventTap, installed only while a suggestion overlay is visible. Consumes the configured accept key(s) and nothing else. The coordinator turns it on/off via the existing overlayController.onStateChange hook.

In DaVinci Resolve (and any other app where no suggestion ever appears) the accept tap is never installed, so Cotabby is fully out of the synchronous keystroke critical path.

Validation

  • swiftlint lint --quiet — exit 0
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build — ** BUILD SUCCEEDED **
  • xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build-for-testing — ** TEST BUILD SUCCEEDED **

Manual verification recommended: with Cotabby running, play/pause in DaVinci Resolve via Spacebar should now feel native. Tab acceptance in normal text fields should continue to work — verify in TextEdit / Notes / VS Code that the accept tap still swallows Tab when a suggestion is shown.

Linked issues

Fixes #328

Risk / rollout notes

  • Touches the global keyboard input path, so worth a manual smoke on Tab acceptance across a few apps.
  • The accept tap now runs at .tailAppendEventTap rather than .headInsertEventTap. The observer is listen-only and never drops events, so the accept tap reliably sees every keystroke regardless of order; this guarantees the observer's classification fires before the accept tap consumes.
  • Follow-up worth filing: FocusTracker still polls AX every 80 ms regardless of isGloballyEnabled. That doesn't block keystrokes anymore, but it is still wasted main-actor work when Cotabby is disabled in the focused app.

Greptile Summary

Replaces the single always-active CGEvent tap with a two-tap design to fix keystroke latency in unrelated apps (DaVinci Resolve #328): a permanent listen-only observer at headInsertEventTap for classification, and a narrow active accept tap at tailAppendEventTap that is installed only while a suggestion overlay is visible.

  • Observer tap (listenOnly, HEAD): always on, never blocks event delivery. Classifies every keyDown and calls onEvent for coordinator notification. The return value of onEvent is now discarded — the old synchronous veto path no longer exists.
  • Accept tap (active, TAIL): installed/removed by setAcceptInterceptionActive in response to overlayController.onStateChange. Independently consumes the accept key based on key-code matching alone, without consulting the coordinator's decision.
  • Risk area: when the coordinator falls through to passTabThrough() (state not ready, permission issue) while the accept tap is installed, it destroys the tap and returns false — but the accept tap may already be committed to the current event delivery chain and could still swallow the Tab key the coordinator intended to pass through.

Confidence Score: 3/5

The core latency fix is architecturally sound, but the two-tap design decouples event consumption from the coordinator's decision in a way that could silently swallow Tab keystrokes in edge cases.

The normal acceptance path (overlay visible → Tab pressed → suggestion ready) works correctly. The concern is the decoupled design: the accept tap consumes the key based on key-code alone, while the coordinator's onEvent return value (previously the exclusive veto gate) is now ignored. If acceptCurrentSuggestion() hits a guard and calls passTabThrough() while the accept tap is in the current event delivery chain, the coordinator's intent to let Tab through may not be honoured.

InputMonitor.swift — specifically handleAcceptTap and the interaction with destroyAcceptTap being called mid-chain from the observer callback.

Important Files Changed

Filename Overview
Cotabby/Services/Input/InputMonitor.swift Core change: replaces a single active defaultTap with a listen-only observer (HEAD) + narrow active accept tap (TAIL, lifecycle-managed). The accept tap independently consumes the key without consulting the coordinator's onEvent return value, breaking the old veto contract and creating a potential Tab-swallow on the passTabThrough path.
Cotabby/App/Coordinators/SuggestionCoordinator.swift Wires up accept tap lifecycle to overlay visibility via onStateChange. The hook is correct, but the coordinator's Bool return from handleInputEvent is now silently discarded by the observer, meaning the coordinator can no longer veto accept-key interception.
Cotabby/Models/SuggestionSubsystemContracts.swift Adds setAcceptInterceptionActive to the protocol; the existing onEvent: ((CapturedInputEvent) -> Bool)? return type is now unused dead weight that misleads future implementors.

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Split CGEvent tap into listen-only obser..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

The single active .defaultTap at .headInsertEventTap held every system
keyDown synchronously until Cotabby's main-actor callback returned.
That made Cotabby's mere presence interfere with keystroke timing in
other apps. DaVinci Resolve's Spacebar play/pause was the canonical
victim (#328): when the main actor was contended by AX polling or
prediction work, the tap delayed event delivery enough that Resolve
mis-paired keyDown/keyUp and required multiple presses to pause.

Steady state is now a listen-only tap at the head of the chain, which
the system does not gate on the callback. A second narrow .defaultTap
is installed at the tail only while a suggestion overlay is visible,
consumes the accept key, and is torn down immediately when the overlay
hides. In apps where no suggestion ever appears (DaVinci Resolve, the
Finder, etc.), Cotabby never sits on the synchronous keystroke path.
@FuJacob FuJacob merged commit d28e81d into main May 28, 2026
4 checks passed
Comment on lines +258 to +273
case .keyDown:
guard shouldProcessEventsProvider() else {
return Unmanaged.passUnretained(event)
}

let keyCode = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))
let flags = event.flags
let noModifiers = flags.isDisjoint(with: [.maskCommand, .maskControl, .maskAlternate, .maskShift])
guard noModifiers else {
return Unmanaged.passUnretained(event)
}

if keyCode == fullAcceptanceKeyCodeProvider() || keyCode == acceptanceKeyCodeProvider() {
return nil
}
return Unmanaged.passUnretained(event)
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.

P1 Accept tap consumes key independently of coordinator's veto

The accept tap returns nil (consumes the event) based only on key-code matching, never consulting the coordinator. When acceptCurrentSuggestion() falls through to passTabThrough() — e.g., the guard case .ready = state check fails because state was changed without the overlay hiding yet — passTabThrough calls hideOverlay()destroyAcceptTap() and returns false. In the old design that false directly prevented consumption. Here, if the accept tap was already committed to the current event delivery chain before CFMachPortInvalidate ran (CGEvent tap chain snapshots are typically taken at dispatch time), it will still fire and silently swallow the Tab key that the coordinator explicitly decided should reach the app. The coordination contract that the Bool return of onEvent expressed is now broken.

Fix in Codex Fix in Claude Code

@@ -34,6 +34,11 @@ protocol SuggestionFocusProviding: AnyObject {
protocol SuggestionInputMonitoring: AnyObject {
var onEvent: ((CapturedInputEvent) -> Bool)? { get set }
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.

P2 onEvent return value is now dead weight

handleObserverTap discards the return with _ = onEvent?(capturedEvent), and the accept tap independently decides whether to consume the event. The Bool return type implies callers can still control interception through this callback, but they no longer can. Changing to Void removes a source of confusion for future maintainers and makes the new architectural split explicit in the protocol contract.

Suggested change
var onEvent: ((CapturedInputEvent) -> Bool)? { get set }
var onEvent: ((CapturedInputEvent) -> Void)? { get set }

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Comment on lines +183 to 209
private func destroyObserverTap() {
if let source = observerRunLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
observerRunLoopSource = nil

runLoopSource = nil

if let tap = eventTap {
if let tap = observerTap {
CFMachPortInvalidate(tap)
}
observerTap = nil
}

private func destroyAcceptTap() {
guard acceptTap != nil || acceptRunLoopSource != nil else {
return
}
if let source = acceptRunLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
acceptRunLoopSource = nil

eventTap = nil
if let tap = acceptTap {
CFMachPortInvalidate(tap)
}
acceptTap = nil
CotabbyLogger.app.info("CGEvent accept tap removed")
}
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.

P2 destroyObserverTap is asymmetric with destroyAcceptTap

destroyObserverTap does not log its removal (no CotabbyLogger.app.info(...) call), while destroyAcceptTap does. This inconsistency makes it harder to diagnose tap lifecycle issues from logs when the observer tap is unexpectedly torn down (e.g., during permission revocation).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

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.

[Bug] Messing with Davinci Resolve

1 participant