fix(ios): rotate a foldable simulator through guest HID

Merged
TH
1h ago
+565-150

Summary

On iPhone Duo (iOS 27.1), agent-device orientation reported Rotated to …, but the screen did not change. XCUIDevice rotation has no effect there, and the runner's readback only repeats the requested value. devicectl device orientation set also returns success without rotating a headless simulator.

On a multi-panel simulator, orientation now sends the guest's own orientation-picker-control vendor HID event through the fold helper's new rotate mode (FoldHelper.rotate). It needs no Device Hub and no host Accessibility permission. devicectl device orientation get then verifies the result (core/device-orientation.ts). That query answers with the value it sampled at the previous query, so the first read after the dispatch is discarded. A timed-out read counts as no reading. The display table's currentDeviceOrientation is not used, because on the Duo inner panel it is a quarter-turn from the device orientation.

Routing gates on the fold admission (appleFoldableFacts), then on the panel table. A resolved panel count is a hardware fact, so the daemon probes each simulator once; later rotations on a single-panel simulator pay no probe.

Refusals, each typed and before any HID dispatch:

  • portrait-upside-down: UNSUPPORTED_OPERATION / unsupported-foldable-orientation. The guest control has no upside-down value.
  • A foldable simulator in a scoped set: UNSUPPORTED_OPERATION / unsupported-device-scope. Since #3970 the guest panel table identifies a scoped Duo, but CoreDevice cannot resolve it, so no reader can verify its rotation. The guest has no usable reader: CoreMotion CMDeviceOrientationManager reports Ambiguous and BKSHIDServicesIsOrientationLockedWithOrientation reports 0 after a rotation that CoreDevice and the screenshots confirm. Before this PR, the runner path reported a false success there.

Single-panel simulators, physical devices, and provider transports keep the runner path.

Reuse: rotation goes through the shared createFoldHelper runner (FoldHelper.rotate), the fold admission, the display inventory, and the extracted setRunnerOrientation. In Fold.m, set and rotate share sendControl and dispatchEvents. The iPhone Duo and single-panel display payloads that display-inventory.test.ts and simctl-facade.test.ts each copied now live in __tests__/display-fixtures.ts, which the new test also uses. ADR 0025 has a new amendment for the decision, and the help text and commands doc state both refusals.

Validation

  • Head 6498d56320 (rebased on f84c1c7efa, which includes #3970): pnpm check:affected --run passed with exactCommit=true. Foldable, display-inventory and simctl-facade tests: 118 passed, including the native Fold.m compile.
  • Live on iPhone Duo with Safari (fa2d0d7126; at this head, re-checked inner-panel landscape-left 669x951 and portrait 951x669, and fold open/closed):
    • Cover panel: landscape-left 678x466, portrait 466x678, landscape-right 678x466.
    • Inner panel after fold open: landscape-left and landscape-right 669x951, portrait 951x669.
    • Upside-down is refused on both panels.
    • A rotation takes 0.7–1.3s end to end through the CLI.
  • One run's screenshot right after the first unfold still showed portrait, although CoreDevice reported landscapeLeft. Two later unfold-then-rotate runs, with three screenshots each, did not reproduce it.
  • A scoped-set Duo was not run live. The unit test covers the refusal through the guest panel path.
  • Base a7cb937fe5: the same command reported success while CoreDevice still read portrait.
  • foldable/orientation.test.ts: removing the fold admission gate, the panel-count cache, the scoped-set refusal, the timeout tolerance, or the stale-read discard each fails a test.