Skip to content

Instantly share code, notes, and snippets.

@rg3915
Last active October 9, 2026 05:46
Show Gist options
  • Select an option

  • Save rg3915/c7eee4580494372d310ddbb6ae405f3b to your computer and use it in GitHub Desktop.

Select an option

Save rg3915/c7eee4580494372d310ddbb6ae405f3b to your computer and use it in GitHub Desktop.
MiddlePaste: Linux-style middle-click paste (primary selection) for macOS

MiddlePaste

Linux-style primary selection for macOS: select text with the mouse and paste it with a middle click, without touching the regular clipboard.

  • Select by dragging, double/triple clicking or Shift+clicking. The text is stored right away.
  • Middle click pastes it where the pointer is, moving the caret there first, as on Linux.
  • Cmd+C / Cmd+V keep working as usual; the selection lives in a separate buffer.

It is a single Swift file that runs as a menu bar app (mouse icon). The menu shows the stored selection and has Enabled (toggle) and Quit. No Xcode project and no dependencies.

Install

Requires macOS 13+ and the Xcode Command Line Tools (xcode-select --install).

./build.sh
open ~/Applications/MiddlePaste.app

On first launch, grant access in System Settings > Privacy & Security > Accessibility. The menu bar icon changes from a warning triangle to a mouse once the permission is in place.

The build is ad-hoc signed, so macOS asks for the permission again after every rebuild. tccutil reset Accessibility com.rg3915.middlepaste clears the stale entry.

Start at login

cp com.rg3915.middlepaste.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.rg3915.middlepaste.plist

To undo it, run launchctl bootout with the same arguments and delete the file.

How it works

  • An event tap watches left and middle mouse buttons.
  • After a selection gesture, the selected text is read from the focused app through the Accessibility API (AXSelectedText, or text markers for web content).
  • Apps that do not expose their selection get a synthetic Cmd+C instead; the text is read and the previous clipboard is restored.
  • A middle click pastes only when the pointer is over a text field. Over links, tabs and buttons it passes through, so "open in new tab" and "close tab" keep working.
  • To paste, the app left-clicks at the pointer, puts the selection on the clipboard (flagged as transient for clipboard managers), sends Cmd+V and restores the clipboard.
  • Password fields are ignored.

Limitations

  • Apps in the alwaysPaste list (Sublime Text, Ghostty, Terminal, iTerm2, Zed, VS Code) do not report what is under the pointer, so a middle click always pastes there, including on their tab bars. Edit the list in MiddlePaste.swift to taste.
  • Selections made with the keyboard are not captured.
  • The Cmd+C fallback uses the physical C and V key positions of a QWERTY layout.
  • Tested end to end only in TextEdit so far. The Cmd+C fallback and the web content path have not been exercised yet.

Debugging

