Lab note · from Grip
Intercept Cmd+Q with a CGEventTap and repost it without a loop
Grip holds back ⌘Q with an active CGEventTap at .cgSessionEventTap whose callback returns nil for the key-down. When the hold completes, it builds a new key-down and key-up from the stored shortcut's key code and modifier flags and sends them with postToPid to the app that was frontmost when the hold began. CGEventPostToPid injects events after the session-level taps, so Grip's own tap never sees the synthetic quit, whereas CGEvent.post(tap: .cgSessionEventTap) would send it back through that tap. The callback also re-enables the tap whenever the system reports it disabled.
Symptoms
Grip makes you hold ⌘Q before a protected app quits: 1.5 seconds by default, adjustable from 0.5 to 3. A green ring fills at the pointer, and letting go early cancels the quit. Building that runs into three failure modes:
- ⌘Q quits at once, because whatever saw it could only observe it. AppKit's global event monitor is one: "you can only observe the event; you cannot modify or otherwise prevent the event from being delivered to its original target application."
- The re-sent ⌘Q comes back into your own tap and is swallowed like the first one, so the app never quits.
- The tap goes quiet because the system has disabled it, and ⌘Q passes through unprotected until something re-enables it.
Grip's first commit, c381207, already posted with postToPid and re-enabled the tap. The loop in the second point is what the SDK header predicts for the naive post, not a bug the repository recorded.
Why it happens
Where taps sit, and where posted events enter
CGEvent.h says taps can be placed "at the point where HIDSystem events enter the server, at the point where HIDSystem and remote control events enter a session, at the point where events have been annotated to flow to a specific application, or at the point where events are delivered to the application." The posting functions enter at different points:
CGEventPost"posts the specified event immediately before any event taps instantiated for that location, and the event passes through any such taps."CGEventPostToPSN, and its replacementCGEventPostToPid, post "immediately before any event taps instantiated for the specified process".
A session tap therefore sees anything posted at the HID or session location, but not an event posted to a process.
Taps get switched off
Apple's tapEnable(tap:enable:) reference: "If an event tap becomes unresponsive, or if a user requests that event taps be disabled, then a kCGEventTapDisabled event is passed to the event tap callback function. Event taps may be re-enabled by calling this function." CGEventTypes.h declares them as out-of-band types, kCGEventTapDisabledByTimeout (0xFFFFFFFE) and kCGEventTapDisabledByUserInput (0xFFFFFFFF), "delivered to the event tap callback to notify it of unusual conditions that disable the event tap". The callback "runs from the CFRunLoop to which the tap CFMachPort is added". Grip adds it to the main run loop, so anything that blocks the main thread can leave the tap unresponsive.
Permission
CGEvent.hsays session taps "may only receive key up and down events if access for assistive devices is enabled" or the caller is trusted, as byAXMakeProcessTrusted. Without that, "the appropriate bits in the mask are cleared", and an empty mask makestapCreatereturnNULL.- macOS 10.15 split this up, adding
CGPreflightListenEventAccessandCGPreflightPostEventAccessto the header. Quinn at Apple DTS: "To listen for keyboard events you'll need the Input Monitoring privilege." Posting uses a separate privilege that "shows up in the UI as System Settings > Privacy & Security > Accessibility". Apple's MDM privacy keys name themListenEventandPostEvent. - Grip calls
AXIsProcessTrustedWithOptionswith the prompt option, and its README tells users to grant Accessibility. It never callsCGRequestListenEventAccessorCGRequestPostEventAccess. We didn't test which single toggle is enough for an active keyboard tap.
The fix
The tap, in QuitInterceptor.swift:
let mask: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
eventsOfInterest: mask,
callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
guard let refcon else { return Unmanaged.passRetained(event) }
return Unmanaged<QuitInterceptor>.fromOpaque(refcon).takeUnretainedValue()
.handleEvent(type: type, event: event)
},
userInfo: Unmanaged.passUnretained(self).toOpaque()
) else { return }
eventTap = tap
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
The callback, trimmed:
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
if let tap = eventTap { CGEvent.tapEnable(tap: tap, enable: true) }
return Unmanaged.passRetained(event)
}
// keyUp of the key, or a modifier released, cancels a hold in progress
guard type == .keyDown else { return Unmanaged.passRetained(event) }
guard let matched = shortcuts.first(where: { $0.isEnabled && $0.matchesEvent(keyCode: keyCode, flags: flags) }) else {
return Unmanaged.passRetained(event)
}
if isHolding { return nil } // auto-repeat while held: swallow
guard let frontApp = NSWorkspace.shared.frontmostApplication,
let bundleID = frontApp.bundleIdentifier,
protectAllApps || protectedBundleIDs.contains(bundleID) else {
return Unmanaged.passRetained(event)
}
isHolding = true; targetApp = frontApp; holdStartTime = Date(); activeShortcut = matched
return nil // the app never sees this ⌘Q
When a 60 Hz timer reports the hold complete:
let src = CGEventSource(stateID: .combinedSessionState)
let pid = target.processIdentifier
let vk = CGKeyCode(sc.keyCode)
let modFlags = CGEventFlags(rawValue: sc.modifierFlags)
if let kd = CGEvent(keyboardEventSource: src, virtualKey: vk, keyDown: true) {
kd.flags = modFlags; kd.postToPid(pid)
}
if let ku = CGEvent(keyboardEventSource: src, virtualKey: vk, keyDown: false) {
ku.flags = modFlags; ku.postToPid(pid)
}
The naive version, not from the repository, differs in one call:
kd.post(tap: .cgSessionEventTap) // passes through Grip's own session tap again
Why it works:
.defaultTapis an active filter, and the callback type's header says to return "NULL if the event is to be deleted". The first key-down and every auto-repeat after it returnnil.postToPidenters after session taps, so the synthetic events skip Grip's tap and nothing needs to tell them apart.isHoldingis already false when they're posted, so under the naive post the synthetic key-down would start a new hold and the synthetic key-up would cancel it.- The key code and flags come from the saved shortcut, not the intercepted event.
matchesEventcompares only the Command, Shift, Option and Control bits. A test that ran the same comparison matched ⌘ with Caps Lock or.maskNonCoalescedset, and rejected ⌘⇧. - The quit goes to
targetApp, captured when the hold began, even if another app is frontmost by then.
How to check you've fixed it
- Call
CGGetEventTapList. It is read-only and reports every installed tap's location, options, tapping process and an enabled flag. Grip's should read session, active (options0) and enabled. - In the tap, log
event.getIntegerValueField(.eventSourceUnixProcessID)for ⌘Q key-downs. After a completed hold, no key-down from Grip's own PID should appear. - Hold ⌘Q in a protected app past the duration: it quits. Let go early: it doesn't, and "Tink" plays if sound is on.
- Pause Grip in the debugger for a few seconds while typing elsewhere, then resume, with a log line in the disabled branch. The line should print and ⌘Q should be held again.
Caveats
- Targets.
Package.swiftsets swift-tools 5.9 and.macOS(.v13);Info.plistsetsLSMinimumSystemVersion13.0. The code builds with Swift 6.4 against the macOS 27.0 SDK. - No retry after permission.
start()runs once, inapplicationDidFinishLaunching, and returns silently iftapCreatefails. The menu's "Grant Accessibility Permission" button only prompts, so protection starts after a relaunch. - Secure input. Apple's TN2150 says the system stops "passing keyboard events to any intercept process whenever any process has enabled secure event input". While that is on, ⌘Q reaches the app unprotected.
- Re-enabling after
tapDisabledByUserInputoverrides what the documentation describes as a user's request to disable taps. - Ownership. Quinn's Swift tap sample returns a passed-through event with
Unmanaged.passUnretained(event): "We don't replace the event, so the new event is the same as the old event, so we return it unretained." Grip returnspassRetained. We haven't measured whether that leaks. - Scope. Since
476e9b7andd9b9272, the same path also protects ⌘W (off by default) and custom shortcuts, each with its own hold duration.
More lab notes
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.
- Claude Code signed out by another app: refresh token rotationAnthropic rotates the refresh token on every refresh, so another app that refreshes Claude Code's login signs it out. Only Claude Code should refresh it.
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.