agent-device 0.20.5 → 0.20.7

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 (196) hide show
  1. package/README.md +5 -3
  2. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.5.apk → agent-device-android-ime-helper-0.20.7.apk} +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.7.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.5.manifest.json → agent-device-android-ime-helper-0.20.7.manifest.json} +4 -4
  5. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.5.apk → agent-device-android-snapshot-helper-0.20.7.apk} +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.7.apk.sha256 +1 -0
  7. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.5.manifest.json → agent-device-android-snapshot-helper-0.20.7.manifest.json} +6 -6
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +48 -0
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +108 -1
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +630 -89
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSelectorMatchPolicy.swift +78 -0
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +126 -187
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h +32 -0
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m +212 -0
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +149 -10
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +161 -32
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CoordinateTextEntryTests.swift +4 -0
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +36 -28
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +23 -5
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +10 -0
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +0 -67
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +18 -1
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +2 -22
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SelectorMatchPolicyTests.swift +4 -0
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +2 -1
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +145 -20
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +0 -20
  29. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +4 -0
  30. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +234 -0
  31. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +106 -448
  32. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift +4 -0
  33. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +602 -0
  34. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +18 -0
  35. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +72 -0
  36. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +89 -0
  37. package/dist/src/agent-browser-tool.js +1 -1
  38. package/dist/src/agent-device-client.js +2 -2
  39. package/dist/src/android-adb.js +1 -1
  40. package/dist/src/android.js +1 -1
  41. package/dist/src/app-helpers.js +1 -1
  42. package/dist/src/app-lifecycle.js +7 -7
  43. package/dist/src/app-lifecycle2.js +2 -0
  44. package/dist/src/app-log-files.js +1 -0
  45. package/dist/src/app-log-reconnect.js +1 -0
  46. package/dist/src/app-log-runtime.js +4 -0
  47. package/dist/src/app-resolution.js +2 -1
  48. package/dist/src/apps.js +5 -5
  49. package/dist/src/audio-probe.js +1 -1
  50. package/dist/src/auth-session.js +1 -0
  51. package/dist/src/auth.js +2 -0
  52. package/dist/src/boot-diagnostics.js +2 -2
  53. package/dist/src/cli-config.js +1 -1
  54. package/dist/src/cli-grammar.js +1 -0
  55. package/dist/src/cli-help.js +204 -254
  56. package/dist/src/cli-output.js +1 -0
  57. package/dist/src/cli-runner.js +1 -2
  58. package/dist/src/cli.js +6 -16
  59. package/dist/src/client-metro.js +1 -1
  60. package/dist/src/cloud-artifacts.d.ts +2 -137
  61. package/dist/src/command-metadata.js +1 -1
  62. package/dist/src/command-schema.js +3 -3
  63. package/dist/src/connection-runtime.js +1 -0
  64. package/dist/src/connection.js +5 -0
  65. package/dist/src/context.js +1 -1
  66. package/dist/src/daemon-shutdown-report.js +1 -0
  67. package/dist/src/daemon.js +2 -0
  68. package/dist/src/debug-symbols.js +3 -3
  69. package/dist/src/device-claim-inspection.js +1 -0
  70. package/dist/src/device-input-state.js +2 -2
  71. package/dist/src/device.js +1 -1
  72. package/dist/src/device2.js +2 -0
  73. package/dist/src/find.js +1 -1
  74. package/dist/src/finders.js +1 -1
  75. package/dist/src/generic-settle.js +1 -0
  76. package/dist/src/generic.js +1 -1
  77. package/dist/src/harmonyos.js +1 -0
  78. package/dist/src/helper.js +1 -1
  79. package/dist/src/host-process.js +2 -2
  80. package/dist/src/ime-lifecycle.js +7 -7
  81. package/dist/src/index.d.ts +69 -18
  82. package/dist/src/input-actions.js +4 -4
  83. package/dist/src/input-actions2.js +1 -1
  84. package/dist/src/input-actions3.js +1 -0
  85. package/dist/src/install-artifact.js +1 -5
  86. package/dist/src/install-artifact2.js +1 -1
  87. package/dist/src/install-source2.js +1 -1
  88. package/dist/src/interaction-runtime.js +1 -0
  89. package/dist/src/interaction-snapshot.js +1 -1
  90. package/dist/src/interaction-touch-reference-frame.js +1 -0
  91. package/dist/src/interaction.js +1 -1
  92. package/dist/src/interactor.js +2 -2
  93. package/dist/src/interactor2.js +1 -1
  94. package/dist/src/internal/bin.js +2 -2
  95. package/dist/src/internal/daemon.js +9 -16
  96. package/dist/src/inventory.js +5 -0
  97. package/dist/src/inventory2.js +1 -0
  98. package/dist/src/inventory3.js +2 -0
  99. package/dist/src/inventory4.js +1 -0
  100. package/dist/src/inventory5.js +1 -0
  101. package/dist/src/inventory6.js +1 -0
  102. package/dist/src/lease-context.js +1 -1
  103. package/dist/src/lease.js +1 -1
  104. package/dist/src/limrun-runtime-dependencies.js +1 -0
  105. package/dist/src/limrun.d.ts +10 -104
  106. package/dist/src/limrun.js +1 -1
  107. package/dist/src/linux.js +1 -1
  108. package/dist/src/logcat.js +1 -1
  109. package/dist/src/manifest.js +1 -1
  110. package/dist/src/owner-identity.js +1 -0
  111. package/dist/src/physical-device-control.js +1 -1
  112. package/dist/src/platform-runtime-app-log-android-transport.js +1 -0
  113. package/dist/src/platform-runtime-app-log-process.js +1 -0
  114. package/dist/src/platform-runtime-host.js +1 -0
  115. package/dist/src/platform-runtime-network-web-transport.js +1 -0
  116. package/dist/src/platform-runtime-operation-host.js +5 -0
  117. package/dist/src/platform-runtime-toolchain-host.js +1 -0
  118. package/dist/src/platform-runtime.js +1 -0
  119. package/dist/src/prepare-kind.js +15 -0
  120. package/dist/src/process-entry.js +1 -0
  121. package/dist/src/process-exit.js +1 -0
  122. package/dist/src/process-lock.js +1 -1
  123. package/dist/src/provider-webdriver.js +1 -0
  124. package/dist/src/provider.js +1 -0
  125. package/dist/src/proxy.js +2 -0
  126. package/dist/src/react-native.js +1 -1
  127. package/dist/src/record-trace.js +1 -1
  128. package/dist/src/registry.js +25 -24
  129. package/dist/src/remote-config.js +1 -1
  130. package/dist/src/remote-config2.js +1 -1
  131. package/dist/src/replay.js +3 -0
  132. package/dist/src/reporting.js +4 -4
  133. package/dist/src/runner-client.js +1 -1
  134. package/dist/src/runner-disposal.js +1 -1
  135. package/dist/src/runner-lease.js +1 -1
  136. package/dist/src/runtime.js +3 -3
  137. package/dist/src/runtime2.js +4 -0
  138. package/dist/src/runtime3.js +3 -0
  139. package/dist/src/runtime4.js +1 -0
  140. package/dist/src/runtime5.js +1 -0
  141. package/dist/src/runtime6.js +1 -0
  142. package/dist/src/runtime7.js +1 -0
  143. package/dist/src/screenshot-result.js +4 -4
  144. package/dist/src/screenshot.js +1 -0
  145. package/dist/src/sdk-android-adb.d.ts +20 -2
  146. package/dist/src/sdk-android-adb.js +2 -2
  147. package/dist/src/sdk-batch-runner.js +3 -3
  148. package/dist/src/sdk-contracts.d.ts +47 -6
  149. package/dist/src/sdk-contracts.js +1 -1
  150. package/dist/src/sdk-device.js +1 -1
  151. package/dist/src/sdk-finders.d.ts +4 -20
  152. package/dist/src/sdk-finders.js +1 -1
  153. package/dist/src/sdk-remote-config.js +1 -1
  154. package/dist/src/sdk-selectors.d.ts +601 -0
  155. package/dist/src/sdk-selectors.js +1 -1
  156. package/dist/src/selector-runtime.js +1 -1
  157. package/dist/src/selector-vocabulary.js +1 -1
  158. package/dist/src/selectors.d.ts +1 -46
  159. package/dist/src/selectors.js +1 -1
  160. package/dist/src/server.js +3 -3
  161. package/dist/src/session-target-evidence.js +1 -1
  162. package/dist/src/session.js +10 -9
  163. package/dist/src/shared.js +2 -0
  164. package/dist/src/shell-quote.js +1 -0
  165. package/dist/src/simulator.js +1 -1
  166. package/dist/src/snapshot-runtime.js +1 -0
  167. package/dist/src/snapshot-session.js +1 -0
  168. package/dist/src/snapshot.js +1 -1
  169. package/dist/src/snapshot2.js +1 -1
  170. package/dist/src/src.js +1 -1
  171. package/dist/src/src2.js +2 -0
  172. package/dist/src/src3.js +1 -0
  173. package/dist/src/text-surface.js +1 -0
  174. package/dist/src/timing-safe-equal.js +1 -0
  175. package/dist/src/tool-provider.js +3 -3
  176. package/dist/src/tool-provider2.js +1 -1
  177. package/dist/src/tool-provider3.js +1 -1
  178. package/dist/src/update-check.js +1 -1
  179. package/dist/src/version.js +1 -1
  180. package/dist/src/web.js +1 -1
  181. package/package.json +72 -50
  182. package/server.json +3 -3
  183. package/skills/agent-device/SKILL.md +6 -31
  184. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.5.apk.sha256 +0 -1
  185. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.5.apk.sha256 +0 -1
  186. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -225
  187. package/dist/src/app-log-request-scope.js +0 -1
  188. package/dist/src/devices.js +0 -1
  189. package/dist/src/devices2.js +0 -1
  190. package/dist/src/devices3.js +0 -1
  191. package/dist/src/devices4.js +0 -1
  192. package/dist/src/interaction.d.ts +0 -230
  193. package/dist/src/provider-limrun-runtime.js +0 -1
  194. package/dist/src/register-builtins.js +0 -1
  195. package/dist/src/screenshot-geometry.js +0 -1
  196. package/dist/src/viewport-dimension.js +0 -14
@@ -2,24 +2,97 @@
2
2
 
3
3
  #import <CoreGraphics/CoreGraphics.h>
4
4
  #import <objc/message.h>
5
+ #import <stdatomic.h>
5
6
 
6
7
  static NSString *const RunnerAXSnapshotOkKey = @"ok";
7
8
  static NSString *const RunnerAXSnapshotErrorKey = @"error";
8
9
  static NSString *const RunnerAXSnapshotRootKey = @"root";
9
10
  static NSString *const RunnerAXSnapshotTruncatedKey = @"truncated";
10
11
 
12
+ NSString *const RunnerAXSnapshotDeepExtensionKey = @"deepExtension";
13
+ NSString *const RunnerAXSnapshotDeepExtensionCallsKey = @"calls";
14
+ NSString *const RunnerAXSnapshotDeepExtensionNodesAddedKey = @"nodesAdded";
15
+ NSString *const RunnerAXSnapshotDeepExtensionPendingKey = @"pendingFrontiers";
16
+ NSString *const RunnerAXSnapshotDeepExtensionMissedKey = @"missedFrontiers";
17
+
18
+ NSString *const RunnerAXSnapshotCustomActionsKey = @"customActions";
19
+ NSString *const RunnerAXSnapshotCustomActionsReadKey = @"read";
20
+ NSString *const RunnerAXSnapshotCustomActionsCandidatesKey = @"candidates";
21
+ NSString *const RunnerAXSnapshotCustomActionsTruncatedKey = @"truncated";
22
+ NSString *const RunnerAXSnapshotCustomActionsBlockedKey = @"blocked";
23
+
24
+ /// The AX server's name for UIAccessibilityCustomActions (React Native's
25
+ /// `accessibilityActions` prop lands here too).
26
+ static NSString *const RunnerAXCustomActionsAttribute = @"XC_kAXXCAttributeCustomActions";
27
+
28
+ /// A single read costs ~100ms against a responsive app. This bound exists for
29
+ /// the unresponsive case: without it one wedged element would swallow the whole
30
+ /// capture's budget and starve every remaining candidate, turning a bounded
31
+ /// enrichment into a capture-length stall.
32
+ static const NSTimeInterval RunnerAXCustomActionReadTimeout = 1.0;
33
+
34
+ /// The AX call is a synchronous XPC round trip that cannot be cancelled once
35
+ /// issued, so the deadline above only frees the CALLER — the call itself keeps
36
+ /// running. Containment therefore has two parts, and both are needed:
37
+ ///
38
+ /// 1. every read runs on this one serial queue, so a wedged call can never be
39
+ /// joined by a second concurrent user of the shared XCAXClient;
40
+ /// 2. a single-flight guard (below) refuses to dispatch while an abandoned
41
+ /// read is still outstanding, so repeated captures cannot pile up work
42
+ /// behind it.
43
+ ///
44
+ /// Reads resume by themselves once the hung call returns (or the runner
45
+ /// restarts); nothing has to be reset by hand.
46
+ static dispatch_queue_t RunnerAXCustomActionReadQueue(void)
47
+ {
48
+ static dispatch_queue_t queue;
49
+ static dispatch_once_t onceToken;
50
+ dispatch_once(&onceToken, ^{
51
+ queue = dispatch_queue_create("com.callstack.agentdevice.runner.ax-custom-actions",
52
+ DISPATCH_QUEUE_SERIAL);
53
+ });
54
+ return queue;
55
+ }
56
+
57
+ /// Reads dispatched but not yet returned. Non-zero after a read blew its
58
+ /// deadline, which is exactly the condition the single-flight guard refuses on.
59
+ static atomic_int RunnerAXCustomActionReadsInFlight = 0;
60
+
61
+ /// Total dispatches. The invariant the containment fix exists to protect is
62
+ /// "a blocked capture adds no work", which is only observable as this counter
63
+ /// staying put, so it is recorded rather than inferred.
64
+ static atomic_long RunnerAXCustomActionReadDispatches = 0;
65
+
66
+ /// Output caps. The element budget bounds how many elements we read; these
67
+ /// bound what any ONE element can put in the response, so a pathological app
68
+ /// cannot spend the whole snapshot on one node's action list.
69
+ static const NSUInteger RunnerAXCustomActionsMaxPerElement = 8;
70
+ static const NSUInteger RunnerAXCustomActionNameMaxLength = 80;
71
+
11
72
  typedef id (*RunnerAXObjectMsgSend)(id, SEL);
12
73
  typedef NSInteger (*RunnerAXIntegerMsgSend)(id, SEL);
13
74
  typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
14
75
 
76
+ // A childless serialized node remembered with its depth (interface in the
77
+ // header for the unit bundle). Nodes at the deepest observed level of a
78
+ // depth-capped capture are the truncation frontier: the AX server withholds
79
+ // children uniformly at its per-request cap, so every capped branch ends at
80
+ // the same level (observed one BELOW the requested maxDepth — the server
81
+ // counts node levels, not edges).
82
+ @implementation RunnerAXSnapshotFrontier
83
+ @end
84
+
15
85
  @implementation RunnerAXSnapshotBridge
16
86
 
17
87
  + (NSDictionary<NSString *, id> *)snapshotTreeForApplication:(XCUIApplication *)application
18
88
  maxDepth:(NSInteger)maxDepth
19
89
  maxNodes:(NSInteger)maxNodes
90
+ deepExtensionCallLimit:(NSInteger)deepExtensionCallLimit
91
+ customActionLimit:(NSInteger)customActionLimit
92
+ deadline:(nullable NSDate *)deadline
20
93
  {
21
94
  @try {
22
- id axClient = [self objectFrom:XCUIDevice.sharedDevice selectorName:@"accessibilityInterface"];
95
+ id axClient = [self accessibilityClient];
23
96
  if (nil == axClient) {
24
97
  return [self failure:@"XCUIDevice accessibilityInterface is unavailable"];
25
98
  }
@@ -29,101 +102,540 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
29
102
  return [self failure:@"Could not match active AX application for XCTest application"];
30
103
  }
31
104
 
32
- NSMutableDictionary *parameters = [NSMutableDictionary dictionary];
33
- id defaults = [self objectFrom:axClient selectorName:@"defaultParameters"];
34
- if ([defaults isKindOfClass:NSDictionary.class]) {
35
- [parameters addEntriesFromDictionary:(NSDictionary *)defaults];
105
+ NSArray *attributes = [self snapshotAttributes];
106
+ NSError *error = nil;
107
+ id root = [self requestSnapshotFromClient:axClient
108
+ target:target
109
+ attributes:attributes
110
+ maxDepth:maxDepth
111
+ maxNodes:maxNodes
112
+ error:&error];
113
+ if (nil == root) {
114
+ return [self failure:error.localizedDescription ?: @"AX snapshot request returned nil"];
36
115
  }
37
- parameters[@"maxDepth"] = @(MAX(0, maxDepth));
38
- parameters[@"maxChildren"] = @(MAX(1, maxNodes));
39
- parameters[@"maxArrayCount"] = @(MAX(1, maxNodes));
40
- parameters[@"traverseFromParentsToChildren"] = @YES;
41
-
42
- SEL requestSelector = NSSelectorFromString(@"requestSnapshotForElement:attributes:parameters:error:");
43
- if (![axClient respondsToSelector:requestSelector]) {
44
- return [self failure:@"AX client does not support requestSnapshotForElement"];
116
+
117
+ BOOL truncated = NO;
118
+ NSInteger nodeCount = 0;
119
+ // nil disables candidate collection entirely — exact --depth captures pay
120
+ // zero extension bookkeeping.
121
+ NSMutableArray<RunnerAXSnapshotFrontier *> *candidates =
122
+ deepExtensionCallLimit > 0 ? [NSMutableArray array] : nil;
123
+ NSMutableArray<RunnerAXSnapshotFrontier *> *merged =
124
+ customActionLimit > 0 ? [NSMutableArray array] : nil;
125
+ NSMutableDictionary *rootNode = [self dictionaryForSnapshot:root
126
+ depth:0
127
+ maxDepth:maxDepth
128
+ maxNodes:maxNodes
129
+ nodeCount:&nodeCount
130
+ truncated:&truncated
131
+ frontiers:candidates
132
+ mergedLeaves:merged];
133
+ if (nil == rootNode) {
134
+ return [self failure:@"AX snapshot root could not be serialized"];
135
+ }
136
+
137
+ NSMutableArray<RunnerAXSnapshotFrontier *> *frontiers =
138
+ [self cappedFrontiersFromCandidates:candidates ?: @[] maxDepth:maxDepth];
139
+ NSDictionary *deepExtension = [self extendSnapshotFrontiers:frontiers
140
+ axClient:axClient
141
+ attributes:attributes
142
+ maxDepth:maxDepth
143
+ maxNodes:maxNodes
144
+ nodeCount:&nodeCount
145
+ truncated:&truncated
146
+ callsAllowed:deepExtensionCallLimit
147
+ mergedLeaves:merged
148
+ deadline:deadline];
149
+ CGRect rootFrame = CGRectZero;
150
+ NSDictionary *rootFrameValue = rootNode[@"frame"];
151
+ if ([rootFrameValue isKindOfClass:NSDictionary.class]) {
152
+ rootFrame = CGRectMake(
153
+ [rootFrameValue[@"x"] doubleValue], [rootFrameValue[@"y"] doubleValue],
154
+ [rootFrameValue[@"width"] doubleValue], [rootFrameValue[@"height"] doubleValue]);
155
+ }
156
+ NSDictionary *customActions = [self annotateCustomActionsOnMergedLeaves:merged
157
+ axClient:axClient
158
+ limit:customActionLimit
159
+ rootFrame:rootFrame
160
+ deadline:deadline];
161
+
162
+ NSMutableDictionary *response = [NSMutableDictionary dictionaryWithDictionary:@{
163
+ RunnerAXSnapshotOkKey: @YES,
164
+ RunnerAXSnapshotRootKey: rootNode,
165
+ RunnerAXSnapshotTruncatedKey: @(truncated),
166
+ }];
167
+ if (nil != deepExtension) {
168
+ response[RunnerAXSnapshotDeepExtensionKey] = deepExtension;
169
+ }
170
+ if (customActions.count > 0) {
171
+ response[RunnerAXSnapshotCustomActionsKey] = customActions;
45
172
  }
173
+ return response.copy;
174
+ } @catch (NSException *exception) {
175
+ return [self failure:exception.reason ?: exception.name ?: @"AX snapshot bridge exception"];
176
+ }
177
+ }
46
178
 
179
+ /// Chains element-rooted snapshot requests from depth-capped frontier nodes.
180
+ /// The AX server's depth limit is per-request (kAXErrorIllegalArgument above a
181
+ /// tree-size-dependent value), so re-rooting the SAME request at a frontier
182
+ /// node's live accessibility element reaches content the app-rooted request
183
+ /// could not, without ever passing a larger depth parameter. Bounded by call
184
+ /// count, the shared node budget, and the capture-plan deadline.
185
+ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray<RunnerAXSnapshotFrontier *> *)frontiers
186
+ axClient:(id)axClient
187
+ attributes:(NSArray *)attributes
188
+ maxDepth:(NSInteger)maxDepth
189
+ maxNodes:(NSInteger)maxNodes
190
+ nodeCount:(NSInteger *)nodeCount
191
+ truncated:(BOOL *)truncated
192
+ callsAllowed:(NSInteger)callsAllowed
193
+ mergedLeaves:(nullable NSMutableArray<RunnerAXSnapshotFrontier *> *)mergedLeaves
194
+ deadline:(nullable NSDate *)deadline
195
+ {
196
+ if (callsAllowed <= 0 || frontiers.count == 0) {
197
+ return nil;
198
+ }
199
+ NSInteger callsUsed = 0;
200
+ NSInteger nodesAdded = 0;
201
+ // A frontier whose live element vanished (list churn between serialization
202
+ // and extension) or whose re-rooted request failed leaves its subtree
203
+ // unresolved — it must count as missed, never as drained, or an all-miss
204
+ // extension would present a capped capture as complete.
205
+ NSInteger missedFrontiers = 0;
206
+ while (frontiers.count > 0) {
207
+ if (callsUsed >= callsAllowed || *nodeCount >= maxNodes
208
+ || (nil != deadline && deadline.timeIntervalSinceNow <= 0)) {
209
+ *truncated = YES;
210
+ break;
211
+ }
212
+ RunnerAXSnapshotFrontier *frontier = frontiers.firstObject;
213
+ [frontiers removeObjectAtIndex:0];
214
+ id element = [self accessibilityElementForSnapshot:frontier.snapshot];
215
+ if (nil == element) {
216
+ missedFrontiers += 1;
217
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION_MISS=element unavailable");
218
+ continue;
219
+ }
220
+ callsUsed += 1;
47
221
  NSError *error = nil;
48
- NSArray<NSString *> *keyPaths = @[
49
- @"elementType",
50
- @"identifier",
51
- @"label",
52
- @"value",
53
- @"frame",
54
- @"enabled",
55
- @"selected",
56
- @"hasFocus",
57
- @"children",
58
- ];
59
- // The AX server expects real accessibility attribute identifiers, not snapshot keypath
60
- // strings; passing raw keypaths silently drops attributes it does not recognize (frame
61
- // came back zeroed). XCElementSnapshot owns the keypath -> AX attribute mapping.
62
- NSArray *attributes = keyPaths;
63
- Class snapshotClass = NSClassFromString(@"XCElementSnapshot");
64
- SEL mapSelector = NSSelectorFromString(@"axAttributesForElementSnapshotKeyPaths:isMacOS:");
65
- if ([snapshotClass respondsToSelector:mapSelector]) {
66
- typedef id (*RunnerAXMapMsgSend)(id, SEL, id, BOOL);
67
- RunnerAXMapMsgSend mapSend = (RunnerAXMapMsgSend)objc_msgSend;
68
- id mapped = mapSend(snapshotClass, mapSelector, keyPaths, NO);
69
- if ([mapped isKindOfClass:NSSet.class]) {
70
- mapped = [(NSSet *)mapped allObjects];
222
+ id subRoot = [self requestSnapshotFromClient:axClient
223
+ target:element
224
+ attributes:attributes
225
+ maxDepth:maxDepth
226
+ maxNodes:maxNodes - *nodeCount
227
+ error:&error];
228
+ if (nil == subRoot) {
229
+ missedFrontiers += 1;
230
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION_MISS=%@",
231
+ error.localizedDescription ?: @"nil subtree");
232
+ continue;
233
+ }
234
+ // The re-rooted snapshot's root IS the frontier element captured again;
235
+ // splice only its children so the frontier node is not duplicated.
236
+ NSInteger before = *nodeCount;
237
+ NSMutableArray<RunnerAXSnapshotFrontier *> *subCandidates = [NSMutableArray array];
238
+ NSMutableArray *children = [NSMutableArray array];
239
+ for (id child in [self childrenForSnapshot:subRoot]) {
240
+ NSMutableDictionary *childNode = [self dictionaryForSnapshot:child
241
+ depth:1
242
+ maxDepth:maxDepth
243
+ maxNodes:maxNodes
244
+ nodeCount:nodeCount
245
+ truncated:truncated
246
+ frontiers:subCandidates
247
+ mergedLeaves:mergedLeaves];
248
+ if (nil != childNode) {
249
+ [children addObject:childNode];
71
250
  }
72
- if ([mapped isKindOfClass:NSArray.class] && [(NSArray *)mapped count] > 0) {
73
- // The mapper expands keypaths with extra attributes (automation type, window display
74
- // id, base type) that are disproportionately expensive for the AX server to compute
75
- // on large React Native trees. Keep only the attributes we actually consume.
76
- NSArray *needed = @[ @"ElementType", @"Identifier", @"Label", @"Value", @"Frame",
77
- @"Enabled", @"Selected", @"Focus" ];
78
- NSMutableArray *filtered = [NSMutableArray array];
79
- for (id attribute in (NSArray *)mapped) {
80
- NSString *name = [attribute description];
81
- for (NSString *suffix in needed) {
82
- if ([name hasSuffix:suffix]) {
83
- [filtered addObject:attribute];
84
- break;
85
- }
251
+ if (*nodeCount >= maxNodes) {
252
+ *truncated = YES;
253
+ break;
254
+ }
255
+ }
256
+ frontier.node[@"children"] = children;
257
+ nodesAdded += *nodeCount - before;
258
+ [frontiers addObjectsFromArray:[self cappedFrontiersFromCandidates:subCandidates
259
+ maxDepth:maxDepth]];
260
+ }
261
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION calls=%ld nodes=%ld pending=%ld missed=%ld",
262
+ (long)callsUsed, (long)nodesAdded, (long)frontiers.count, (long)missedFrontiers);
263
+ return @{
264
+ RunnerAXSnapshotDeepExtensionCallsKey: @(callsUsed),
265
+ RunnerAXSnapshotDeepExtensionNodesAddedKey: @(nodesAdded),
266
+ RunnerAXSnapshotDeepExtensionPendingKey: @(frontiers.count),
267
+ RunnerAXSnapshotDeepExtensionMissedKey: @(missedFrontiers),
268
+ };
269
+ }
270
+
271
+ /// Keeps only candidates at the deepest observed level, and only when that
272
+ /// level sits at the request's cap (the server emits maxDepth node LEVELS, so
273
+ /// the deepest possible level is maxDepth - 1). A tree that ended naturally
274
+ /// above the cap yields no frontiers and costs nothing.
275
+ + (NSMutableArray<RunnerAXSnapshotFrontier *> *)cappedFrontiersFromCandidates:
276
+ (NSArray<RunnerAXSnapshotFrontier *> *)candidates
277
+ maxDepth:(NSInteger)maxDepth
278
+ {
279
+ NSMutableArray<RunnerAXSnapshotFrontier *> *frontiers = [NSMutableArray array];
280
+ NSInteger deepest = -1;
281
+ for (RunnerAXSnapshotFrontier *candidate in candidates) {
282
+ deepest = MAX(deepest, candidate.depth);
283
+ }
284
+ if (deepest < maxDepth - 1) {
285
+ return frontiers;
286
+ }
287
+ for (RunnerAXSnapshotFrontier *candidate in candidates) {
288
+ if (candidate.depth == deepest) {
289
+ [frontiers addObject:candidate];
290
+ }
291
+ }
292
+ return frontiers;
293
+ }
294
+
295
+ + (nullable id)requestSnapshotFromClient:(id)axClient
296
+ target:(id)target
297
+ attributes:(NSArray *)attributes
298
+ maxDepth:(NSInteger)maxDepth
299
+ maxNodes:(NSInteger)maxNodes
300
+ error:(NSError **)error
301
+ {
302
+ SEL requestSelector = NSSelectorFromString(@"requestSnapshotForElement:attributes:parameters:error:");
303
+ if (![axClient respondsToSelector:requestSelector]) {
304
+ if (NULL != error) {
305
+ *error = [NSError errorWithDomain:@"agent-device.runner"
306
+ code:1
307
+ userInfo:@{
308
+ NSLocalizedDescriptionKey:
309
+ @"AX client does not support requestSnapshotForElement"
310
+ }];
311
+ }
312
+ return nil;
313
+ }
314
+ NSMutableDictionary *parameters = [NSMutableDictionary dictionary];
315
+ id defaults = [self objectFrom:axClient selectorName:@"defaultParameters"];
316
+ if ([defaults isKindOfClass:NSDictionary.class]) {
317
+ [parameters addEntriesFromDictionary:(NSDictionary *)defaults];
318
+ }
319
+ parameters[@"maxDepth"] = @(MAX(0, maxDepth));
320
+ parameters[@"maxChildren"] = @(MAX(1, maxNodes));
321
+ parameters[@"maxArrayCount"] = @(MAX(1, maxNodes));
322
+ parameters[@"traverseFromParentsToChildren"] = @YES;
323
+
324
+ RunnerAXSnapshotMsgSend send = (RunnerAXSnapshotMsgSend)objc_msgSend;
325
+ id result = send(axClient, requestSelector, target, attributes, parameters.copy, error);
326
+ if (nil == result) {
327
+ return nil;
328
+ }
329
+ id root = nil;
330
+ @try {
331
+ root = [result valueForKey:@"_rootElementSnapshot"];
332
+ } @catch (NSException *exception) {
333
+ root = nil;
334
+ }
335
+ return root ?: result;
336
+ }
337
+
338
+ + (NSArray *)snapshotAttributes
339
+ {
340
+ NSArray<NSString *> *keyPaths = @[
341
+ @"elementType",
342
+ @"identifier",
343
+ @"label",
344
+ @"value",
345
+ @"frame",
346
+ @"enabled",
347
+ @"selected",
348
+ @"hasFocus",
349
+ @"children",
350
+ ];
351
+ // The AX server expects real accessibility attribute identifiers, not snapshot keypath
352
+ // strings; passing raw keypaths silently drops attributes it does not recognize (frame
353
+ // came back zeroed). XCElementSnapshot owns the keypath -> AX attribute mapping.
354
+ NSArray *attributes = keyPaths;
355
+ Class snapshotClass = NSClassFromString(@"XCElementSnapshot");
356
+ SEL mapSelector = NSSelectorFromString(@"axAttributesForElementSnapshotKeyPaths:isMacOS:");
357
+ if ([snapshotClass respondsToSelector:mapSelector]) {
358
+ typedef id (*RunnerAXMapMsgSend)(id, SEL, id, BOOL);
359
+ RunnerAXMapMsgSend mapSend = (RunnerAXMapMsgSend)objc_msgSend;
360
+ id mapped = mapSend(snapshotClass, mapSelector, keyPaths, NO);
361
+ if ([mapped isKindOfClass:NSSet.class]) {
362
+ mapped = [(NSSet *)mapped allObjects];
363
+ }
364
+ if ([mapped isKindOfClass:NSArray.class] && [(NSArray *)mapped count] > 0) {
365
+ // The mapper expands keypaths with extra attributes (automation type, window display
366
+ // id, base type) that are disproportionately expensive for the AX server to compute
367
+ // on large React Native trees. Keep only the attributes we actually consume.
368
+ NSArray *needed = @[ @"ElementType", @"Identifier", @"Label", @"Value", @"Frame",
369
+ @"Enabled", @"Selected", @"Focus" ];
370
+ NSMutableArray *filtered = [NSMutableArray array];
371
+ for (id attribute in (NSArray *)mapped) {
372
+ NSString *name = [attribute description];
373
+ for (NSString *suffix in needed) {
374
+ if ([name hasSuffix:suffix]) {
375
+ [filtered addObject:attribute];
376
+ break;
86
377
  }
87
378
  }
88
- attributes = filtered.count > 0 ? filtered : mapped;
89
379
  }
380
+ attributes = filtered.count > 0 ? filtered : mapped;
90
381
  }
91
- RunnerAXSnapshotMsgSend send = (RunnerAXSnapshotMsgSend)objc_msgSend;
92
- id result = send(axClient, requestSelector, target, attributes, parameters.copy, &error);
93
- if (nil == result) {
94
- return [self failure:error.localizedDescription ?: @"AX snapshot request returned nil"];
382
+ }
383
+ return attributes;
384
+ }
385
+
386
+ /// Reads custom actions for the merged leaves in traversal order until the
387
+ /// limit or the capture deadline is spent. Each read is its own AX round trip
388
+ /// (~100ms on an idle simulator), so this is strictly opt-in and strictly
389
+ /// bounded.
390
+ ///
391
+ /// Returns {read, candidates} so the response can DISCLOSE an incomplete pass.
392
+ /// An unread element is byte-identical to one that genuinely has no actions, so
393
+ /// silence here would teach a reader that later cards have no affordances —
394
+ /// exactly the mis-inference this capture exists to prevent.
395
+ ///
396
+ /// `candidates` counts only elements still eligible at read time: a leaf that
397
+ /// deep extension filled in since serialization is not a merged element, so
398
+ /// counting it would inflate the denominator with nodes that never needed a
399
+ /// read. A leaf whose live element vanished stays counted and unread, because
400
+ /// that one really is an element we failed to answer for.
401
+ ///
402
+ /// On-screen candidates are read first. The budget is smaller than a long
403
+ /// feed's card count, so the order decides which elements an agent can act on
404
+ /// at all — and it also makes the disclosure's remedy true: scrolling changes
405
+ /// the on-screen set, so a re-run reads elements the previous pass could not.
406
+ /// Note that `--scope` deliberately is NOT the ordering key: scope is applied
407
+ /// when the Swift walk builds nodes, long after this pass, so it cannot narrow
408
+ /// the read budget without duplicating the scope predicate here.
409
+ + (NSDictionary<NSString *, NSNumber *> *)
410
+ annotateCustomActionsOnMergedLeaves:(nullable NSArray<RunnerAXSnapshotFrontier *> *)leaves
411
+ axClient:(id)axClient
412
+ limit:(NSInteger)limit
413
+ rootFrame:(CGRect)rootFrame
414
+ deadline:(nullable NSDate *)deadline
415
+ {
416
+ if (limit <= 0 || leaves.count == 0) {
417
+ return @{};
418
+ }
419
+ NSMutableArray<RunnerAXSnapshotFrontier *> *onScreen = [NSMutableArray array];
420
+ NSMutableArray<RunnerAXSnapshotFrontier *> *offScreen = [NSMutableArray array];
421
+ for (RunnerAXSnapshotFrontier *leaf in leaves) {
422
+ if ([leaf.node[@"children"] isKindOfClass:NSArray.class]
423
+ && [(NSArray *)leaf.node[@"children"] count] > 0) {
424
+ continue;
425
+ }
426
+ [([self isFrameOnScreen:leaf.node[@"frame"] rootFrame:rootFrame] ? onScreen : offScreen)
427
+ addObject:leaf];
428
+ }
429
+ NSInteger candidates = onScreen.count + offScreen.count;
430
+ NSInteger attempts = 0;
431
+ NSInteger reads = 0;
432
+ NSInteger annotated = 0;
433
+ NSInteger truncatedElements = 0;
434
+ BOOL blocked = NO;
435
+ for (RunnerAXSnapshotFrontier *leaf in [onScreen arrayByAddingObjectsFromArray:offScreen]) {
436
+ if (attempts >= limit || (nil != deadline && deadline.timeIntervalSinceNow <= 0)) {
437
+ break;
438
+ }
439
+ // An abandoned read from an earlier capture is still outstanding. Stop the
440
+ // pass here rather than spending a deadline per element on reads that will
441
+ // all be refused, and record why so the response does not present the
442
+ // unread elements as "has no actions".
443
+ if ([self customActionReadsInFlight] > 0) {
444
+ blocked = YES;
445
+ break;
446
+ }
447
+ id element = [self accessibilityElementForSnapshot:leaf.snapshot];
448
+ if (nil == element) {
449
+ continue;
450
+ }
451
+ // The attempt is what the budget buys; only a completed read counts as
452
+ // read, so a timed-out element stays in the undisclosed remainder instead
453
+ // of being reported as "read, and it has no actions".
454
+ attempts += 1;
455
+ BOOL completed = NO;
456
+ NSArray<NSString *> *names = [self customActionNamesForElement:element
457
+ axClient:axClient
458
+ completed:&completed];
459
+ if (!completed) {
460
+ continue;
95
461
  }
462
+ reads += 1;
463
+ BOOL truncated = NO;
464
+ NSArray<NSString *> *capped = [self cappedActionNames:names ?: @[] truncated:&truncated];
465
+ if (truncated) {
466
+ truncatedElements += 1;
467
+ }
468
+ if (capped.count > 0) {
469
+ leaf.node[@"actions"] = capped;
470
+ annotated += 1;
471
+ }
472
+ }
473
+ NSLog(
474
+ @"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS reads=%ld attempts=%ld annotated=%ld "
475
+ "candidates=%ld onScreen=%ld truncated=%ld blocked=%d dispatches=%ld",
476
+ (long)reads, (long)attempts, (long)annotated, (long)candidates, (long)onScreen.count,
477
+ (long)truncatedElements, (int)blocked, (long)[self customActionReadDispatchCount]);
478
+ return @{
479
+ RunnerAXSnapshotCustomActionsReadKey: @(reads),
480
+ RunnerAXSnapshotCustomActionsCandidatesKey: @(candidates),
481
+ RunnerAXSnapshotCustomActionsTruncatedKey: @(truncatedElements),
482
+ RunnerAXSnapshotCustomActionsBlockedKey: @(blocked),
483
+ };
484
+ }
96
485
 
97
- id root = nil;
486
+ /// An empty root frame means the capture has no viewport to judge against, so
487
+ /// every candidate counts as on-screen and the order stays document order.
488
+ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame
489
+ {
490
+ if (CGRectIsEmpty(rootFrame)) {
491
+ return YES;
492
+ }
493
+ if (![frameValue isKindOfClass:NSDictionary.class]) {
494
+ return NO;
495
+ }
496
+ NSDictionary *frame = (NSDictionary *)frameValue;
497
+ CGRect rect = CGRectMake(
498
+ [frame[@"x"] doubleValue], [frame[@"y"] doubleValue],
499
+ [frame[@"width"] doubleValue], [frame[@"height"] doubleValue]);
500
+ return !CGRectIsEmpty(rect) && CGRectIntersectsRect(rect, rootFrame);
501
+ }
502
+
503
+ + (nullable NSArray<NSString *> *)customActionNamesForElement:(id)element
504
+ axClient:(id)axClient
505
+ completed:(BOOL *)completed
506
+ {
507
+ if (NULL != completed) {
508
+ *completed = NO;
509
+ }
510
+ if (nil == element || nil == axClient) {
511
+ return nil;
512
+ }
513
+ SEL selector = NSSelectorFromString(@"attributesForElement:attributes:error:");
514
+ if (![axClient respondsToSelector:selector]) {
515
+ return nil;
516
+ }
517
+ // Single flight. An abandoned read still owns the serial queue, so
518
+ // dispatching here would only queue work behind it — and repeating the
519
+ // capture would keep queueing more. Refuse without dispatching instead.
520
+ if (atomic_load(&RunnerAXCustomActionReadsInFlight) > 0) {
521
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS_READ_BLOCKED");
522
+ return nil;
523
+ }
524
+ // The AX call is a synchronous XPC round trip with no timeout of its own, so
525
+ // it runs off-thread behind a bounded wait. On timeout the result box is
526
+ // never read, so a late-returning wedged call cannot race this thread.
527
+ atomic_fetch_add(&RunnerAXCustomActionReadsInFlight, 1);
528
+ atomic_fetch_add(&RunnerAXCustomActionReadDispatches, 1);
529
+ NSMutableArray *box = [NSMutableArray array];
530
+ dispatch_semaphore_t finished = dispatch_semaphore_create(0);
531
+ dispatch_async(RunnerAXCustomActionReadQueue(), ^{
532
+ typedef id (*RunnerAXAttributesMsgSend)(id, SEL, id, id, NSError **);
533
+ RunnerAXAttributesMsgSend send = (RunnerAXAttributesMsgSend)objc_msgSend;
534
+ NSError *error = nil;
535
+ id result = nil;
98
536
  @try {
99
- root = [result valueForKey:@"_rootElementSnapshot"];
537
+ result = send(axClient, selector, element, @[ RunnerAXCustomActionsAttribute ], &error);
100
538
  } @catch (NSException *exception) {
101
- root = nil;
539
+ result = nil;
102
540
  }
103
- if (nil == root) {
104
- root = result;
541
+ if (nil != result) {
542
+ [box addObject:result];
105
543
  }
544
+ // Clear in-flight BEFORE signalling, so a waiter that wakes on this signal
545
+ // never observes its own completed read as still outstanding.
546
+ atomic_fetch_sub(&RunnerAXCustomActionReadsInFlight, 1);
547
+ dispatch_semaphore_signal(finished);
548
+ });
549
+ long waited = dispatch_semaphore_wait(
550
+ finished,
551
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(RunnerAXCustomActionReadTimeout * NSEC_PER_SEC)));
552
+ if (waited != 0) {
553
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS_READ_TIMEOUT");
554
+ return nil;
555
+ }
556
+ if (NULL != completed) {
557
+ *completed = YES;
558
+ }
559
+ id values = box.firstObject;
560
+ if (![values isKindOfClass:NSDictionary.class]) {
561
+ return nil;
562
+ }
563
+ id actions = ((NSDictionary *)values)[RunnerAXCustomActionsAttribute];
564
+ if (![actions isKindOfClass:NSArray.class]) {
565
+ return nil;
566
+ }
567
+ NSMutableArray<NSString *> *names = [NSMutableArray array];
568
+ for (id action in (NSArray *)actions) {
569
+ if (![action isKindOfClass:NSDictionary.class]) {
570
+ continue;
571
+ }
572
+ id name = ((NSDictionary *)action)[@"CustomActionName"];
573
+ if ([name isKindOfClass:NSString.class] && [(NSString *)name length] > 0) {
574
+ [names addObject:name];
575
+ }
576
+ }
577
+ return names.count > 0 ? names.copy : nil;
578
+ }
106
579
 
107
- BOOL truncated = NO;
108
- NSInteger nodeCount = 0;
109
- NSDictionary *rootNode = [self dictionaryForSnapshot:root
110
- depth:0
111
- maxDepth:maxDepth
112
- maxNodes:maxNodes
113
- nodeCount:&nodeCount
114
- truncated:&truncated];
115
- if (nil == rootNode) {
116
- return [self failure:@"AX snapshot root could not be serialized"];
580
+ /// Bounds what one element can contribute to the response. The names come from
581
+ /// the app, so their count and length are the app's choice, not ours — a list
582
+ /// long enough to dominate a snapshot is a plausible accident (generated rows)
583
+ /// as well as a hostile case. Truncation is reported, never silent: a clipped
584
+ /// list is exactly as misleading as an unread element if it looks complete.
585
+ + (NSArray<NSString *> *)cappedActionNames:(NSArray<NSString *> *)names
586
+ truncated:(BOOL *)truncated
587
+ {
588
+ if (NULL != truncated) {
589
+ *truncated = NO;
590
+ }
591
+ NSMutableArray<NSString *> *capped = [NSMutableArray array];
592
+ for (NSString *name in names) {
593
+ if (capped.count >= RunnerAXCustomActionsMaxPerElement) {
594
+ if (NULL != truncated) {
595
+ *truncated = YES;
596
+ }
597
+ break;
117
598
  }
599
+ if (name.length > RunnerAXCustomActionNameMaxLength) {
600
+ // Clip on composed character boundaries so a truncated name stays a
601
+ // valid string rather than a split grapheme.
602
+ NSRange clip = [name rangeOfComposedCharacterSequencesForRange:
603
+ NSMakeRange(0, RunnerAXCustomActionNameMaxLength)];
604
+ [capped addObject:[[name substringWithRange:clip] stringByAppendingString:@"…"]];
605
+ if (NULL != truncated) {
606
+ *truncated = YES;
607
+ }
608
+ continue;
609
+ }
610
+ [capped addObject:name];
611
+ }
612
+ return capped.copy;
613
+ }
118
614
 
119
- return @{
120
- RunnerAXSnapshotOkKey: @YES,
121
- RunnerAXSnapshotRootKey: rootNode,
122
- RunnerAXSnapshotTruncatedKey: @(truncated),
123
- };
615
+ + (NSInteger)customActionReadsInFlight
616
+ {
617
+ return atomic_load(&RunnerAXCustomActionReadsInFlight);
618
+ }
619
+
620
+ + (NSInteger)customActionReadDispatchCount
621
+ {
622
+ return (NSInteger)atomic_load(&RunnerAXCustomActionReadDispatches);
623
+ }
624
+
625
+ + (nullable id)accessibilityClient
626
+ {
627
+ return [self objectFrom:XCUIDevice.sharedDevice selectorName:@"accessibilityInterface"];
628
+ }
629
+
630
+ + (nullable id)accessibilityElementForSnapshot:(id)snapshot
631
+ {
632
+ id element = nil;
633
+ @try {
634
+ element = [snapshot valueForKey:@"accessibilityElement"];
124
635
  } @catch (NSException *exception) {
125
- return [self failure:exception.reason ?: exception.name ?: @"AX snapshot bridge exception"];
636
+ element = nil;
126
637
  }
638
+ return element;
127
639
  }
128
640
 
129
641
  + (NSDictionary<NSString *, id> *)failure:(NSString *)message
@@ -186,12 +698,14 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
186
698
  return nil;
187
699
  }
188
700
 
189
- + (nullable NSDictionary *)dictionaryForSnapshot:(id)snapshot
190
- depth:(NSInteger)depth
191
- maxDepth:(NSInteger)maxDepth
192
- maxNodes:(NSInteger)maxNodes
193
- nodeCount:(NSInteger *)nodeCount
194
- truncated:(BOOL *)truncated
701
+ + (nullable NSMutableDictionary *)dictionaryForSnapshot:(id)snapshot
702
+ depth:(NSInteger)depth
703
+ maxDepth:(NSInteger)maxDepth
704
+ maxNodes:(NSInteger)maxNodes
705
+ nodeCount:(NSInteger *)nodeCount
706
+ truncated:(BOOL *)truncated
707
+ frontiers:(NSMutableArray<RunnerAXSnapshotFrontier *> *)frontiers
708
+ mergedLeaves:(NSMutableArray<RunnerAXSnapshotFrontier *> *)mergedLeaves
195
709
  {
196
710
  if (nil == snapshot || *nodeCount >= maxNodes) {
197
711
  *truncated = YES;
@@ -212,12 +726,14 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
212
726
  NSMutableArray *children = [NSMutableArray array];
213
727
  if (depth < maxDepth) {
214
728
  for (id child in [self childrenForSnapshot:snapshot]) {
215
- NSDictionary *childNode = [self dictionaryForSnapshot:child
216
- depth:depth + 1
217
- maxDepth:maxDepth
218
- maxNodes:maxNodes
219
- nodeCount:nodeCount
220
- truncated:truncated];
729
+ NSMutableDictionary *childNode = [self dictionaryForSnapshot:child
730
+ depth:depth + 1
731
+ maxDepth:maxDepth
732
+ maxNodes:maxNodes
733
+ nodeCount:nodeCount
734
+ truncated:truncated
735
+ frontiers:frontiers
736
+ mergedLeaves:mergedLeaves];
221
737
  if (nil != childNode) {
222
738
  [children addObject:childNode];
223
739
  }
@@ -227,8 +743,33 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
227
743
  }
228
744
  }
229
745
  }
746
+ if (children.count == 0 && nil != mergedLeaves
747
+ && [result[@"label"] isKindOfClass:NSString.class]
748
+ && [(NSString *)result[@"label"] length] > 0) {
749
+ // A labelled childless node is the shape a merged card takes: the container
750
+ // is marked accessible, so its real controls never appear as children. Only
751
+ // these are worth an action read — unlabelled leaves are decoration.
752
+ RunnerAXSnapshotFrontier *candidate = [[RunnerAXSnapshotFrontier alloc] init];
753
+ candidate.snapshot = snapshot;
754
+ candidate.node = result;
755
+ candidate.depth = depth;
756
+ [mergedLeaves addObject:candidate];
757
+ }
758
+ if (children.count == 0 && nil != frontiers && depth >= maxDepth - 1) {
759
+ // Childless node at the cap boundary: either a real leaf or a branch whose
760
+ // children the AX server withheld. The two are indistinguishable here, so
761
+ // boundary childless nodes become candidates; the caller keeps only the
762
+ // deepest observed level, where every capped branch necessarily ends.
763
+ // Shallower childless nodes are provably real leaves and are never
764
+ // collected.
765
+ RunnerAXSnapshotFrontier *frontier = [[RunnerAXSnapshotFrontier alloc] init];
766
+ frontier.snapshot = snapshot;
767
+ frontier.node = result;
768
+ frontier.depth = depth;
769
+ [frontiers addObject:frontier];
770
+ }
230
771
  result[@"children"] = children;
231
- return result.copy;
772
+ return result;
232
773
  }
233
774
 
234
775
  + (NSArray *)childrenForSnapshot:(id)snapshot