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.
- package/README.md +5 -3
- 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
- package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.7.apk.sha256 +1 -0
- 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
- 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
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.7.apk.sha256 +1 -0
- 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
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +48 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +108 -1
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +630 -89
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSelectorMatchPolicy.swift +78 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +126 -187
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h +32 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m +212 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +149 -10
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +161 -32
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CoordinateTextEntryTests.swift +4 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +36 -28
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +23 -5
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +10 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +0 -67
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +18 -1
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +2 -22
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SelectorMatchPolicyTests.swift +4 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +2 -1
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +145 -20
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +0 -20
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +4 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +234 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +106 -448
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift +4 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +602 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +18 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +72 -0
- package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +89 -0
- package/dist/src/agent-browser-tool.js +1 -1
- package/dist/src/agent-device-client.js +2 -2
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android.js +1 -1
- package/dist/src/app-helpers.js +1 -1
- package/dist/src/app-lifecycle.js +7 -7
- package/dist/src/app-lifecycle2.js +2 -0
- package/dist/src/app-log-files.js +1 -0
- package/dist/src/app-log-reconnect.js +1 -0
- package/dist/src/app-log-runtime.js +4 -0
- package/dist/src/app-resolution.js +2 -1
- package/dist/src/apps.js +5 -5
- package/dist/src/audio-probe.js +1 -1
- package/dist/src/auth-session.js +1 -0
- package/dist/src/auth.js +2 -0
- package/dist/src/boot-diagnostics.js +2 -2
- package/dist/src/cli-config.js +1 -1
- package/dist/src/cli-grammar.js +1 -0
- package/dist/src/cli-help.js +204 -254
- package/dist/src/cli-output.js +1 -0
- package/dist/src/cli-runner.js +1 -2
- package/dist/src/cli.js +6 -16
- package/dist/src/client-metro.js +1 -1
- package/dist/src/cloud-artifacts.d.ts +2 -137
- package/dist/src/command-metadata.js +1 -1
- package/dist/src/command-schema.js +3 -3
- package/dist/src/connection-runtime.js +1 -0
- package/dist/src/connection.js +5 -0
- package/dist/src/context.js +1 -1
- package/dist/src/daemon-shutdown-report.js +1 -0
- package/dist/src/daemon.js +2 -0
- package/dist/src/debug-symbols.js +3 -3
- package/dist/src/device-claim-inspection.js +1 -0
- package/dist/src/device-input-state.js +2 -2
- package/dist/src/device.js +1 -1
- package/dist/src/device2.js +2 -0
- package/dist/src/find.js +1 -1
- package/dist/src/finders.js +1 -1
- package/dist/src/generic-settle.js +1 -0
- package/dist/src/generic.js +1 -1
- package/dist/src/harmonyos.js +1 -0
- package/dist/src/helper.js +1 -1
- package/dist/src/host-process.js +2 -2
- package/dist/src/ime-lifecycle.js +7 -7
- package/dist/src/index.d.ts +69 -18
- package/dist/src/input-actions.js +4 -4
- package/dist/src/input-actions2.js +1 -1
- package/dist/src/input-actions3.js +1 -0
- package/dist/src/install-artifact.js +1 -5
- package/dist/src/install-artifact2.js +1 -1
- package/dist/src/install-source2.js +1 -1
- package/dist/src/interaction-runtime.js +1 -0
- package/dist/src/interaction-snapshot.js +1 -1
- package/dist/src/interaction-touch-reference-frame.js +1 -0
- package/dist/src/interaction.js +1 -1
- package/dist/src/interactor.js +2 -2
- package/dist/src/interactor2.js +1 -1
- package/dist/src/internal/bin.js +2 -2
- package/dist/src/internal/daemon.js +9 -16
- package/dist/src/inventory.js +5 -0
- package/dist/src/inventory2.js +1 -0
- package/dist/src/inventory3.js +2 -0
- package/dist/src/inventory4.js +1 -0
- package/dist/src/inventory5.js +1 -0
- package/dist/src/inventory6.js +1 -0
- package/dist/src/lease-context.js +1 -1
- package/dist/src/lease.js +1 -1
- package/dist/src/limrun-runtime-dependencies.js +1 -0
- package/dist/src/limrun.d.ts +10 -104
- package/dist/src/limrun.js +1 -1
- package/dist/src/linux.js +1 -1
- package/dist/src/logcat.js +1 -1
- package/dist/src/manifest.js +1 -1
- package/dist/src/owner-identity.js +1 -0
- package/dist/src/physical-device-control.js +1 -1
- package/dist/src/platform-runtime-app-log-android-transport.js +1 -0
- package/dist/src/platform-runtime-app-log-process.js +1 -0
- package/dist/src/platform-runtime-host.js +1 -0
- package/dist/src/platform-runtime-network-web-transport.js +1 -0
- package/dist/src/platform-runtime-operation-host.js +5 -0
- package/dist/src/platform-runtime-toolchain-host.js +1 -0
- package/dist/src/platform-runtime.js +1 -0
- package/dist/src/prepare-kind.js +15 -0
- package/dist/src/process-entry.js +1 -0
- package/dist/src/process-exit.js +1 -0
- package/dist/src/process-lock.js +1 -1
- package/dist/src/provider-webdriver.js +1 -0
- package/dist/src/provider.js +1 -0
- package/dist/src/proxy.js +2 -0
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace.js +1 -1
- package/dist/src/registry.js +25 -24
- package/dist/src/remote-config.js +1 -1
- package/dist/src/remote-config2.js +1 -1
- package/dist/src/replay.js +3 -0
- package/dist/src/reporting.js +4 -4
- package/dist/src/runner-client.js +1 -1
- package/dist/src/runner-disposal.js +1 -1
- package/dist/src/runner-lease.js +1 -1
- package/dist/src/runtime.js +3 -3
- package/dist/src/runtime2.js +4 -0
- package/dist/src/runtime3.js +3 -0
- package/dist/src/runtime4.js +1 -0
- package/dist/src/runtime5.js +1 -0
- package/dist/src/runtime6.js +1 -0
- package/dist/src/runtime7.js +1 -0
- package/dist/src/screenshot-result.js +4 -4
- package/dist/src/screenshot.js +1 -0
- package/dist/src/sdk-android-adb.d.ts +20 -2
- package/dist/src/sdk-android-adb.js +2 -2
- package/dist/src/sdk-batch-runner.js +3 -3
- package/dist/src/sdk-contracts.d.ts +47 -6
- package/dist/src/sdk-contracts.js +1 -1
- package/dist/src/sdk-device.js +1 -1
- package/dist/src/sdk-finders.d.ts +4 -20
- package/dist/src/sdk-finders.js +1 -1
- package/dist/src/sdk-remote-config.js +1 -1
- package/dist/src/sdk-selectors.d.ts +601 -0
- package/dist/src/sdk-selectors.js +1 -1
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/selector-vocabulary.js +1 -1
- package/dist/src/selectors.d.ts +1 -46
- package/dist/src/selectors.js +1 -1
- package/dist/src/server.js +3 -3
- package/dist/src/session-target-evidence.js +1 -1
- package/dist/src/session.js +10 -9
- package/dist/src/shared.js +2 -0
- package/dist/src/shell-quote.js +1 -0
- package/dist/src/simulator.js +1 -1
- package/dist/src/snapshot-runtime.js +1 -0
- package/dist/src/snapshot-session.js +1 -0
- package/dist/src/snapshot.js +1 -1
- package/dist/src/snapshot2.js +1 -1
- package/dist/src/src.js +1 -1
- package/dist/src/src2.js +2 -0
- package/dist/src/src3.js +1 -0
- package/dist/src/text-surface.js +1 -0
- package/dist/src/timing-safe-equal.js +1 -0
- package/dist/src/tool-provider.js +3 -3
- package/dist/src/tool-provider2.js +1 -1
- package/dist/src/tool-provider3.js +1 -1
- package/dist/src/update-check.js +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/web.js +1 -1
- package/package.json +72 -50
- package/server.json +3 -3
- package/skills/agent-device/SKILL.md +6 -31
- package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.5.apk.sha256 +0 -1
- package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.5.apk.sha256 +0 -1
- package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -225
- package/dist/src/app-log-request-scope.js +0 -1
- package/dist/src/devices.js +0 -1
- package/dist/src/devices2.js +0 -1
- package/dist/src/devices3.js +0 -1
- package/dist/src/devices4.js +0 -1
- package/dist/src/interaction.d.ts +0 -230
- package/dist/src/provider-limrun-runtime.js +0 -1
- package/dist/src/register-builtins.js +0 -1
- package/dist/src/screenshot-geometry.js +0 -1
- package/dist/src/viewport-dimension.js +0 -14
package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m
CHANGED
|
@@ -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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
+
result = send(axClient, selector, element, @[ RunnerAXCustomActionsAttribute ], &error);
|
|
100
538
|
} @catch (NSException *exception) {
|
|
101
|
-
|
|
539
|
+
result = nil;
|
|
102
540
|
}
|
|
103
|
-
if (nil
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
772
|
+
return result;
|
|
232
773
|
}
|
|
233
774
|
|
|
234
775
|
+ (NSArray *)childrenForSnapshot:(id)snapshot
|