Files
Michał Pierzchała 47b1cae548 fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484) (#2486)
* 0.21.1

* fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484)

Since 0.21.0 the host AX bridge is the snapshot source for local iOS
Simulators. It reads one process, and a WebKit page lives in another:
Safari and WKWebView screens were published as chrome plus empty webview
nodes, with no ref reaching the page.

The decoder now counts AXRemoteElement leaves that sit under a WebView
ancestor and reach the viewport, and the source refuses such a tree as
remote-content-boundary. The existing route fallback serves XCTest, which
resolves remote elements, for the rest of the app generation and discloses
the switch in the snapshot warning. Frameless leaves are refused; zero-area
and off-screen ones are published.

Adds a fixture-backed smoke scenario that drives the WebView lab through the
default route, amends ADR 0004 and the bridge README, and shares the e2e
snapshotNodes helper.
2026-09-11 13:12:57 +02:00
..

Simulator AX bridge

This directory contains the small private accessibility reader used by the Apple platform acquisition facet. The framed server and request validation live in SnapshotBridge.m; private runtime binding lives in SnapshotBridgeRuntime.m. It is compiled for the iOS Simulator on first use and is never downloaded, pre-signed, or built by npm installation.

The guest process uses the XCTAccessibilityFramework remote-access client from the simulator runtime and the userTestingSnapshotForElement:options:error: snapshot API. Requests and responses are length-prefixed JSON frames:

uint32 big-endian byte length
UTF-8 JSON object

The host owns all target identity, bounds, deadlines, and lifecycle decisions. The guest returns only a bounded raw tree, the target pid, truncation, and protocol/source versions. It does not expose an HTTP route or a public CLI surface.

The private API is intentionally pinned to the idb v1.5.2-compatible shape. See LICENSE.idb for attribution.

Why Objective-C

The selected #2192 mechanism was idb v1.5.2's Objective-C SimulatorFrameworkBridge; the Python used during the spike was only a client for exercising that guest reader. This bridge keeps the proven native boundary and removes the Python/idb client dependency.

Objective-C is the narrowest implementation for this private runtime adapter: it resolves unavailable classes and functions with dlopen, dlsym, and the Objective-C runtime, invokes dynamically discovered selectors, and contains NSException failures. A Swift implementation would still require an Objective-C shim for those operations, adding another native boundary. Keeping the guest in Objective-C also allows direct lazy compilation with clang without an Xcode project or Swift module for private headers.

Foreground ownership

The reader checks AXRuntime's primary foreground application before and after acquisition. If the target is covered by system UI, ownership is unavailable, or the owner changes during capture, it returns a typed failure without the app tree. The existing route then uses XCTest, which owns system-modal resolution. Secondary owners such as the return-to-app status-bar control do not replace the native primary owner. The route's generation circuit remains disabled after fallback until that app relaunches.

Remote content

The reader snapshots one process. A WebKit page — Safari's or a WKWebView's — lives in a WebContent process and appears in that tree as an AXRemoteElement leaf under the web view. The guest returns the leaf as delivered; the host refuses a tree in which such a leaf sits under a web view and reaches the viewport, or reports no frame (remote-content-boundary), and routes that app generation to XCTest, which resolves remote elements (#2484). A zero-area or off-screen leaf is published: it hosts nothing the capture can miss.

Bounded depth recovery

A healthy capture uses one native request. If native acquisition rejects it, SnapshotBridgeCapture.m retries supported native failure codes at lower depths and fetches withheld children from their accessibility elements. The completed tree keeps the original depth and node limits; partial trees disclose truncation. The traversal depth counts edges below the root; native requests count the root as one level. Each acquisition allows two lower-depth retries, and recovery allows at most 32 native requests within the existing capture deadline, checks foreground ownership on every request, and returns a failure when it cannot complete a continuation. Budget exhaustion and malformed continuations use non-launch failure codes, so the route falls back without launch re-polling. At each native fragment boundary, an absent or invalid child count means unknown completeness and fails closed. Natural leaves above that boundary need no continuation evidence. Unchanged native dictionaries and child arrays are reused.

Accepted-depth hints

The host source (packages/platform-apple/src/snapshot-source/depth-hints.ts) remembers the native levels a finished recovery accepted, keyed by the resolved target id, its app generation, and this producer. The next capture of that generation sends nativeLevelsHint, so the guest's first request asks for the accepted levels instead of re-paying the known rejection. A hint changes the request strategy only: the delivered depth, node bounds, and completeness rules are unchanged. Hints are learned only from a recovery that observed a rejection and then finished (a tree bounded by the requested depth or node budget still teaches), never cross apps, generations, or producers, and expire after eight hinted captures so the next capture probes the full depth again; a capture that merely succeeds at the hinted depth does not renew it. Explicit raw-depth requests neither use nor teach hints. Every response carries recovery (requests, rejected, continuations, acceptedLevels), which the host emits as the ios_snapshot_source_recovery diagnostic.

Recovery conformance

contracts/fixtures/ios-ax-recovery-conformance.json is the shared, executable recovery contract for this bridge and the XCTest runner's private AX bridge. packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m replays each case through captureSnapshotTree; the runner replays the same cases through its own bridge in RunnerTests+AXRecoveryConformanceTests.swift. Each expectation names the outcome, the native request accounting, and the delivered tree as a canonical preorder signature with its retained node count, so a producer that drops, duplicates, reorders, or re-parents nodes cannot pass as complete. The fixture records each producer's expectation and documents the intentional differences (depth vocabulary, ladders, completeness evidence, budgets, hint lifetime).