Skip to content

Commit 5bb3ea3

Browse files
authored
feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge * fix: address Simulator AX bridge review comments * docs: refresh Simulator AX evidence * fix: address new Simulator AX bridge review comments * docs: record public snapshot source timings * fix: preserve size report helper on base checkout * fix: allow base packages without snapshot bridge * fix: close simulator snapshot source ownership gaps * docs: explain simulator bridge language choice
1 parent 17b6ca3 commit 5bb3ea3

41 files changed

Lines changed: 4541 additions & 10 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.fallowrc.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,29 @@
300300
"file": "packages/platform-android/src/mechanics.ts",
301301
"exports": ["*"]
302302
},
303+
{
304+
"comment": "Apple Simulator snapshot acquisition is a private package facet consumed by downstream runtime work; Fallow cannot see external consumers through the workspace exports map. Keep only the deliberately narrow public factory, source interface, and request/outcome types here; host injection, cache metadata, and preparation internals stay private.",
305+
"file": "packages/platform-apple/src/snapshot-source-facade.ts",
306+
"exports": [
307+
"createSimulatorSnapshotSource",
308+
"SimulatorSnapshotSource",
309+
"SnapshotSourceFailure",
310+
"SnapshotSourceFailureKind",
311+
"SnapshotSourceLimits",
312+
"SnapshotSourceOutcome",
313+
"SnapshotSourceRequest",
314+
"SnapshotSourceTarget"
315+
]
316+
},
317+
{
318+
"comment": "The native wire vocabulary is exported only so the parity test can pin the Objective-C literals; it is not part of the snapshot-source package facet.",
319+
"file": "packages/platform-apple/src/snapshot-source/protocol.ts",
320+
"exports": [
321+
"SNAPSHOT_SOURCE_ATTRIBUTE_KEYS",
322+
"SNAPSHOT_SOURCE_RESPONSE_KEYS",
323+
"SNAPSHOT_SOURCE_WIRE_KEYS"
324+
]
325+
},
303326
{
304327
"comment": "Deliberately kept off the @agent-device/maestro façade (index.test.ts asserts its absence) and consumed only by the conformance harness under packages/maestro/test/.",
305328
"file": "packages/maestro/src/internal/program-ir-command-parser.ts",

.github/workflows/ios.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ jobs:
126126
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
127127
preferred-device-name: iPhone 17 Pro
128128

129+
- name: Verify clean-installed Simulator snapshot bridge preparation
130+
if: github.event_name == 'pull_request'
131+
env:
132+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
133+
run: |
134+
git fetch origin "$BASE_SHA" --depth=1
135+
if git diff --quiet "$BASE_SHA"...HEAD -- \
136+
apple/snapshot-bridge \
137+
packages/platform-apple/src/snapshot-source \
138+
scripts/check-package.ts \
139+
scripts/size-report-install.mjs \
140+
scripts/size-report-package.mjs; then
141+
echo "Snapshot bridge packaging is unchanged; skipping preparation proof."
142+
exit 0
143+
fi
144+
pnpm build
145+
pnpm check:package -- --verify-snapshot-bridge-preparation
146+
129147
- name: Run targeted iOS runner XCTest regressions
130148
run: |
131149
XCTESTRUN_PATH="$(find "$AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH/Build/Products" -maxdepth 1 -name '*.xctestrun' -print -quit)"

apple/snapshot-bridge/LICENSE.idb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
The Simulator AX bridge contains code adapted from Meta Platforms, Inc. idb
2+
v1.5.2, specifically SimulatorFrameworkBridge/AccessibilityService.m and
3+
SimulatorFrameworkBridge/AccessibilityRuntime.m.
4+
5+
Copyright (c) Meta Platforms, Inc. and affiliates.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.

apple/snapshot-bridge/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Simulator AX bridge
2+
3+
This directory contains the small private accessibility reader used by the
4+
Apple platform acquisition facet. The framed server and request validation
5+
live in `SnapshotBridge.m`; private runtime binding lives in
6+
`SnapshotBridgeRuntime.m`. It is compiled for the iOS Simulator on first use
7+
and is never downloaded, pre-signed, or built by npm installation.
8+
9+
The guest process uses the `XCTAccessibilityFramework` remote-access client
10+
from the simulator runtime and the `userTestingSnapshotForElement:options:error:`
11+
single-fetch API. Requests and responses are length-prefixed JSON frames:
12+
13+
```text
14+
uint32 big-endian byte length
15+
UTF-8 JSON object
16+
```
17+
18+
The host owns all target identity, bounds, deadlines, and lifecycle decisions.
19+
The guest returns only a bounded raw tree, the target pid, truncation, and
20+
protocol/source versions. It does not expose an HTTP route or a public CLI
21+
surface.
22+
23+
The private API is intentionally pinned to the idb v1.5.2-compatible shape.
24+
See `LICENSE.idb` for attribution.
25+
26+
## Why Objective-C
27+
28+
The selected #2192 mechanism was idb v1.5.2's Objective-C
29+
`SimulatorFrameworkBridge`; the Python used during the spike was only a client
30+
for exercising that guest reader. This bridge keeps the proven native boundary
31+
and removes the Python/idb client dependency.
32+
33+
Objective-C is the narrowest implementation for this private runtime adapter:
34+
it resolves unavailable classes and functions with `dlopen`, `dlsym`, and the
35+
Objective-C runtime, invokes dynamically discovered selectors, and contains
36+
`NSException` failures. A Swift implementation would still require an
37+
Objective-C shim for those operations, adding another native boundary. Keeping
38+
the guest in Objective-C also allows direct lazy compilation with `clang`
39+
without an Xcode project or Swift module for private headers.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*
2+
* The framed server and request validation for the private Simulator AX reader.
3+
* The runtime binding is isolated in SnapshotBridgeRuntime.m.
4+
*/
5+
6+
#import "SnapshotBridgeRuntime.h"
7+
8+
#import <Foundation/Foundation.h>
9+
10+
#import <arpa/inet.h>
11+
#import <errno.h>
12+
#import <limits.h>
13+
#import <math.h>
14+
#import <poll.h>
15+
#import <sys/socket.h>
16+
#import <sys/stat.h>
17+
#import <sys/types.h>
18+
#import <sys/un.h>
19+
#import <unistd.h>
20+
21+
static const int kDefaultIdleTimeoutSeconds = 60;
22+
23+
static void bridgeLog(NSString *message)
24+
{
25+
fprintf(stderr, "[agent-device-snapshot-bridge] %s\n", message.UTF8String ?: "(no message)");
26+
fflush(stderr);
27+
}
28+
29+
NSDictionary *failureResponse(NSString *requestId,
30+
NSString *kind,
31+
NSString *code,
32+
NSString *message)
33+
{
34+
return @{
35+
kProtocolVersionKey : @(kProtocolVersion),
36+
kSourceVersionKey : kSourceVersion,
37+
kRequestIdKey : requestId ?: @"",
38+
@"ok" : @NO,
39+
@"error_kind" : kind ?: @"reader_unavailable",
40+
@"error_code" : code ?: @"unknown",
41+
@"error" : message ?: @"snapshot bridge request failed",
42+
};
43+
}
44+
45+
static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, NSUInteger *output)
46+
{
47+
if (![value isKindOfClass:NSNumber.class]) return NO;
48+
NSNumber *number = value;
49+
if (number.doubleValue != floor(number.doubleValue)) return NO;
50+
if (number.unsignedIntegerValue < minimum || number.unsignedIntegerValue > maximum) return NO;
51+
if (output) *output = number.unsignedIntegerValue;
52+
return YES;
53+
}
54+
55+
static NSDictionary *handleRequest(NSDictionary *request)
56+
{
57+
NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @"";
58+
id verb = request[@"verb"];
59+
if (![verb isKindOfClass:NSString.class] || ![verb isEqualToString:@"describe"]) {
60+
return failureResponse(requestId, @"bad_request", @"verb-not-supported", @"snapshot bridge accepts describe requests only");
61+
}
62+
NSNumber *pidValue = request[@"pid"];
63+
if (!validBoundInteger(pidValue, 1, INT_MAX, NULL)) {
64+
return failureResponse(requestId, @"bad_request", @"pid-required", @"describe requires a positive target pid");
65+
}
66+
NSString *generation = [request[@"generation"] isKindOfClass:NSString.class] ? request[@"generation"] : @"";
67+
if (generation.length == 0) {
68+
return failureResponse(requestId, @"bad_request", @"generation-required", @"describe requires an opaque target generation");
69+
}
70+
id snapshotTree = request[@"snapshotTree"];
71+
if (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue]) {
72+
return failureResponse(requestId, @"bad_request", @"snapshot-tree-required", @"snapshotTree must be enabled");
73+
}
74+
id automationMode = request[@"automationMode"];
75+
if (![automationMode isKindOfClass:NSNumber.class] || ![automationMode boolValue]) {
76+
return failureResponse(requestId, @"bad_request", @"automation-mode-required", @"automationMode must be enabled");
77+
}
78+
NSUInteger maxDepth = 0;
79+
NSUInteger maxNodes = 0;
80+
NSUInteger maxDurationMs = 0;
81+
NSUInteger maxResponseBytes = 0;
82+
if (!validBoundInteger(request[@"maxDepth"], 0, kMaximumDepth, &maxDepth) ||
83+
!validBoundInteger(request[@"maxNodes"], 1, kMaximumNodes, &maxNodes) ||
84+
!validBoundInteger(request[@"maxDurationMs"], 1, kMaximumDurationMs, &maxDurationMs) ||
85+
!validBoundInteger(request[@"maxResponseBytes"], 1024, kMaximumFrameBytes, &maxResponseBytes)) {
86+
return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"snapshot bridge request bounds are outside the bridge limits");
87+
}
88+
89+
NSString *setupError = nil;
90+
BridgeRuntime *runtime = sharedRuntime(&setupError);
91+
if (!runtime) {
92+
NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy];
93+
unavailable[@"pid"] = pidValue;
94+
unavailable[@"generation"] = generation;
95+
return unavailable;
96+
}
97+
NSDictionary *error = nil;
98+
NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue
99+
maxDepth:maxDepth
100+
maxNodes:maxNodes
101+
requestId:requestId
102+
generation:generation
103+
maxDurationMs:maxDurationMs
104+
error:&error];
105+
if (response) return response;
106+
if (error) {
107+
NSMutableDictionary *annotated = [error mutableCopy];
108+
annotated[@"pid"] = pidValue;
109+
annotated[@"generation"] = generation;
110+
return annotated;
111+
}
112+
return failureResponse(requestId, @"reader_unavailable", @"empty-response", @"AX bridge returned no response");
113+
}
114+
115+
static BOOL readFully(int fd, void *buffer, size_t length)
116+
{
117+
size_t offset = 0;
118+
while (offset < length) {
119+
ssize_t count = recv(fd, (char *)buffer + offset, length - offset, 0);
120+
if (count > 0) {
121+
offset += (size_t)count;
122+
continue;
123+
}
124+
if (count < 0 && errno == EINTR) continue;
125+
return NO;
126+
}
127+
return YES;
128+
}
129+
130+
static BOOL writeFully(int fd, const void *buffer, size_t length)
131+
{
132+
size_t offset = 0;
133+
while (offset < length) {
134+
ssize_t count = send(fd, (const char *)buffer + offset, length - offset, MSG_NOSIGNAL);
135+
if (count > 0) {
136+
offset += (size_t)count;
137+
continue;
138+
}
139+
if (count < 0 && errno == EINTR) continue;
140+
return NO;
141+
}
142+
return YES;
143+
}
144+
145+
static NSData *serializedResponse(NSDictionary *response, NSUInteger maxResponseBytes)
146+
{
147+
NSError *error = nil;
148+
NSData *data = nil;
149+
@try {
150+
data = [NSJSONSerialization dataWithJSONObject:response options:0 error:&error];
151+
if (data && data.length + sizeof(uint32_t) <= maxResponseBytes) return data;
152+
} @catch (NSException *exception) {
153+
bridgeLog(exception.reason ?: @"response serialization raised an exception");
154+
}
155+
NSMutableDictionary *fallback = [failureResponse(
156+
response[kRequestIdKey],
157+
data ? @"response_limit_exceeded" : @"malformed_tree",
158+
data ? @"response-too-large" : @"response-not-json-safe",
159+
data ? @"snapshot response exceeds the per-request response bound" : (error.localizedDescription ?: @"response was not JSON serializable")) mutableCopy];
160+
if (response[@"pid"] != nil) fallback[@"pid"] = response[@"pid"];
161+
if (response[@"generation"] != nil) fallback[@"generation"] = response[@"generation"];
162+
return [NSJSONSerialization dataWithJSONObject:fallback options:0 error:NULL];
163+
}
164+
165+
static NSUInteger responseLimitForRequest(id request)
166+
{
167+
if (![request isKindOfClass:NSDictionary.class]) return kMaximumFrameBytes;
168+
NSNumber *value = request[@"maxResponseBytes"];
169+
if (![value isKindOfClass:NSNumber.class]) return kMaximumFrameBytes;
170+
NSUInteger result = value.unsignedIntegerValue;
171+
return result >= 1024 && result <= kMaximumFrameBytes ? result : kMaximumFrameBytes;
172+
}
173+
174+
static int serve(NSString *socketPath, int idleTimeoutSeconds, BOOL exitOnDisconnect)
175+
{
176+
if (socketPath.length == 0 || socketPath.length >= sizeof(((struct sockaddr_un *)0)->sun_path)) {
177+
bridgeLog(@"socket path is empty or too long");
178+
return 1;
179+
}
180+
181+
int listener = socket(AF_UNIX, SOCK_STREAM, 0);
182+
if (listener < 0) {
183+
bridgeLog([NSString stringWithFormat:@"socket failed: %s", strerror(errno)]);
184+
return 1;
185+
}
186+
struct sockaddr_un address = {0};
187+
address.sun_family = AF_UNIX;
188+
strlcpy(address.sun_path, socketPath.fileSystemRepresentation, sizeof(address.sun_path));
189+
unlink(address.sun_path);
190+
if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(listener, 4) != 0) {
191+
bridgeLog([NSString stringWithFormat:@"bind/listen failed for %@: %s", socketPath, strerror(errno)]);
192+
close(listener);
193+
return 1;
194+
}
195+
chmod(address.sun_path, S_IRUSR | S_IWUSR);
196+
bridgeLog([NSString stringWithFormat:@"serving protocol %lu on %@", (unsigned long)kProtocolVersion, socketPath]);
197+
198+
BOOL done = NO;
199+
while (!done) {
200+
struct pollfd waitForClient = {.fd = listener, .events = POLLIN, .revents = 0};
201+
int ready = poll(&waitForClient, 1, idleTimeoutSeconds * 1000);
202+
if (ready == 0) break;
203+
if (ready < 0) {
204+
if (errno == EINTR) continue;
205+
break;
206+
}
207+
int connection = accept(listener, NULL, NULL);
208+
if (connection < 0) {
209+
if (errno == EINTR) continue;
210+
break;
211+
}
212+
struct timeval timeout = {.tv_sec = idleTimeoutSeconds, .tv_usec = 0};
213+
setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
214+
setsockopt(connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
215+
while (YES) {
216+
@autoreleasepool {
217+
uint32_t networkLength = 0;
218+
if (!readFully(connection, &networkLength, sizeof(networkLength))) break;
219+
uint32_t length = ntohl(networkLength);
220+
if (length == 0 || length > kMaximumFrameBytes) break;
221+
NSMutableData *body = [NSMutableData dataWithLength:length];
222+
if (!readFully(connection, body.mutableBytes, length)) break;
223+
id parsed = [NSJSONSerialization JSONObjectWithData:body options:0 error:NULL];
224+
NSDictionary *response = [parsed isKindOfClass:NSDictionary.class]
225+
? handleRequest(parsed)
226+
: failureResponse(@"", @"bad_request", @"json-object-required", @"request frame must be a JSON object");
227+
NSData *encoded = serializedResponse(response, responseLimitForRequest(parsed));
228+
if (encoded.length > kMaximumFrameBytes) break;
229+
uint32_t responseLength = htonl((uint32_t)encoded.length);
230+
if (!writeFully(connection, &responseLength, sizeof(responseLength)) ||
231+
!writeFully(connection, encoded.bytes, encoded.length)) break;
232+
}
233+
}
234+
close(connection);
235+
if (exitOnDisconnect) done = YES;
236+
}
237+
238+
close(listener);
239+
unlink(address.sun_path);
240+
return 0;
241+
}
242+
243+
static int integerArgument(NSArray<NSString *> *arguments, NSString *flag, int fallback)
244+
{
245+
for (NSUInteger index = 0; index + 1 < arguments.count; index += 1) {
246+
if (![arguments[index] isEqualToString:flag]) continue;
247+
NSInteger value = arguments[index + 1].integerValue;
248+
if (value > 0 && value <= INT_MAX) return (int)value;
249+
}
250+
return fallback;
251+
}
252+
253+
static BOOL boolArgument(NSArray<NSString *> *arguments, NSString *flag, BOOL fallback)
254+
{
255+
for (NSUInteger index = 0; index + 1 < arguments.count; index += 1) {
256+
if ([arguments[index] isEqualToString:flag]) return [arguments[index + 1] boolValue];
257+
}
258+
return fallback;
259+
}
260+
261+
int main(int argc, const char *argv[])
262+
{
263+
@autoreleasepool {
264+
if (argc < 3 || strcmp(argv[1], "serve") != 0) {
265+
fprintf(stderr, "Usage: %s serve <socket> [--idle-timeout <seconds>] [--exit-on-disconnect <bool>]\n", argv[0]);
266+
return 2;
267+
}
268+
NSMutableArray<NSString *> *arguments = [NSMutableArray array];
269+
for (int index = 2; index < argc; index += 1) {
270+
NSString *value = [NSString stringWithUTF8String:argv[index]];
271+
if (value) [arguments addObject:value];
272+
}
273+
NSString *socketPath = arguments.firstObject;
274+
if (socketPath.length == 0) return 2;
275+
NSArray<NSString *> *flags = [arguments subarrayWithRange:NSMakeRange(1, arguments.count - 1)];
276+
return serve(socketPath,
277+
integerArgument(flags, @"--idle-timeout", kDefaultIdleTimeoutSeconds),
278+
boolArgument(flags, @"--exit-on-disconnect", YES));
279+
}
280+
}

0 commit comments

Comments
 (0)