fix(ios): capture the lit panel on foldable Apple devices - #2703
Conversation
simctl io screenshot picks the highest screen ID when no display is named, which on a closed iPhone Duo is the dark inner panel: the capture exits 0 with an all-black PNG. Resolve the CoreDevice display table per capture, name the panel the device actually lights, and normalize density with that panel's own point scale. Panel power comes from backlightState because CoreDevice omits the active key on real payloads, and a multi-panel device always names a display so an ambiguous read degrades to a diagnostic instead of a black screenshot.
simctl io recordVideo shares screenshot's implicit display default, so a recording of a closed foldable is also black. The Apple package now builds the recordVideo argv and reaches the display inventory through a function-scoped import, which keeps the simctl facade's eager closure at its budget and puts the panel decision where the display model lives.
Adds ADR 0025, the `help foldable` topic with its benchmark case, and the verification traps that cost time on a Duo: the pinned 27.1 toolchain, the dark panel that captures black while exiting 0, and the UIScene lifecycle requirement that makes a legacy app trap at launch. Pose stays operator-controlled: no official host API sets it, so no command pretends.
The display symbols leave the facade through a function-scoped import read by a dynamic platform-runtime consumer, so fallow cannot connect the member read to the re-export. Scoped to the two facade symbols it names.
Size Report
Startup median (7 runs, lower is better):
|
|
Reviewed at 83308f7.
The recorder change in simctl-facade.ts:14 adds Not blocking: the timeout-vs-malformed-JSON error classification in display-inventory.ts looks inverted, the display probe re-runs on every capture even though the panel count is per-device and does not change between calls, most of the pose/role/inventory-shape code in display-inventory.ts has no reader outside its own module and test, the screenshot test suite runs the real probe because its deps don't override Is the size of this module justified? The production need is one string, the panel name for CI reports 19 checks green at 83308f7, with no failing job to attribute; note that the new probe never runs on Linux unit runners, so this green run doesn't exercise the devicectl route at all. The device numbers, the claim that Routing the JSON read through the existing devicectl helper and posting a live recordVideo run on the Duo are the two things needed before merge; trimming the unread pose/role layer would also be the cheapest way to settle the size question. |
Screenshots from the booted iPhone DuoCaptured on Before — After — same command, same device, this head: Interaction on the lit panel — Still unverified: the open pose / inner panel. No official host API sets the hinge angle, so this needs an operator to open the Duo in Device Hub. Recorded as a gap in ADR 0025. |
Open-pose verification, plus the
|
| argv | exported size | mean luma |
|---|---|---|
recordVideo --display=LCD-1 (lit inner) |
2006x2852 | 241.42 |
recordVideo --display=LCD (dark outer) |
1398x2034 | 0.00 |
recordVideo with no --display |
2006x2852 | 241.42 |
agent-device record start --scope device --hide-touches → 2006x2852, mean luma 241.42 over the 0.01/0.50/0.98 sample points (attached below).
Two honest caveats.
- A pre-existing bug, not from this change. With the default touch-overlay post-processing, an open-pose Duo recording exports as
480x336with mean luma0.00. It reproduces with none of this PR's code in the path: feeding a rawsimctlfile (the bright2006x2852one above) straight into the untouchedapple/runner/AgentDeviceRunner/RecordingScripts/recording-overlay.swiftyields a0x0/ zero-duration output. The same pipeline is fine on a non-rotated target — iPhone 17 exports1206x2622at mean luma228.30— so this is the overlay exporter mishandling the inner panel'srot90track.--hide-touchesavoids it. I can file this separately. - The open pose is not the regression case. With the inner panel lit, simctl's implicit highest-screen-ID default already lands on the lit panel, so pre-change behavior looked correct here. The closed pose in my earlier comment is where the old argv captured black while exiting 0, and the open-pose numbers above are what confirm the fix does not over-reach.
duo-open-panel-recording.mp4
`queryAppleDisplayInventory` copied the devicectl JSON protocol — temp path, `--json-output`, `runXcrun` with allowFailure, payload read, cleanup — and had already dropped the hint mapping the owning helper applies. Export `runIosDevicectlJsonRequest` as that single protocol implementation with a timeout and signal, and let the throwing command and the probe both build on it. Classify the probe by what actually happened: an exec kill stays a timeout via `isCommandTimeoutError`, `unreadable-json` now means devicectl answered with a payload that did not parse, and only those two reasons claim the toolchain lacks the subcommand. The old mapping labelled an AppError — i.e. the timeout — as an unreadable payload, which is inverted. Drop the panel labels nothing read. `outer`/`inner` and the derived pose had no consumer outside the module and its tests, while `primary` plus panel power is what every decision already used; the geometry conflict still reports `apple_display_primary_geometry_conflict`. Also stub `resolveCaptureDisplay` in the screenshot suite so it stops paying for a real CoreDevice probe, assert the caller signal actually reaches `runXcrun` instead of asserting it was never aborted, and drop the dynamic import of a module the facade already imports statically.
… defect The operator opened the Duo mid-verification, so the open pose is now evidence rather than a gap: the capture names `LCD-1` at 951x669, taps resolve inside that surface, typed text reads back, and a pre-fold ref is refused instead of replayed at the new point size. What stays open is the quarter turn and the runner fallback, both restated as gaps. `recordVideo` gets the per-panel table that proves `--display` is honored (2006x2852 luma 241.42 for the lit panel against 1398x2034 luma 0.00 for the dark one), plus the separate finding that the touch-overlay exporter returns a 480x336 black video for the inner panel's rot90 track — reproducible by feeding a raw simctl capture straight into the untouched `recording-overlay.swift`, and fine on a non-rotated iPhone 17 export. Also drop the pose and role wording from the decision table, since the inventory no longer carries either.
|
Addressed in 4780b46 and 616e843. Duplicated devicectl protocol — agreed, and the copy had already drifted the way you predicted.
Your prediction about late failure was right in a way I did not expect: with the default touch-overlay post-processing the open-pose clip exports as Inverted classification — fixed, and you were right that it was inverted. exec raises its own typed kill, so the probe now asks Unread pose/role surface — removed rather than defended. Nothing outside the module and its tests read Screenshot suite paying for a real probe — all five Probe per capture — deliberately left uncached, and I would like to keep it that way. The panel count is static, but the thing the resolver returns is the lit panel, and folding the device is exactly what changes it; a cache keyed on the device would resume capturing the dark panel, which is the failure this PR exists to fix. The cost is measured and documented at 0.16–0.26s against a 4.93s screenshot under a 5s budget. If you want the single-panel case cheap, the honest place for that is a device-type capability check, not a cache.
|
|
Correction on the recording I attached earlier: that clip only exercised What happens in this clip, on the open Duo with only the inner panel lit (
Snapshot node count goes from 9 visible (address overlay) to 19 visible (loaded page with browser chrome), and the recorded mean luma falls from 241.95 to 194.06 across the clip, so the frames are the interaction rather than one frozen screen. Two honest notes about what this clip does not show:
duo-open-navigation.mp4 |
|
Reviewed at 616e843. The lit-panel fix looks correct, and the delta answers both blocking items from the earlier round: Two things the delta itself introduced. In devicectl.ts:210 Take or leave the rest: five deps sites in screenshot.test.ts still pass Smoke Tests is still running at this head with no failure logged. It does exercise the changed route, since every simulator screenshot now runs the panel probe, so it is worth watching rather than assuming; the previous head was green on that same route. I did not wait for the run to finish and did not reproduce the Duo numbers, which are taken as reported. Once Smoke Tests reports green, this is ready to land. |
|






Summary
A foldable Apple device (iPhone Duo, iOS 27.1) lights one of its two integrated panels at a time, and
simctl io screenshot/recordVideoname the highest screen ID when no display is given — on a closed Duo that is the dark inner panel, so the capture exited 0 with an all-black PNG and every consumer trusted it.Each iOS simulator capture now resolves the CoreDevice display table, names the lit panel, and normalizes density with that panel's own
pointScale. Panel power comes frombacklightState, becauseactiveis absent from real payloads (a single-panel iPhone 17 reports onlybacklightState). A multi-panel device always names a display: an ambiguous read falls back toprimarywith anapple_display_capture_ambiguousdiagnostic rather than the black-producing default. Pose is derived only (closed/fully-open/unknown); no official host API sets it, so no command pretends to —agent-device help foldablesays so.15 files, +1340/-17 (637 test, 213 docs, 490 production). Gross churn is above the 1,000-line budget; say the word and I will split recording out.
Validation
Tested at
83308f7a8:pnpm checkexit 0,pnpm check:affected --runpassed.Live on a booted iPhone Duo: capture went from
669x951 @1xmean luma 0.09 to466x678 @1xmean luma 65.8; iPhone 17 unchanged at402x874, so no single-panel regression. On theexamples/test-appdev build:snapshot -ireturned 41 nodes,tap @e189dismissed the dev-menu sheet, and a Catalog tap settled+19 -15.Unresolved: open-pose frames, inner-panel tap routing, and the
XCUIScreen.mainrunner fallback need an operator-folded device (ADR 0025 records both gaps).