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
otherMouseDown, button 2, at the cursor positionotherMouseDown(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: the contact count rises to exactly N, no touch moves more than a few percent of the pad, no button event occurs, and the count returns to zero within about 300 ms. Then post a synthetic
otherMouseDownandotherMouseUpat the cursor viaCGEvent.post. - Click: a
leftMouseDownarrives while the contact count is N. The tap callback changes the event's type tootherMouseDownand sets its button-number field to 2 before returning it. SubsequentleftMouseDraggedandleftMouseUpevents are rewritten the same way until the button is released, which is what makes middle-drag work.
// 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
- Ordering. On a fast three-finger press the button event can arrive one frame before the gesture frame that shows three contacts. Accepting a click that lands within 200 ms of the last three-contact frame fixed Click mode.
- Tap timeouts. If a tap callback is slow, WindowServer disables the tap and sends
tapDisabledByTimeout. Re-enable it in the callback and keep the callback trivial. - Resting fingers. People rest a thumb. Counting only touches whose phase isn't ended, and requiring the count to return to zero, avoids most false taps.
- Permission caching. macOS keys the Accessibility grant to the app's code signature. Rebuilding with a different signature silently invalidates it while the app still appears in the list. During development, sign every build with the same Apple-issued certificate.