fix(ios): rotate a foldable iPhone simulator in a scoped simulator set (#4343)

SI
4h ago
+999-37

Summary

orientation on an iPhone Duo simulator in a scoped simulator set
(--ios-simulator-device-set) no longer refuses with
unsupported-device-scope. The fold helper already reaches a scoped set,
so the rotation is dispatched through it and confirmed from the fold
helper's panel table, using the lit panel's currentOrientation.
CoreDevice is not needed.

This PR also answers the two open questions from triage, measured on the
device:

  • Outer-panel offset K = 0. The outer panel reads the
    orientation-picker value itself. The inner panel reads it plus 90
    degrees, as reported earlier.
  • Across a fold, the picker value carries over and K follows the lit
    panel.
    The guest keeps one picker value through a pose change, which
    sends no picker event. The first read after the change already shows
    that value plus the newly lit panel's own offset. This held in both
    directions (3 fold to unfold, 3 unfold to fold, plus closed to
    half-open).

Changes:

  • packages/platform-apple/src/foldable/orientation.ts: a scoped set
    runs setScopedFoldableOrientation instead of refusing. The loop reads
    the lit panel, dispatches the picker value for the turn still needed,
    and re-reads until the panel table reports the target. Only a table
    that names exactly one lit panel can confirm. A repeat of the
    orientation already showing is a read-only success. Failure is
    COMMAND_FAILED with reason orientation-unverified-scoped-set.
  • packages/platform-apple/src/foldable/picker-delta.ts (new): pure
    angle logic, with the measured offsets per panel (primary, i.e. outer,
    0; inner +90). An offset that disagrees with the table is re-learned
    from the reading after a dispatch, so another runtime still converges.
  • apple/fold-helper/Fold.m: portrait-upside-down is sent as the
    picker's wire value pud. The guest ignores the long spelling. The
    inner panel needs pud to reach landscape-left (270 - 90 = 180).
  • fold-helper.ts: rotate accepts all four picker values, but user
    targets stay limited to FoldableRotation, so portrait-upside-down
    as a target is still refused.
  • config.ts: 6 scoped attempts with a 600 ms settle.
  • Help text and commands.md describe the scoped-set behaviour; there
    is a changeset.

Root cause

  1. setAppleFoldableOrientation refused any simulator with a
    simulatorSetPath before dispatching, because it verified rotation
    through CoreDevice, which only sees the default device set.
  2. The lit panel shows picker + K(panel), not the picker value. On the
    inner panel K = +90, so one absolute dispatch landed a quarter-turn
    away. That looked like "one command late", and repeating the same
    value was a no-op. The earlier "relative picker" description is the
    same thing seen on the inner panel: each event turns the panel by
    (new - previous picker value). Measurement G1 below shows the
    absolute-plus-offset form is the one that predicts the device when
    the lit panel was not showing picker + K.
  3. The 180 picker value is spelled pud, so without the mapping one
    inner-panel orientation was unreachable.

Measurements

iPhone Duo simulator, iOS 27.3, Xcode 27.3, scoped simulator set, Safari
in front (it allows all four orientations on the inner panel and has no
upside-down on the outer panel). Readings are the lit panel's
currentOrientation from the fold helper's panel table, taken by the
rotation loop before each dispatch and on confirmation. Angles follow
CADisplay rotN: portrait 0, landscape-right 90, upside-down 180 (wire
pud), landscape-left 270. The measuring build always sent the target
first, so most inner-panel commands show two dispatches. Screenshot size
(669x951 / 951x669 inner, 466x678 / 678x466 outer) agreed with the
reading at every step.

A. Inner panel (open)

StepTargetPicker beforePanel beforePicker sentPanel afterafter - sent
U1aportraitportrait (boot)rot90portraitrot90+90
U1bportraitportraitrot90landscape-leftrot0+90
U2alandscape-leftlandscape-leftrot0landscape-leftrot0+90
U2blandscape-leftlandscape-leftrot0pudrot270+90
U3alandscape-rightpudrot270landscape-rightrot180+90
U3blandscape-rightlandscape-rightrot180portraitrot90+90
U4aportraitportraitrot90portraitrot90+90
U4bportraitportraitrot90landscape-leftrot0+90
O1alandscape-rightlandscape-leftrot0landscape-rightrot180+90
O1blandscape-rightlandscape-rightrot180portraitrot90+90
O2alandscape-leftportraitrot90landscape-leftrot0+90
O2blandscape-leftlandscape-leftrot0pudrot270+90
H1aportraitlandscape-rightrot180portraitrot90+90
H1bportraitportraitrot90landscape-leftrot0+90
H2alandscape-rightlandscape-leftrot0landscape-rightrot180+90
H2blandscape-rightlandscape-rightrot180portraitrot90+90
J1a (half-open)landscape-rightlandscape-leftrot0landscape-rightrot180+90
J1b (half-open)landscape-rightlandscape-rightrot180portraitrot90+90

B. Outer panel (closed): K = 0

StepTargetPicker beforePanel beforePicker sentPanel afterafter - sent
F1portraitlandscape-leftrot270portraitrot00
F2landscape-leftportraitrot0landscape-leftrot2700
F3landscape-rightlandscape-leftrot270landscape-rightrot900
F4portraitlandscape-rightrot90portraitrot00
F5landscape-leftportraitrot0landscape-leftrot2700
G1landscape-rightpudrot270landscape-rightrot900
G2portraitlandscape-rightrot90portraitrot00
G3landscape-rightportraitrot0landscape-rightrot900
H4landscape-leftportraitrot0landscape-leftrot2700

C. Pose changes: first lit-panel read after the change (no picker
event in between)

StepChangePicker heldBefore (panel, reading)After (panel, first reading)reading - picker
F0open -> closedlandscape-leftinner rot0outer rot2700
O0closed -> openlandscape-leftouter rot270inner rot0+90
G0open -> closedpudinner rot270outer rot270see G1 note
H0closed -> openlandscape-rightouter rot90inner rot180+90
H3open -> closedportraitinner rot90outer rot00
J0closed -> half-openlandscape-leftouter rot270inner rot0+90

G1 note. After G0 the picker held pud. The outer panel's
foreground UI has no upside-down, so the outer panel kept landscape-left
(rot270) instead of showing pud + 0. One event of landscape-right
then landed rot90. Absolute plus offset predicts that (90 + 0). A purely
relative model predicts rot270 + (90 - 180) = rot180. So when the lit
panel cannot show picker + K, the right first dispatch is
target - K, and that is what the branch sends when a command starts.

D. Panel power right after folding closed. For about 3-4 s after a
fold to closed, the panel table reports both panels lit
(apple_display_capture_ambiguous, litPanelCount: 2). After that, only
the outer panel is lit. The rotation loop treats those reads as "no
reading": it never confirms from them. A first command in that window
sends one calibration dispatch (the target, which is exact on the outer
panel) and confirms after the table settles. Unfolding showed one lit
panel by the first read.

What was verified

  • Unit tests: vitest run --project unit-core packages/platform-apple
    gave 1536 passed and 62 skipped. The foldable tests use a fake Duo
    guest built from these measurements: one picker value, a per-panel
    offset, folds that keep the picker, a panel that cannot show an
    orientation keeping its last one, and both panels lit for a few reads
    after a fold to closed. They also include a fixture of the raw
    readings above; every row must give its panel's measured offset.
  • tsc -b tsconfig.typecheck.json examples/sdk/tsconfig.json, oxlint --deny-warnings and oxfmt --check all pass.
  • On the device: the branch's scoped-rotation logic (picker-delta.ts
    and setScopedFoldableOrientation at 7caf447), transliterated into
    the released 0.21.35 bundle with the pud Fold.m change, on a fresh
    scoped-set iPhone Duo lease:
CmdPanelTargetPanel beforePicker sentDispatchesExitScreenshot
B1innerportraitrot90landscape-left10669x951
B2innerlandscape-leftrot0pud10951x669
B3innerlandscape-rightrot270portrait10951x669
B4innerportraitread timed outportrait (send timed out)0 applied1951x669
C1innerportraitrot90landscape-left10669x951
C2innerlandscape-leftrot0pud10951x669
C3innerlandscape-rightrot270portrait10951x669
C4innerportraitrot90landscape-left10669x951
B6outerportraitrot0none (already showing)00466x678
B7outerlandscape-leftrot0landscape-left10678x466
B8outerlandscape-rightrot270landscape-right10678x466
B9outerportraitrot90portrait10466x678
C6outer, right after folding closedlandscape-rightboth lit: no readinglandscape-right10678x466
C7outerportraitrot90portrait10466x678
C8outerlandscape-leftrot0landscape-left10678x466

The same port was then run on the released 0.21.36 bundle (fresh lease):
inner panel portrait, landscape-left, landscape-right, portrait, and
outer panel landscape-right (right after folding), portrait,
landscape-left, portrait. All 8 exited 0, each on the first command with
one dispatch.

Every inner and outer command landed on the first command with one
dispatch, except B4. In B4 the host was under load: the panel read and
then the HID send (each a simctl spawn of the fold helper) hit their
5 s timeouts, and the command failed with
orientation-hid-dispatch-failed. Repeating the same pose (C1-C4)
passed.

Limits

  • The on-device run used a line-for-line JavaScript port of the branch
    logic inside the released 0.21.35 bundle, not a build of this branch.
    The test host runs a release install behind a device-pool daemon.
  • One device type (iPhone Duo), one runtime (iOS 27.3), one Xcode
    (27.3), and Safari as the foreground app. An app that does not allow
    an orientation makes the panel keep its previous one. The loop then
    ends with orientation-unverified-scoped-set instead of a false
    success, but that path was only exercised in unit tests.
  • The picker value at boot is not readable; portrait is inferred from
    the inner panel lighting at rot90.
  • The default device set path (CoreDevice verification) is unchanged and
    was not re-tested on the device.
  • A HID send that times out is not retried, same as the default-set
    path (B4).
  • Tests that need xcrun were not run (Linux CI host).

Fixes #4343.

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.