Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

CGEventTap on macOS: Global Input Monitoring with Safe Teardown

Create a narrow Core Graphics event tap on macOS with explicit Input Monitoring consent, fast callbacks, disabled-tap recovery, and deterministic cleanup.

CGEventTap observes or filters low-level input events beyond the current AppKit window. It is useful for assistive tools, keyboard utilities, and user-authorized global shortcuts that cannot be implemented with an in-process NSEvent monitor. That breadth is also the reason it has a strict privacy boundary: keyboard and pointer events can reveal what a person is doing in other applications. A responsible tap observes the smallest event set, clearly explains why the feature needs Input Monitoring, and avoids retaining raw event streams.

Do not use an event tap as a generic keylogger, hidden surveillance hook, or workaround for missing application APIs. For shortcuts that only matter while your app is active, use AppKit’s local event handling. For accessibility automation, use semantic accessibility APIs where possible. Global event taps should be reserved for a user-requested feature whose behavior cannot be achieved with a narrower mechanism.

Choose location, mode, and event mask narrowly

CGEventTapCreate takes a tap location, insertion point, options, event mask, callback, and context pointer. The location controls where events enter the tap chain; the options distinguish a passive listen-only tap from an active filter. The event mask should include only the event types needed for the feature. A tap that requests every event has a larger privacy and performance surface and is harder to justify.

Create the tap only while the user has enabled the feature. Check or request the system’s Listen Event access through the documented Core Graphics privacy APIs, explain the exact behavior, and handle denial without repeatedly prompting. Do not tell users to disable System Integrity Protection or edit the TCC database. Input Monitoring consent and Accessibility trust are separate controls; request only the one required by the selected API and operation.

An event tap returns a Core Foundation Mach port. Add a run-loop source for the thread that will own the callback, keep the port and source alive together, and release them symmetrically. If the tap cannot be created, report whether access is unavailable or the configuration failed. A NULL return is not a valid tap that can be repaired by blindly adding a run-loop source.

#include <ApplicationServices/ApplicationServices.h>

static CGEventRef ObserveEvent(CGEventTapProxy proxy,
                              CGEventType type,
                              CGEventRef event,
                              void *context) {
    (void)proxy;
    (void)context;
    if (type == kCGEventTapDisabledByTimeout ||
        type == kCGEventTapDisabledByUserInput) {
        return event;
    }
    /* Inspect only the minimal fields needed for the active feature. */
    return event; /* A passive observer must return the original event. */
}

int main(void) {
    CGEventMask mask = CGEventMaskBit(kCGEventKeyDown);
    CFMachPortRef tap = CGEventTapCreate(
        kCGSessionEventTap,
        kCGHeadInsertEventTap,
        kCGEventTapOptionListenOnly,
        mask,
        ObserveEvent,
        NULL);
    if (tap == NULL) return 1;

    CFRunLoopSourceRef source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0);
    if (source == NULL) {
        CFRelease(tap);
        return 2;
    }
    CFRunLoopAddSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
    CFRunLoopRun();

    CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, kCFRunLoopCommonModes);
    CFRelease(source);
    CFRelease(tap);
    return 0;
}

This demonstrates ownership, not a complete application. The main run loop normally continues until the app shuts down; production code should put lifecycle in an object, remove the source when the feature is disabled, and release both resources on every exit path. Compile against the macOS SDK you target and test with the exact privacy configuration of the signed product.

Keep the callback bounded and privacy-preserving

Event-tap callbacks must return promptly. Do not perform disk writes, network requests, synchronous IPC, expensive parsing, or modal UI inside the callback. A delayed callback can cause the system to disable the tap and harms input responsiveness. If processing is necessary, extract the minimum non-sensitive event fact, enqueue it to a bounded worker, and drop work safely when the queue is full rather than blocking the event stream.

Do not store keystroke text by default. A shortcut utility usually needs to match a small set of modifier and key-code combinations, then discard the event. It does not need a transcript. Never record password fields, full event payloads, or application content. If a diagnostic requires event counts, aggregate counts and discard the source values quickly. Make telemetry opt-in and disclose it separately from the permission needed to operate the feature.

