|
// 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() |
|
} |