mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
506 lines
14 KiB
Swift
506 lines
14 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import ApplicationServices
|
|
|
|
// Force AppKit initialization so cursor images are populated in CLI context
|
|
let _ = NSApplication.shared
|
|
|
|
func knownCursorCandidates() -> [(String, NSCursor)] {
|
|
var candidates: [(String, NSCursor)] = [
|
|
("arrow", .arrow),
|
|
("text", .iBeam),
|
|
("pointer", .pointingHand),
|
|
("pointer", .dragCopy),
|
|
("pointer", .dragLink),
|
|
("pointer", .contextualMenu),
|
|
("crosshair", .crosshair),
|
|
("open-hand", .openHand),
|
|
("closed-hand", .closedHand),
|
|
("resize-ew", .resizeLeft),
|
|
("resize-ew", .resizeRight),
|
|
("resize-ew", .resizeLeftRight),
|
|
("resize-ns", .resizeUp),
|
|
("resize-ns", .resizeDown),
|
|
("resize-ns", .resizeUpDown),
|
|
("not-allowed", .operationNotAllowed),
|
|
]
|
|
|
|
if #available(macOS 10.13, *) {
|
|
candidates.append(("text", .iBeamCursorForVerticalLayout))
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
let signatureAcceptanceThreshold = 12000
|
|
let relaxedSignatureAcceptanceThresholds: [String: Int] = [
|
|
"text": 28000,
|
|
"crosshair": 32000,
|
|
]
|
|
let strictSignatureAcceptanceThresholds: [String: Int] = [
|
|
"open-hand": 3200,
|
|
"closed-hand": 3200,
|
|
]
|
|
|
|
let systemWideElement = AXUIElementCreateSystemWide()
|
|
let totalScreenHeight = NSScreen.screens.reduce(CGFloat(0)) { max($0, $1.frame.maxY) }
|
|
let axEditableAttribute = "AXEditable"
|
|
let axLinkRole = "AXLink"
|
|
|
|
struct CursorSignature {
|
|
let aspectRatio: Double
|
|
let hotspotXRatio: Double
|
|
let hotspotYRatio: Double
|
|
let shapeSamples: [UInt8]
|
|
}
|
|
|
|
func signature(for cursor: NSCursor, sampleSize: Int = 32) -> CursorSignature? {
|
|
let image = cursor.image
|
|
let sourceSize = image.size
|
|
guard sourceSize.width > 0, sourceSize.height > 0 else {
|
|
return nil
|
|
}
|
|
|
|
guard let bitmap = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: sampleSize,
|
|
pixelsHigh: sampleSize,
|
|
bitsPerSample: 8,
|
|
samplesPerPixel: 4,
|
|
hasAlpha: true,
|
|
isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0,
|
|
bitsPerPixel: 0
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
bitmap.size = NSSize(width: sampleSize, height: sampleSize)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
return nil
|
|
}
|
|
|
|
NSGraphicsContext.current = context
|
|
context.imageInterpolation = .high
|
|
|
|
let scale = min(CGFloat(sampleSize) / sourceSize.width, CGFloat(sampleSize) / sourceSize.height)
|
|
let drawWidth = sourceSize.width * scale
|
|
let drawHeight = sourceSize.height * scale
|
|
let drawRect = NSRect(
|
|
x: (CGFloat(sampleSize) - drawWidth) / 2,
|
|
y: (CGFloat(sampleSize) - drawHeight) / 2,
|
|
width: drawWidth,
|
|
height: drawHeight
|
|
)
|
|
|
|
image.draw(in: drawRect, from: .zero, operation: .copy, fraction: 1)
|
|
context.flushGraphics()
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
guard let data = bitmap.bitmapData else {
|
|
return nil
|
|
}
|
|
|
|
var alphaSamples: [UInt8] = []
|
|
alphaSamples.reserveCapacity(sampleSize * sampleSize)
|
|
let bytesPerRow = bitmap.bytesPerRow
|
|
|
|
for y in 0..<sampleSize {
|
|
for x in 0..<sampleSize {
|
|
let offset = y * bytesPerRow + x * 4
|
|
let alpha = data[offset + 3]
|
|
alphaSamples.append(alpha > 24 ? 255 : 0)
|
|
}
|
|
}
|
|
|
|
let hotspot = cursor.hotSpot
|
|
return CursorSignature(
|
|
aspectRatio: Double(sourceSize.width / max(1, sourceSize.height)),
|
|
hotspotXRatio: Double(hotspot.x / max(1, sourceSize.width)),
|
|
hotspotYRatio: Double(hotspot.y / max(1, sourceSize.height)),
|
|
shapeSamples: alphaSamples
|
|
)
|
|
}
|
|
|
|
func signatureScore(_ lhs: CursorSignature, _ rhs: CursorSignature) -> Int {
|
|
let count = min(lhs.shapeSamples.count, rhs.shapeSamples.count)
|
|
var imageDifference = 0
|
|
for index in 0..<count {
|
|
imageDifference += abs(Int(lhs.shapeSamples[index]) - Int(rhs.shapeSamples[index]))
|
|
}
|
|
|
|
let aspectPenalty = Int(abs(lhs.aspectRatio - rhs.aspectRatio) * 1800)
|
|
let hotspotPenalty = Int((abs(lhs.hotspotXRatio - rhs.hotspotXRatio) + abs(lhs.hotspotYRatio - rhs.hotspotYRatio)) * 2200)
|
|
return imageDifference + aspectPenalty + hotspotPenalty
|
|
}
|
|
|
|
let knownCursorSignatures: [(String, CursorSignature)] = knownCursorCandidates().compactMap { entry in
|
|
guard let cursorSignature = signature(for: entry.1) else {
|
|
return nil
|
|
}
|
|
|
|
return (entry.0, cursorSignature)
|
|
}
|
|
|
|
func attributeString(_ element: AXUIElement, _ attribute: String) -> String? {
|
|
var value: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
|
|
guard error == .success else {
|
|
return nil
|
|
}
|
|
|
|
return value as? String
|
|
}
|
|
|
|
func attributeBool(_ element: AXUIElement, _ attribute: String) -> Bool? {
|
|
var value: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
|
|
guard error == .success else {
|
|
return nil
|
|
}
|
|
|
|
return value as? Bool
|
|
}
|
|
|
|
func actionNames(_ element: AXUIElement) -> [String] {
|
|
var names: CFArray?
|
|
let error = AXUIElementCopyActionNames(element, &names)
|
|
guard error == .success, let actions = names as? [String] else {
|
|
return []
|
|
}
|
|
|
|
return actions
|
|
}
|
|
|
|
func currentElement() -> AXUIElement? {
|
|
guard let location = CGEvent(source: nil)?.location else {
|
|
return nil
|
|
}
|
|
|
|
var element: AXUIElement?
|
|
let y = totalScreenHeight > 0 ? totalScreenHeight - location.y : location.y
|
|
let error = AXUIElementCopyElementAtPosition(systemWideElement, Float(location.x), Float(y), &element)
|
|
guard error == .success else {
|
|
return nil
|
|
}
|
|
|
|
return element
|
|
}
|
|
|
|
func focusedElement() -> AXUIElement? {
|
|
var value: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &value)
|
|
guard error == .success, let value else {
|
|
return nil
|
|
}
|
|
|
|
return unsafeBitCast(value, to: AXUIElement.self)
|
|
}
|
|
|
|
func parentElement(of element: AXUIElement) -> AXUIElement? {
|
|
var value: CFTypeRef?
|
|
let error = AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value)
|
|
guard error == .success, let value else {
|
|
return nil
|
|
}
|
|
|
|
return unsafeBitCast(value, to: AXUIElement.self)
|
|
}
|
|
|
|
func hasAttribute(_ element: AXUIElement, _ attribute: String) -> Bool {
|
|
var value: CFTypeRef?
|
|
return AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success
|
|
}
|
|
|
|
func ancestorChain(startingAt element: AXUIElement?, maxDepth: Int = 4) -> [AXUIElement] {
|
|
guard let element else {
|
|
return []
|
|
}
|
|
|
|
var elements: [AXUIElement] = [element]
|
|
var current = element
|
|
var depth = 0
|
|
|
|
while depth < maxDepth, let parent = parentElement(of: current) {
|
|
elements.append(parent)
|
|
current = parent
|
|
depth += 1
|
|
}
|
|
|
|
return elements
|
|
}
|
|
|
|
func metadataString(for element: AXUIElement) -> String {
|
|
return [
|
|
attributeString(element, kAXRoleAttribute),
|
|
attributeString(element, kAXSubroleAttribute),
|
|
attributeString(element, kAXRoleDescriptionAttribute),
|
|
attributeString(element, kAXDescriptionAttribute),
|
|
attributeString(element, kAXHelpAttribute),
|
|
attributeString(element, kAXTitleAttribute),
|
|
]
|
|
.compactMap { $0?.lowercased() }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
func elementLooksTextual(_ element: AXUIElement) -> Bool {
|
|
let role = attributeString(element, kAXRoleAttribute)
|
|
let subrole = attributeString(element, kAXSubroleAttribute)
|
|
let editable = attributeBool(element, axEditableAttribute)
|
|
let metadata = metadataString(for: element)
|
|
let actions = actionNames(element)
|
|
|
|
let textRoles: Set<String> = [
|
|
kAXTextFieldRole as String,
|
|
kAXTextAreaRole as String,
|
|
kAXComboBoxRole as String,
|
|
kAXSearchFieldSubrole as String,
|
|
]
|
|
|
|
if editable == true || textRoles.contains(role ?? "") || textRoles.contains(subrole ?? "") {
|
|
return true
|
|
}
|
|
|
|
if metadata.contains("text field")
|
|
|| metadata.contains("search field")
|
|
|| metadata.contains("editor")
|
|
|| metadata.contains("insertion point")
|
|
|| metadata.contains("caret")
|
|
|| metadata.contains("source editor") {
|
|
return true
|
|
}
|
|
|
|
return hasAttribute(element, kAXSelectedTextRangeAttribute as String)
|
|
|| hasAttribute(element, kAXNumberOfCharactersAttribute as String)
|
|
|| (actions.contains(kAXPressAction as String) && metadata.contains("text"))
|
|
}
|
|
|
|
func accessibilityCursorMatch() -> String? {
|
|
let hoveredChain = ancestorChain(startingAt: currentElement())
|
|
let focusedChain = ancestorChain(startingAt: focusedElement())
|
|
|
|
for element in hoveredChain + focusedChain {
|
|
if elementLooksTextual(element) {
|
|
return "text"
|
|
}
|
|
}
|
|
|
|
guard let element = hoveredChain.first else {
|
|
return nil
|
|
}
|
|
|
|
let role = attributeString(element, kAXRoleAttribute)
|
|
let enabled = attributeBool(element, kAXEnabledAttribute)
|
|
let actions = actionNames(element)
|
|
let metadata = hoveredChain
|
|
.map { metadataString(for: $0) }
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " ")
|
|
if metadata.contains("crosshair") || metadata.contains("cross hair") || metadata.contains("precision") || metadata.contains("crop") {
|
|
return "crosshair"
|
|
}
|
|
|
|
if role == kAXSplitterRole as String {
|
|
return "resize-ew"
|
|
}
|
|
|
|
let pressableRoles: Set<String> = [
|
|
kAXButtonRole as String,
|
|
axLinkRole,
|
|
kAXMenuItemRole as String,
|
|
kAXPopUpButtonRole as String,
|
|
kAXRadioButtonRole as String,
|
|
kAXCheckBoxRole as String,
|
|
kAXTabGroupRole as String,
|
|
]
|
|
let hasPressAction = actions.contains(kAXPressAction as String)
|
|
if enabled == false && (hasPressAction || pressableRoles.contains(role ?? "")) {
|
|
return "not-allowed"
|
|
}
|
|
if hasPressAction || pressableRoles.contains(role ?? "") {
|
|
return "pointer"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func currentSystemCursorType() -> String {
|
|
let resolvedCursor: NSCursor? = DispatchQueue.main.sync {
|
|
if #available(macOS 14.0, *) {
|
|
return NSCursor.currentSystem ?? NSCursor.current
|
|
}
|
|
|
|
return NSCursor.current
|
|
}
|
|
|
|
guard let resolvedCursor else {
|
|
return accessibilityCursorMatch() ?? "arrow"
|
|
}
|
|
|
|
guard let currentSignature = signature(for: resolvedCursor) else {
|
|
return accessibilityCursorMatch() ?? "arrow"
|
|
}
|
|
|
|
guard let bestMatch = knownCursorSignatures.min(by: { lhs, rhs in
|
|
signatureScore(currentSignature, lhs.1) < signatureScore(currentSignature, rhs.1)
|
|
}) else {
|
|
return accessibilityCursorMatch() ?? "arrow"
|
|
}
|
|
|
|
let bestScore = signatureScore(currentSignature, bestMatch.1)
|
|
let primaryThreshold = strictSignatureAcceptanceThresholds[bestMatch.0] ?? signatureAcceptanceThreshold
|
|
let matchedCursorType: String
|
|
if bestScore > primaryThreshold {
|
|
if let relaxedThreshold = relaxedSignatureAcceptanceThresholds[bestMatch.0], bestScore <= relaxedThreshold {
|
|
matchedCursorType = bestMatch.0
|
|
} else {
|
|
matchedCursorType = "arrow"
|
|
}
|
|
} else {
|
|
matchedCursorType = bestMatch.0
|
|
}
|
|
|
|
return matchedCursorType
|
|
}
|
|
|
|
func exportCursorImages() {
|
|
let cursorForType: [(String, NSCursor)] = [
|
|
("arrow", .arrow),
|
|
("text", .iBeam),
|
|
("pointer", .pointingHand),
|
|
("crosshair", .crosshair),
|
|
("open-hand", .openHand),
|
|
("closed-hand", .closedHand),
|
|
("resize-ew", .resizeLeftRight),
|
|
("resize-ns", .resizeUpDown),
|
|
("not-allowed", .operationNotAllowed),
|
|
]
|
|
|
|
for (name, cursor) in cursorForType {
|
|
let image = cursor.image
|
|
let hotspot = cursor.hotSpot
|
|
let size = image.size
|
|
guard size.width > 0, size.height > 0 else { continue }
|
|
|
|
guard let tiffData = image.tiffRepresentation,
|
|
let bitmapRep = NSBitmapImageRep(data: tiffData),
|
|
let pngData = bitmapRep.representation(using: .png, properties: [:]) else {
|
|
continue
|
|
}
|
|
|
|
let base64 = pngData.base64EncodedString()
|
|
let hotspotXRatio = hotspot.x / size.width
|
|
let hotspotYRatio = hotspot.y / size.height
|
|
let aspectRatio = size.width / size.height
|
|
print("CURSOR_IMAGE:\(name):\(hotspotXRatio):\(hotspotYRatio):\(aspectRatio):\(base64)")
|
|
fflush(stdout)
|
|
}
|
|
}
|
|
|
|
if CommandLine.arguments.contains("--export-images") {
|
|
exportCursorImages()
|
|
exit(0)
|
|
}
|
|
|
|
func mouseInteractionCallback(
|
|
proxy: CGEventTapProxy,
|
|
type: CGEventType,
|
|
event: CGEvent,
|
|
refcon: UnsafeMutableRawPointer?
|
|
) -> Unmanaged<CGEvent>? {
|
|
let action: String
|
|
let button: Int
|
|
switch type {
|
|
case .leftMouseDown:
|
|
action = "mousedown"
|
|
button = 1
|
|
case .leftMouseUp:
|
|
action = "mouseup"
|
|
button = 1
|
|
case .rightMouseDown:
|
|
action = "mousedown"
|
|
button = 2
|
|
case .rightMouseUp:
|
|
action = "mouseup"
|
|
button = 2
|
|
case .otherMouseDown:
|
|
guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else {
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
action = "mousedown"
|
|
button = 3
|
|
case .otherMouseUp:
|
|
guard event.getIntegerValueField(.mouseEventButtonNumber) == 2 else {
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
action = "mouseup"
|
|
button = 3
|
|
default:
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
|
|
print("INTERACTION:\(action):\(button)")
|
|
fflush(stdout)
|
|
return Unmanaged.passUnretained(event)
|
|
}
|
|
|
|
let mouseEventTypes: [CGEventType] = [
|
|
.leftMouseDown,
|
|
.leftMouseUp,
|
|
.rightMouseDown,
|
|
.rightMouseUp,
|
|
.otherMouseDown,
|
|
.otherMouseUp,
|
|
]
|
|
let mouseEventMask = mouseEventTypes.reduce(CGEventMask(0)) { mask, type in
|
|
mask | (CGEventMask(1) << type.rawValue)
|
|
}
|
|
let mouseEventTap = CGEvent.tapCreate(
|
|
tap: .cgSessionEventTap,
|
|
place: .headInsertEventTap,
|
|
options: .listenOnly,
|
|
eventsOfInterest: mouseEventMask,
|
|
callback: mouseInteractionCallback,
|
|
userInfo: nil
|
|
)
|
|
if let mouseEventTap,
|
|
let eventTapSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, mouseEventTap, 0) {
|
|
CFRunLoopAddSource(CFRunLoopGetMain(), eventTapSource, .commonModes)
|
|
CGEvent.tapEnable(tap: mouseEventTap, enable: true)
|
|
} else {
|
|
fputs("Mouse interaction event tap unavailable; click telemetry disabled\n", stderr)
|
|
fflush(stderr)
|
|
}
|
|
|
|
var lastState = ""
|
|
func emitStateIfNeeded() {
|
|
let state = currentSystemCursorType()
|
|
if state != lastState {
|
|
lastState = state
|
|
print("STATE:\(state)")
|
|
fflush(stdout)
|
|
}
|
|
}
|
|
|
|
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
|
|
timer.schedule(deadline: .now(), repeating: .milliseconds(50))
|
|
timer.setEventHandler {
|
|
emitStateIfNeeded()
|
|
}
|
|
timer.resume()
|
|
|
|
DispatchQueue.global(qos: .utility).async {
|
|
while let line = readLine(strippingNewline: true)?.lowercased() {
|
|
if line == "stop" {
|
|
exit(0)
|
|
}
|
|
}
|
|
exit(0)
|
|
}
|
|
|
|
RunLoop.main.run()
|