fix(ios): rotate a foldable simulator through guest HID
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: CoreMotionCMDeviceOrientationManagerreportsAmbiguousandBKSHIDServicesIsOrientationLockedWithOrientationreports 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 onf84c1c7efa, which includes #3970):pnpm check:affected --runpassed with exactCommit=true. Foldable, display-inventory and simctl-facade tests: 118 passed, including the nativeFold.mcompile. - Live on iPhone Duo with Safari (
fa2d0d7126; at this head, re-checked inner-panellandscape-left669x951 andportrait951x669, andfold open/closed):- Cover panel:
landscape-left678x466,portrait466x678,landscape-right678x466. - Inner panel after
fold open:landscape-leftandlandscape-right669x951,portrait951x669. - Upside-down is refused on both panels.
- A rotation takes 0.7–1.3s end to end through the CLI.
- Cover panel:
- 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 readportrait. foldable/orientation.test.ts: removing thefoldadmission gate, the panel-count cache, the scoped-set refusal, the timeout tolerance, or the stale-read discard each fails a test.