agent-device 0.4.2 → 0.5.0

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.
Files changed (91) hide show
  1. package/README.md +2 -9
  2. package/dist/src/797.js +1 -1
  3. package/dist/src/bin.js +5 -5
  4. package/dist/src/daemon.js +16 -20
  5. package/package.json +7 -9
  6. package/skills/agent-device/SKILL.md +3 -6
  7. package/skills/agent-device/references/permissions.md +3 -15
  8. package/skills/agent-device/references/snapshot-refs.md +1 -4
  9. package/dist/bin/axsnapshot +0 -0
  10. package/ios-runner/AXSnapshot/Package.swift +0 -18
  11. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
  12. package/src/__tests__/cli-close.test.ts +0 -155
  13. package/src/__tests__/cli-help.test.ts +0 -102
  14. package/src/bin.ts +0 -3
  15. package/src/cli.ts +0 -305
  16. package/src/core/__tests__/capabilities.test.ts +0 -75
  17. package/src/core/__tests__/dispatch-open.test.ts +0 -25
  18. package/src/core/__tests__/open-target.test.ts +0 -55
  19. package/src/core/capabilities.ts +0 -57
  20. package/src/core/dispatch.ts +0 -382
  21. package/src/core/open-target.ts +0 -27
  22. package/src/daemon/__tests__/device-ready.test.ts +0 -52
  23. package/src/daemon/__tests__/is-predicates.test.ts +0 -68
  24. package/src/daemon/__tests__/selectors.test.ts +0 -261
  25. package/src/daemon/__tests__/session-routing.test.ts +0 -108
  26. package/src/daemon/__tests__/session-selector.test.ts +0 -64
  27. package/src/daemon/__tests__/session-store.test.ts +0 -142
  28. package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
  29. package/src/daemon/action-utils.ts +0 -29
  30. package/src/daemon/context.ts +0 -48
  31. package/src/daemon/device-ready.ts +0 -155
  32. package/src/daemon/handlers/__tests__/find.test.ts +0 -99
  33. package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
  34. package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
  35. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
  36. package/src/daemon/handlers/__tests__/session.test.ts +0 -820
  37. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
  38. package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
  39. package/src/daemon/handlers/find.ts +0 -324
  40. package/src/daemon/handlers/interaction.ts +0 -550
  41. package/src/daemon/handlers/parse-utils.ts +0 -8
  42. package/src/daemon/handlers/record-trace.ts +0 -154
  43. package/src/daemon/handlers/session.ts +0 -1137
  44. package/src/daemon/handlers/snapshot.ts +0 -439
  45. package/src/daemon/is-predicates.ts +0 -46
  46. package/src/daemon/selectors.ts +0 -540
  47. package/src/daemon/session-routing.ts +0 -22
  48. package/src/daemon/session-selector.ts +0 -39
  49. package/src/daemon/session-store.ts +0 -296
  50. package/src/daemon/snapshot-processing.ts +0 -131
  51. package/src/daemon/types.ts +0 -56
  52. package/src/daemon-client.ts +0 -272
  53. package/src/daemon.ts +0 -295
  54. package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
  55. package/src/platforms/android/__tests__/index.test.ts +0 -274
  56. package/src/platforms/android/devices.ts +0 -196
  57. package/src/platforms/android/index.ts +0 -784
  58. package/src/platforms/android/ui-hierarchy.ts +0 -312
  59. package/src/platforms/boot-diagnostics.ts +0 -128
  60. package/src/platforms/ios/__tests__/index.test.ts +0 -312
  61. package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
  62. package/src/platforms/ios/apps.ts +0 -358
  63. package/src/platforms/ios/ax-snapshot.ts +0 -207
  64. package/src/platforms/ios/config.ts +0 -28
  65. package/src/platforms/ios/devicectl.ts +0 -134
  66. package/src/platforms/ios/devices.ts +0 -100
  67. package/src/platforms/ios/index.ts +0 -20
  68. package/src/platforms/ios/runner-client.ts +0 -994
  69. package/src/platforms/ios/simulator.ts +0 -164
  70. package/src/utils/__tests__/args.test.ts +0 -239
  71. package/src/utils/__tests__/daemon-client.test.ts +0 -95
  72. package/src/utils/__tests__/exec.test.ts +0 -16
  73. package/src/utils/__tests__/finders.test.ts +0 -34
  74. package/src/utils/__tests__/keyed-lock.test.ts +0 -55
  75. package/src/utils/__tests__/process-identity.test.ts +0 -33
  76. package/src/utils/__tests__/retry.test.ts +0 -44
  77. package/src/utils/args.ts +0 -239
  78. package/src/utils/command-schema.ts +0 -622
  79. package/src/utils/device.ts +0 -84
  80. package/src/utils/errors.ts +0 -35
  81. package/src/utils/exec.ts +0 -339
  82. package/src/utils/finders.ts +0 -101
  83. package/src/utils/interactive.ts +0 -4
  84. package/src/utils/interactors.ts +0 -173
  85. package/src/utils/keyed-lock.ts +0 -14
  86. package/src/utils/output.ts +0 -204
  87. package/src/utils/process-identity.ts +0 -100
  88. package/src/utils/retry.ts +0 -180
  89. package/src/utils/snapshot.ts +0 -64
  90. package/src/utils/timeouts.ts +0 -9
  91. package/src/utils/version.ts +0 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -14,16 +14,15 @@
