Initial OpenScreen import
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Bump Nix package on release / bump (release) Has been cancelled
Update Homebrew Cask / update-cask (release) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled
Bump Nix package on release / bump (release) Has been cancelled
Update Homebrew Cask / update-cask (release) Has been cancelled
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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 == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
||||
reenableTap()
|
||||
return
|
||||
}
|
||||
if type == .leftMouseDown {
|
||||
leftDownCount += 1
|
||||
} else if type == .leftMouseUp {
|
||||
leftUpCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func reenableTap() {
|
||||
if let eventTap {
|
||||
CGEvent.tapEnable(tap: eventTap, enable: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 primaryHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
|
||||
return CGPoint(x: mouse.x, y: primaryHeight - 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)
|
||||
}
|
||||
Reference in New Issue
Block a user