Skip to content
All lab notes

Lab note · from Grip

Intercept Cmd+Q with a CGEventTap and repost it without a loop

By Mark Santos · · 5 min read

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 replacement CGEventPostToPid, 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.h says session taps "may only receive key up and down events if access for assistive devices is enabled" or the caller is trusted, as by AXMakeProcessTrusted. Without that, "the appropriate bits in the mask are cleared", and an empty mask makes tapCreate return NULL.
  • macOS 10.15 split this up, adding CGPreflightListenEventAccess and CGPreflightPostEventAccess to 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 them ListenEvent and PostEvent.
  • Grip calls AXIsProcessTrustedWithOptions with the prompt option, and its README tells users to grant Accessibility. It never calls CGRequestListenEventAccess or CGRequestPostEventAccess. 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:

  • .defaultTap is 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 return nil.
  • postToPid enters after session taps, so the synthetic events skip Grip's tap and nothing needs to tell them apart. isHolding is 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. matchesEvent compares only the Command, Shift, Option and Control bits. A test that ran the same comparison matched ⌘ with Caps Lock or .maskNonCoalesced set, 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 (options 0) 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.swift sets swift-tools 5.9 and .macOS(.v13); Info.plist sets LSMinimumSystemVersion 13.0. The code builds with Swift 6.4 against the macOS 27.0 SDK.
  • No retry after permission. start() runs once, in applicationDidFinishLaunching, and returns silently if tapCreate fails. 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 tapDisabledByUserInput overrides 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 returns passRetained. We haven't measured whether that leaks.
  • Scope. Since 476e9b7 and d9b9272, the same path also protects ⌘W (off by default) and custom shortcuts, each with its own hold duration.

More lab notes