14
14
  "scripts": {
15
15
  "lint": "node --eval \"console.log('no lint')\"",
16
16
  "build": "rslib build",
17
- "build:node": "pnpm build && rm -f ~/.agent-device/daemon.json",
18
- "build:swift": "swift build -c release --package-path ios-runner/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",
17
+ "clean:daemon": "rm -f ~/.agent-device/daemon.json && rm -f ~/.agent-device/daemon.lock",
18
+ "build:node": "pnpm build && pnpm clean:daemon",
20
19
  "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",
21
- "build:clis": "pnpm build:node && pnpm build:axsnapshot",
22
- "build:all": "pnpm build:node && pnpm build:axsnapshot && pnpm build:xcuitest",
20
+ "build:clis": "pnpm build:node",
21
+ "build:all": "pnpm build:node && pnpm build:xcuitest",
23
22
  "ad": "node bin/agent-device.mjs",
24
23
  "format": "prettier --write .",
25
- "prepublishOnly": "pnpm build:node && pnpm build:axsnapshot",
26
- "prepack": "pnpm build:node && pnpm build:axsnapshot",
24
+ "prepublishOnly": "pnpm build:node",
25
+ "prepack": "pnpm build:node",
27
26
  "typecheck": "tsc -p tsconfig.json",
28
27
  "test": "node --test",
29
28
  "test:unit": "node --test src/__tests__/*.test.ts src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
@@ -39,7 +38,6 @@
39
38
  "!ios-runner/**/xcuserdata",
40
39
  "!ios-runner/**/*.xcuserstate",
41
40
  "skills",
42
- "src",
43
41
  "README.md",
44
42
  "LICENSE"
45
43
  ],
@@ -64,11 +64,9 @@ agent-device snapshot -c # Compact output
64
64
  agent-device snapshot -d 3 # Limit depth
65
65
  agent-device snapshot -s "Camera" # Scope to label/identifier
66
66
  agent-device snapshot --raw # Raw node output
67
- agent-device snapshot --backend xctest # default: XCTest snapshot (fast, complete, no permissions)
68
- agent-device snapshot --backend ax # macOS Accessibility tree (manual diagnostics only; no automatic fallback)
69
67
  ```
70
68
 
71
- XCTest is the default: fast and complete and does not require permissions. Use AX only for manual diagnostics, and prefer XCTest for normal automation flows. agent-device does not automatically fall back to AX.
69
+ XCTest is the iOS snapshot engine: fast, complete, and no Accessibility permission required.
72
70
 
73
71
  ### Find (semantic)
74
72
 
@@ -157,7 +155,7 @@ agent-device replay -u ./session.ad # Update selector drift and rewrite .ad sc
157
155
  `--save-script` path is a file path; parent directories are created automatically.
158
156
  For ambiguous bare values, use `--save-script=workflow.ad` or `./workflow.ad`.
159
157
 
160
- ### Trace logs (AX/XCTest)
158
+ ### Trace logs (XCTest)
161
159
 
162
160
  ```bash
163
161
  agent-device trace start # Start trace capture
@@ -187,12 +185,11 @@ agent-device apps --platform android --user-installed
187
185
  - Snapshot refs are the core mechanism for interactive agent flows.
188
186
  - Use selectors for deterministic replay artifacts and assertions (e.g. in e2e test workflows).
189
187
  - Prefer `snapshot -i` to reduce output size.
190
- - On iOS, `xctest` is the default and does not require Accessibility permission.
188
+ - On iOS, snapshots use XCTest and do not require Accessibility permission.
191
189
  - If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
192
190
  - `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.
193
191
  - `open <app>` updates session app bundle context; `open <app> <url>` opens a deep link on iOS.
194
192
  - Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
195
- - If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
196
193
  - Use `--session <name>` for parallel sessions; avoid device contention.
197
194
  - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
198
195
  - On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
@@ -1,19 +1,8 @@
1
1
  # Permissions and Setup
2
2
 
3
- ## iOS AX snapshot
3
+ ## iOS snapshots
4
4
 
5
- AX snapshot is available for manual diagnostics when needed; it is not used as an automatic fallback. It uses macOS Accessibility APIs and requires permission:
6
-
7
- System Settings > Privacy & Security > Accessibility
8
-
9
- If permission is missing, use XCTest backend:
10
-
11
- ```bash
12
- agent-device snapshot --backend xctest --platform ios
13
- ```
14
-
15
- Hybrid/AX is fast; XCTest is equally fast but does not require permissions.
16
- AX backend is simulator-only.
5
+ iOS snapshots use XCTest and do not require macOS Accessibility permissions.
17
6
 
18
7
  ## iOS physical device runner
19
8
 
@@ -35,5 +24,4 @@ If daemon startup fails with stale metadata hints, clean stale files and retry:
35
24
 
36
25
  ## Simulator troubleshooting
37
26
 
38
- - If AX shows the Simulator chrome instead of app, restart Simulator.
39
- - If AX returns empty, restart Simulator and re-open app.
27
+ - If snapshots return 0 nodes, restart Simulator and re-open the app.
@@ -53,10 +53,7 @@ agent-device snapshot -i -s @e3
53
53
  ## Troubleshooting
54
54
 
55
55
  - Ref not found: re-snapshot.
56
- - AX returns Simulator window: restart Simulator and re-run.
57
- - AX empty: verify Accessibility permission or use `--backend xctest` (XCTest is more complete).
58
- - AX backend is simulator-only; use `--backend xctest` on iOS devices.
59
- - agent-device does not automatically fall back to AX when XCTest fails.
56
+ - If XCTest returns 0 nodes, foreground app state may have changed. Re-open the app or retry after state is stable.
60
57
 
61
58
  ## Replay note
62
59
 
Binary file
@@ -1,18 +0,0 @@
1
- // swift-tools-version: 5.9
2
- import PackageDescription
3
-
4
- let package = Package(
5
- name: "axsnapshot",
6
- platforms: [
7
- .macOS(.v13)
8
- ],
9
- products: [
10
- .executable(name: "axsnapshot", targets: ["AXSnapshot"])
11
- ],
12
- targets: [
13
- .executableTarget(
14
- name: "AXSnapshot",
15
- path: "Sources/AXSnapshot"
16
- )
17
- ]
18
- )
@@ -1,444 +0,0 @@
1
- import Foundation
2
- import ApplicationServices
3
- import Cocoa
4
-
5
- struct AXNode: Codable {
6
- struct Frame: Codable {
7
- let x: Double
8
- let y: Double
9
- let width: Double
10
- let height: Double
11
- }
12
-
13
- let role: String?
14
- let subrole: String?
15
- let label: String?
16
- let value: String?
17
- let identifier: String?
18
- let frame: Frame?
19
- let children: [AXNode]
20
- }
21
-
22
- struct AXSnapshotError: Error, CustomStringConvertible {
23
- let message: String
24
- var description: String { message }
25
- }
26
-
27
- let simulatorBundleId = "com.apple.iphonesimulator"
28
- let defaultMaxDepth = 40
29
-
30
- func hasAccessibilityPermission() -> Bool {
31
- AXIsProcessTrusted()
32
- }
33
-
34
- func findSimulatorApp() -> NSRunningApplication? {
35
- NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier == simulatorBundleId }
36
- }
37
-
38
- func axElement(for app: NSRunningApplication) -> AXUIElement {
39
- AXUIElementCreateApplication(app.processIdentifier)
40
- }
41
-
42
- func getAttribute<T>(_ element: AXUIElement, _ attribute: CFString) -> T? {
43
- var value: AnyObject?
44
- let result = AXUIElementCopyAttributeValue(element, attribute, &value)
45
- guard result == .success else { return nil }
46
- return value as? T
47
- }
48
-
49
- func getChildren(_ element: AXUIElement) -> [AXUIElement] {
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
- }
64
-
65
- func getLabel(_ element: AXUIElement) -> String? {
66
- if let label: String = getAttribute(element, "AXLabel" as CFString) {
67
- return label
68
- }
69
- if let desc: String = getAttribute(element, kAXDescriptionAttribute as CFString) {
70
- return desc
71
- }
72
- return nil
73
- }
74
-
75
- func getDescription(_ element: AXUIElement) -> String? {
76
- getAttribute(element, kAXDescriptionAttribute as CFString)
77
- }
78
-
79
- func getValue(_ element: AXUIElement) -> String? {
80
- if let value: String = getAttribute(element, kAXValueAttribute as CFString) {
81
- return value
82
- }
83
- if let number: NSNumber = getAttribute(element, kAXValueAttribute as CFString) {
84
- return number.stringValue
85
- }
86
- return nil
87
- }
88
-
89
- func getIdentifier(_ element: AXUIElement) -> String? {
90
- getAttribute(element, kAXIdentifierAttribute as CFString)
91
- }
92
-
93
- func getFrame(_ element: AXUIElement) -> AXNode.Frame? {
94
- var positionRef: CFTypeRef?
95
- var sizeRef: CFTypeRef?
96
- AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef)
97
- AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef)
98
- guard let posValue = positionRef, let sizeValue = sizeRef else {
99
- return nil
100
- }
101
- if CFGetTypeID(posValue) != AXValueGetTypeID() || CFGetTypeID(sizeValue) != AXValueGetTypeID() {
102
- return nil
103
- }
104
- let posAx = posValue as! AXValue
105
- let sizeAx = sizeValue as! AXValue
106
- var point = CGPoint.zero
107
- var size = CGSize.zero
108
- AXValueGetValue(posAx, .cgPoint, &point)
109
- AXValueGetValue(sizeAx, .cgSize, &size)
110
- return AXNode.Frame(
111
- x: Double(point.x),
112
- y: Double(point.y),
113
- width: Double(size.width),
114
- height: Double(size.height)
115
- )
116
- }
117
-
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
- : []
122
- return AXNode(
123
- role: getAttribute(element, kAXRoleAttribute as CFString),
124
- subrole: getAttribute(element, kAXSubroleAttribute as CFString),
125
- label: getLabel(element),
126
- value: getValue(element),
127
- identifier: getIdentifier(element),
128
- frame: getFrame(element),
129
- children: children
130
- )
131
- }
132
-
133
- func findIOSAppSnapshot(in simulator: NSRunningApplication) -> (AXUIElement, AXNode.Frame?, AXUIElement, [AXUIElement], [AXUIElement])? {
134
- let appElement = axElement(for: simulator)
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] = []
170
- for window in windows {
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)
266
- }
267
- }
268
- stack.append(contentsOf: getChildren(current))
269
- }
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
377
- }
378
-
379
- func main() throws {
380
- guard hasAccessibilityPermission() else {
381
- throw AXSnapshotError(message: "Accessibility permission not granted. Enable it in System Settings > Privacy & Security > Accessibility.")
382
- }
383
- guard let simulator = findSimulatorApp() else {
384
- throw AXSnapshotError(message: "iOS Simulator is not running.")
385
- }
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 {
401
- throw AXSnapshotError(message: "Could not find iOS app content in Simulator.")
402
- }
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)
429
- let encoder = JSONEncoder()
430
- encoder.outputFormatting = [.sortedKeys]
431
- let data = try encoder.encode(payload)
432
- if let json = String(data: data, encoding: .utf8) {
433
- print(json)
434
- } else {
435
- throw AXSnapshotError(message: "Failed to encode AX snapshot JSON.")
436
- }
437
- }
438
-
439
- do {
440
- try main()
441
- } catch {
442
- fputs("axsnapshot error: \(error)\n", stderr)
443
- exit(1)
444
- }