~/Applications/MiddlePaste.app/Contents/MacOS/MiddlePaste --check   # is Accessibility granted?
~/Applications/MiddlePaste.app/Contents/MacOS/MiddlePaste --sel 3   # wait 3 s, print the focused app's selection
#!/bin/bash
# Builds MiddlePaste and assembles the .app bundle in ~/Applications.
set -euo pipefail
cd "$(dirname "$0")"
APP="$HOME/Applications/MiddlePaste.app"
mkdir -p "$APP/Contents/MacOS"
swiftc -O -swift-version 5 MiddlePaste.swift \
-o "$APP/Contents/MacOS/MiddlePaste"
cat > "$APP/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>MiddlePaste</string>
<key>CFBundleIdentifier</key><string>com.rg3915.middlepaste</string>
<key>CFBundleExecutable</key><string>MiddlePaste</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>LSMinimumSystemVersion</key><string>13.0</string>
<key>LSUIElement</key><true/>
</dict>
</plist>
PLIST
codesign --force --sign - "$APP" >/dev/null 2>&1 || true
echo "$APP"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.rg3915.middlepaste</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>MiddlePaste</string>
</array>
<key>RunAtLoad</key><true/>
</dict>
</plist>
// Linux-style primary selection for macOS: selecting text stores it, and a
// middle click pastes it wherever the pointer is. The regular clipboard
// (Cmd+C / Cmd+V) is left untouched.
import AppKit
import ApplicationServices
/// Tags the events posted from here, so the tap does not react to them.
let syntheticTag: Int64 = 0x4D505354
/// Apps without a useful accessibility tree: a middle click always pastes there.
let alwaysPaste: Set<String> = [
"com.sublimetext.4", "com.sublimetext.3", "com.mitchellh.ghostty",
"com.apple.Terminal", "com.googlecode.iterm2", "dev.zed.Zed",
"com.microsoft.VSCode",
]
/// Apps where the Cmd+C fallback makes no sense (it would copy files).
let neverKeyboardCopy: Set<String> = ["com.apple.finder"]
let editableRoles: Set<String> = ["AXTextField", "AXTextArea", "AXComboBox"]
let clickableRoles: Set<String> = [
"AXLink", "AXButton", "AXTabGroup", "AXRadioButton", "AXMenuItem", "AXImage",
]
let systemWide = AXUIElementCreateSystemWide()
let work = DispatchQueue(label: "middle-paste.work")
let transientType = NSPasteboard.PasteboardType("org.nspasteboard.TransientType")
let lock = NSLock()
var _primary = ""
var primary: String {
get { lock.lock(); defer { lock.unlock() }; return _primary }
set { lock.lock(); _primary = newValue; lock.unlock() }
}
var enabled = true
var downPoint = CGPoint.zero
var dragged = false
var swallowMiddleUp = false
var tap: CFMachPort?
// MARK: - Accessibility
func axString(_ el: AXUIElement, _ attr: String) -> String? {
var v: CFTypeRef?
guard AXUIElementCopyAttributeValue(el, attr as CFString, &v) == .success else { return nil }
return v as? String
}
func bundleID(of el: AXUIElement) -> String? {
var pid: pid_t = 0
guard AXUIElementGetPid(el, &pid) == .success else { return nil }
return NSRunningApplication(processIdentifier: pid)?.bundleIdentifier
}
enum Selection {
case text(String)
case empty // the app answers, but nothing is selected
case unsupported // the app does not expose its selection
}
func axSelection() -> Selection {
var v: CFTypeRef?
guard AXUIElementCopyAttributeValue(
systemWide, kAXFocusedUIElementAttribute as CFString, &v) == .success,
let focused = v, CFGetTypeID(focused) == AXUIElementGetTypeID()
else { return .unsupported }
let el = focused as! AXUIElement
if axString(el, kAXSubroleAttribute) == "AXSecureTextField" { return .empty }
if let s = axString(el, kAXSelectedTextAttribute) {
return s.isEmpty ? .empty : .text(s)
}
// Web content (Chrome, Safari) exposes the selection through text markers.
var range: CFTypeRef?
if AXUIElementCopyAttributeValue(
el, "AXSelectedTextMarkerRange" as CFString, &range) == .success, let range {
var s: CFTypeRef?
if AXUIElementCopyParameterizedAttributeValue(
el, "AXStringForTextMarkerRange" as CFString, range, &s) == .success,
let s = s as? String {
return s.isEmpty ? .empty : .text(s)
}
}
return .unsupported
}
/// A middle click only pastes into text fields; on links, tabs etc. it passes through.
func isEditable(at p: CGPoint) -> Bool {
var hit: AXUIElement?
guard AXUIElementCopyElementAtPosition(systemWide, Float(p.x), Float(p.y), &hit) == .success,
var el = hit
else {
let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? ""
return alwaysPaste.contains(front)
}
if let id = bundleID(of: el), alwaysPaste.contains(id) { return true }
for _ in 0..<6 {
let role = axString(el, kAXRoleAttribute) ?? ""
if editableRoles.contains(role) {
return axString(el, kAXSubroleAttribute) != "AXSecureTextField"
}
if clickableRoles.contains(role) { return false }
var parent: CFTypeRef?
guard AXUIElementCopyAttributeValue(
el, kAXParentAttribute as CFString, &parent) == .success,
let parent, CFGetTypeID(parent) == AXUIElementGetTypeID()
else { break }
el = parent as! AXUIElement
}
return false
}
// MARK: - Clipboard and synthetic events
typealias Snapshot = [[NSPasteboard.PasteboardType: Data]]
func snapshot(_ pb: NSPasteboard) -> Snapshot {
(pb.pasteboardItems ?? []).map { item in
var copy: [NSPasteboard.PasteboardType: Data] = [:]
for type in item.types { copy[type] = item.data(forType: type) }
return copy
}
}
func restore(_ pb: NSPasteboard, _ saved: Snapshot) {
pb.clearContents()
let items = saved.map { entry -> NSPasteboardItem in
let item = NSPasteboardItem()
for (type, data) in entry { item.setData(data, forType: type) }
return item
}
if !items.isEmpty { pb.writeObjects(items) }
}
func post(_ event: CGEvent?) {
event?.setIntegerValueField(.eventSourceUserData, value: syntheticTag)
event?.post(tap: .cghidEventTap)
}
func pressCommand(_ key: CGKeyCode) {
for down in [true, false] {
let e = CGEvent(keyboardEventSource: nil, virtualKey: key, keyDown: down)
e?.flags = .maskCommand
post(e)
}
}
func leftClick(at p: CGPoint) {
for type in [CGEventType.leftMouseDown, .leftMouseUp] {
post(CGEvent(mouseEventSource: nil, mouseType: type,
mouseCursorPosition: p, mouseButton: .left))
}
}
/// Fallback for apps without accessibility: Cmd+C, then put the clipboard back.
func keyboardCopy() -> String? {
let pb = NSPasteboard.general
let saved = snapshot(pb)
let before = pb.changeCount
pressCommand(8) // C
for _ in 0..<30 where pb.changeCount == before { usleep(10_000) }
guard pb.changeCount != before else { return nil }
let text = pb.string(forType: .string)
restore(pb, saved)
return text
}
func captureSelection() {
switch axSelection() {
case .text(let s):
primary = s
case .empty:
break
case .unsupported:
let front = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? ""
if neverKeyboardCopy.contains(front) || front == Bundle.main.bundleIdentifier { return }
if let s = keyboardCopy(), !s.isEmpty { primary = s }
}
}
func paste(at p: CGPoint) {
let text = primary
// As on Linux: the click moves the caret to where the pointer is.
leftClick(at: p)
usleep(80_000)
let pb = NSPasteboard.general
let saved = snapshot(pb)
pb.clearContents()
pb.setString(text, forType: .string)
pb.setData(Data(), forType: transientType)
pressCommand(9) // V
usleep(300_000)
restore(pb, saved)
}
// MARK: - Event tap
func handle(_ type: CGEventType, _ event: CGEvent) -> Unmanaged<CGEvent>? {
let pass = Unmanaged.passUnretained(event)
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
if let tap { CGEvent.tapEnable(tap: tap, enable: true) }
return pass
}
guard enabled, event.getIntegerValueField(.eventSourceUserData) != syntheticTag
else { return pass }
let p = event.location
switch type {
case .leftMouseDown:
downPoint = p
dragged = false
case .leftMouseDragged:
if hypot(p.x - downPoint.x, p.y - downPoint.y) > 4 { dragged = true }
case .leftMouseUp:
let clicks = event.getIntegerValueField(.mouseEventClickState)
if dragged || clicks >= 2 || event.flags.contains(.maskShift) {
work.asyncAfter(deadline: .now() + 0.12) { captureSelection() }
}
case .otherMouseDown where event.getIntegerValueField(.mouseEventButtonNumber) == 2:
if !primary.isEmpty, isEditable(at: p) {
swallowMiddleUp = true
work.async { paste(at: p) }
return nil
}
case .otherMouseUp where event.getIntegerValueField(.mouseEventButtonNumber) == 2:
if swallowMiddleUp {
swallowMiddleUp = false
return nil
}
default:
break
}
return pass
}
func startTap() -> Bool {
let types: [CGEventType] = [
.leftMouseDown, .leftMouseDragged, .leftMouseUp, .otherMouseDown, .otherMouseUp,
]
let mask = types.reduce(CGEventMask(0)) { $0 | (1 << CGEventMask($1.rawValue)) }
guard let port = CGEvent.tapCreate(
tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap,
eventsOfInterest: mask,
callback: { _, type, event, _ in handle(type, event) },
userInfo: nil)
else { return false }
tap = port
let source = CFMachPortCreateRunLoopSource(nil, port, 0)
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: port, enable: true)
return true
}
// MARK: - Menu bar
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let preview = NSMenuItem(title: "", action: nil, keyEquivalent: "")
let toggle = NSMenuItem(title: "Enabled", action: #selector(toggleEnabled), keyEquivalent: "")
var permissionTimer: Timer?
func applicationDidFinishLaunching(_ note: Notification) {
let menu = NSMenu()
menu.delegate = self
menu.addItem(preview)
menu.addItem(.separator())
toggle.target = self
menu.addItem(toggle)
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q"))
item.menu = menu
refresh()
let prompt = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
if AXIsProcessTrustedWithOptions(prompt as CFDictionary), startTap() {
refresh()
return
}
// No permission yet: wait for the user to grant it in System Settings.
permissionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] t in
if AXIsProcessTrusted(), startTap() {
t.invalidate()
self?.refresh()
}
}
}
func refresh() {
let name = tap == nil ? "exclamationmark.triangle"
: enabled ? "computermouse.fill" : "computermouse"
item.button?.image = NSImage(systemSymbolName: name, accessibilityDescription: "Middle-click paste")
toggle.state = enabled ? .on : .off
if tap == nil {
preview.title = "Grant access in System Settings > Privacy & Security > Accessibility"
return
}
let text = primary.replacingOccurrences(of: "\n", with: " ⏎ ")
preview.title = text.isEmpty ? "Nothing selected yet"
: "Selection: " + (text.count > 50 ? text.prefix(50) + "…" : text)
}
func menuWillOpen(_ menu: NSMenu) { refresh() }
@objc func toggleEnabled() {
enabled.toggle()
refresh()
}
}
// MARK: - Entry point
AXUIElementSetMessagingTimeout(systemWide, 0.3)
switch CommandLine.arguments.dropFirst().first {
case "--check":
print(AXIsProcessTrusted() ? "accessibility: granted" : "accessibility: denied")
case "--sel":
// Prints what would be captured from the focused app, without the menu bar.
sleep(UInt32(CommandLine.arguments.dropFirst(2).first ?? "") ?? 0)
print(axSelection())
default:
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment