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.
- package/README.md +2 -9
- package/dist/src/797.js +1 -1
- package/dist/src/bin.js +5 -5
- package/dist/src/daemon.js +16 -20
- package/package.json +7 -9
- package/skills/agent-device/SKILL.md +3 -6
- package/skills/agent-device/references/permissions.md +3 -15
- package/skills/agent-device/references/snapshot-refs.md +1 -4
- package/dist/bin/axsnapshot +0 -0
- package/ios-runner/AXSnapshot/Package.swift +0 -18
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
- package/src/__tests__/cli-close.test.ts +0 -155
- package/src/__tests__/cli-help.test.ts +0 -102
- package/src/bin.ts +0 -3
- package/src/cli.ts +0 -305
- package/src/core/__tests__/capabilities.test.ts +0 -75
- package/src/core/__tests__/dispatch-open.test.ts +0 -25
- package/src/core/__tests__/open-target.test.ts +0 -55
- package/src/core/capabilities.ts +0 -57
- package/src/core/dispatch.ts +0 -382
- package/src/core/open-target.ts +0 -27
- package/src/daemon/__tests__/device-ready.test.ts +0 -52
- package/src/daemon/__tests__/is-predicates.test.ts +0 -68
- package/src/daemon/__tests__/selectors.test.ts +0 -261
- package/src/daemon/__tests__/session-routing.test.ts +0 -108
- package/src/daemon/__tests__/session-selector.test.ts +0 -64
- package/src/daemon/__tests__/session-store.test.ts +0 -142
- package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
- package/src/daemon/action-utils.ts +0 -29
- package/src/daemon/context.ts +0 -48
- package/src/daemon/device-ready.ts +0 -155
- package/src/daemon/handlers/__tests__/find.test.ts +0 -99
- package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
- package/src/daemon/handlers/__tests__/session.test.ts +0 -820
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
- package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
- package/src/daemon/handlers/find.ts +0 -324
- package/src/daemon/handlers/interaction.ts +0 -550
- package/src/daemon/handlers/parse-utils.ts +0 -8
- package/src/daemon/handlers/record-trace.ts +0 -154
- package/src/daemon/handlers/session.ts +0 -1137
- package/src/daemon/handlers/snapshot.ts +0 -439
- package/src/daemon/is-predicates.ts +0 -46
- package/src/daemon/selectors.ts +0 -540
- package/src/daemon/session-routing.ts +0 -22
- package/src/daemon/session-selector.ts +0 -39
- package/src/daemon/session-store.ts +0 -296
- package/src/daemon/snapshot-processing.ts +0 -131
- package/src/daemon/types.ts +0 -56
- package/src/daemon-client.ts +0 -272
- package/src/daemon.ts +0 -295
- package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
- package/src/platforms/android/__tests__/index.test.ts +0 -274
- package/src/platforms/android/devices.ts +0 -196
- package/src/platforms/android/index.ts +0 -784
- package/src/platforms/android/ui-hierarchy.ts +0 -312
- package/src/platforms/boot-diagnostics.ts +0 -128
- package/src/platforms/ios/__tests__/index.test.ts +0 -312
- package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
- package/src/platforms/ios/apps.ts +0 -358
- package/src/platforms/ios/ax-snapshot.ts +0 -207
- package/src/platforms/ios/config.ts +0 -28
- package/src/platforms/ios/devicectl.ts +0 -134
- package/src/platforms/ios/devices.ts +0 -100
- package/src/platforms/ios/index.ts +0 -20
- package/src/platforms/ios/runner-client.ts +0 -994
- package/src/platforms/ios/simulator.ts +0 -164
- package/src/utils/__tests__/args.test.ts +0 -239
- package/src/utils/__tests__/daemon-client.test.ts +0 -95
- package/src/utils/__tests__/exec.test.ts +0 -16
- package/src/utils/__tests__/finders.test.ts +0 -34
- package/src/utils/__tests__/keyed-lock.test.ts +0 -55
- package/src/utils/__tests__/process-identity.test.ts +0 -33
- package/src/utils/__tests__/retry.test.ts +0 -44
- package/src/utils/args.ts +0 -239
- package/src/utils/command-schema.ts +0 -622
- package/src/utils/device.ts +0 -84
- package/src/utils/errors.ts +0 -35
- package/src/utils/exec.ts +0 -339
- package/src/utils/finders.ts +0 -101
- package/src/utils/interactive.ts +0 -4
- package/src/utils/interactors.ts +0 -173
- package/src/utils/keyed-lock.ts +0 -14
- package/src/utils/output.ts +0 -204
- package/src/utils/process-identity.ts +0 -100
- package/src/utils/retry.ts +0 -180
- package/src/utils/snapshot.ts +0 -64
- package/src/utils/timeouts.ts +0 -9
- 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.
|
|
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
|
-
"
|
|
18
|
-
"build:
|
|
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
|
|
22
|
-
"build:all": "pnpm build:node && pnpm build:
|
|
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
|
|
26
|
-
"prepack": "pnpm build:node
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
|
3
|
+
## iOS snapshots
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
package/dist/bin/axsnapshot
DELETED
|
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
|
-
}
|