agent-device 0.1.1 → 0.1.3
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/README.md +17 -6
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/861.js +1 -1
- package/dist/src/bin.js +61 -38
- package/dist/src/daemon.js +4 -4
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +305 -28
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +59 -20
- package/package.json +4 -3
- package/src/cli.ts +22 -0
- package/src/core/dispatch.ts +72 -4
- package/src/daemon.ts +358 -38
- package/src/platforms/android/devices.ts +13 -1
- package/src/platforms/android/index.ts +69 -0
- package/src/platforms/ios/ax-snapshot.ts +8 -10
- package/src/platforms/ios/index.ts +44 -30
- package/src/platforms/ios/runner-client.ts +13 -1
- package/src/utils/args.ts +55 -25
- package/src/utils/exec.ts +74 -3
- package/src/utils/interactors.ts +5 -0
- package/bin/axsnapshot +0 -0
|
@@ -19,17 +19,13 @@ struct AXNode: Codable {
|
|
|
19
19
|
let children: [AXNode]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
struct AXSnapshot: Codable {
|
|
23
|
-
let windowFrame: AXNode.Frame?
|
|
24
|
-
let root: AXNode
|
|
25
|
-
}
|
|
26
|
-
|
|
27
22
|
struct AXSnapshotError: Error, CustomStringConvertible {
|
|
28
23
|
let message: String
|
|
29
24
|
var description: String { message }
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
let simulatorBundleId = "com.apple.iphonesimulator"
|
|
28
|
+
let defaultMaxDepth = 40
|
|
33
29
|
|
|
34
30
|
func hasAccessibilityPermission() -> Bool {
|
|
35
31
|
AXIsProcessTrusted()
|
|
@@ -51,15 +47,19 @@ func getAttribute<T>(_ element: AXUIElement, _ attribute: CFString) -> T? {
|
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
func getChildren(_ element: AXUIElement) -> [AXUIElement] {
|
|
54
|
-
getAttribute(element, kAXChildrenAttribute as CFString)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
getAttribute(element,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
getAttribute(element,
|
|
50
|
+
if let children: [AXUIElement] = getAttribute(element, kAXChildrenAttribute as CFString),
|
|
51
|
+
!children.isEmpty {
|
|
52
|
+
return children
|
|
53
|
+
}
|
|
54
|
+
if let children: [AXUIElement] = getAttribute(element, kAXVisibleChildrenAttribute as CFString),
|
|
55
|
+
!children.isEmpty {
|
|
56
|
+
return children
|
|
57
|
+
}
|
|
58
|
+
if let children: [AXUIElement] = getAttribute(element, kAXContentsAttribute as CFString),
|
|
59
|
+
!children.isEmpty {
|
|
60
|
+
return children
|
|
61
|
+
}
|
|
62
|
+
return []
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
func getLabel(_ element: AXUIElement) -> String? {
|
|
@@ -72,6 +72,10 @@ func getLabel(_ element: AXUIElement) -> String? {
|
|
|
72
72
|
return nil
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
func getDescription(_ element: AXUIElement) -> String? {
|
|
76
|
+
getAttribute(element, kAXDescriptionAttribute as CFString)
|
|
77
|
+
}
|
|
78
|
+
|
|
75
79
|
func getValue(_ element: AXUIElement) -> String? {
|
|
76
80
|
if let value: String = getAttribute(element, kAXValueAttribute as CFString) {
|
|
77
81
|
return value
|
|
@@ -111,11 +115,13 @@ func getFrame(_ element: AXUIElement) -> AXNode.Frame? {
|
|
|
111
115
|
)
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
func buildTree(_ element: AXUIElement, depth: Int = 0, maxDepth: Int =
|
|
115
|
-
let children = depth < maxDepth
|
|
118
|
+
func buildTree(_ element: AXUIElement, depth: Int = 0, maxDepth: Int = defaultMaxDepth) -> AXNode {
|
|
119
|
+
let children = depth < maxDepth
|
|
120
|
+
? getChildren(element).map { buildTree($0, depth: depth + 1, maxDepth: maxDepth) }
|
|
121
|
+
: []
|
|
116
122
|
return AXNode(
|
|
117
|
-
role:
|
|
118
|
-
subrole:
|
|
123
|
+
role: getAttribute(element, kAXRoleAttribute as CFString),
|
|
124
|
+
subrole: getAttribute(element, kAXSubroleAttribute as CFString),
|
|
119
125
|
label: getLabel(element),
|
|
120
126
|
value: getValue(element),
|
|
121
127
|
identifier: getIdentifier(element),
|
|
@@ -124,17 +130,250 @@ func buildTree(_ element: AXUIElement, depth: Int = 0, maxDepth: Int = 40) -> AX
|
|
|
124
130
|
)
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
func
|
|
133
|
+
func findIOSAppSnapshot(in simulator: NSRunningApplication) -> (AXUIElement, AXNode.Frame?, AXUIElement, [AXUIElement], [AXUIElement])? {
|
|
128
134
|
let appElement = axElement(for: simulator)
|
|
129
|
-
let windows = getChildren(appElement).filter {
|
|
135
|
+
let windows = getChildren(appElement).filter {
|
|
136
|
+
(getAttribute($0, kAXRoleAttribute as CFString) as String?) == (kAXWindowRole as String)
|
|
137
|
+
}
|
|
138
|
+
if windows.isEmpty { return nil }
|
|
139
|
+
|
|
140
|
+
if let focused: AXUIElement = getAttribute(appElement, kAXFocusedWindowAttribute as CFString),
|
|
141
|
+
let root = chooseRoot(in: focused) {
|
|
142
|
+
let extras = dedupeElements(findToolbarExtras(in: focused, root: root) + findTabBarExtras(in: focused, root: root))
|
|
143
|
+
let modalRoots = findAdditionalWindowRoots(windows: windows, excluding: focused, windowFrame: getFrame(focused))
|
|
144
|
+
return (root, getFrame(focused), focused, extras, modalRoots)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let sorted = windows.sorted { lhs, rhs in
|
|
148
|
+
let l = getFrame(lhs)
|
|
149
|
+
let r = getFrame(rhs)
|
|
150
|
+
let la = (l?.width ?? 0) * (l?.height ?? 0)
|
|
151
|
+
let ra = (r?.width ?? 0) * (r?.height ?? 0)
|
|
152
|
+
return la > ra
|
|
153
|
+
}
|
|
154
|
+
for window in sorted {
|
|
155
|
+
if let root = chooseRoot(in: window) {
|
|
156
|
+
let extras = dedupeElements(findToolbarExtras(in: window, root: root) + findTabBarExtras(in: window, root: root))
|
|
157
|
+
let modalRoots = findAdditionalWindowRoots(windows: windows, excluding: window, windowFrame: getFrame(window))
|
|
158
|
+
return (root, getFrame(window), window, extras, modalRoots)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return nil
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private func findAdditionalWindowRoots(
|
|
165
|
+
windows: [AXUIElement],
|
|
166
|
+
excluding mainWindow: AXUIElement,
|
|
167
|
+
windowFrame: AXNode.Frame?
|
|
168
|
+
) -> [AXUIElement] {
|
|
169
|
+
var roots: [AXUIElement] = []
|
|
130
170
|
for window in windows {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
if CFEqual(window, mainWindow) { continue }
|
|
172
|
+
let frame = getFrame(window)
|
|
173
|
+
if let windowFrame = windowFrame, !frameIntersects(frame, windowFrame) {
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if let root = chooseRoot(in: window) {
|
|
177
|
+
roots.append(root)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return dedupeElements(roots)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func dedupeElements(_ elements: [AXUIElement]) -> [AXUIElement] {
|
|
184
|
+
var seen: Set<AXWrapper> = []
|
|
185
|
+
var result: [AXUIElement] = []
|
|
186
|
+
for element in elements {
|
|
187
|
+
let wrapper = AXWrapper(element)
|
|
188
|
+
if seen.contains(wrapper) { continue }
|
|
189
|
+
seen.insert(wrapper)
|
|
190
|
+
result.append(element)
|
|
191
|
+
}
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func chooseRoot(in window: AXUIElement) -> AXUIElement? {
|
|
196
|
+
let windowFrame = getFrame(window)
|
|
197
|
+
let candidates = findGroupCandidates(in: window, windowFrame: windowFrame)
|
|
198
|
+
if let best = candidates.first?.element { return best }
|
|
199
|
+
return findLargestChildCandidate(in: window, windowFrame: windowFrame)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private func findLargestChildCandidate(in window: AXUIElement, windowFrame: AXNode.Frame?) -> AXUIElement? {
|
|
203
|
+
var best: (element: AXUIElement, area: Double)? = nil
|
|
204
|
+
for child in getChildren(window) {
|
|
205
|
+
let children = getChildren(child)
|
|
206
|
+
if children.isEmpty { continue }
|
|
207
|
+
let area = frameArea(getFrame(child), windowFrame: windowFrame)
|
|
208
|
+
if area <= 0 { continue }
|
|
209
|
+
if best == nil || area > best!.area {
|
|
210
|
+
best = (child, area)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return best?.element
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private func frameIntersects(_ frame: AXNode.Frame?, _ target: AXNode.Frame?) -> Bool {
|
|
217
|
+
guard let frame = frame, let target = target else { return false }
|
|
218
|
+
let fx1 = frame.x
|
|
219
|
+
let fy1 = frame.y
|
|
220
|
+
let fx2 = frame.x + frame.width
|
|
221
|
+
let fy2 = frame.y + frame.height
|
|
222
|
+
let tx1 = target.x
|
|
223
|
+
let ty1 = target.y
|
|
224
|
+
let tx2 = target.x + target.width
|
|
225
|
+
let ty2 = target.y + target.height
|
|
226
|
+
return fx1 < tx2 && fx2 > tx1 && fy1 < ty2 && fy2 > ty1
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private func isToolbarLike(_ element: AXUIElement) -> Bool {
|
|
230
|
+
let role = (getAttribute(element, kAXRoleAttribute as CFString) as String?) ?? ""
|
|
231
|
+
let subrole = (getAttribute(element, kAXSubroleAttribute as CFString) as String?) ?? ""
|
|
232
|
+
if role == (kAXToolbarRole as String) ||
|
|
233
|
+
role == (kAXTabGroupRole as String) ||
|
|
234
|
+
role == "AXTabBar" {
|
|
235
|
+
return true
|
|
236
|
+
}
|
|
237
|
+
if subrole == "AXTabBar" {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private func isTabBarLike(_ element: AXUIElement) -> Bool {
|
|
244
|
+
let role = (getAttribute(element, kAXRoleAttribute as CFString) as String?) ?? ""
|
|
245
|
+
let subrole = (getAttribute(element, kAXSubroleAttribute as CFString) as String?) ?? ""
|
|
246
|
+
if role == (kAXTabGroupRole as String) || role == "AXTabBar" { return true }
|
|
247
|
+
if subrole == "AXTabBar" { return true }
|
|
248
|
+
let desc = (getDescription(element) ?? "").lowercased()
|
|
249
|
+
if desc.contains("tab bar") { return true }
|
|
250
|
+
let label = (getLabel(element) ?? "").lowercased()
|
|
251
|
+
if label.contains("tab bar") { return true }
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func findToolbarExtras(in window: AXUIElement, root: AXUIElement) -> [AXUIElement] {
|
|
256
|
+
let rootFrame = getFrame(root)
|
|
257
|
+
let rootIds = collectDescendantWrappers(from: root)
|
|
258
|
+
var extras: [AXUIElement] = []
|
|
259
|
+
var stack = getChildren(window)
|
|
260
|
+
while !stack.isEmpty {
|
|
261
|
+
let current = stack.removeLast()
|
|
262
|
+
if isToolbarLike(current) && !rootIds.contains(AXWrapper(current)) {
|
|
263
|
+
let frame = getFrame(current)
|
|
264
|
+
if frameIntersects(frame, rootFrame) {
|
|
265
|
+
extras.append(current)
|
|
134
266
|
}
|
|
135
267
|
}
|
|
268
|
+
stack.append(contentsOf: getChildren(current))
|
|
136
269
|
}
|
|
137
|
-
return
|
|
270
|
+
return extras
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func findTabBarExtras(in window: AXUIElement, root: AXUIElement) -> [AXUIElement] {
|
|
274
|
+
let rootFrame = getFrame(root)
|
|
275
|
+
let rootIds = collectDescendantWrappers(from: root)
|
|
276
|
+
var extras: [AXUIElement] = []
|
|
277
|
+
var stack = getChildren(window)
|
|
278
|
+
while !stack.isEmpty {
|
|
279
|
+
let current = stack.removeLast()
|
|
280
|
+
if isTabBarLike(current) && !rootIds.contains(AXWrapper(current)) {
|
|
281
|
+
let frame = getFrame(current)
|
|
282
|
+
if frameIntersects(frame, rootFrame) {
|
|
283
|
+
extras.append(current)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
stack.append(contentsOf: getChildren(current))
|
|
287
|
+
}
|
|
288
|
+
return extras
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private struct GroupCandidate {
|
|
292
|
+
let element: AXUIElement
|
|
293
|
+
let area: Double
|
|
294
|
+
let childCount: Int
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private func findGroupCandidates(in root: AXUIElement, windowFrame: AXNode.Frame?) -> [GroupCandidate] {
|
|
298
|
+
var candidates: [GroupCandidate] = []
|
|
299
|
+
var visited: Set<AXWrapper> = []
|
|
300
|
+
func walk(_ element: AXUIElement) {
|
|
301
|
+
let wrapper = AXWrapper(element)
|
|
302
|
+
if visited.contains(wrapper) { return }
|
|
303
|
+
visited.insert(wrapper)
|
|
304
|
+
let children = getChildren(element)
|
|
305
|
+
let role = (getAttribute(element, kAXRoleAttribute as CFString) as String?) ?? ""
|
|
306
|
+
let isContainer = role == (kAXGroupRole as String) ||
|
|
307
|
+
role == (kAXScrollAreaRole as String) ||
|
|
308
|
+
role == (kAXUnknownRole as String)
|
|
309
|
+
if isContainer {
|
|
310
|
+
let hasNonToolbarChild = children.contains {
|
|
311
|
+
((getAttribute($0, kAXRoleAttribute as CFString) as String?) ?? "") != (kAXToolbarRole as String)
|
|
312
|
+
}
|
|
313
|
+
if hasNonToolbarChild {
|
|
314
|
+
let frame = getFrame(element)
|
|
315
|
+
let area = frameArea(frame, windowFrame: windowFrame)
|
|
316
|
+
if area > 0 {
|
|
317
|
+
let childCount = children.count
|
|
318
|
+
candidates.append(
|
|
319
|
+
GroupCandidate(
|
|
320
|
+
element: element,
|
|
321
|
+
area: area,
|
|
322
|
+
childCount: childCount
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for child in children {
|
|
329
|
+
walk(child)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
walk(root)
|
|
333
|
+
candidates.sort { lhs, rhs in
|
|
334
|
+
if lhs.area == rhs.area { return lhs.childCount > rhs.childCount }
|
|
335
|
+
return lhs.area > rhs.area
|
|
336
|
+
}
|
|
337
|
+
return candidates
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private func frameArea(_ frame: AXNode.Frame?, windowFrame: AXNode.Frame?) -> Double {
|
|
341
|
+
guard let frame = frame else { return 0 }
|
|
342
|
+
if let windowFrame = windowFrame {
|
|
343
|
+
let windowArea = max(1.0, windowFrame.width * windowFrame.height)
|
|
344
|
+
let area = frame.width * frame.height
|
|
345
|
+
if area > windowArea { return 0 }
|
|
346
|
+
return area
|
|
347
|
+
}
|
|
348
|
+
return frame.width * frame.height
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private final class AXWrapper: Hashable {
|
|
352
|
+
let element: AXUIElement
|
|
353
|
+
init(_ element: AXUIElement) { self.element = element }
|
|
354
|
+
func hash(into hasher: inout Hasher) { hasher.combine(CFHash(element)) }
|
|
355
|
+
static func == (lhs: AXWrapper, rhs: AXWrapper) -> Bool {
|
|
356
|
+
return CFEqual(lhs.element, rhs.element)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private func collectDescendantWrappers(from root: AXUIElement) -> Set<AXWrapper> {
|
|
361
|
+
var seen: Set<AXWrapper> = []
|
|
362
|
+
var stack = [root]
|
|
363
|
+
while !stack.isEmpty {
|
|
364
|
+
let current = stack.removeLast()
|
|
365
|
+
let wrapper = AXWrapper(current)
|
|
366
|
+
if seen.contains(wrapper) { continue }
|
|
367
|
+
seen.insert(wrapper)
|
|
368
|
+
stack.append(contentsOf: getChildren(current))
|
|
369
|
+
}
|
|
370
|
+
return seen
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
private struct SnapshotPayload: Codable {
|
|
375
|
+
let windowFrame: AXNode.Frame?
|
|
376
|
+
let root: AXNode
|
|
138
377
|
}
|
|
139
378
|
|
|
140
379
|
func main() throws {
|
|
@@ -144,14 +383,52 @@ func main() throws {
|
|
|
144
383
|
guard let simulator = findSimulatorApp() else {
|
|
145
384
|
throw AXSnapshotError(message: "iOS Simulator is not running.")
|
|
146
385
|
}
|
|
147
|
-
|
|
386
|
+
let maxAttempts = 5
|
|
387
|
+
var snapshot: (AXUIElement, AXNode.Frame?, AXUIElement, [AXUIElement], [AXUIElement])? = nil
|
|
388
|
+
for attempt in 0..<maxAttempts {
|
|
389
|
+
if let candidate = findIOSAppSnapshot(in: simulator) {
|
|
390
|
+
let (root, _, _, _, modalRoots) = candidate
|
|
391
|
+
if !getChildren(root).isEmpty || !modalRoots.isEmpty {
|
|
392
|
+
snapshot = candidate
|
|
393
|
+
break
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if attempt < maxAttempts - 1 {
|
|
397
|
+
usleep(300_000)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
guard let (root, windowFrame, _, extras, modalRoots) = snapshot else {
|
|
148
401
|
throw AXSnapshotError(message: "Could not find iOS app content in Simulator.")
|
|
149
402
|
}
|
|
150
|
-
|
|
151
|
-
|
|
403
|
+
var tree = buildTree(root)
|
|
404
|
+
if !extras.isEmpty {
|
|
405
|
+
let extraNodes = extras.map { buildTree($0) }
|
|
406
|
+
tree = AXNode(
|
|
407
|
+
role: tree.role,
|
|
408
|
+
subrole: tree.subrole,
|
|
409
|
+
label: tree.label,
|
|
410
|
+
value: tree.value,
|
|
411
|
+
identifier: tree.identifier,
|
|
412
|
+
frame: tree.frame,
|
|
413
|
+
children: tree.children + extraNodes
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
if !modalRoots.isEmpty {
|
|
417
|
+
let modalNodes = modalRoots.map { buildTree($0) }
|
|
418
|
+
tree = AXNode(
|
|
419
|
+
role: tree.role,
|
|
420
|
+
subrole: tree.subrole,
|
|
421
|
+
label: tree.label,
|
|
422
|
+
value: tree.value,
|
|
423
|
+
identifier: tree.identifier,
|
|
424
|
+
frame: tree.frame,
|
|
425
|
+
children: tree.children + modalNodes
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
let payload = SnapshotPayload(windowFrame: windowFrame, root: tree)
|
|
152
429
|
let encoder = JSONEncoder()
|
|
153
430
|
encoder.outputFormatting = [.sortedKeys]
|
|
154
|
-
let data = try encoder.encode(
|
|
431
|
+
let data = try encoder.encode(payload)
|
|
155
432
|
if let json = String(data: data, encoding: .utf8) {
|
|
156
433
|
print(json)
|
|
157
434
|
} else {
|
|
@@ -249,21 +249,6 @@ final class RunnerTests: XCTestCase {
|
|
|
249
249
|
}
|
|
250
250
|
let found = findElement(app: activeApp, text: text) != nil
|
|
251
251
|
return Response(ok: true, data: DataPayload(found: found))
|
|
252
|
-
case .rect:
|
|
253
|
-
guard let text = command.text else {
|
|
254
|
-
return Response(ok: false, error: ErrorPayload(message: "rect requires text"))
|
|
255
|
-
}
|
|
256
|
-
guard let element = findElement(app: activeApp, text: text) else {
|
|
257
|
-
return Response(ok: false, error: ErrorPayload(message: "element not found"))
|
|
258
|
-
}
|
|
259
|
-
let frame = element.frame
|
|
260
|
-
let rect = SnapshotRect(
|
|
261
|
-
x: Double(frame.origin.x),
|
|
262
|
-
y: Double(frame.origin.y),
|
|
263
|
-
width: Double(frame.size.width),
|
|
264
|
-
height: Double(frame.size.height)
|
|
265
|
-
)
|
|
266
|
-
return Response(ok: true, data: DataPayload(rect: rect))
|
|
267
252
|
case .listTappables:
|
|
268
253
|
let elements = activeApp.descendants(matching: .any).allElementsBoundByIndex
|
|
269
254
|
let labels = elements.compactMap { element -> String? in
|
|
@@ -287,9 +272,62 @@ final class RunnerTests: XCTestCase {
|
|
|
287
272
|
return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
|
|
288
273
|
}
|
|
289
274
|
return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
|
|
275
|
+
case .back:
|
|
276
|
+
if tapNavigationBack(app: activeApp) {
|
|
277
|
+
return Response(ok: true, data: DataPayload(message: "back"))
|
|
278
|
+
}
|
|
279
|
+
performBackGesture(app: activeApp)
|
|
280
|
+
return Response(ok: true, data: DataPayload(message: "back"))
|
|
281
|
+
case .home:
|
|
282
|
+
XCUIDevice.shared.press(.home)
|
|
283
|
+
return Response(ok: true, data: DataPayload(message: "home"))
|
|
284
|
+
case .appSwitcher:
|
|
285
|
+
performAppSwitcherGesture(app: activeApp)
|
|
286
|
+
return Response(ok: true, data: DataPayload(message: "appSwitcher"))
|
|
287
|
+
case .alert:
|
|
288
|
+
let action = (command.action ?? "get").lowercased()
|
|
289
|
+
let alert = activeApp.alerts.firstMatch
|
|
290
|
+
if !alert.exists {
|
|
291
|
+
return Response(ok: false, error: ErrorPayload(message: "alert not found"))
|
|
292
|
+
}
|
|
293
|
+
if action == "accept" {
|
|
294
|
+
let button = alert.buttons.allElementsBoundByIndex.first
|
|
295
|
+
button?.tap()
|
|
296
|
+
return Response(ok: true, data: DataPayload(message: "accepted"))
|
|
297
|
+
}
|
|
298
|
+
if action == "dismiss" {
|
|
299
|
+
let button = alert.buttons.allElementsBoundByIndex.last
|
|
300
|
+
button?.tap()
|
|
301
|
+
return Response(ok: true, data: DataPayload(message: "dismissed"))
|
|
302
|
+
}
|
|
303
|
+
let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
|
|
304
|
+
return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
|
|
290
305
|
}
|
|
291
306
|
}
|
|
292
307
|
|
|
308
|
+
private func tapNavigationBack(app: XCUIApplication) -> Bool {
|
|
309
|
+
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
|
|
310
|
+
if let back = buttons.first(where: { $0.isHittable }) {
|
|
311
|
+
back.tap()
|
|
312
|
+
return true
|
|
313
|
+
}
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private func performBackGesture(app: XCUIApplication) {
|
|
318
|
+
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
319
|
+
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
|
|
320
|
+
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
|
321
|
+
start.press(forDuration: 0.05, thenDragTo: end)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func performAppSwitcherGesture(app: XCUIApplication) {
|
|
325
|
+
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
326
|
+
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
|
|
327
|
+
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
|
|
328
|
+
start.press(forDuration: 0.6, thenDragTo: end)
|
|
329
|
+
}
|
|
330
|
+
|
|
293
331
|
private func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
|
|
294
332
|
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
|
|
295
333
|
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
@@ -602,7 +640,10 @@ enum CommandType: String, Codable {
|
|
|
602
640
|
case findText
|
|
603
641
|
case listTappables
|
|
604
642
|
case snapshot
|
|
605
|
-
case
|
|
643
|
+
case back
|
|
644
|
+
case home
|
|
645
|
+
case appSwitcher
|
|
646
|
+
case alert
|
|
606
647
|
case shutdown
|
|
607
648
|
}
|
|
608
649
|
|
|
@@ -617,6 +658,7 @@ struct Command: Codable {
|
|
|
617
658
|
let command: CommandType
|
|
618
659
|
let appBundleId: String?
|
|
619
660
|
let text: String?
|
|
661
|
+
let action: String?
|
|
620
662
|
let x: Double?
|
|
621
663
|
let y: Double?
|
|
622
664
|
let direction: SwipeDirection?
|
|
@@ -645,22 +687,19 @@ struct DataPayload: Codable {
|
|
|
645
687
|
let items: [String]?
|
|
646
688
|
let nodes: [SnapshotNode]?
|
|
647
689
|
let truncated: Bool?
|
|
648
|
-
let rect: SnapshotRect?
|
|
649
690
|
|
|
650
691
|
init(
|
|
651
692
|
message: String? = nil,
|
|
652
693
|
found: Bool? = nil,
|
|
653
694
|
items: [String]? = nil,
|
|
654
695
|
nodes: [SnapshotNode]? = nil,
|
|
655
|
-
truncated: Bool? = nil
|
|
656
|
-
rect: SnapshotRect? = nil
|
|
696
|
+
truncated: Bool? = nil
|
|
657
697
|
) {
|
|
658
698
|
self.message = message
|
|
659
699
|
self.found = found
|
|
660
700
|
self.items = items
|
|
661
701
|
self.nodes = nodes
|
|
662
702
|
self.truncated = truncated
|
|
663
|
-
self.rect = rect
|
|
664
703
|
}
|
|
665
704
|
}
|
|
666
705
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-device",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Callstack",
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
"build": "rslib build",
|
|
17
17
|
"build:node": "pnpm build && rm -f ~/.agent-device/daemon.json",
|
|
18
18
|
"build:swift": "swift build -c release --package-path ios-runner/AXSnapshot",
|
|
19
|
-
"build:axsnapshot": "pnpm build:swift && mkdir -p bin && cp -f ios-runner/AXSnapshot/.build/release/axsnapshot bin/axsnapshot && chmod +x bin/axsnapshot",
|
|
19
|
+
"build:axsnapshot": "pnpm build:swift && mkdir -p dist/bin && cp -f ios-runner/AXSnapshot/.build/release/axsnapshot dist/bin/axsnapshot && chmod +x dist/bin/axsnapshot",
|
|
20
|
+
"build:xcuitest": "AGENT_DEVICE_IOS_CLEAN_DERIVED=1 xcodebuild build-for-testing -project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -destination \"generic/platform=iOS Simulator\" -derivedDataPath ~/.agent-device/ios-runner/derived",
|
|
20
21
|
"build:clis": "pnpm build:node && pnpm build:axsnapshot",
|
|
22
|
+
"build:all": "pnpm build:node && pnpm build:axsnapshot && pnpm build:xcuitest",
|
|
21
23
|
"format": "prettier --write .",
|
|
22
24
|
"prepublishOnly": "pnpm build:node && pnpm build:axsnapshot",
|
|
23
25
|
"prepack": "pnpm build:node && pnpm build:axsnapshot",
|
|
@@ -29,7 +31,6 @@
|
|
|
29
31
|
"files": [
|
|
30
32
|
"bin",
|
|
31
33
|
"dist",
|
|
32
|
-
"bin/axsnapshot",
|
|
33
34
|
"ios-runner",
|
|
34
35
|
"!ios-runner/**/.build",
|
|
35
36
|
"!ios-runner/**/.swiftpm",
|
package/src/cli.ts
CHANGED
|
@@ -84,6 +84,28 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
84
84
|
if (logTailStopper) logTailStopper();
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
|
+
if (response.data && typeof response.data === 'object') {
|
|
88
|
+
const data = response.data as Record<string, unknown>;
|
|
89
|
+
if (command === 'devices') {
|
|
90
|
+
const devices = Array.isArray((data as any).devices) ? (data as any).devices : [];
|
|
91
|
+
const lines = devices.map((d: any) => {
|
|
92
|
+
const name = d?.name ?? d?.id ?? 'unknown';
|
|
93
|
+
const platform = d?.platform ?? 'unknown';
|
|
94
|
+
const kind = d?.kind ? ` ${d.kind}` : '';
|
|
95
|
+
const booted = typeof d?.booted === 'boolean' ? ` booted=${d.booted}` : '';
|
|
96
|
+
return `${name} (${platform}${kind})${booted}`;
|
|
97
|
+
});
|
|
98
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
99
|
+
if (logTailStopper) logTailStopper();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (command === 'apps') {
|
|
103
|
+
const apps = Array.isArray((data as any).apps) ? (data as any).apps : [];
|
|
104
|
+
process.stdout.write(`${apps.join('\n')}\n`);
|
|
105
|
+
if (logTailStopper) logTailStopper();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
87
109
|
if (logTailStopper) logTailStopper();
|
|
88
110
|
return;
|
|
89
111
|
}
|