Skip to content

A foldable simulator's screen stream follows the panel in use, across folds, without a reconnect #1531

Open
0 of 2 issues completed

Description

@V3RON

Request: none

Problem

On a foldable iOS simulator (iPhone Duo), the screen stream always shows the cover panel. Unfolded, the cover is painted black, so every viewer of the lease's stream sees black while screenshots of the panel in use show the screen. Taps through a lease's input miss on a Duo in both poses: folded they never land, and unfolded they land turned a quarter turn. Reported in #1457, which stays open until this feature ships. Proof of concept: #1525.

Who it is for

Anyone who watches or drives a leased iPhone Duo simulator: an operator on the console's Watch page, and a lease holder's own app that reads GET /v1/leases/{id}/stream and sends taps over WS /v1/leases/{id}/input, for example to test an app on both panels of a foldable.

Outcome

The picture is always the panel in use, upright, in both poses. When the device folds or unfolds, the open stream switches to the other panel within about 5 seconds, with no reconnect. A tap at a point of the picture lands on that point of the panel in use, folded or unfolded, and keeps doing so after a fold with the input socket left open. Every other device streams and takes taps as today.

How it works for the user

sequenceDiagram
  participant U as Viewer (console or app)
  participant W as Worker
  participant D as iPhone Duo
  U->>W: GET /v1/leases/{id}/stream
  W->>D: start the capture: pick the lit panel
  W-->>U: headers (size of that panel), meta, frames
  U->>W: WS /v1/leases/{id}/input
  W-->>U: screen config (that panel's size)
  Note over D: the device unfolds
  W-->>U: meta (inner panel's size), keyframe, frames
  W-->>U: screen config (inner panel's size)
  U->>W: tap (0.8, 0.3)
  W->>D: lands at (0.8, 0.3) of the inner panel
  Note over W: the log line never comes (another iOS)
  W-->>U: stays on the panel picked at open, frames go on
Loading

Before, unfolded, through the stream (from daemon.log):

Screen stream opened {"codec":"h264","width":494,"height":720}   (cover; the picture is black)

After, a stream held open across unfold, then fold:

Screen stream opened {"codec":"h264","width":494,"height":720}
[capture] picture size changed to 720x506      (inner, upright)
[capture] picture size changed to 494x720      (cover)

Examples

Which panel a stream starts on, and what it does next:

Device, pose at open Log line Today After
iPhone 16 (one panel) — its screen its screen, unchanged
Duo, folded any cover, lit cover, lit
Duo, unfolded any cover, black inner, upright, lit from the first picture
Duo, unfolded, app showing all black read within 10 s cover, black cover first, then inner once the boot's log is read
Duo, folds during the stream .inner:pri stays on cover, black switches to inner within 5 s
Duo, folds during the stream never comes stays on cover, black stays on the panel picked at open; one log line

Where a tap at fraction (u, v) of the picture lands:

Device, pose Today After
iPhone 16 (u, v) (u, v)
Duo, folded nowhere (u, v) of the cover
Duo, unfolded (v', u'): turned a quarter (e.g. sent (0.2, 0.1) landed (0.9, 0.2)) (u, v) of the upright inner panel

What could go wrong

  • A new iOS release changes SpringBoard's log wording: folds are no longer followed and the picture stays on the panel it opened on. Likely over time; the log says so.
  • A stream opened unfolded on an all-black screen shows black for a few seconds before it switches. Rare.
  • An app viewer that reads the picture size once draws a stretched picture after a fold until it reconnects. Only apps written against today's single size.
  • A physical iPhone that rotates mid-stream now changes size too; nobody has run that on hardware.

Words used

  • panel: one of a foldable's two screens. The Duo's cover is the outside one (1398×2034), the inner one opens like a book (2007×2853, mounted sideways).
  • cover, inner: SpringBoard's own names for the two panels.
  • panel in use: the panel SpringBoard shows the picture on; the other is painted black.
  • lit: a panel whose picture is not all black.
  • meta frame: the first frame of a stream body, saying the codec and the picture's size; after this feature it may come again when the size changes.
  • screen config: the input socket's message (0x82) that tells a client the screen's size.
  • SpringBoard's log: the simulator's own system log; its DisplayContentMode lines name the panel in use after each fold, e.g. .inner:pri .cover:aux.

Non-goals

  • Two viewers of one device still run two captures (One capture per lease, shared by every viewer of its screen #602).
  • A rotated simulator keeps today's picture and taps.
  • Physical foldables, and devices with more than two panels (they stay on the first panel and log one line).
  • The half-open pose: only open and closed are followed.
  • Multi-touch, scroll and edge gestures on the inner panel are sent through the same mapping but are not checked by this feature.

Completion conditions

  • On a leased Duo opened unfolded, the console's Watch page and GET /v1/leases/{id}/stream show the inner panel upright from the first lit frame.
  • With the stream held open, unfolding then folding the Duo switches the picture each time within 5 s of the fold command, in one response, with no reconnect; each part decodes.
  • Over WS /v1/leases/{id}/input, taps at (0.2, 0.1), (0.8, 0.3) and (0.5, 0.9) land at those points of the panel in use, folded and unfolded, with the socket left open across the fold; the screen config after the fold names the new panel's size.
  • An iPhone 16 lease streams and takes taps as before.
  • After a SIGKILL of a Duo's capture or input helper, no log stream process for that simulator is left after 5 s.
  • The website's watching-the-screen page and the stream's OpenAPI header descriptions say the size may change mid-stream.

Open questions

Decisions

  • ADR 0075 — A foldable simulator streams the panel in use, and a stream may change size while it runs (docs(adr): propose 0075, a foldable simulator streams the panel in use (#1531) #1532): picks the panel, makes the log a hint, allows a mid-stream size change for every source, and kills the helpers with the capture.
  • ADR 0049 — A lease holder watches and drives its device's screen: input speaks serve-sim's messages, positions are fractions of the screen, and a later screen config goes to every socket.
  • ADR 0031 — A physical iPhone streams through CoreDevice: the screen-source seam this feature changes the size rule of (narrowed by 0075).
  • Decided by the maintainer: the panel is picked by which one is lit when the stream opens, then followed through SpringBoard's log while it runs.
  • Decided by the maintainer: a size change mid-stream is announced by a new meta frame; the size headers describe the picture at open.
  • Decided by the maintainer: a physical iPhone that rotates mid-stream is rescaled the same way; listed as untested on hardware.
  • Decided by the maintainer: taps follow the panel in use.
  • Decided in the session: a new ADR, not an edit of 0031, because 0031 is about the physical iPhone's source.
  • Decided in the session: a log line that never comes keeps the panel picked at open and logs one line, because the log text is undocumented and must never fail a stream.
  • Decided in the session: the log child of a capture or input helper ends within 5 s of the helper's end, SIGKILL included, because a leaked log stream runs until the simulator shuts down.
  • Decided in the session: the Swift package gets a test target, run by swift test on a Mac and listed in toolchain.md, first for the log-line parser and the tap mapping, because those break on a new iOS release and no lane runs Swift today.
  • Decided in the session: the capture and the input helper share one panel follower in simlock-screen-capture, because they must agree on the panel and the input helper cannot read which panel is lit from the host.
  • Decided in the session: both panels dark (or both lit) at open starts on the cover and takes the boot log's last move when it is read, because a black app must not pick the wrong panel for good.
  • Decided in the session: a tap on a two-panel device goes to the panel's own digitizer target (screenID | 0x40000000: cover 0x40000001, inner 0x40000003), and the inner panel's fraction is sent as (v, 1 − u); a one-panel device keeps target 0x32. Measured on an iOS 27.1 Duo, because 0x32 misses the cover.
  • Decided in the session: the screen config stays orientation: "portrait" and gives the upright size of the panel in use (cover 1398×2034, inner 2853×2007), because a client maps fractions on the picture it sees.
  • Decided in the session: each simctl spawn … log runs under a /bin/sh watchdog whose stdin is a pipe only its helper holds; when the pipe closes (the helper ends any way, SIGKILL included) the watchdog kills simctl, which ends the log inside the simulator. Run on an iOS 26.5 simulator: after a SIGKILL of the parent, simctl and the in-simulator log were gone within 3 s.
  • Decided in the session: which panel is lit is read by xcrun simctl io <udid> screenshot --display=<screen id> - of both panels at once, cut at 3 s, because the input helper captures no framebuffer and the capture's FrameCapture hands over one panel only; a failed or late read counts as dark.
  • Decided in the session: of two panels the smaller is the cover; a panel's screen id is the one simctl io <udid> enumerate lists under the panel's deviceName (Duo: primary = 1, primary-1 = 3), because that id is also its touch target.
  • Decided in the session: the inner panel's picture is turned clockwise a quarter (undoing nativeRotation 270), so it matches simctl io screenshot of that panel, the frame the taps were measured in.
  • Decided in the session: the slow lane folds with agent-device fold open|closed (agent-device 0.21.35 or later, found through SIMLOCK_E2E_AGENT_DEVICE as e2e/slow-agent-device.test.ts:52 does, per agent-device help foldable) and sees taps through a small committed fixture app; the test is skipped with a reason when agent-device, the iPhone Duo device type or an iOS 27.1+ runtime is missing, because the repo has no fold of its own and no iOS slow test reads a tap today.
  • Decided in the session: the switch shows within 5 s of the fold (decided by the maintainer 2026-11-12: the lane measured 3.7–4.0 s on this host); a few black frames during the fold are fine, because the PoC measured 1.2–1.5 s.

Behaviours

Id Behaviour (invoker, action, what it sees) Seam Cases Task
B1 The panel follower reads SpringBoard's DisplayContentMode lines and reports the panel in use Panels.primary(inLogLine:) (I1), swift test settled swap line .inner:pri .cover:aux → inner; .cover:pri .inner:aux → cover; a transition with an arrow (.inner:off→pri) → none; the log header that repeats the filter (no [com.apple.SpringBoard:DisplayContentMode] tag) → none; two pri → none; an unrelated line → none #1534
B2 The panel follower picks the panel at start PanelFollower (I2), swift test with a fake PanelSampler, PanelLog and clock one panel → follower not started; cover lit, inner dark → cover; inner lit, cover dark → inner; both dark → cover, then the boot log's last move when read; both lit → cover, then the boot log's last move when read; both dark and the boot log unread in 10 s → cover; more than two panels → first panel, one log line #1534
B3 The panel follower follows live moves PanelFollower (I2), swift test with a fake PanelLog and clock a live move → reported once; the same panel again → nothing; a live move heard while the boot log is still read → the live one wins; the live log ends while wanted → started again after 1 s; no line ever → stays, one log line #1534
B4 A viewer of a Duo's stream sees the panel in use, upright, and the switch on a fold, in one response GET /v1/leases/{id}/stream on a leased Duo, slow lane e2e/slow-ios-foldable.test.ts (new; folds with I10) opened folded → cover 494×720 lit; each part decoded with ffmpeg; opened unfolded → inner 720×506 lit, and a decoded frame matches simctl io screenshot --display=3 (both scaled to 64 px on the long edge, mean absolute difference under 10%); unfold then fold with the stream open → three meta frames, each part decodes and is lit, each switch within 5 s of the fold command; the toolchain.md "iOS foldable" machine check fails → the hardware verifier reports unavailable, never a pass #1534
B5 Any source whose picture changes size gets a new scale, a meta frame and a keyframe encodeLoop (I4), swift test with a fake source and the test sink of Wire (I11) same size each frame → one meta frame; a new size → one new meta frame before the first frame drawn at it, and a keyframe; back to the first size → another meta frame #1534
B6 A capture's log children end with it the capture process on a leased Duo, slow lane e2e/slow-ios-foldable.test.ts; the watchdog (S1) alone in swift test with a fake command stdin closed → its simctl spawn and in-simulator log gone within 5 s; SIGTERM → gone within 5 s; SIGKILL → gone within 5 s; the log child ending by itself → the watchdog ends too #1534
B11 An input helper's log children end with it the input helper process on a leased Duo, slow lane e2e/slow-ios-foldable.test.ts stdin closed → its simctl spawn and in-simulator log gone within 5 s; SIGTERM → gone within 5 s; SIGKILL → gone within 5 s #1535
B7 A one-panel device's stream is unchanged slow lane e2e/slow-ios-stream.test.ts (existing iPhone 16 lease) iPhone 16 → one meta frame, no panel follower and no log child started #1534
B8 A lease holder's tap lands at its fraction of the panel in use WS /v1/leases/{id}/input on a leased Duo with the touch-probe app (I9) in front, slow lane e2e/slow-ios-foldable.test.ts each landing read from the app's touches.txt and within 0.02 of the sent fraction; folded, (0.2, 0.1), (0.8, 0.3), (0.5, 0.9) → those points of the cover; unfolded → those points of the upright inner panel; unfolded after a fold with the socket left open → the same; one-panel iPhone 16 → those points, as today, in its own describe that needs only the iOS check #1535
B9 The input helper turns a fraction into a panel's touch PanelTouch.map (I6), swift test cover (u, v) → target 0x40000001, (u, v); inner (u, v) → target 0x40000003, (v, 1 − u); one panel → 0x32, (u, v); (0, 0) and (1, 1) corners on each #1535
B10 An input socket left open across a fold gets the new panel's screen config, and its taps land on it the iOS driver's device input (I7), src/drivers/ios/input/device-input.test.ts with a fake helper; the socket fan-out is X8's, unchanged, and the slow lane's B8 reads the config after a fold a second size frame from the helper → onConfig listeners get it; a listener added after the change gets the latest; the same size again → no new config; a malformed later frame → kept config, one warn #1535

Stored state

Id What and where Created, changed, deleted by Listed or swept by A restart mid-step leaves Schema step Task Used by
S1 The xcrun simctl [--set <set>] spawn <udid> log stream --style compact --predicate <P> and … log show --last boot --style compact --predicate <P> children of a capture or input helper, each under a /bin/sh watchdog whose stdin is a pipe the helper holds; <P> = process == "SpringBoard" AND category == "DisplayContentMode" AND (eventMessage CONTAINS ".cover:pri" OR eventMessage CONTAINS ".inner:pri") started by the panel follower (I2) on a two-panel device; ended by it on stop, and by the watchdog when the pipe closes (any end of the helper) (B6) — a daemon restart closes every helper's stdin, so its children end; a SIGKILLed helper's children end within 5 s (B6) none #1534 #1535

Interfaces

Id What Shape and bounds Errors and limits Where Task Used by
I1 Panels.primary(inLogLine:) -> PanelRole? one log line; PanelRole = cover | inner; counts only a line with the tag [com.apple.SpringBoard:DisplayContentMode], no arrow in any role, exactly one pri none: an unreadable line answers nil vendor/screen-capture/Sources/simlock-screen-capture/Panels.swift (new, ours) #1534 #1535
I2 PanelFollower(panels, sampler: PanelSampler, log: PanelLog, clock): start(onPanel), stop(). Panel = {name, screenID, width, height, nativeRotation, role} from the device type's capabilities.plist (integrated, digitizer, chrome identifier) and simctl io enumerate; of two the smaller is cover. PanelSampler.lit(panels) async -> [screenID: Bool]; production: simctl io <udid> screenshot --display=<screenID> - for every panel at once, lit = mean luminance above 2%. PanelLog: bootLines(timeout) async -> [String], live(onLine, onEnd), stop(); production: the S1 children onPanel(Panel) once at start, before start returns, and once per change; start returns within 3 s plus the sampler the sampler is cut at 3 s, a failed or late read is dark; the boot log read is cut at 10 s; a live log that ends is started again after 1 s while wanted; no line in 30 s → one line [panels] no panel move heard; staying on <role> Panels.swift #1534 #1535
I3 simlock-screen-capture <udid> stream body unchanged envelope; on a two-panel device the picture is the panel in use, upright: the inner panel's framebuffer turned clockwise a quarter (undoing nativeRotation 270), the orientation of simctl io screenshot --display=<inner> as today SimulatorSource.swift #1534 —
I4 Meta frame (0x05) as today (udid, codec, width, height, fps); now written again before the first picture of each new size, followed by fresh parameter sets and a keyframe; log line picture size changed to <w>x<h> — SimlockCapture/Lifecycle.swift encodeLoop, prepareForEncode #1534 —
I5 simlock-screen-capture <udid> --input touch 0x03 {type, x, y} fractions of the picture, as today; on a two-panel device mapped by I6 to the panel in use; screen config 0x82 {width, height} written once the follower's start returns (within 3 s plus the sampler, inside the driver's 10 s open deadline) and again on each panel change, upright size of the panel in use as today InputMode.swift, HIDInjector.swift #1535 —
I6 PanelTouch.map(role: PanelRole?, x, y) -> (target: UInt32, x, y) nil role (one panel) → 0x32; cover → 0x40000001; inner → 0x40000003 with (y, 1 − x) — HIDInjector.swift or a new PanelTouch.swift #1535 —
I7 IosDeviceInput.onConfig every 0x82 the helper writes after the first becomes the current config (orientation: "portrait", plus keyboardUnavailable as today) and goes to every listener; a listener added later gets the current one a later frame that does not parse keeps the current config and logs The input helper sent a screen size that did not parse once src/drivers/ios/input/device-input.ts #1535 —
I9 The touch-probe fixture app, e2e/fixtures/touch-probe/ (Swift source, its build commands in a README, and the built simulator .app, as e2e/fixtures/appduct/ does) full-screen window; each touch ended appends <x>,<y>\n, fractions of its window to 3 decimals, to Documents/touches.txt in its data container (simctl get_app_container <udid> <bundle> data); bundle id dev.simlock.touch-probe — e2e/fixtures/touch-probe/ #1535 —
I10 The slow lane's fold $SIMLOCK_E2E_AGENT_DEVICE fold open | closed on the lease's simulator: a session opened on the lease's UDID with --ios-simulator-device-set <the worker's owned set> (the slow lane leases into Simlock's own set, e2e/slow-ios-stream.test.ts:60-67), waited on until it reports the pose its own failure fails the test with its message; SIMLOCK_E2E_AGENT_DEVICE unset → the test is skipped with that reason e2e/slow-ios-foldable.test.ts #1534 #1535
I11 Wire's sink encodeLoop writes through a sink a test can set instead of claimStdout's descriptor — vendor/screen-capture/Sources/SimlockCapture/Options.swift:99-135 #1534 —
I8 Stream headers simlock-stream-width, simlock-stream-height (OpenAPI descriptions) "Picture width (height) in pixels when the stream opens; a meta frame in the body gives each later size." — src/stream/definition.ts:13-18 #1534 —

Existing code

Commit 84c85b1.

Searches: metaEmitted (1), session.scaler (2), expectedScreenSizes (5), preferredScreenSize (3), nativeScreenSizes (3), touchTarget (3), readConfig (2), onConfig in src/ non-test (4), simlock-stream-width (1), Rotation in the website docs (1).

Id Code (path:line) Today With this feature Handled by
X1 vendor/screen-capture/Sources/SimlockCapture/Lifecycle.swift:188 the scaler is built once, from the first picture rebuilt for each new picture size B5
X2 Lifecycle.swift:213-228 the meta frame is written once (metaEmitted) written again before each new size, with a keyframe B5
X3 vendor/screen-capture/Sources/simlock-screen-capture/FramebufferSurfaceSelector.swift:64 of the live expected surfaces it picks the smallest (the Duo's cover) unless a preferred size pins one the follower sets the preferred size (FrameCapture.swift:53, :180) to the panel in use B2, B3, B4
X4 vendor/screen-capture/Sources/simlock-screen-capture/SimulatorSource.swift:24-25 hands each picture over as it is turns the inner panel's picture upright; starts the follower on a two-panel device B4
X5 vendor/screen-capture/Sources/simlock-screen-capture/InputMode.swift:69-72 the screen config is the first native size (the Duo's cover), written once the upright size of the panel in use, again on each change B8, B10
X6 vendor/screen-capture/Sources/simlock-screen-capture/HIDInjector.swift:81, :191 every touch goes to target 0x32 with the raw fraction (size 1×1; screen size unused) the target and fraction come from I6 B8, B9
X7 src/drivers/ios/input/device-input.ts:85-106, :212-215 reads the helper's first 0x82 only; onConfig calls a listener once with it keeps reading frames; each later size goes to the listeners B10
X8 src/input/sessions.ts:135-138 sends every config a device input reports to every admitted socket unchanged; it now gets more than one on iOS B10
X9 ui/src/data/stream.ts:178-180, :229-236; ui/src/state/screen.ts:226-236 resets the decoder on a new description, takes every meta frame, sizes the canvas from each frame unchanged; already correct (proved by #1525) B4
X10 src/stream/definition.ts:13-18 headers describe "Picture width/height in pixels" describe the size at open, and point at the meta frame I8
X11 website/src/content/docs/docs/using-devices/watching-the-screen.mdx:79 "A rotated iOS simulator or iPhone keeps the scale of the first picture" a rotated simulator keeps it; an iPhone and a foldable change size; a foldable shows the panel in use #1534
X12 src/drivers/ios/AGENTS.md Known gaps ("An iPhone rotated mid-stream keeps the first picture's scale"); src/drivers/ios/input/AGENTS.md Rules ("The config is always portrait … sent once") as quoted rewritten to the new behaviour (manual rule) #1534, #1535
X13 vendor/screen-capture/Package.swift:34 no test target a test target for Panels and PanelTouch B1, B9
X14 docs/internal/agent-rules/toolchain.md:160-170, :197-209 Swift programs are checked by hand; the machine-check table has no foldable row names swift test --package-path vendor/screen-capture; adds the row "iOS foldable": the iOS check, plus: xcrun simctl list runtimes lists iOS 27.1 or later and xcrun simctl list devicetypes lists iPhone Duo; SIMLOCK_E2E_AGENT_DEVICE names agent-device 0.21.35 or later; command -v ffmpeg finds it. Run scripts/slow-e2e.sh e2e/slow-ios-foldable.test.ts #1534
X16 vendor/screen-capture/Sources/SimlockCapture/Options.swift:99-135 Wire.emit writes to Wire.fd, set only by claimStdout a test sink (I11) B5
X17 website/src/content/docs/docs/using-devices/watching-the-screen.mdx:152, :169, :184 the input section: 0x82 always says portrait, and touch positions are fractions of the screen in portrait on a foldable the config is the upright size of the panel in use and changes on a fold; fractions are of the picture shown #1535
X18 src/drivers/ios/screen-capture.ts:175-180 the stop closes stdin, then SIGKILLs the helper's pid only unchanged; the watchdog (S1) ends the children B6
X15 vendor/screen-capture/NOTICE.md lists every source file's origin adds Panels.swift (and PanelTouch.swift if split) as ours #1534, #1535

Tasks

flowchart LR
  A["#1534 stream follows the panel"] --> B["#1535 taps follow the panel"]
Loading

Completion conditions:

Open work in the same area:

Written by an agent.

Activity

  1. added
    feature:specSpec in progress, business and technical together.
    platform:iosiOS simulators or devices
    on Nov 11, 2026
  2. V3RON commented on Nov 11, 2026

    @V3RON
    ContributorAuthor

    Design posted: business sections, Decisions, Behaviours B1–B10, Stored state S1, Interfaces I1–I8, Existing code X1–X15, split into T1 (stream) and T2 (taps). ADR 0075 in #1532.

    Written by an agent.

  3. V3RON commented on Nov 11, 2026

    @V3RON
    ContributorAuthor

    Spec updated: tasks #1534 (stream) and #1535 (taps) posted; T1/T2 replaced by their numbers; Tasks section filled.

    Written by an agent.

  4. added
    feature:plannedSplit into tasks. Never picked up itself.
    and removed
    feature:specSpec in progress, business and technical together.
    on Nov 11, 2026
  5. V3RON commented on Nov 12, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: none
    Parked: #1534 — needs-hardware: the slow lane was unavailable (a Simlock worker held the machine); PR #1536 is reviewed and ready, stacked on #1532
    Waiting: #1535 — on #1534
    Asleep: 189 min

    Written by an agent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    feature:plannedSplit into tasks. Never picked up itself.platform:iosiOS simulators or devices

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions