Skip to content

Commit bcfe9dc

Browse files
committed
perf: prune interactive AX snapshots earlier
1 parent b2cb16d commit bcfe9dc

4 files changed

Lines changed: 107 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Private simulator behavior is implemented locally in:
7979

8080
The current repo uses the private boot path, private display bridge, and private accessibility translation bridge directly. The browser streams frames from that bridge, injects touch and keyboard events through the same native session layer, inspects accessibility through `AccessibilityPlatformTranslation`, and renders device chrome from `cli/XCWChromeRenderer.*`.
8181
CoreSimulator service contexts resolve the active developer directory from `DEVELOPER_DIR`, then `xcode-select -p`, then `/Applications/Xcode.app/Contents/Developer`. The display bridge prefers direct CoreSimulator screen IOSurface callbacks and activates the SimulatorKit offscreen renderable view only if direct callbacks are unavailable.
82-
Accessibility recovery may use simulator launchctl UIKit application state plus hit-tested translations to recover candidate foreground pids; the returned tree must still be rooted at tokenized `AXPTranslator` application objects, because `translationApplicationObjectForPid:` can omit the bridge delegate token after private display lifecycle changes. Full-tree snapshots merge those recovered roots with the private frontmost application translation. Shallow snapshots with `maxDepth <= 2` use the tokenized frontmost application translation directly when it is available, and only run the expensive recovery sweep if frontmost lookup fails, so agent-oriented describe loops avoid launchctl and hit-test recovery overhead. When multiple candidate application roots are discovered, serialize all of them in preferred order: non-extension app roots first, then largest translated roots, with `.appex`/PlugIns processes de-prioritized so SpringBoard and Safari app roots stay primary while widgets and WebContent roots remain debuggable. Widget renderer extension roots may report local frames; normalize those roots and children against matching SpringBoard widget placeholder frames before returning the snapshot.
82+
Accessibility recovery may use simulator launchctl UIKit application state plus hit-tested translations to recover candidate foreground pids; the returned tree must still be rooted at tokenized `AXPTranslator` application objects, because `translationApplicationObjectForPid:` can omit the bridge delegate token after private display lifecycle changes. Full-tree snapshots merge those recovered roots with the private frontmost application translation. Shallow snapshots with `maxDepth <= 2` use the tokenized frontmost application translation directly when it is available, and only run the expensive recovery sweep if frontmost lookup fails, so agent-oriented describe loops avoid launchctl and hit-test recovery overhead. Interactive-only snapshots also prune non-actionable native AX leaves during Objective-C serialization before the Rust-side compacting pass; keep this native pruning conservative so selector taps still retain actionable rows plus their ancestors. When multiple candidate application roots are discovered, serialize all of them in preferred order: non-extension app roots first, then largest translated roots, with `.appex`/PlugIns processes de-prioritized so SpringBoard and Safari app roots stay primary while widgets and WebContent roots remain debuggable. Widget renderer extension roots may report local frames; normalize those roots and children against matching SpringBoard widget placeholder frames before returning the snapshot.
8383
Physical chrome button support uses DeviceKit `chrome.json` input geometry for browser hit targets. Volume, action, mute, Apple Watch digital crown, Watch side button, and Watch left-side button dispatch through `IndigoHIDMessageForHIDArbitrary` with consumer/telephony/vendor HID usage pairs from the device chrome metadata; home, lock, and app-switcher remain on the existing SimulatorKit button paths. Apple Watch Digital Crown rotation dispatches through `IndigoHIDMessageForDigitalCrownEvent` when SimulatorKit exposes it, with `IndigoHIDMessageForScrollEvent(..., target=0x34)` as the fallback. tvOS simulators do not support direct screen touch; browser/API tap maps to Enter, swipe maps to arrow keys, and the native bridge rejects tvOS touch packets before they reach guest `SimulatorHID`. watchOS/tvOS skip dynamic pointer/mouse service warm-up because those guest runtimes abort on unsupported virtual services. Apple TV and Apple Watch simulators are fixed-orientation devices, so client and server rotation paths must not expose or dispatch device rotation for those families.
8484
Two-point multi-touch dispatch prefers the current SimulatorKit/Indigo packet constructor and falls back to SimDeck's manual Indigo packet adapter. On Xcode 26 SimulatorKit, the constructor expects pixel-space points and stable two-finger movement requires sending `LeftMouseDown` for both `began` and `moved`, then `LeftMouseUp` for `ended`/`cancelled`; using `LeftMouseDragged` for multi-touch moves only advances one contact in UIKit. Do not coalesce multi-touch move packets in the WebSocket or WebRTC control paths, because gesture recognizers need the intermediate two-contact samples.
8585
WebKit inspection uses the simulator `webinspectord` Unix socket named `com.apple.webinspectord_sim.socket` and WebKit's binary-plist Remote Inspector selectors. It lists only WebKit content that the runtime exposes as inspectable. For app-owned `WKWebView` on iOS 16.4 and newer, the app must set `isInspectable = true`.

cli/XCWAccessibilityBridge.m

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,52 @@ static id XCWAXJSONValue(id value) {
681681
return role ?: @"";
682682
}
683683

684+
static BOOL XCWAXRoleLooksInteractive(NSString *role) {
685+
NSString *lowercase = (role ?: @"").lowercaseString;
686+
for (NSString *needle in @[
687+
@"button",
688+
@"cell",
689+
@"checkbox",
690+
@"collection",
691+
@"combobox",
692+
@"control",
693+
@"link",
694+
@"menu",
695+
@"picker",
696+
@"radio",
697+
@"scroll",
698+
@"search",
699+
@"segmented",
700+
@"select",
701+
@"slider",
702+
@"stepper",
703+
@"switch",
704+
@"tab",
705+
@"table",
706+
@"textfield",
707+
@"text field",
708+
@"textinput",
709+
@"text input",
710+
@"toggle",
711+
@"webview",
712+
]) {
713+
if ([lowercase containsString:needle]) {
714+
return YES;
715+
}
716+
}
717+
return NO;
718+
}
719+
720+
static BOOL XCWAXElementLooksInteractive(id element, NSString *role) {
721+
if (XCWAXBool(element, "isAccessibilityHidden")) {
722+
return NO;
723+
}
724+
if (XCWAXBool(element, "isAccessibilityFocused")) {
725+
return YES;
726+
}
727+
return XCWAXRoleLooksInteractive(role) || XCWAXRoleLooksInteractive(XCWAXRoleType(role));
728+
}
729+
684730
static pid_t XCWAXElementPID(id element) {
685731
id translation = XCWAXObject(element, "translation");
686732
SEL selector = sel_registerName("pid");
@@ -718,7 +764,7 @@ static pid_t XCWAXElementPID(id element) {
718764
return values;
719765
}
720766

721-
static NSMutableDictionary *XCWAXSerializeElement(id element, NSString *token, NSHashTable *visited, NSUInteger depth, NSUInteger maxDepth) {
767+
static NSMutableDictionary *XCWAXSerializeElementWithOptions(id element, NSString *token, NSHashTable *visited, NSUInteger depth, NSUInteger maxDepth, BOOL interactiveOnly) {
722768
if (element == nil || depth > maxDepth || [visited containsObject:element]) {
723769
return nil;
724770
}
@@ -729,21 +775,33 @@ static pid_t XCWAXElementPID(id element) {
729775
((void(*)(id, SEL, id))objc_msgSend)(translation, sel_registerName("setBridgeDelegateToken:"), token);
730776
}
731777

732-
NSMutableDictionary *values = [XCWAXDictionaryForElement(element) mutableCopy];
733778
NSMutableArray *childrenValues = [NSMutableArray array];
734779
id children = depth < maxDepth ? XCWAXObject(element, "accessibilityChildren") : nil;
735780
if ([children isKindOfClass:NSArray.class]) {
736781
for (id child in (NSArray *)children) {
737-
NSMutableDictionary *childValues = XCWAXSerializeElement(child, token, visited, depth + 1, maxDepth);
782+
NSMutableDictionary *childValues = XCWAXSerializeElementWithOptions(child, token, visited, depth + 1, maxDepth, interactiveOnly);
738783
if (childValues != nil) {
739784
[childrenValues addObject:childValues];
740785
}
741786
}
742787
}
743-
values[@"children"] = childrenValues;
788+
789+
NSString *role = XCWAXObject(element, "accessibilityRole");
790+
if (interactiveOnly && childrenValues.count == 0 && !XCWAXElementLooksInteractive(element, role)) {
791+
return nil;
792+
}
793+
794+
NSMutableDictionary *values = [XCWAXDictionaryForElement(element) mutableCopy];
795+
if (!interactiveOnly || childrenValues.count > 0) {
796+
values[@"children"] = childrenValues;
797+
}
744798
return values;
745799
}
746800

801+
static NSMutableDictionary *XCWAXSerializeElement(id element, NSString *token, NSHashTable *visited, NSUInteger depth, NSUInteger maxDepth) {
802+
return XCWAXSerializeElementWithOptions(element, token, visited, depth, maxDepth, NO);
803+
}
804+
747805
static NSArray<NSValue *> *XCWAXRootRecoveryHitTestPoints(void) {
748806
NSMutableArray<NSValue *> *points = [NSMutableArray array];
749807
NSArray<NSNumber *> *xValues = @[@80, @220, @360];
@@ -1511,7 +1569,7 @@ + (nullable NSDictionary *)accessibilitySnapshotForSimulatorUDID:(NSString *)udi
15111569
}
15121570

15131571
NSHashTable *visited = [NSHashTable hashTableWithOptions:NSPointerFunctionsObjectPointerPersonality];
1514-
NSMutableDictionary *root = XCWAXSerializeElement(element, token, visited, 0, MIN(maxDepth, XCWAXMaxDepth));
1572+
NSMutableDictionary *root = XCWAXSerializeElementWithOptions(element, token, visited, 0, MIN(maxDepth, XCWAXMaxDepth), interactiveOnly);
15151573
if (root != nil) {
15161574
XCWAXApplyRecoveredRootMetadata(root, rootItem);
15171575
[serializedRootItems addObject:@{

scripts/integration/cli.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,10 @@ async function runRestControls() {
654654
await measuredStep(
655655
"REST accessibility-tree interactive",
656656
async () => {
657+
const fullTree = await httpJson(
658+
"GET",
659+
`/api/simulators/${simulatorUDID}/accessibility-tree?source=native-ax&maxDepth=8`,
660+
);
657661
const tree = await httpJson(
658662
"GET",
659663
`/api/simulators/${simulatorUDID}/accessibility-tree?source=native-ax&maxDepth=8&interactiveOnly=true`,
@@ -662,6 +666,13 @@ async function runRestControls() {
662666
if (tree.interactiveOnly !== true) {
663667
throw new Error("interactive tree did not report interactiveOnly=true");
664668
}
669+
const fullCount = countAccessibilityNodes(fullTree);
670+
const interactiveCount = countAccessibilityNodes(tree);
671+
if (interactiveCount <= 0 || interactiveCount > fullCount) {
672+
throw new Error(
673+
`interactive tree node count ${interactiveCount} was not a pruned subset of full count ${fullCount}`,
674+
);
675+
}
665676
},
666677
{ phase: phaseSetup },
667678
);
@@ -1911,6 +1922,24 @@ function assertRoots(payload, label) {
19111922
}
19121923
}
19131924

1925+
function countAccessibilityNodes(snapshot) {
1926+
const roots = Array.isArray(snapshot?.roots) ? snapshot.roots : [];
1927+
let count = 0;
1928+
const visit = (node) => {
1929+
if (!node || typeof node !== "object") {
1930+
return;
1931+
}
1932+
count += 1;
1933+
for (const child of Array.isArray(node.children) ? node.children : []) {
1934+
visit(child);
1935+
}
1936+
};
1937+
for (const root of roots) {
1938+
visit(root);
1939+
}
1940+
return count;
1941+
}
1942+
19141943
function assertJson(payload, label) {
19151944
if (!payload || typeof payload !== "object") {
19161945
throw new Error(`${label} did not return a JSON object`);

server/src/main.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1837,7 +1837,7 @@ fn daemon_is_healthy(metadata: &DaemonMetadata) -> bool {
18371837
}
18381838

18391839
fn daemon_matches_launch_options(metadata: &DaemonMetadata, options: &DaemonLaunchOptions) -> bool {
1840-
metadata.port == options.port
1840+
daemon_port_matches_launch_options(metadata.port, options.port)
18411841
&& metadata.bind == options.bind
18421842
&& metadata.advertise_host == options.advertise_host
18431843
&& metadata.client_root == options.client_root
@@ -1852,6 +1852,11 @@ fn daemon_matches_launch_options(metadata: &DaemonMetadata, options: &DaemonLaun
18521852
&& metadata.local_stream_fps == options.local_stream_fps
18531853
}
18541854

1855+
fn daemon_port_matches_launch_options(actual: u16, preferred: u16) -> bool {
1856+
let start = preferred.max(1024);
1857+
actual >= start && actual < start.saturating_add(200)
1858+
}
1859+
18551860
fn read_daemon_metadata() -> anyhow::Result<Option<DaemonMetadata>> {
18561861
let path = daemon_metadata_path()?;
18571862
if !path.exists() {
@@ -5875,6 +5880,14 @@ mod tests {
58755880
assert!(!daemon_matches_launch_options(&metadata, &options));
58765881
}
58775882

5883+
#[test]
5884+
fn daemon_launch_options_accept_probed_port() {
5885+
let metadata = daemon_metadata_for_test(4313, "127.0.0.1", None, None);
5886+
let options = daemon_launch_options_for_test(4311, "127.0.0.1", None, None);
5887+
5888+
assert!(daemon_matches_launch_options(&metadata, &options));
5889+
}
5890+
58785891
#[test]
58795892
fn daemon_launch_options_reject_different_bind_or_client() {
58805893
let metadata =

0 commit comments

Comments
 (0)