When filtering rather than listening, the callback must return the original event to allow it through or NULL to suppress it, according to the documented API contract. Use active filtering only for a precise, user-understood purpose. A bug in an active filter can make the keyboard or pointer appear broken. Provide an immediate disable action that does not depend on the filtered input itself, and ensure a crash or timeout does not permanently suppress events.

Recover from disabled taps

Core Graphics defines special event types for a tap disabled by timeout or user input. Your callback can receive those control events; handle them without trying to read them as ordinary key events. A timeout indicates the callback failed to return quickly enough. Fix the work budget first. Re-enable with CGEventTapEnable only when the user still expects the feature to be active and the system has not revoked access.

Avoid an aggressive loop that continually re-enables a disabled tap. That can create CPU churn, repeated privacy-sensitive access attempts, and a poor user experience. Track state transitions: created, enabled, disabled-by-timeout, disabled-by-user, permission-revoked, and stopped. Expose a status indicator and a user-controlled retry. If permission changes in System Settings, refresh the authorization state before recreating the tap.

The callback’s userInfo pointer has a lifetime obligation. If it references an object, retain that object until after the run-loop source is removed and the tap can no longer call back. Tear down on the owning thread or serialize teardown to the callback queue. Releasing a Swift object or C context while a callback is still in flight can produce a use-after-free that appears only during app shutdown or rapid enable/disable operations.

Separate global input from AppKit event monitors

An AppKit local event monitor sees events delivered to the current process and can transform or discard them before the app handles them. A global event monitor observes a different class of events and does not provide the same filtering semantics as a low-level Quartz tap. Choose based on scope: local view interaction, app-wide behavior, or session-wide monitoring. A global keyboard shortcut should not automatically require the broadest tap if an established system shortcut or a supported app-specific mechanism satisfies the need.

Coordinate spaces, keyboard layouts, modifier flags, key repeat, and input methods complicate matching. A hardware key code is not a localized character. Do not assume a key means the same printed symbol on every keyboard layout. Treat IME composition and dead keys carefully, and avoid intercepting text events in ways that break assistive technologies or language input.

Design the matcher as a small state machine. If a shortcut depends on a key-down followed by a key-up, track that state for the active session and clear it on focus or permission transitions. Do not infer a full text sequence from key codes, and do not retain modifier state indefinitely after a missed event. Taps may be disabled or suspended, and a key-up can be lost when the app stops. For a command shortcut, it is usually safer to detect one chord and issue one action than to interpret an arbitrary sequence.

Use the event type and a minimal set of fields; do not serialize CGEvent data wholesale. If the feature needs a key code and modifier flags, read only those values and discard the event reference after the callback. Add a bounded queue for handoff to the UI, with coalescing or drop behavior when the UI is busy. A queue that grows without limit is still a responsiveness defect even if the callback itself returns quickly.

Validate the user-facing lifecycle

Test with Input Monitoring unset, approved, denied, revoked while active, and restored after restart. Test event delivery from multiple apps, sleep/wake, fast user switching, display changes, rapid enable/disable, callback overload, and app termination while events are arriving. For passive monitoring, verify that ordinary input reaches the front application unchanged. For filtering, test an explicit emergency bypass and confirm that it remains available when the tap is disabled.

Run a privacy review of all fields the tap reads. Record why each event type is necessary and how long any derived value exists. Add performance tests with high event rates, and confirm callback latency is consistently bounded on supported hardware. A production tap should have an observable permission state, low overhead, no surprise collection, and cleanup that returns input to normal when the feature stops.

For security testing, verify that your app cannot continue monitoring after the user disables its permission and that a restart does not silently restore the feature without a new visible state check. Confirm the menu-bar control accurately reflects whether the tap was created and enabled, not merely whether a preference says it should be enabled. Exercise screen lock, logout, fast user switching, and launch at login, since the consent context and active session can differ from a normal foreground launch.

Review release signing and app identity as part of the test plan: privacy decisions are associated with the requesting app identity, and changing bundle IDs or signatures during development can make local results confusing. Use an isolated test user to reset permissions through supported controls. Do not ship test commands that reset TCC automatically or attempt to make an approval appear for the user.

Related:

Sources:

Comments