crewly 1.20.40 → 1.20.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/skills/_common/desktop-guards.sh +485 -0
- package/config/skills/_common/desktop-guards.test.sh +242 -0
- package/config/skills/_common/desktop-perceive.swift +530 -0
- package/config/skills/_common/desktop-presence.swift +343 -0
- package/config/skills/agent/_common/desktop-guards.sh +4 -0
- package/config/skills/agent/computer-use/SKILL.md +88 -0
- package/config/skills/agent/computer-use/execute.sh +249 -3
- package/config/skills/agent/desktop-app-control/SKILL.md +19 -0
- package/config/skills/agent/remote-browser/SKILL.md +19 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts +105 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.js +278 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts +21 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.js +31 -0
- package/dist/backend/backend/src/controllers/desktop/desktop.routes.js.map +1 -0
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +3 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +9 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts +18 -0
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +33 -6
- package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +60 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts +18 -1
- package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack.service.js +38 -2
- package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/backend/backend/src/types/slack.types.d.ts +10 -0
- package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/slack.types.js.map +1 -1
- package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
- package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
- package/dist/backend/backend/src/utils/incomplete-turn.utils.js +4 -0
- package/dist/backend/backend/src/utils/incomplete-turn.utils.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts +18 -0
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +33 -6
- package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +60 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts +18 -1
- package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack.service.js +38 -2
- package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
- package/dist/cli/backend/src/types/slack.types.d.ts +10 -0
- package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/slack.types.js.map +1 -1
- package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
- package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
- package/dist/cli/backend/src/utils/incomplete-turn.utils.js +4 -0
- package/dist/cli/backend/src/utils/incomplete-turn.utils.js.map +1 -1
- package/package.json +1 -1
- package/packages/crewly-agent/src/eval/desktop/desktop-tasks.test.ts +96 -0
- package/packages/crewly-agent/src/eval/desktop/desktop-tasks.ts +226 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +20 -1
- package/packages/crewly-agent/src/runtime/computer.tool.test.ts +219 -0
- package/packages/crewly-agent/src/runtime/computer.tool.ts +405 -0
- package/packages/crewly-agent/src/runtime/desktop-checkpoint.test.ts +136 -0
- package/packages/crewly-agent/src/runtime/desktop-checkpoint.ts +231 -0
- package/packages/crewly-agent/src/runtime/desktop-recovery.test.ts +100 -0
- package/packages/crewly-agent/src/runtime/desktop-recovery.ts +195 -0
- package/packages/crewly-agent/src/runtime/desktop-task-runtime.test.ts +251 -0
- package/packages/crewly-agent/src/runtime/desktop-task-runtime.ts +423 -0
- package/packages/crewly-agent/src/runtime/desktop-task.tool.test.ts +218 -0
- package/packages/crewly-agent/src/runtime/desktop-task.tool.ts +343 -0
- package/packages/crewly-agent/src/runtime/tool-registry.test.ts +17 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +54 -0
- package/packages/crewly-agent/src/runtime/types.ts +10 -1
- package/config/skills/agent/vnc-browser/SKILL.md +0 -140
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// desktop-perceive — the eyes of Crewly's desktop control.
|
|
3
|
+
//
|
|
4
|
+
// Phase 2 of docs/research/computer-use-capability-assessment.md. Until now an
|
|
5
|
+
// agent driving the desktop saw pixels: it took a screenshot and guessed
|
|
6
|
+
// coordinates, so it misclicked, it re-sent a whole image every step, and any
|
|
7
|
+
// change of theme, resolution or window position broke it.
|
|
8
|
+
//
|
|
9
|
+
// This gives it elements instead. One accessibility snapshot returns a flat
|
|
10
|
+
// list of refs (@e1, @e2 …) an agent can act on by name, the way the browser
|
|
11
|
+
// line already works with DOM selectors and agent-browser works with its own
|
|
12
|
+
// refs — so an agent learns one idea, not three.
|
|
13
|
+
//
|
|
14
|
+
// Written in Swift rather than JXA because the scripting bridge costs a round
|
|
15
|
+
// trip per attribute: reading one mid-sized window took seconds, which is too
|
|
16
|
+
// slow to do before every action. Raw AXUIElement reads the same tree in
|
|
17
|
+
// milliseconds, and Vision (for OCR) lives in the same binary.
|
|
18
|
+
//
|
|
19
|
+
// Subcommands:
|
|
20
|
+
// snapshot [--app NAME] [--max N] [--all-windows] [--menus] elements of an app
|
|
21
|
+
// resolve --ref @eN re-find one element
|
|
22
|
+
// ocr [--region x,y,w,h] [--image PATH] text and its boxes
|
|
23
|
+
// displays screens and their frames
|
|
24
|
+
//
|
|
25
|
+
// Every subcommand prints one JSON object on stdout.
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
import AppKit
|
|
29
|
+
import ApplicationServices
|
|
30
|
+
import Foundation
|
|
31
|
+
import Vision
|
|
32
|
+
|
|
33
|
+
// MARK: - Output
|
|
34
|
+
|
|
35
|
+
/// Print a JSON object and exit. All output goes through here so a caller can
|
|
36
|
+
/// always parse stdout, including on failure.
|
|
37
|
+
func emit(_ object: [String: Any], exitCode: Int32 = 0) -> Never {
|
|
38
|
+
let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
|
39
|
+
FileHandle.standardOutput.write(data ?? Data("{}".utf8))
|
|
40
|
+
FileHandle.standardOutput.write(Data("\n".utf8))
|
|
41
|
+
exit(exitCode)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Fail in the same shape the shell guards use, so an agent parses one format.
|
|
45
|
+
func fail(_ reason: String, _ message: String, extra: [String: Any] = [:]) -> Never {
|
|
46
|
+
var out: [String: Any] = ["success": false, "reason": reason, "message": message]
|
|
47
|
+
out.merge(extra) { a, _ in a }
|
|
48
|
+
emit(out, exitCode: 1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// MARK: - Accessibility helpers
|
|
52
|
+
|
|
53
|
+
/// Read one attribute, returning nil rather than throwing — most elements are
|
|
54
|
+
/// missing most attributes and that is normal, not an error.
|
|
55
|
+
func attr(_ element: AXUIElement, _ name: String) -> CFTypeRef? {
|
|
56
|
+
var value: CFTypeRef?
|
|
57
|
+
return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func stringAttr(_ element: AXUIElement, _ name: String) -> String? {
|
|
61
|
+
guard let raw = attr(element, name) else { return nil }
|
|
62
|
+
if let s = raw as? String { return s.isEmpty ? nil : s }
|
|
63
|
+
if let n = raw as? NSNumber { return n.stringValue }
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func boolAttr(_ element: AXUIElement, _ name: String) -> Bool? {
|
|
68
|
+
(attr(element, name) as? NSNumber)?.boolValue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Screen frame of an element, in the top-left origin coordinates the rest of
|
|
72
|
+
/// the skill uses (AX already reports top-left, unlike AppKit).
|
|
73
|
+
func frameOf(_ element: AXUIElement) -> CGRect? {
|
|
74
|
+
guard let posRaw = attr(element, kAXPositionAttribute as String),
|
|
75
|
+
let sizeRaw = attr(element, kAXSizeAttribute as String) else { return nil }
|
|
76
|
+
var point = CGPoint.zero
|
|
77
|
+
var size = CGSize.zero
|
|
78
|
+
// swiftlint:disable:next force_cast
|
|
79
|
+
guard AXValueGetValue(posRaw as! AXValue, .cgPoint, &point),
|
|
80
|
+
AXValueGetValue(sizeRaw as! AXValue, .cgSize, &size) else { return nil }
|
|
81
|
+
return CGRect(origin: point, size: size)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func childrenOf(_ element: AXUIElement) -> [AXUIElement] {
|
|
85
|
+
(attr(element, kAXChildrenAttribute as String) as? [AXUIElement]) ?? []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MARK: - Snapshot
|
|
89
|
+
|
|
90
|
+
/// One element as an agent sees it.
|
|
91
|
+
///
|
|
92
|
+
/// `path` is how the element is found again in a later process: the child
|
|
93
|
+
/// indices from the application root. AXUIElement handles cannot cross a
|
|
94
|
+
/// process boundary, so the ref must be re-resolved, and the role/title/frame
|
|
95
|
+
/// travel with it so re-resolution can be checked rather than assumed.
|
|
96
|
+
struct Node {
|
|
97
|
+
let ref: String
|
|
98
|
+
let role: String
|
|
99
|
+
let subrole: String?
|
|
100
|
+
let name: String?
|
|
101
|
+
let value: String?
|
|
102
|
+
let frame: CGRect?
|
|
103
|
+
let enabled: Bool?
|
|
104
|
+
let focused: Bool?
|
|
105
|
+
let path: [Int]
|
|
106
|
+
|
|
107
|
+
var json: [String: Any] {
|
|
108
|
+
var out: [String: Any] = ["ref": ref, "role": role, "path": path]
|
|
109
|
+
if let subrole { out["subrole"] = subrole }
|
|
110
|
+
if let name { out["name"] = name }
|
|
111
|
+
if let value { out["value"] = value }
|
|
112
|
+
if let frame {
|
|
113
|
+
out["frame"] = [Int(frame.origin.x), Int(frame.origin.y), Int(frame.width), Int(frame.height)]
|
|
114
|
+
}
|
|
115
|
+
if let enabled { out["enabled"] = enabled }
|
|
116
|
+
if focused == true { out["focused"] = true }
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Roles that carry no information on their own. Keeping them would triple the
|
|
122
|
+
/// list an agent has to read for nothing — an agent acts on buttons and fields,
|
|
123
|
+
/// not on the groups that hold them.
|
|
124
|
+
let skeletalRoles: Set<String> = [
|
|
125
|
+
"AXGroup", "AXSplitGroup", "AXScrollArea", "AXLayoutArea", "AXLayoutItem",
|
|
126
|
+
"AXUnknown", "AXSplitter", "AXGrowArea",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
/// Roles worth reporting even with no title — an agent may still need to click
|
|
130
|
+
/// or read them.
|
|
131
|
+
let alwaysKeepRoles: Set<String> = [
|
|
132
|
+
"AXTextField", "AXTextArea", "AXSecureTextField", "AXComboBox", "AXSlider",
|
|
133
|
+
"AXCheckBox", "AXRadioButton", "AXPopUpButton", "AXWindow", "AXSheet", "AXTable", "AXList",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
/// Walk an application's accessibility tree into a flat list.
|
|
137
|
+
///
|
|
138
|
+
/// Flat, not nested, because an agent has to name one element, not navigate a
|
|
139
|
+
/// structure; the `path` field keeps the structure available where it matters.
|
|
140
|
+
func snapshot(app: AXUIElement, limit: Int, allWindows: Bool, includeMenus: Bool) -> [Node] {
|
|
141
|
+
var out: [Node] = []
|
|
142
|
+
var counter = 0
|
|
143
|
+
|
|
144
|
+
// Depth cap alongside the node cap: a runaway tree (a huge table) would
|
|
145
|
+
// otherwise spend the whole budget on one branch and miss the toolbar.
|
|
146
|
+
func walk(_ element: AXUIElement, path: [Int], depth: Int) {
|
|
147
|
+
if out.count >= limit || depth > 24 { return }
|
|
148
|
+
|
|
149
|
+
let role = stringAttr(element, kAXRoleAttribute as String) ?? "AXUnknown"
|
|
150
|
+
let name = stringAttr(element, kAXTitleAttribute as String)
|
|
151
|
+
?? stringAttr(element, kAXDescriptionAttribute as String)
|
|
152
|
+
?? stringAttr(element, "AXLabel")
|
|
153
|
+
let rawValue = stringAttr(element, kAXValueAttribute as String)
|
|
154
|
+
// A long text area would otherwise dominate the output.
|
|
155
|
+
let value = rawValue.map { $0.count > 200 ? String($0.prefix(200)) + "…" : $0 }
|
|
156
|
+
let frame = frameOf(element)
|
|
157
|
+
|
|
158
|
+
// Zero-sized and offscreen elements are real in the tree but cannot be
|
|
159
|
+
// clicked, so reporting them only invites the agent to try.
|
|
160
|
+
let visible = frame.map { $0.width > 1 && $0.height > 1 } ?? false
|
|
161
|
+
let informative = !skeletalRoles.contains(role) && (name != nil || value != nil || alwaysKeepRoles.contains(role))
|
|
162
|
+
|
|
163
|
+
if visible && informative {
|
|
164
|
+
counter += 1
|
|
165
|
+
out.append(Node(
|
|
166
|
+
ref: "@e\(counter)",
|
|
167
|
+
role: role,
|
|
168
|
+
subrole: stringAttr(element, kAXSubroleAttribute as String),
|
|
169
|
+
name: name,
|
|
170
|
+
value: value,
|
|
171
|
+
frame: frame,
|
|
172
|
+
enabled: boolAttr(element, kAXEnabledAttribute as String),
|
|
173
|
+
focused: boolAttr(element, kAXFocusedAttribute as String),
|
|
174
|
+
path: path
|
|
175
|
+
))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (index, child) in childrenOf(element).enumerated() {
|
|
179
|
+
walk(child, path: path + [index], depth: depth + 1)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Which subtrees to walk, and in what order. This matters more than it
|
|
184
|
+
// looks: the menu bar is an app's first child and carries every menu of
|
|
185
|
+
// every menu title, so walking the app root spends the whole budget on
|
|
186
|
+
// "Apple / File / Edit / View" and never reaches the window the agent
|
|
187
|
+
// asked about. Windows come first, and the menu bar only when asked for.
|
|
188
|
+
let appChildren = childrenOf(app)
|
|
189
|
+
func indexOf(_ element: AXUIElement) -> Int {
|
|
190
|
+
appChildren.firstIndex { CFEqual($0, element) } ?? 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let windows = (attr(app, kAXWindowsAttribute as String) as? [AXUIElement]) ?? []
|
|
194
|
+
let focused = attr(app, kAXFocusedWindowAttribute as String).map { unsafeBitCast($0, to: AXUIElement.self) }
|
|
195
|
+
|
|
196
|
+
var roots: [AXUIElement] = []
|
|
197
|
+
if allWindows {
|
|
198
|
+
roots = windows
|
|
199
|
+
} else if let focused {
|
|
200
|
+
roots = [focused]
|
|
201
|
+
} else if let first = windows.first {
|
|
202
|
+
roots = [first]
|
|
203
|
+
}
|
|
204
|
+
if includeMenus || roots.isEmpty {
|
|
205
|
+
// No window at all (a menu-bar-only app, or everything minimised):
|
|
206
|
+
// the menu bar is then the only thing there is to act on.
|
|
207
|
+
if let menuBar = attr(app, kAXMenuBarAttribute as String).map({ unsafeBitCast($0, to: AXUIElement.self) }) {
|
|
208
|
+
roots.append(menuBar)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if roots.isEmpty { roots = [app] }
|
|
212
|
+
|
|
213
|
+
for root in roots { walk(root, path: [indexOf(root)], depth: 0) }
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// The application to snapshot: the one named, else whatever is in front.
|
|
218
|
+
func targetApp(named: String?) -> (AXUIElement, String, pid_t) {
|
|
219
|
+
let running = NSWorkspace.shared.runningApplications
|
|
220
|
+
let chosen: NSRunningApplication?
|
|
221
|
+
if let named {
|
|
222
|
+
chosen = running.first {
|
|
223
|
+
$0.localizedName?.compare(named, options: .caseInsensitive) == .orderedSame
|
|
224
|
+
|| $0.bundleIdentifier?.compare(named, options: .caseInsensitive) == .orderedSame
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
chosen = NSWorkspace.shared.frontmostApplication
|
|
228
|
+
}
|
|
229
|
+
guard let appProcess = chosen, let pid = Optional(appProcess.processIdentifier) else {
|
|
230
|
+
fail("app_not_found",
|
|
231
|
+
named.map { "No running application called \($0). Use list-apps to see what is open." }
|
|
232
|
+
?? "Could not determine the frontmost application.")
|
|
233
|
+
}
|
|
234
|
+
return (AXUIElementCreateApplication(pid), appProcess.localizedName ?? "unknown", pid)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: - Ref cache
|
|
238
|
+
//
|
|
239
|
+
// A ref is only meaningful next to the snapshot that minted it, so the cache
|
|
240
|
+
// records which app and which snapshot, and `resolve` refuses a ref from a
|
|
241
|
+
// different app rather than acting on whatever happens to sit at that path.
|
|
242
|
+
|
|
243
|
+
let cacheURL: URL = {
|
|
244
|
+
let home = ProcessInfo.processInfo.environment["CREWLY_HOME"]
|
|
245
|
+
?? (NSHomeDirectory() as NSString).appendingPathComponent(".crewly")
|
|
246
|
+
try? FileManager.default.createDirectory(atPath: home, withIntermediateDirectories: true)
|
|
247
|
+
return URL(fileURLWithPath: (home as NSString).appendingPathComponent("desktop-refs.json"))
|
|
248
|
+
}()
|
|
249
|
+
|
|
250
|
+
func writeCache(app: String, pid: pid_t, nodes: [Node]) {
|
|
251
|
+
var refs: [String: Any] = [:]
|
|
252
|
+
for node in nodes {
|
|
253
|
+
refs[node.ref] = [
|
|
254
|
+
"path": node.path, "role": node.role,
|
|
255
|
+
"name": node.name ?? "", "frame": node.frame.map { [$0.midX, $0.midY] } ?? [],
|
|
256
|
+
]
|
|
257
|
+
}
|
|
258
|
+
let payload: [String: Any] = ["app": app, "pid": Int(pid), "at": Date().timeIntervalSince1970, "refs": refs]
|
|
259
|
+
try? JSONSerialization.data(withJSONObject: payload).write(to: cacheURL)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// Re-find a ref in the live tree.
|
|
263
|
+
///
|
|
264
|
+
/// Walking the recorded path can land on a different element if the window
|
|
265
|
+
/// changed, so the role is checked and the name compared. A mismatch is
|
|
266
|
+
/// reported rather than hidden: the caller decides whether to click the last
|
|
267
|
+
/// known position or take a fresh snapshot.
|
|
268
|
+
func resolve(ref: String) -> [String: Any] {
|
|
269
|
+
guard let data = try? Data(contentsOf: cacheURL),
|
|
270
|
+
let cache = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
271
|
+
let refs = cache["refs"] as? [String: Any],
|
|
272
|
+
let entry = refs[ref] as? [String: Any] else {
|
|
273
|
+
fail("unknown_ref", "\(ref) is not in the last snapshot. Take a snapshot first.")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let expectedRole = entry["role"] as? String ?? ""
|
|
277
|
+
let expectedName = entry["name"] as? String ?? ""
|
|
278
|
+
let path = entry["path"] as? [Int] ?? []
|
|
279
|
+
let lastKnown = entry["frame"] as? [Double] ?? []
|
|
280
|
+
let appName = cache["app"] as? String ?? ""
|
|
281
|
+
|
|
282
|
+
let (app, liveName, _) = targetApp(named: appName)
|
|
283
|
+
if liveName.compare(appName, options: .caseInsensitive) != .orderedSame {
|
|
284
|
+
fail("app_changed",
|
|
285
|
+
"\(ref) was captured in \(appName) but \(liveName) is in front now. Take a new snapshot.",
|
|
286
|
+
extra: ["expectedApp": appName, "actualApp": liveName])
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
var element = app
|
|
290
|
+
for index in path {
|
|
291
|
+
let kids = childrenOf(element)
|
|
292
|
+
guard index < kids.count else {
|
|
293
|
+
return ["success": false, "reason": "ref_stale", "ref": ref,
|
|
294
|
+
"message": "\(ref) no longer exists — the window changed. Take a new snapshot.",
|
|
295
|
+
"lastKnownCenter": lastKnown]
|
|
296
|
+
}
|
|
297
|
+
element = kids[index]
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let role = stringAttr(element, kAXRoleAttribute as String) ?? ""
|
|
301
|
+
let name = stringAttr(element, kAXTitleAttribute as String)
|
|
302
|
+
?? stringAttr(element, kAXDescriptionAttribute as String) ?? ""
|
|
303
|
+
let frame = frameOf(element)
|
|
304
|
+
let matches = role == expectedRole && (expectedName.isEmpty || name == expectedName)
|
|
305
|
+
|
|
306
|
+
var out: [String: Any] = [
|
|
307
|
+
"success": true, "ref": ref, "role": role, "name": name,
|
|
308
|
+
"matches": matches,
|
|
309
|
+
"center": frame.map { [Int($0.midX), Int($0.midY)] } ?? lastKnown.map { Int($0) },
|
|
310
|
+
]
|
|
311
|
+
if !matches {
|
|
312
|
+
out["warning"] = "The element at this position is now \(role) \"\(name)\", not \(expectedRole) \"\(expectedName)\". Take a new snapshot before acting."
|
|
313
|
+
}
|
|
314
|
+
// Whether it can be pressed directly decides if the caller needs a click.
|
|
315
|
+
var actions: CFArray?
|
|
316
|
+
if AXUIElementCopyActionNames(element, &actions) == .success,
|
|
317
|
+
let names = actions as? [String] {
|
|
318
|
+
out["actions"] = names
|
|
319
|
+
out["pressable"] = names.contains(kAXPressAction as String)
|
|
320
|
+
}
|
|
321
|
+
return out
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/// Perform AXPress on a ref. Returns false when the element has no press
|
|
325
|
+
/// action, so the caller can fall back to clicking its centre.
|
|
326
|
+
func press(ref: String) -> [String: Any] {
|
|
327
|
+
let info = resolve(ref: ref)
|
|
328
|
+
guard info["success"] as? Bool == true else { return info }
|
|
329
|
+
guard let cacheData = try? Data(contentsOf: cacheURL),
|
|
330
|
+
let cache = try? JSONSerialization.jsonObject(with: cacheData) as? [String: Any],
|
|
331
|
+
let refs = cache["refs"] as? [String: Any],
|
|
332
|
+
let entry = refs[ref] as? [String: Any],
|
|
333
|
+
let path = entry["path"] as? [Int] else {
|
|
334
|
+
return ["success": false, "reason": "unknown_ref", "ref": ref]
|
|
335
|
+
}
|
|
336
|
+
let (app, _, _) = targetApp(named: cache["app"] as? String)
|
|
337
|
+
var element = app
|
|
338
|
+
for index in path {
|
|
339
|
+
let kids = childrenOf(element)
|
|
340
|
+
guard index < kids.count else { return ["success": false, "reason": "ref_stale", "ref": ref] }
|
|
341
|
+
element = kids[index]
|
|
342
|
+
}
|
|
343
|
+
let result = AXUIElementPerformAction(element, kAXPressAction as CFString)
|
|
344
|
+
return ["success": result == .success, "ref": ref, "method": "AXPress",
|
|
345
|
+
"center": info["center"] ?? [], "axError": result == .success ? 0 : Int(result.rawValue)]
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// Set a text field's value directly. Far more reliable than typing, which
|
|
349
|
+
/// depends on focus and on the keyboard layout.
|
|
350
|
+
func setValue(ref: String, text: String) -> [String: Any] {
|
|
351
|
+
guard let cacheData = try? Data(contentsOf: cacheURL),
|
|
352
|
+
let cache = try? JSONSerialization.jsonObject(with: cacheData) as? [String: Any],
|
|
353
|
+
let refs = cache["refs"] as? [String: Any],
|
|
354
|
+
let entry = refs[ref] as? [String: Any],
|
|
355
|
+
let path = entry["path"] as? [Int] else {
|
|
356
|
+
fail("unknown_ref", "\(ref) is not in the last snapshot. Take a snapshot first.")
|
|
357
|
+
}
|
|
358
|
+
let (app, _, _) = targetApp(named: cache["app"] as? String)
|
|
359
|
+
var element = app
|
|
360
|
+
for index in path {
|
|
361
|
+
let kids = childrenOf(element)
|
|
362
|
+
guard index < kids.count else { return ["success": false, "reason": "ref_stale", "ref": ref] }
|
|
363
|
+
element = kids[index]
|
|
364
|
+
}
|
|
365
|
+
let role = stringAttr(element, kAXRoleAttribute as String) ?? ""
|
|
366
|
+
if role == "AXSecureTextField" {
|
|
367
|
+
fail("secure_field", "\(ref) is a password field. Desktop control never fills one — ask the owner.")
|
|
368
|
+
}
|
|
369
|
+
let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFTypeRef)
|
|
370
|
+
return ["success": result == .success, "ref": ref, "role": role,
|
|
371
|
+
"axError": result == .success ? 0 : Int(result.rawValue)]
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// MARK: - OCR
|
|
375
|
+
|
|
376
|
+
/// Read the text on screen, with a box for each piece.
|
|
377
|
+
///
|
|
378
|
+
/// Vision runs locally and free, handles Chinese and English, and covers what
|
|
379
|
+
/// the accessibility tree does not expose — a canvas, a PDF page, an app that
|
|
380
|
+
/// simply does not implement AX.
|
|
381
|
+
func ocr(region: CGRect?, imagePath: String?) -> [String: Any] {
|
|
382
|
+
// Capture via `screencapture` rather than CoreGraphics: CGDisplayCreateImage
|
|
383
|
+
// was obsoleted in macOS 15 and its replacement, ScreenCaptureKit, is async
|
|
384
|
+
// and far heavier than this needs. The shell tool is also what the rest of
|
|
385
|
+
// the skill already uses, so one TCC grant covers both.
|
|
386
|
+
let path: String
|
|
387
|
+
var temporary: String?
|
|
388
|
+
if let imagePath {
|
|
389
|
+
path = imagePath
|
|
390
|
+
} else {
|
|
391
|
+
let scratch = NSTemporaryDirectory() + "crewly-ocr-\(getpid()).png"
|
|
392
|
+
var args = ["-x"]
|
|
393
|
+
if let region {
|
|
394
|
+
args += ["-R", "\(Int(region.origin.x)),\(Int(region.origin.y)),\(Int(region.width)),\(Int(region.height))"]
|
|
395
|
+
}
|
|
396
|
+
args.append(scratch)
|
|
397
|
+
let task = Process()
|
|
398
|
+
task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
|
|
399
|
+
task.arguments = args
|
|
400
|
+
try? task.run()
|
|
401
|
+
task.waitUntilExit()
|
|
402
|
+
guard FileManager.default.fileExists(atPath: scratch) else {
|
|
403
|
+
fail("screenshot_failed",
|
|
404
|
+
"screencapture produced nothing. Screen Recording is probably not granted to this process.",
|
|
405
|
+
extra: ["permission": "screen-recording"])
|
|
406
|
+
}
|
|
407
|
+
path = scratch
|
|
408
|
+
temporary = scratch
|
|
409
|
+
}
|
|
410
|
+
defer { if let temporary { try? FileManager.default.removeItem(atPath: temporary) } }
|
|
411
|
+
|
|
412
|
+
guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),
|
|
413
|
+
let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
|
414
|
+
fail("bad_image", "Could not read the image at \(path).")
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let request = VNRecognizeTextRequest()
|
|
418
|
+
request.recognitionLevel = .accurate
|
|
419
|
+
request.usesLanguageCorrection = true
|
|
420
|
+
request.recognitionLanguages = ["zh-Hans", "en-US"]
|
|
421
|
+
|
|
422
|
+
do {
|
|
423
|
+
try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
|
|
424
|
+
} catch {
|
|
425
|
+
fail("ocr_failed", "Vision could not read the image: \(error.localizedDescription)")
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// screencapture writes backing pixels; the rest of the skill speaks screen
|
|
429
|
+
// points, so divide by the scale of the display the region came from.
|
|
430
|
+
let scale = NSScreen.main.map { Double($0.backingScaleFactor) } ?? 2.0
|
|
431
|
+
let widthPoints = Double(image.width) / scale
|
|
432
|
+
let heightPoints = Double(image.height) / scale
|
|
433
|
+
let originX = Double(region?.origin.x ?? 0)
|
|
434
|
+
let originY = Double(region?.origin.y ?? 0)
|
|
435
|
+
|
|
436
|
+
var items: [[String: Any]] = []
|
|
437
|
+
for observation in request.results ?? [] {
|
|
438
|
+
guard let candidate = observation.topCandidates(1).first else { continue }
|
|
439
|
+
// Vision reports a unit box with a bottom-left origin; screen points
|
|
440
|
+
// run from the top left.
|
|
441
|
+
let box = observation.boundingBox
|
|
442
|
+
let x = originX + Double(box.origin.x) * widthPoints
|
|
443
|
+
let y = originY + (1 - Double(box.origin.y) - Double(box.height)) * heightPoints
|
|
444
|
+
let w = Double(box.width) * widthPoints
|
|
445
|
+
let h = Double(box.height) * heightPoints
|
|
446
|
+
items.append([
|
|
447
|
+
"text": candidate.string,
|
|
448
|
+
"confidence": Double(round(candidate.confidence * 100) / 100),
|
|
449
|
+
"frame": [Int(x), Int(y), Int(w), Int(h)],
|
|
450
|
+
"center": [Int(x + w / 2), Int(y + h / 2)],
|
|
451
|
+
])
|
|
452
|
+
}
|
|
453
|
+
return ["success": true, "action": "ocr", "count": items.count, "items": items]
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// MARK: - Displays
|
|
457
|
+
|
|
458
|
+
func displays() -> [String: Any] {
|
|
459
|
+
let screens = NSScreen.screens.enumerated().map { index, screen -> [String: Any] in
|
|
460
|
+
let frame = screen.frame
|
|
461
|
+
return [
|
|
462
|
+
"index": index,
|
|
463
|
+
"frame": [Int(frame.origin.x), Int(frame.origin.y), Int(frame.width), Int(frame.height)],
|
|
464
|
+
"scale": Double(screen.backingScaleFactor),
|
|
465
|
+
"main": screen == NSScreen.main,
|
|
466
|
+
]
|
|
467
|
+
}
|
|
468
|
+
return ["success": true, "action": "displays", "count": screens.count, "displays": screens]
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// MARK: - Entry
|
|
472
|
+
|
|
473
|
+
var args = Array(CommandLine.arguments.dropFirst())
|
|
474
|
+
guard let command = args.first else {
|
|
475
|
+
fail("usage", "Usage: desktop-perceive snapshot|resolve|press|set-value|ocr|displays [options]")
|
|
476
|
+
}
|
|
477
|
+
args = Array(args.dropFirst())
|
|
478
|
+
|
|
479
|
+
func option(_ name: String) -> String? {
|
|
480
|
+
guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
|
|
481
|
+
return args[index + 1]
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Every subcommand but `displays` reads the accessibility tree or the screen.
|
|
485
|
+
if command != "displays" && !AXIsProcessTrusted() {
|
|
486
|
+
fail("permission_required",
|
|
487
|
+
"Accessibility is not granted to this process, so the element tree is invisible.",
|
|
488
|
+
extra: ["permission": "accessibility",
|
|
489
|
+
"howTo": "System Settings → Privacy & Security → Accessibility"])
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
switch command {
|
|
493
|
+
case "snapshot":
|
|
494
|
+
let limit = Int(option("--max") ?? "") ?? 200
|
|
495
|
+
let (app, name, pid) = targetApp(named: option("--app"))
|
|
496
|
+
let nodes = snapshot(app: app, limit: limit, allWindows: args.contains("--all-windows"),
|
|
497
|
+
includeMenus: args.contains("--menus"))
|
|
498
|
+
writeCache(app: name, pid: pid, nodes: nodes)
|
|
499
|
+
emit(["success": true, "action": "snapshot", "app": name, "count": nodes.count,
|
|
500
|
+
"truncated": nodes.count >= limit, "elements": nodes.map(\.json)])
|
|
501
|
+
|
|
502
|
+
case "resolve":
|
|
503
|
+
guard let ref = option("--ref") else { fail("usage", "resolve needs --ref @eN") }
|
|
504
|
+
emit(resolve(ref: ref))
|
|
505
|
+
|
|
506
|
+
case "press":
|
|
507
|
+
guard let ref = option("--ref") else { fail("usage", "press needs --ref @eN") }
|
|
508
|
+
emit(press(ref: ref))
|
|
509
|
+
|
|
510
|
+
case "set-value":
|
|
511
|
+
guard let ref = option("--ref"), let text = option("--text") else {
|
|
512
|
+
fail("usage", "set-value needs --ref @eN --text STRING")
|
|
513
|
+
}
|
|
514
|
+
emit(setValue(ref: ref, text: text))
|
|
515
|
+
|
|
516
|
+
case "ocr":
|
|
517
|
+
var region: CGRect?
|
|
518
|
+
if let spec = option("--region") {
|
|
519
|
+
let parts = spec.split(separator: ",").compactMap { Double($0) }
|
|
520
|
+
guard parts.count == 4 else { fail("usage", "--region takes x,y,w,h") }
|
|
521
|
+
region = CGRect(x: parts[0], y: parts[1], width: parts[2], height: parts[3])
|
|
522
|
+
}
|
|
523
|
+
emit(ocr(region: region, imagePath: option("--image")))
|
|
524
|
+
|
|
525
|
+
case "displays":
|
|
526
|
+
emit(displays())
|
|
527
|
+
|
|
528
|
+
default:
|
|
529
|
+
fail("usage", "Unknown subcommand \(command). Use snapshot, resolve, press, set-value, ocr or displays.")
|
|
530
|
+
}
|