Macos – Universal Back/Forward mouse buttons in OSX instead of M4/M5

logitech-mousemacosmousenavigation

I'm stumped by the behavior of the two side buttons on my Logitech MX Master mouse. On all my other mice, the side buttons are detected as generic "button 4" and "button 5". (I verified this using Xcode.) OS X, in contrast to Windows, seems to treat these commands as middle clicks, so in order to get the back/forward behavior you might expect, you need to use a tool like USB Overdrive to map them to +[ and +]. Unfortunately, this workaround doesn't work in every app and blinks the menu bar when you trigger it.

Meanwhile, the side buttons on the Master don't get detected by my Xcode mouse tap at all, but somehow they work in practically any app with a nav bar. I've tested them in Finder, Safari, System Preferences, and Xcode. There's no menu blink and the mouse cursor has to be over area of the window controlled by the nav bar, which implies that there's some sort of universal back/forward event being sent (as opposed to the usual M4/M5). However, I can't find documentation of such an event existing in OS X. Most M4/M5 fixes involve mapping those buttons to +[ and +].

So what's the Master doing with those side buttons, and how can I replicate the same behavior on all my other mice?

Best Answer

I added a tap to all my NSWindow events. Turns out... the Master is simulating swipe events!

NSEvent: type=Swipe loc=(252,60) time=5443.9 flags=0x100 win=0x100b091f0 winNum=2014 ctxt=0x0 phase: 1 axis:0 amount=0.000 velocity={0, 0}
NSEvent: type=Swipe loc=(252,60) time=5443.9 flags=0x100 win=0x100b091f0 winNum=2014 ctxt=0x0 phase: 8 axis:0 amount=0.000 velocity={0, 0}
NSEvent: type=Swipe loc=(252,60) time=5445.7 flags=0x100 win=0x100b091f0 winNum=2014 ctxt=0x0 phase: 1 axis:0 amount=0.000 velocity={0, 0}
NSEvent: type=Swipe loc=(252,60) time=5445.7 flags=0x100 win=0x100b091f0 winNum=2014 ctxt=0x0 phase: 8 axis:0 amount=0.000 velocity={0, 0}

OK, that's pretty clever, since it basically means that it'll work in any view that supports the swipeWithEvent: selector. I have no idea why this isn't the default behavior for the side buttons! Now I have to figure out how to add this functionality to my other mice. I don't think USB Overdrive can do something like this... unless AppleScript has a way to simulate gestures.

UPDATE: I have managed to replicate these events using natevw's reverse-engineered gesture simulation functions, https://github.com/calftrail/Touch. Might still need to be fixed up a bit, but it works! Final step will be to create an always-running app that eats M4 and M5 events and spits out these gestures.

TLInfoSwipeDirection dir = kTLInfoSwipeLeft;

NSDictionary* swipeInfo1 = [NSDictionary dictionaryWithObjectsAndKeys:
                            @(kTLInfoSubtypeSwipe), kTLInfoKeyGestureSubtype,
                            @(1), kTLInfoKeyGesturePhase,
                            nil];

NSDictionary* swipeInfo2 = [NSDictionary dictionaryWithObjectsAndKeys:
                            @(kTLInfoSubtypeSwipe), kTLInfoKeyGestureSubtype,
                            @(dir), kTLInfoKeySwipeDirection,
                            @(4), kTLInfoKeyGesturePhase,
                            nil];

CGEventRef event1 = tl_CGEventCreateFromGesture((__bridge CFDictionaryRef)(swipeInfo1), (__bridge CFArrayRef)@[]);
CGEventRef event2 = tl_CGEventCreateFromGesture((__bridge CFDictionaryRef)(swipeInfo2), (__bridge CFArrayRef)@[]);

CGEventPost(kCGHIDEventTap, event1);
CGEventPost(kCGHIDEventTap, event2);

// not sure if necessary under ARC
CFRelease(event1);
CFRelease(event2);

UPDATE 2: Here's a rough working sketch of a View Controller that globally captures M4 and M5 and emits swipes.

static void SBFFakeSwipe(TLInfoSwipeDirection dir) {
        NSDictionary* swipeInfo1 = [NSDictionary dictionaryWithObjectsAndKeys:
                                    @(kTLInfoSubtypeSwipe), kTLInfoKeyGestureSubtype,
                                    @(1), kTLInfoKeyGesturePhase,
                                    nil];

        NSDictionary* swipeInfo2 = [NSDictionary dictionaryWithObjectsAndKeys:
                                    @(kTLInfoSubtypeSwipe), kTLInfoKeyGestureSubtype,
                                    @(dir), kTLInfoKeySwipeDirection,
                                    @(4), kTLInfoKeyGesturePhase,
                                    nil];

        CGEventRef event1 = tl_CGEventCreateFromGesture((__bridge CFDictionaryRef)(swipeInfo1), (__bridge CFArrayRef)@[]);
        CGEventRef event2 = tl_CGEventCreateFromGesture((__bridge CFDictionaryRef)(swipeInfo2), (__bridge CFArrayRef)@[]);

        CGEventPost(kCGHIDEventTap, event1);
        CGEventPost(kCGHIDEventTap, event2);

        CFRelease(event1);
        CFRelease(event2);
}

static CGEventRef KeyDownCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
    int64_t number = CGEventGetIntegerValueField(event, kCGMouseEventButtonNumber);
    BOOL down = (CGEventGetType(event) == kCGEventOtherMouseDown);

    if (number == 3) {
        if (down) {
            SBFFakeSwipe(kTLInfoSwipeLeft);
        }

        return NULL;
    }
    else if (number == 4) {
        if (down) {
            SBFFakeSwipe(kTLInfoSwipeRight);
        }

        return NULL;
    }
    else {
        return event;
    }
}

@implementation ViewController

-(void) viewDidLoad {
    [super viewDidLoad];

    NSDictionary* options = @{ (__bridge id)kAXTrustedCheckOptionPrompt: @YES };
    BOOL accessibilityEnabled = AXIsProcessTrustedWithOptions((CFDictionaryRef)options);

    assert(accessibilityEnabled);

    CFMachPortRef eventTap = CGEventTapCreate(kCGHIDEventTap,
                                              kCGHeadInsertEventTap,
                                              kCGEventTapOptionDefault,
                                              CGEventMaskBit(kCGEventOtherMouseUp)|CGEventMaskBit(kCGEventOtherMouseDown),
                                              &KeyDownCallback,
                                              NULL);

    assert(eventTap != NULL);

    CFRunLoopSourceRef runLoopSource = CFMachPortCreateRunLoopSource(NULL, eventTap, 0);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes);
    CFRelease(runLoopSource);

    CGEventTapEnable(eventTap, true);

    //CFRelease(eventTap); -- needs to be done on dealloc, I think
}

@end

UPDATE 3: I've released an open-source menu bar app that replicates the Master's behavior for all third-party mice. It's called SensibleSideButtons. The technical details are described on the website.

Related Question