feat(ios): publish the presented surface on every iOS capture (#3565)

Merged
TH
2d ago
+539-13

Summary

Makes the Apple runner answer the presented-surface fact in Swift, the same way layer 2's TypeScript rule
answers it (#3565). Layer 3 of 3 and the layer that closes the issue: from here every iOS capture route
carries presentedSurface — the daemon-side engines from layer 2, and the runner's own tiers from here —
so #1831's dismissal confirmation can arm on a producer fact instead of tree shape. 6 files, +515/−13.
Consumer-side narrowing stays in #3470.

On the runner, the classification happens inside makeSnapshotBackendCapture, which now takes the
acquisition beside the presentation it produced. That seam is where a tier's own tree is in hand and
in-bundle unit tests reach it, so the fact cannot go missing by reading the presented tree or by deleting
one line there. It answers unknown when the tier's nodes array is missing or empty, and
presented/unknown when nodes carry no class names, which is every runner tier today. The fact crosses
the wire as text because a Decodable Bool? decodes "presented" into true, which the shared
reader refuses; the Swift test decodes through the real JSONValue + wire reader, so a round-trip
failure fails the build rather than a manual run.

The Swift rule mirrors layer 2's TypeScript rule exactly, including where evidence is matched on the node
occurrence a walk stood on rather than on the number it carries, so a keyboard host's subtree cannot
swallow an app window's own dimming view, while absent still requires numbering that identifies each
node once.

The Swift rule and its parity test are here rather than with the TypeScript rule that shares their golden
table, for two reasons that are the repository's gates rather than preference: layer 2 needed the
publication to give its rule a production consumer (check:production-exports), and carrying the Swift
half as well would have put it at 1,242 gross lines against the 1,000-line budget. The table itself is in
layer 2, so this parity test is checked against a fixture that already exists — the Swift half cannot
silently redefine a case.

Evidence coverage (iPhone 17 simulator, RN test app, stack top 857adacc0)

Rows marked live were read off the device at 857adacc0; the rebased head 0bbea999f differs only by a layer-1 backend field no producer wrote. The rest are golden cases, said so plainly.

PresentationAnswerBasis
Home, Catalog, automation lab — one content window, one containerabsentlive, snapshot and snapshot -i
The same lab after the sheet is dismissedabsentlive
RN pageSheet sheet — window holds 2 containers, dimming views at 4 and 134presentedlive
That sheet through the projected cut (6 nodes) and the interactive cut (4 nodes), neither of which publishes a dimming viewpresentedlive — the fact is read off the acquisition, not the projection
Home with the Expo dev-menu floating button on screen (a second content window)unknownlive — a window this rule cannot name
Any screen through the private-AX backend (--actions, 0 class names published)unknownlive — the class-blind gap
Sheet resting at an undimmed detentpresentedgolden case, not device-captured
RN Alert.alert (own window, no dimming view)presentedlive on the bridge route, and a golden case
Alert whose producer names only Element(7)presentedgolden case, both readings agree
Full-screen / custom modal, no dimming viewunknowngolden case — nothing positive, no guessing
SwiftUI .sheetpresented expectedsame UIKit container as a page sheet; not device-captured
SpringBoard system alertout of scoperesolveBlockingSystemModal stamps .presented (layer 1)
Private-AX runner tier (--actions): Home and the open sheetunknownlive — no class names, and the sheet publishes no Sheet element
Private-AX runner tier: RN Alert.alert, published as Element(7)presentedlive — the raw Alert element type is positive on its own
Capped, unstated, malformed numberingunknown, or presented on evidence it did readgolden cases

Two things the live run is worth stating out loud. The dev-menu floating button puts a real
EXDevMenu.DevMenuFABWindow beside the app window, and the honest answer for that screen is unknown —
the four absent rows above were taken with it disabled
(defaults write com.callstack.agentdevicelab EXDevMenuShowFloatingActionButton -bool NO), which is the
single-content-window shape the rule reads absence from. And the interactive cut of the open sheet
publishes four nodes, none of them a dimming view, while the fact still answers presented: that is the
failure mode this fact replaces, since a consumer reading the published tree would have had to answer
absent on a screen with a sheet over it.

Validation

0bbea999f, rebased onto main: pnpm typecheck clean, with the serialized pnpm check:affected --run result posted on the PR. Before the rebase, at c06d8ec9a: swift test runs the parity suite at 23/23 against the layer-2
fixture, check:production-exports passes, pnpm build and pnpm typecheck are clean, and vitest over
packages/capture-kit, packages/kernel and packages/platform-apple is 2,963 green, eager-closure
budgets included. The runner's own suite runs on the iPhone 17 simulator: 430 tests, with testCommand
the one failure — it refuses to listen without the listener port and token the daemon injects, so it
cannot pass under a bare xcodebuild test run, and this stack does not go near that file. The live
capture table above is from the same head.

The repository-wide local gate for this head is deliberately not claimed here: four other worktrees on
this host run subprocess-heavy suites concurrently, and the earlier local failures in this stack — an ADB
connect failure, a daemon startup timeout, one abort-timing case — were contention from exactly that,
each passing in isolation (47/47, 6/6) while failing under load. GitHub CI is still queued at this head;
the Compatibility & Provenance lane, which the previous head genuinely failed, is the one worth reading
on this PR first — its check is reproduced locally above.

Both parity tests read contracts/fixtures/ios-presented-surface.json, the single cross-language table
from layer 2, so either language answering one of these 18 cases differently fails its own suite, and the
cross-language claim is enforced rather than asserted. The runner seam is pinned in
RunnerTests+SnapshotCapturePlanTests.swift: a missing nodes array answers unknown with
presentedSurfacePresented nil, a bare Sheet element type answers presented (a positive is strong
even with no class names), and an unnamed tree answers presented/unknown and never absent. That
seam's test now also asserts its own premise — that the presentation the tier produced does not carry the
node the evidence sits on — so the assertion below it cannot silently become a test that reads one tree
twice; swapping the evidence node for one the projection retains fails that premise assertion.