fix(ios): rotate a foldable iPhone simulator in a scoped simulator set (#4343)
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
runssetScopedFoldableOrientationinstead 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_FAILEDwith reasonorientation-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-downis sent as the
picker's wire valuepud. The guest ignores the long spelling. The
inner panel needspudto reach landscape-left (270 - 90 = 180).fold-helper.ts:rotateaccepts all four picker values, but user
targets stay limited toFoldableRotation, soportrait-upside-down
as a target is still refused.config.ts: 6 scoped attempts with a 600 ms settle.- Help text and
commands.mddescribe the scoped-set behaviour; there
is a changeset.
Root cause
setAppleFoldableOrientationrefused any simulator with a
simulatorSetPathbefore dispatching, because it verified rotation
through CoreDevice, which only sees the default device set.- 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 showingpicker + K. - 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)
| Step | Target | Picker before | Panel before | Picker sent | Panel after | after - sent |
|---|---|---|---|---|---|---|
| U1a | portrait | portrait (boot) | rot90 | portrait | rot90 | +90 |
| U1b | portrait | portrait | rot90 | landscape-left | rot0 | +90 |
| U2a | landscape-left | landscape-left | rot0 | landscape-left | rot0 | +90 |
| U2b | landscape-left | landscape-left | rot0 | pud | rot270 | +90 |
| U3a | landscape-right | pud | rot270 | landscape-right | rot180 | +90 |
| U3b | landscape-right | landscape-right | rot180 | portrait | rot90 | +90 |
| U4a | portrait | portrait | rot90 | portrait | rot90 | +90 |
| U4b | portrait | portrait | rot90 | landscape-left | rot0 | +90 |
| O1a | landscape-right | landscape-left | rot0 | landscape-right | rot180 | +90 |
| O1b | landscape-right | landscape-right | rot180 | portrait | rot90 | +90 |
| O2a | landscape-left | portrait | rot90 | landscape-left | rot0 | +90 |
| O2b | landscape-left | landscape-left | rot0 | pud | rot270 | +90 |
| H1a | portrait | landscape-right | rot180 | portrait | rot90 | +90 |
| H1b | portrait | portrait | rot90 | landscape-left | rot0 | +90 |
| H2a | landscape-right | landscape-left | rot0 | landscape-right | rot180 | +90 |
| H2b | landscape-right | landscape-right | rot180 | portrait | rot90 | +90 |
| J1a (half-open) | landscape-right | landscape-left | rot0 | landscape-right | rot180 | +90 |
| J1b (half-open) | landscape-right | landscape-right | rot180 | portrait | rot90 | +90 |
B. Outer panel (closed): K = 0
| Step | Target | Picker before | Panel before | Picker sent | Panel after | after - sent |
|---|---|---|---|---|---|---|
| F1 | portrait | landscape-left | rot270 | portrait | rot0 | 0 |
| F2 | landscape-left | portrait | rot0 | landscape-left | rot270 | 0 |
| F3 | landscape-right | landscape-left | rot270 | landscape-right | rot90 | 0 |
| F4 | portrait | landscape-right | rot90 | portrait | rot0 | 0 |
| F5 | landscape-left | portrait | rot0 | landscape-left | rot270 | 0 |
| G1 | landscape-right | pud | rot270 | landscape-right | rot90 | 0 |
| G2 | portrait | landscape-right | rot90 | portrait | rot0 | 0 |
| G3 | landscape-right | portrait | rot0 | landscape-right | rot90 | 0 |
| H4 | landscape-left | portrait | rot0 | landscape-left | rot270 | 0 |
C. Pose changes: first lit-panel read after the change (no picker
event in between)
| Step | Change | Picker held | Before (panel, reading) | After (panel, first reading) | reading - picker |
|---|---|---|---|---|---|
| F0 | open -> closed | landscape-left | inner rot0 | outer rot270 | 0 |
| O0 | closed -> open | landscape-left | outer rot270 | inner rot0 | +90 |
| G0 | open -> closed | pud | inner rot270 | outer rot270 | see G1 note |
| H0 | closed -> open | landscape-right | outer rot90 | inner rot180 | +90 |
| H3 | open -> closed | portrait | inner rot90 | outer rot0 | 0 |
| J0 | closed -> half-open | landscape-left | outer rot270 | inner 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-warningsandoxfmt --checkall pass.- On the device: the branch's scoped-rotation logic (
picker-delta.ts
andsetScopedFoldableOrientationat7caf447), transliterated into
the released 0.21.35 bundle with thepudFold.m change, on a fresh
scoped-set iPhone Duo lease:
| Cmd | Panel | Target | Panel before | Picker sent | Dispatches | Exit | Screenshot |
|---|---|---|---|---|---|---|---|
| B1 | inner | portrait | rot90 | landscape-left | 1 | 0 | 669x951 |
| B2 | inner | landscape-left | rot0 | pud | 1 | 0 | 951x669 |
| B3 | inner | landscape-right | rot270 | portrait | 1 | 0 | 951x669 |
| B4 | inner | portrait | read timed out | portrait (send timed out) | 0 applied | 1 | 951x669 |
| C1 | inner | portrait | rot90 | landscape-left | 1 | 0 | 669x951 |
| C2 | inner | landscape-left | rot0 | pud | 1 | 0 | 951x669 |
| C3 | inner | landscape-right | rot270 | portrait | 1 | 0 | 951x669 |
| C4 | inner | portrait | rot90 | landscape-left | 1 | 0 | 669x951 |
| B6 | outer | portrait | rot0 | none (already showing) | 0 | 0 | 466x678 |
| B7 | outer | landscape-left | rot0 | landscape-left | 1 | 0 | 678x466 |
| B8 | outer | landscape-right | rot270 | landscape-right | 1 | 0 | 678x466 |
| B9 | outer | portrait | rot90 | portrait | 1 | 0 | 466x678 |
| C6 | outer, right after folding closed | landscape-right | both lit: no reading | landscape-right | 1 | 0 | 678x466 |
| C7 | outer | portrait | rot90 | portrait | 1 | 0 | 466x678 |
| C8 | outer | landscape-left | rot0 | landscape-left | 1 | 0 | 678x466 |
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 withorientation-unverified-scoped-setinstead 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
xcrunwere not run (Linux CI host).
Fixes #4343.
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.