import AppKit
|
import ApplicationServices
|
import Foundation
|
|
struct MatchDescriptor {
|
let identifier: String
|
let rect: CGRect?
|
let hasParentWindow: Bool
|
}
|
|
enum AXReadFailure: Error {
|
case nonBenign(AXError)
|
}
|
|
func isBenignAbsence(_ error: AXError) -> Bool {
|
error == .noValue || error == .attributeUnsupported
|
}
|
|
func completeMatches(_ result: Result<[MatchDescriptor], AXReadFailure>) throws -> [MatchDescriptor] {
|
try result.get()
|
}
|
|
func validRect(_ rect: CGRect?) -> Bool {
|
guard let rect else { return false }
|
return [rect.origin.x, rect.origin.y, rect.width, rect.height].allSatisfy(\.isFinite)
|
&& rect.width > 0 && rect.height > 0
|
}
|
|
func postMutationReadbackExit(
|
_ readback: Result<Bool, AXReadFailure>,
|
validationFailure: Int32,
|
cleanupFailure: Int32,
|
rollback: () -> Bool
|
) -> Int32? {
|
if case .success(true) = readback { return nil }
|
return rollback() ? validationFailure : cleanupFailure
|
}
|
|
func uniqueMatch(_ matches: [MatchDescriptor], expected: String) -> MatchDescriptor? {
|
let exact = matches.filter { $0.identifier == expected }
|
guard exact.count == 1,
|
exact[0].hasParentWindow,
|
validRect(exact[0].rect) else { return nil }
|
return exact[0]
|
}
|
|
func selfTest() -> Int32 {
|
let good = MatchDescriptor(identifier: "fixture.excalidraw", rect: CGRect(x: 1, y: 2, width: 3, height: 4), hasParentWindow: true)
|
let traversalErrorRejected = (try? completeMatches(.failure(.nonBenign(.cannotComplete)))) == nil
|
let invalidParentRects: [CGRect] = [
|
CGRect(x: 0, y: 0, width: 0, height: 1),
|
CGRect(x: 0, y: 0, width: 1, height: 0),
|
CGRect(x: CGFloat.nan, y: 0, width: 1, height: 1),
|
CGRect(x: 0, y: CGFloat.infinity, width: 1, height: 1),
|
]
|
var rollbackCalled = 0
|
let readErrorCleanupSuccess = postMutationReadbackExit(
|
.failure(.nonBenign(.cannotComplete)), validationFailure: 69, cleanupFailure: 71
|
) { rollbackCalled += 1; return true }
|
let readErrorCleanupFailure = postMutationReadbackExit(
|
.failure(.nonBenign(.cannotComplete)), validationFailure: 69, cleanupFailure: 71
|
) { rollbackCalled += 1; return false }
|
guard uniqueMatch([good], expected: "fixture.excalidraw") != nil,
|
uniqueMatch([good], expected: "fixture") == nil,
|
uniqueMatch([good, good], expected: "fixture.excalidraw") == nil,
|
uniqueMatch([MatchDescriptor(identifier: good.identifier, rect: .zero, hasParentWindow: true)], expected: good.identifier) == nil,
|
uniqueMatch([MatchDescriptor(identifier: good.identifier, rect: good.rect, hasParentWindow: false)], expected: good.identifier) == nil,
|
traversalErrorRejected,
|
isBenignAbsence(.noValue), isBenignAbsence(.attributeUnsupported),
|
!isBenignAbsence(.cannotComplete), !isBenignAbsence(.invalidUIElement), !isBenignAbsence(.apiDisabled),
|
(invalidParentRects.allSatisfy { !validRect($0) }),
|
rollbackCalled == 2, readErrorCleanupSuccess == 69, readErrorCleanupFailure == 71,
|
postMutationReadbackExit(.success(true), validationFailure: 69, cleanupFailure: 71, rollback: { false }) == nil else { return 1 }
|
print("self_test=PASS explicit_exact_unique_source=true traversal_read_error_rejected=true parent_geometry_rejected=true post_mutation_read_error_rollback=true cleanup_status_distinct=true")
|
return 0
|
}
|
|
if CommandLine.arguments == [CommandLine.arguments[0], "--self-test"] { exit(selfTest()) }
|
guard CommandLine.arguments.count == 2, !CommandLine.arguments[1].isEmpty else {
|
fputs("usage: PlaceFinderSource exactSourceIdentifier\n", stderr)
|
exit(64)
|
}
|
let expected = CommandLine.arguments[1]
|
|
func attribute(_ element: AXUIElement, _ name: String) throws -> CFTypeRef? {
|
var value: CFTypeRef?
|
let error = AXUIElementCopyAttributeValue(element, name as CFString, &value)
|
if error == .success { return value }
|
if isBenignAbsence(error) { return nil }
|
throw AXReadFailure.nonBenign(error)
|
}
|
func stringValue(_ element: AXUIElement, _ name: String) throws -> String? {
|
guard let value = try attribute(element, name), CFGetTypeID(value) == CFStringGetTypeID() else { return nil }
|
return value as? String
|
}
|
func rect(_ element: AXUIElement) throws -> CGRect? {
|
guard let pv = try attribute(element, kAXPositionAttribute), CFGetTypeID(pv) == AXValueGetTypeID(),
|
let sv = try attribute(element, kAXSizeAttribute), CFGetTypeID(sv) == AXValueGetTypeID() else { return nil }
|
var point = CGPoint.zero
|
var size = CGSize.zero
|
guard AXValueGetValue(pv as! AXValue, .cgPoint, &point), AXValueGetValue(sv as! AXValue, .cgSize, &size) else { return nil }
|
return CGRect(origin: point, size: size)
|
}
|
func parentWindow(_ element: AXUIElement) throws -> AXUIElement? {
|
var current = element
|
for _ in 0..<16 {
|
if try stringValue(current, kAXRoleAttribute) == (kAXWindowRole as String) { return current }
|
guard let parent = try attribute(current, kAXParentAttribute), CFGetTypeID(parent) == AXUIElementGetTypeID() else { return nil }
|
current = parent as! AXUIElement
|
}
|
return nil
|
}
|
func collect(_ element: AXUIElement, depth: Int = 0, into result: inout [AXUIElement]) throws {
|
if try stringValue(element, kAXRoleAttribute) == (kAXTextFieldRole as String),
|
try stringValue(element, kAXValueAttribute) == expected { result.append(element) }
|
guard depth < 14, let children = try attribute(element, kAXChildrenAttribute) as? [AXUIElement] else { return }
|
for child in children { try collect(child, depth: depth + 1, into: &result) }
|
}
|
func setPoint(_ element: AXUIElement, _ value: CGPoint) -> AXError {
|
var value = value
|
return AXUIElementSetAttributeValue(element, kAXPositionAttribute as CFString, AXValueCreate(.cgPoint, &value)!)
|
}
|
func setSize(_ element: AXUIElement, _ value: CGSize) -> AXError {
|
var value = value
|
return AXUIElementSetAttributeValue(element, kAXSizeAttribute as CFString, AXValueCreate(.cgSize, &value)!)
|
}
|
|
guard let finder = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first else { exit(66) }
|
let app = AXUIElementCreateApplication(finder.processIdentifier)
|
var matches: [AXUIElement] = []
|
try collect(app, into: &matches)
|
let descriptors = try completeMatches(.success(try matches.map { element in
|
MatchDescriptor(
|
identifier: try stringValue(element, kAXValueAttribute) ?? "",
|
rect: try rect(element),
|
hasParentWindow: try parentWindow(element) != nil
|
)
|
}))
|
guard matches.count == 1,
|
uniqueMatch(descriptors, expected: expected) != nil,
|
let window = try parentWindow(matches[0]),
|
let originalWindowRect = try rect(window), validRect(originalWindowRect),
|
let sourceRectBefore = try rect(matches[0]), validRect(sourceRectBefore) else {
|
fputs("exact unique source binding failed\n", stderr)
|
exit(67)
|
}
|
|
let desiredPosition = CGPoint(x: 10, y: 70)
|
let desiredSize = CGSize(width: 500, height: 320)
|
let positionError = setPoint(window, desiredPosition)
|
guard positionError == .success else { exit(70) }
|
let sizeError = setSize(window, desiredSize)
|
guard sizeError == .success else {
|
let rollback = setPoint(window, originalWindowRect.origin)
|
exit(rollback == .success ? 70 : 71)
|
}
|
let raiseError = AXUIElementPerformAction(window, kAXRaiseAction as CFString)
|
guard raiseError == .success else {
|
let sizeRollback = setSize(window, originalWindowRect.size)
|
let pointRollback = setPoint(window, originalWindowRect.origin)
|
exit(sizeRollback == .success && pointRollback == .success ? 70 : 71)
|
}
|
Thread.sleep(forTimeInterval: 0.6)
|
let readbackResult: Result<Bool, AXReadFailure>
|
var verifiedSourceRect: CGRect?
|
var verifiedWindowRect: CGRect?
|
do {
|
let sourceRect = try rect(matches[0])
|
let windowRect = try rect(window)
|
readbackResult = .success(validRect(sourceRect) && validRect(windowRect))
|
if validRect(sourceRect) && validRect(windowRect) {
|
verifiedSourceRect = sourceRect
|
verifiedWindowRect = windowRect
|
}
|
} catch let error as AXReadFailure {
|
readbackResult = .failure(error)
|
}
|
if let exitCode = postMutationReadbackExit(readbackResult, validationFailure: 69, cleanupFailure: 71, rollback: {
|
let sizeRollback = setSize(window, originalWindowRect.size)
|
let pointRollback = setPoint(window, originalWindowRect.origin)
|
return sizeRollback == .success && pointRollback == .success
|
}) {
|
exit(exitCode)
|
}
|
let sourceRect = verifiedSourceRect!
|
let windowRect = verifiedWindowRect!
|
print("source=<QA_SOURCE> parent_window=<QA_WINDOW> exact_unique=true")
|
print("window_rect=\(windowRect.origin.x),\(windowRect.origin.y),\(windowRect.width),\(windowRect.height)")
|
print("source_rect=\(sourceRect.origin.x),\(sourceRect.origin.y),\(sourceRect.width),\(sourceRect.height)")
|
print("source_center=\(sourceRect.midX),\(sourceRect.midY)")
|