Friday, December 28, 2012

Logging Key Events in Mac OS X

A friend asked me to look into writing a Mac version of a little Eclipse plugin he had been developing for Windows. What it does is it records all your keystrokes in order to allow you to analyse your typing efficiency and to "eliminate" unnecessary keystrokes :-)

I liked the idea as it was a bit on the out of the ordinary side of things. Especially because one of the requirements was to be able to record the use of hotkeys and all of this was supposed to be done from within a Java application (I will cover that aspect in a separate post)

As far as I know, there are three ways of achieving this goal with the public APIs provided by Apple:

  1. Using Cocoa's NSEvent class
  2. Carbon's InstallApplicationEventHandler API
  3. Core Graphics offers a low level C API

Cocoa

By far the easiest approach is to use the Cocoa API. It's basically a one-liner:

    [NSEvent addLocalMonitorForEventsMatchingMask:NSKeyDownMask 
                                          handler:
      ^NSEvent *(NSEvent * event) {

        // Here goes your logging...
        return event;
    } ];

This works great. It gives you all the information you would want about pressed modifier keys etc. The callback is implemented as an Objective-C block, which is nice and concise.

Carbon

If you can't use the Cocoa API for some reason, you could give the old Carbon API a go. Documentation is scarce as Carbon is considered legacy technology by Apple. But it's not hard to come up with something similar to this:

/**
 * The callback function
 */
pascal OSStatus KeyEventHandler(EventHandlerCallRef  nextHandler,
                                EventRef             theEvent,
                                void*                userData) {
    
    // here goes the logging
    return CallNextEventHandler(nextHandler, theEvent);
    
}

/** --------------snip ------------------**/

    EventTypeSpec eventType;
    EventHandlerUPP handlerUPP;
    
    eventType.eventClass = kEventClassKeyboard;
    eventType.eventKind = kEventRawKeyDown;
    handlerUPP = NewEventHandlerUPP(KeyEventHandler);
    InstallApplicationEventHandler(handlerUPP,
                                   1, 
                                   &eventType,
                                   NULL,
                                   NULL);

There is one small catch though: it won't work from Cocoa apps, because the key events will never reach the Carbon event monitor. While I haven't quite figured out why it does not work, my suspicion is that you have to have a Carbon event loop running in order to listen for the raw key events. While there is some bridging code in [NSApplication sendEvent:] calling Carbon's SendEventToEventTarget, it does so only for hotkey events and not for every raw key event.

Quartz

But all is not lost because there are still Quartz Event Taps to help you out. This API was originally designed to support the implementation of assistive devices. That's why the user has to enable the accessibility features in System Preferences for you to be able to install a Quartz Event Tap (or the process must run with root privileges).

The API is similar in structure to the Carbon version. You need a callback function:

CGEventRef keyDownCallback (CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
    /* 
     * do something with the event here
     *  turn Xs into Us if you want ...
     */
    return event;
}
Being a low level API, it gives you much more control about where you place your tap: you can get at the events as they enter the window server, the login session or when they are annotated to go to your application.
    CFMachPortRef keyDownEventTap = CGEventTapCreate( kCGHIDEventTap,
                                                      kCGHeadInsertEventTap,
                                                      kCGEventTapOptionListenOnly,
                                                    CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventKeyDown),
                                                      &keyDownCallback,NULL);

    CFRunLoopSourceRef keyUpRunLoopSourceRef = CFMachPortCreateRunLoopSource(NULL,
                                                                    keyDownEventTap,
                                                                    0);
    CFRelease(keyDownEventTap);
    CFRunLoopAddSource(CFRunLoopGetCurrent(),
                       keyUpRunLoopSourceRef,
                       kCFRunLoopDefaultMode);

    CFRelease(keyUpRunLoopSourceRef);

It is also well worth noting that the method shown above creates a global event tap that listens to all events not just the ones going to your application. There is

CFMachPortRef CGEventTapCreateForPSN(void *processSerialNumber,
  CGEventTapPlacement place, CGEventTapOptions options,
  CGEventMask eventsOfInterest, CGEventTapCallBack callback,
  void *userInfo)
to tap into the events targeted at a specific application.

No comments: