259 lines
6.2 KiB
Swift
259 lines
6.2 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import Foundation
|
|
|
|
struct CursorHelperRequest: Decodable {
|
|
let sampleIntervalMs: Int?
|
|
}
|
|
|
|
final class MouseButtonTracker {
|
|
private let lock = NSLock()
|
|
private var leftDownCount = 0
|
|
private var leftUpCount = 0
|
|
private var eventTap: CFMachPort?
|
|
private var runLoopSource: CFRunLoopSource?
|
|
|
|
struct Events {
|
|
let leftDownCount: Int
|
|
let leftUpCount: Int
|
|
}
|
|
|
|
func start() -> Bool {
|
|
let mask =
|
|
(1 << CGEventType.leftMouseDown.rawValue) |
|
|
(1 << CGEventType.leftMouseUp.rawValue)
|
|
guard let tap = CGEvent.tapCreate(
|
|
tap: .cgSessionEventTap,
|
|
place: .headInsertEventTap,
|
|
options: .listenOnly,
|
|
eventsOfInterest: CGEventMask(mask),
|
|
callback: { _, type, event, userInfo in
|
|
if let userInfo {
|
|
let tracker = Unmanaged<MouseButtonTracker>.fromOpaque(userInfo).takeUnretainedValue()
|
|
tracker.record(type)
|
|
}
|
|
return Unmanaged.passUnretained(event)
|
|
},
|
|
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
|
|
) else {
|
|
return false
|
|
}
|
|
|
|
guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else {
|
|
return false
|
|
}
|
|
|
|
eventTap = tap
|
|
runLoopSource = source
|
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
CGEvent.tapEnable(tap: tap, enable: true)
|
|
return true
|
|
}
|
|
|
|
func pump() {
|
|
CFRunLoopRunInMode(.defaultMode, 0.001, false)
|
|
}
|
|
|
|
func consume() -> Events {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
let events = Events(leftDownCount: leftDownCount, leftUpCount: leftUpCount)
|
|
leftDownCount = 0
|
|
leftUpCount = 0
|
|
return events
|
|
}
|
|
|
|
private func record(_ type: CGEventType) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if type == .leftMouseDown {
|
|
leftDownCount += 1
|
|
} else if type == .leftMouseUp {
|
|
leftUpCount += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func emit(_ fields: [String: Any?]) {
|
|
let compacted = fields.compactMapValues { $0 }
|
|
if let data = try? JSONSerialization.data(withJSONObject: compacted, options: []),
|
|
let line = String(data: data, encoding: .utf8)
|
|
{
|
|
print(line)
|
|
fflush(stdout)
|
|
}
|
|
}
|
|
|
|
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
|
var value: CFTypeRef?
|
|
let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
|
|
guard result == .success else {
|
|
return nil
|
|
}
|
|
|
|
return value as? String
|
|
}
|
|
|
|
func parentElement(_ element: AXUIElement) -> AXUIElement? {
|
|
var value: CFTypeRef?
|
|
let result = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value)
|
|
guard result == .success else {
|
|
return nil
|
|
}
|
|
|
|
guard CFGetTypeID(value) == AXUIElementGetTypeID() else {
|
|
return nil
|
|
}
|
|
|
|
return (value as! AXUIElement)
|
|
}
|
|
|
|
func roleDescription(_ element: AXUIElement) -> String? {
|
|
var value: CFTypeRef?
|
|
let result = AXUIElementCopyAttributeValue(element, kAXRoleDescriptionAttribute as CFString, &value)
|
|
guard result == .success else {
|
|
return nil
|
|
}
|
|
|
|
return value as? String
|
|
}
|
|
|
|
func actionNames(_ element: AXUIElement) -> [String] {
|
|
var value: CFArray?
|
|
let result = AXUIElementCopyActionNames(element, &value)
|
|
guard result == .success, let value else {
|
|
return []
|
|
}
|
|
|
|
return (value as NSArray).compactMap { $0 as? String }
|
|
}
|
|
func isTextInputRole(_ role: String?) -> Bool {
|
|
role == "AXTextField" ||
|
|
role == "AXTextArea" ||
|
|
role == "AXTextView" ||
|
|
role == "AXComboBox"
|
|
}
|
|
|
|
func isPointerRole(_ role: String?, _ subrole: String?, _ description: String?) -> Bool {
|
|
if role == "AXLink" ||
|
|
subrole?.localizedCaseInsensitiveContains("link") == true ||
|
|
description?.contains("link") == true
|
|
{
|
|
return true
|
|
}
|
|
|
|
return role == "AXButton" ||
|
|
role == "AXMenuButton" ||
|
|
role == "AXPopUpButton" ||
|
|
role == "AXCheckBox" ||
|
|
role == "AXRadioButton" ||
|
|
role == "AXSwitch" ||
|
|
role == "AXDisclosureTriangle" ||
|
|
role == "AXTab" ||
|
|
role == "AXMenuItem"
|
|
}
|
|
|
|
func cursorTypeForElement(_ element: AXUIElement) -> String? {
|
|
var current: AXUIElement? = element
|
|
|
|
for _ in 0..<5 {
|
|
guard let element = current else {
|
|
break
|
|
}
|
|
|
|
let role = stringAttribute(element, kAXRoleAttribute)
|
|
let subrole = stringAttribute(element, kAXSubroleAttribute)
|
|
let description = roleDescription(element)?.lowercased()
|
|
|
|
if isTextInputRole(role) {
|
|
return "text"
|
|
}
|
|
|
|
if isPointerRole(role, subrole, description) {
|
|
return "pointer"
|
|
}
|
|
|
|
current = parentElement(element)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func accessibilityPointForMouse() -> CGPoint {
|
|
let mouse = NSEvent.mouseLocation
|
|
let maxY = NSScreen.screens.map { $0.frame.maxY }.max() ?? NSScreen.main?.frame.height ?? 0
|
|
return CGPoint(x: mouse.x, y: maxY - mouse.y)
|
|
}
|
|
|
|
func currentCursorType() -> String? {
|
|
guard AXIsProcessTrusted() else {
|
|
return nil
|
|
}
|
|
|
|
let point = accessibilityPointForMouse()
|
|
let systemWide = AXUIElementCreateSystemWide()
|
|
var element: AXUIElement?
|
|
let result = AXUIElementCopyElementAtPosition(
|
|
systemWide,
|
|
Float(point.x),
|
|
Float(point.y),
|
|
&element
|
|
)
|
|
|
|
guard result == .success, let element else {
|
|
return "arrow"
|
|
}
|
|
|
|
return cursorTypeForElement(element) ?? "arrow"
|
|
}
|
|
|
|
func timestampMs() -> Int {
|
|
Int(Date().timeIntervalSince1970 * 1000)
|
|
}
|
|
|
|
func leftButtonDown() -> Bool {
|
|
CGEventSource.buttonState(.hidSystemState, button: .left)
|
|
}
|
|
|
|
func requestAccessibilityTrust() -> Bool {
|
|
let options = [
|
|
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true
|
|
] as CFDictionary
|
|
return AXIsProcessTrustedWithOptions(options)
|
|
}
|
|
|
|
let request: CursorHelperRequest
|
|
if CommandLine.arguments.count >= 2,
|
|
let data = CommandLine.arguments[1].data(using: .utf8),
|
|
let decoded = try? JSONDecoder().decode(CursorHelperRequest.self, from: data)
|
|
{
|
|
request = decoded
|
|
} else {
|
|
request = CursorHelperRequest(sampleIntervalMs: nil)
|
|
}
|
|
|
|
let intervalMs = max(8, request.sampleIntervalMs ?? 33)
|
|
let accessibilityTrusted = requestAccessibilityTrust()
|
|
let mouseTracker = MouseButtonTracker()
|
|
let mouseTapReady = mouseTracker.start()
|
|
emit([
|
|
"type": "ready",
|
|
"timestampMs": timestampMs(),
|
|
"accessibilityTrusted": accessibilityTrusted,
|
|
"mouseTapReady": mouseTapReady,
|
|
])
|
|
|
|
while true {
|
|
mouseTracker.pump()
|
|
let mouseEvents = mouseTracker.consume()
|
|
emit([
|
|
"type": "sample",
|
|
"timestampMs": timestampMs(),
|
|
"cursorType": currentCursorType(),
|
|
"leftButtonDown": leftButtonDown(),
|
|
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
|
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
|
])
|
|
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
|
}
|