2026-10-17 22:43:38 -07:00
|
|
|
import AppKit
|
|
|
|
|
import ApplicationServices
|
|
|
|
|
|
2026-10-17 23:03:33 -07:00
|
|
|
func point(_ v: Any?, _ info: WindowInfo, _ table: RefTable) throws -> CGPoint {
|
2026-10-17 22:43:38 -07:00
|
|
|
if let ref = v as? String {
|
2026-10-17 23:03:33 -07:00
|
|
|
guard let f = frame(try element(ref, in: info, table)) else {
|
2026-10-17 22:43:38 -07:00
|
|
|
throw ToolError("\(ref) has no position on screen")
|
|
|
|
|
}
|
|
|
|
|
return CGPoint(x: f.midX, y: f.midY)
|
|
|
|
|
}
|
|
|
|
|
if let xy = v as? [NSNumber], xy.count == 2 {
|
|
|
|
|
return CGPoint(x: info.frame.minX + CGFloat(xy[0].doubleValue), y: info.frame.minY + CGFloat(xy[1].doubleValue))
|
|
|
|
|
}
|
|
|
|
|
throw ToolError("a point is a ref (\"e5\") or window coordinates ([x, y])")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func check(_ err: AXError, _ what: String) throws {
|
|
|
|
|
if err != .success {
|
|
|
|
|
throw ToolError("\(what) failed: AXError \(err.rawValue)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let inputKeys = ["click", "double_click", "right_click", "drag", "scroll", "type", "key"]
|
|
|
|
|
|
|
|
|
|
func isInput(_ step: [String: Any]) -> Bool {
|
|
|
|
|
return step.keys.contains(where: inputKeys.contains)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 23:03:33 -07:00
|
|
|
func runStep(_ step: [String: Any], _ id: Int, _ w: AXUIElement, _ table: RefTable) async throws -> String {
|
2026-10-17 22:43:38 -07:00
|
|
|
var info = try window(id)
|
|
|
|
|
if isInput(step) {
|
|
|
|
|
info = try await focusWindow(id, w)
|
|
|
|
|
}
|
|
|
|
|
if let ref = step.string("press") {
|
2026-10-17 23:03:33 -07:00
|
|
|
try check(AXUIElementPerformAction(try element(ref, in: info, table), kAXPressAction as CFString), "press \(ref)")
|
2026-10-17 22:43:38 -07:00
|
|
|
return "pressed \(ref)"
|
|
|
|
|
}
|
|
|
|
|
if let ref = step.string("confirm") {
|
2026-10-17 23:03:33 -07:00
|
|
|
try check(AXUIElementPerformAction(try element(ref, in: info, table), kAXConfirmAction as CFString), "confirm \(ref)")
|
2026-10-17 22:43:38 -07:00
|
|
|
return "confirmed \(ref)"
|
|
|
|
|
}
|
|
|
|
|
if let ref = step.string("focus") {
|
2026-10-17 23:03:33 -07:00
|
|
|
try check(AXUIElementSetAttributeValue(try element(ref, in: info, table), kAXFocusedAttribute as CFString, kCFBooleanTrue), "focus \(ref)")
|
2026-10-17 22:43:38 -07:00
|
|
|
return "focused \(ref)"
|
|
|
|
|
}
|
|
|
|
|
if let ref = step.string("set") {
|
|
|
|
|
let value = try step.require("value", step.string)
|
2026-10-17 23:03:33 -07:00
|
|
|
try check(AXUIElementSetAttributeValue(try element(ref, in: info, table), kAXValueAttribute as CFString, value as CFString), "set \(ref)")
|
2026-10-17 22:43:38 -07:00
|
|
|
return "set \(ref)"
|
|
|
|
|
}
|
|
|
|
|
if let name = step.string("action") {
|
|
|
|
|
let ref = try step.require("ref", step.string)
|
2026-10-17 23:03:33 -07:00
|
|
|
try check(AXUIElementPerformAction(try element(ref, in: info, table), name as CFString), "\(name) \(ref)")
|
2026-10-17 22:43:38 -07:00
|
|
|
return "\(name) \(ref)"
|
|
|
|
|
}
|
|
|
|
|
for (k, right, count) in [("click", false, 1), ("double_click", false, 2), ("right_click", true, 1)] {
|
|
|
|
|
if step[k] != nil {
|
2026-10-17 23:03:33 -07:00
|
|
|
let p = try point(step[k], info, table)
|
2026-10-17 22:43:38 -07:00
|
|
|
await click(p, right: right, count: count)
|
|
|
|
|
return "\(k) at screen \(Int(p.x)),\(Int(p.y))"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if step["drag"] != nil {
|
2026-10-17 23:03:33 -07:00
|
|
|
let from = try point(step["drag"], info, table)
|
|
|
|
|
let to = try point(step["to"], info, table)
|
2026-10-17 22:43:38 -07:00
|
|
|
await drag(from, to)
|
|
|
|
|
return "dragged"
|
|
|
|
|
}
|
|
|
|
|
if step["scroll"] != nil {
|
2026-10-17 23:03:33 -07:00
|
|
|
let p = try point(step["scroll"], info, table)
|
2026-10-17 22:43:38 -07:00
|
|
|
let dx = step.int("dx") ?? 0
|
|
|
|
|
let dy = step.int("dy") ?? 0
|
|
|
|
|
guard (-1000...1000).contains(dx), (-1000...1000).contains(dy) else {
|
|
|
|
|
throw ToolError("dx and dy must be between -1000 and 1000 lines")
|
|
|
|
|
}
|
|
|
|
|
scroll(p, dx: Int32(dx), dy: Int32(dy))
|
|
|
|
|
return "scrolled"
|
|
|
|
|
}
|
|
|
|
|
if let s = step.string("type") {
|
|
|
|
|
await type(s)
|
|
|
|
|
return "typed \(s.count) characters"
|
|
|
|
|
}
|
|
|
|
|
if let k = step.string("key") {
|
|
|
|
|
try key(k)
|
|
|
|
|
return "key \(k)"
|
|
|
|
|
}
|
|
|
|
|
if let ms = try step.ms("wait") {
|
|
|
|
|
await pause(ms)
|
|
|
|
|
return "waited \(ms)ms"
|
|
|
|
|
}
|
|
|
|
|
throw ToolError("unknown step \(step.keys.sorted())")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 23:03:33 -07:00
|
|
|
func waitFor(_ args: [String: Any], acted: Bool, _ present: (String) async throws -> Bool) async throws -> String? {
|
2026-10-17 22:43:38 -07:00
|
|
|
let wait = try args.ms("wait_ms")
|
|
|
|
|
let timeout = Double(try args.ms("timeout_ms") ?? 10000) / 1000
|
|
|
|
|
if let wait {
|
|
|
|
|
await pause(wait)
|
|
|
|
|
} else if acted {
|
|
|
|
|
await pause(300)
|
|
|
|
|
}
|
|
|
|
|
guard let needle = args.string("wait_for") else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
let start = Date()
|
|
|
|
|
while true {
|
|
|
|
|
if try await present(needle) {
|
|
|
|
|
return String(format: "found %@ after %.1fs", needle.debugDescription, Date().timeIntervalSince(start))
|
|
|
|
|
}
|
|
|
|
|
if Date().timeIntervalSince(start) > timeout {
|
|
|
|
|
return String(format: "gave up waiting for %@ after %.1fs", needle.debugDescription, timeout)
|
|
|
|
|
}
|
|
|
|
|
await pause(200)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 23:03:33 -07:00
|
|
|
func runSteps(_ steps: [[String: Any]], _ id: Int, _ w: AXUIElement, _ table: RefTable) async throws -> [String] {
|
2026-10-17 22:43:38 -07:00
|
|
|
var log: [String] = []
|
|
|
|
|
for (i, step) in steps.enumerated() {
|
|
|
|
|
do {
|
2026-10-17 23:03:33 -07:00
|
|
|
log.append("\(i + 1). " + (try await runStep(step, id, w, table)))
|
2026-10-17 22:43:38 -07:00
|
|
|
await pause(50)
|
|
|
|
|
} catch let e as ToolError {
|
|
|
|
|
log.append("\(i + 1). " + e.message + "; stopped here")
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return log
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func actTool(_ args: [String: Any]) async throws -> [Content] {
|
2026-10-17 23:03:33 -07:00
|
|
|
let table = currentRefs()
|
2026-10-17 22:43:38 -07:00
|
|
|
let id = try args.require("window", args.int)
|
|
|
|
|
let (_, w) = try axWindow(id)
|
|
|
|
|
let steps = args["steps"] as? [[String: Any]] ?? []
|
|
|
|
|
let lock = steps.isEmpty ? nil : try await lockInput()
|
|
|
|
|
defer {
|
|
|
|
|
if let lock {
|
|
|
|
|
close(lock)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-17 23:11:31 -07:00
|
|
|
var before = 0
|
|
|
|
|
if !steps.isEmpty, let needle = args.string("wait_for") {
|
|
|
|
|
before = try windowMatches(w, needle)
|
|
|
|
|
}
|
2026-10-17 23:03:33 -07:00
|
|
|
var log = try await runSteps(steps, id, w, table)
|
2026-10-17 23:11:31 -07:00
|
|
|
if let note = try await waitFor(args, acted: !steps.isEmpty, { try windowMatches(w, $0) > before }) {
|
2026-10-17 22:43:38 -07:00
|
|
|
log.append(note)
|
|
|
|
|
}
|
|
|
|
|
var out: [Content] = []
|
|
|
|
|
if args.bool("tree") {
|
|
|
|
|
let current = try window(id)
|
2026-10-17 23:03:33 -07:00
|
|
|
log.append("\n" + (try axTree(w, RefSource(pid: current.pid, window: id), treeOptions(args, origin: current.frame.origin))))
|
2026-10-17 22:43:38 -07:00
|
|
|
}
|
|
|
|
|
if args.bool("capture") {
|
|
|
|
|
let shot = try await capture(window: id, display: nil, crop: args.dict("crop"), maxEdge: args.double("max_edge") ?? 1568)
|
|
|
|
|
log.append(shot.note)
|
|
|
|
|
out.append(.png(shot.png))
|
|
|
|
|
}
|
|
|
|
|
return [.text(log.joined(separator: "\n"))] + out
|
|
|
|
|
}
|