How middle click actually works on macOS

This is the mechanism behind The Mid Click, written down because the author had to work it out from scratch. It's useful if you're building something similar, debugging why a middle click doesn't arrive, or just curious.

Button numbers

Core Graphics numbers mouse buttons from zero: left is 0, right is 1, middle is 2. AppKit delivers them as three distinct event types: leftMouseDown, rightMouseDown, and otherMouseDown with buttonNumber == 2. "Button 3" in X11 and older documentation is the same physical button; the off-by-one is historical. Anything above 2 is a side or extra button, also delivered as otherMouseDown.

The normal path, with a mouse

USB or Bluetooth HID report: button 2 pressed
IOHIDSystem turns it into a low-level event
WindowServer creates a CGEvent of type otherMouseDown, button 2, at the cursor position
Event taps registered by other processes see it here, in order of insertion
The frontmost app's run loop receives it and AppKit wraps it as an NSEvent
The view under the cursor gets otherMouseDown(with:)

What a trackpad sends instead

A trackpad reports two different streams. One is ordinary pointer movement and a single physical button (the whole pad is the button), so a press with any number of fingers arrives as leftMouseDown. The other is a gesture stream: a frame several times a second describing every finger in contact, with position, size and phase. Apple's frameworks use the gesture stream for pinch, rotate, swipe and Force Touch.

Those gesture frames are visible to other processes too. They pass through the same event tap mechanism as mouse events, as CGEvents of type 29 (NSEvent.EventType.gesture). Converting one to an NSEvent and calling allTouches() yields the contact list: each NSTouch has an identity, a normalized position on the pad, and a phase (began, moved, stationary, ended). That is the documented, public way to see finger count without touching Apple's private MultitouchSupport framework, which is what older utilities used and what tends to break between OS versions.

// Tap gesture + left-button events system-wide. Requires Accessibility trust.
var mask: CGEventMask = NSEvent.EventTypeMask.gesture.rawValue          // 1 << 29
for t in [CGEventType.leftMouseDown, .leftMouseUp, .leftMouseDragged] {
    mask |= CGEventMask(1) << CGEventMask(t.rawValue)
}
let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
                            options: .defaultTap, eventsOfInterest: mask,
                            callback: callback, userInfo: nil)

// Inside the callback, for a gesture event:
let touches = NSEvent(cgEvent: event)?.allTouches() ?? []
let fingers = touches.filter { $0.phase != .ended && $0.phase != .cancelled }.count

Turning a gesture into button 2

With both streams in hand, two recognisers are enough:

// Tap mode: post a middle click at the cursor.
let loc = CGEvent(source: nil)!.location
let src = CGEventSource(stateID: .hidSystemState)
for type in [CGEventType.otherMouseDown, .otherMouseUp] {
    let e = CGEvent(mouseEventSource: src, mouseType: type,
                    mouseCursorPosition: loc, mouseButton: .center)!
    e.post(tap: .cghidEventTap)
}

// Click mode: rewrite the left click in flight, inside the tap callback.
event.type = .otherMouseDown                                   // later: .otherMouseDragged / .otherMouseUp
event.setIntegerValueField(.mouseEventButtonNumber, value: 2)  // 2 = middle
return Unmanaged.passUnretained(event)

Rewriting in place is cleaner than swallowing the left click and posting a new one: the event keeps its original timestamp, position, and click count, and apps can't tell it from a real middle button. The relevant Apple documentation: CGEvent (tap creation, posting, value fields), CGEventType, NSEvent and NSTouch.

Where permissions come in

An event tap that can modify events, and posting any event, both require the process to be trusted in Accessibility. A listen-only tap needs the lesser Input Monitoring permission, but that doesn't cover posting, so a middle-click utility ends up needing Accessibility regardless. Here's what that means in practice.

Gotchas we hit