Skip to content

Commit 83a4ebb

Browse files
fix(ios): retry transient foldable panel reads without a model guard
1 parent e79ae67 commit 83a4ebb

6 files changed

Lines changed: 67 additions & 35 deletions

File tree

‎.changeset/foldable-scoped-rotation.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@
55
Fix foldable iPhone simulator rotation in scoped sets and confirm the lit panel in both default and scoped sets. Rotation in both sets uses the fold helper, with no CoreDevice orientation polling. An already visible target dispatches nothing. Incomplete, unknown-power and multi-lit panel tables neither confirm nor dispatch. A target the panel never shows fails with `orientation-unverified`.
66

77
Use the fixed iPhone Duo picker offsets measured in both sets (outer 0 degrees, inner +90 degrees), replacing runtime calibration. Inner landscape-left sends `portrait-upside-down`, mapped to the HID wire value `pud`; it remains unsupported as a user target. Delayed reads wait without resending the same picker value to the same panel.
8+
9+
Unresolved guest reads inside the settle loop neither confirm nor dispatch and are retried. An unresolved final read fails with `orientation-panel-table-unavailable`. Multi-panel classification admits rotation without a model-name guard; a new multi-panel model needs its own picker mapping.

‎docs/adr/0025-foldable-apple-panels.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,21 +372,22 @@ Rejected alternatives:
372372

373373
`XCUIDevice.orientation` has no effect on an iPhone Duo simulator, and the runner readback repeats the requested value.
374374
`devicectl device orientation set` reports success and has no effect on a simulator that `simctl boot` started.
375-
`orientation` therefore rotates a multi-panel iPhone Duo simulator through the guest device-state control: the HID event that Device Hub's orientation menu sends (`orientation-picker-control`, type `enum`).
375+
`orientation` therefore rotates a multi-panel simulator through the guest device-state control: the HID event that Device Hub's orientation menu sends (`orientation-picker-control`, type `enum`).
376376
The picker accepts `pud` for upside-down dispatch, which the inner panel needs to show landscape-left.
377377
The user target `portrait-upside-down` remains refused with `unsupported-foldable-orientation`.
378378
Routing uses the `fold` admission and the set-based panel-count source. A resolved panel count is a hardware fact, so the daemon probes each simulator once.
379379
A supplied helper is a reuse handle; only an explicit guest-source request changes the default-set source.
380380
Thus, classifying a single-panel default-set simulator does not prepare the fold helper.
381381

382-
The picker mapping requires the discovered model to be iPhone Duo; other multi-panel models are refused.
382+
The picker offsets were measured on iPhone Duo; a new multi-panel model needs its own mapping.
383383
In both simulator sets, the helper reads the guest panel table and dispatches the fixed picker value for the established lit panel.
384384
The table maps the outer panel at zero offset and the inner panel at +90 degrees.
385385
Only a fresh `currentOrientation` from an established lit panel confirms the requested visible orientation.
386386
An incomplete table, unknown-power sibling, or multiple lit panels cannot confirm or dispatch.
387387
A repeated visible target dispatches nothing; delayed reads wait without resending to the same panel.
388388
A target that never appears is refused with `orientation-unverified` in either set.
389-
An unreadable guest table reports `orientation-panel-table-unavailable` and points to the inventory diagnostic.
389+
An unresolved guest read inside the settle loop neither confirms nor dispatches; the loop retries it.
390+
An unresolved final read reports `orientation-panel-table-unavailable` and points to the inventory diagnostic.
390391

391392
Rejected alternatives:
392393

‎packages/command-surface/src/system/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ const backCommandDescription =
290290
const homeCommandDescription =
291291
'Send the selected device to its home screen. This leaves the app session open but moves the foreground away from the app.';
292292
const orientationCommandDescription =
293-
'Set device orientation on iOS and Android. auto hands rotation back to the sensor on ADB-backed Android (local and Limrun); other backends refuse it. A foldable iPhone simulator (iPhone Duo) refuses portrait-upside-down. In both default and scoped simulator sets it rotates through the fold helper and succeeds only when the lit panel shows the requested orientation; otherwise it fails with orientation-unverified, or orientation-panel-table-unavailable when the guest panel table cannot be read.';
293+
'Set device orientation on iOS and Android. auto hands rotation back to the sensor on ADB-backed Android (local and Limrun); other backends refuse it. A foldable iPhone simulator (iPhone Duo) refuses portrait-upside-down. In both default and scoped simulator sets it rotates through the fold helper and succeeds only when the lit panel shows the requested orientation; otherwise it fails with orientation-unverified, or orientation-panel-table-unavailable when the final guest panel table cannot be read.';
294294
const foldGuidance = {
295295
hingeEvent: 'simulator HID hinge event',
296296
permissions: 'Device Hub and host Accessibility permission are not required.',

‎packages/platform-apple/src/foldable/orientation.test.ts‎

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createLocalAppleToolProvider, withAppleToolProvider } from '../core/too
1717
import { createAppleInteractor } from '../interactor.ts';
1818
import { isAppleFoldableSimulator, readFoldablePanelTable } from './orientation.ts';
1919
import { ensureFoldHelperBinary } from './fold-helper-cache.ts';
20+
import { IOS_FOLDABLE_ORIENTATION_SETTLE_ATTEMPTS } from '../core/config.ts';
2021
import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/device-fixtures.ts';
2122
import { IPHONE_17_SINGLE_PANEL, IPHONE_DUO_CLOSED } from '../__tests__/display-fixtures.ts';
2223

@@ -35,7 +36,6 @@ function simulator(overrides: Partial<DeviceInfo> = {}): DeviceInfo {
3536
simulatorCount += 1;
3637
return {
3738
...IOS_SIMULATOR,
38-
model: 'iPhone Duo',
3939
id: `${IOS_SIMULATOR.id}-${simulatorCount}`,
4040
...overrides,
4141
};
@@ -184,6 +184,7 @@ function fakeDuo(
184184
picker: DeviceRotation;
185185
offsets?: { inner: number; outer: number };
186186
followsHid?: boolean;
187+
unresolvedGuestReads?: readonly number[] | 'all';
187188
/** Rotations a panel's foreground UI cannot show; the panel keeps its last rotation. */
188189
cannotShow?: Partial<Record<DuoPanel, readonly DeviceRotation[]>>;
189190
/** The rotation a dark panel last showed, for the fold that lights it. */
@@ -208,6 +209,7 @@ function fakeDuo(
208209
let lagReadsLeft = 0;
209210
let bothLitReads = 0;
210211
let panelReads = 0;
212+
let guestReads = 0;
211213
const shown: Record<DuoPanel, number> = {
212214
inner: PANEL_ANGLE[options.lastShown?.inner ?? 'portrait'],
213215
outer: PANEL_ANGLE[options.lastShown?.outer ?? 'portrait'],
@@ -264,6 +266,13 @@ function fakeDuo(
264266
const mode = spawn.slice(3);
265267
helpers.push(mode);
266268
if (mode[0] === 'panels') {
269+
guestReads += 1;
270+
if (
271+
options.unresolvedGuestReads === 'all' ||
272+
options.unresolvedGuestReads?.includes(guestReads)
273+
) {
274+
return { stdout: '', stderr: 'transient guest panel read failure', exitCode: 1 };
275+
}
267276
const displays = readPanels();
268277
return { stdout: JSON.stringify({ version: 1, displays }), stderr: '', exitCode: 0 };
269278
}
@@ -354,6 +363,50 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
354363
},
355364
);
356365

366+
test.each([1, 2])('retries unresolved guest read %s before confirming', async (failedRead) => {
367+
const device = simulator({ simulatorSetPath });
368+
const classification = fakeDuo(device, { lit: 'outer', picker: 'portrait' });
369+
await expect(rotateDuo(classification, 'portrait')).resolves.toEqual({
370+
orientation: 'portrait',
371+
});
372+
const sim = fakeDuo(device, {
373+
lit: 'outer',
374+
picker: 'portrait',
375+
unresolvedGuestReads: [failedRead],
376+
});
377+
await expect(rotateDuo(sim, 'landscape-left')).resolves.toEqual({
378+
orientation: 'landscape-left',
379+
});
380+
expect(sim.helpers.map((mode) => mode[0])).toEqual(
381+
failedRead === 1
382+
? ['panels', 'panels', 'rotate', 'panels']
383+
: ['panels', 'rotate', 'panels', 'panels'],
384+
);
385+
expect(sim.rotations()).toEqual(['landscape-left']);
386+
expect(mockRunner).not.toHaveBeenCalled();
387+
});
388+
389+
test('always unresolved guest reads exhaust the loop without dispatch', async () => {
390+
const device = simulator({ simulatorSetPath });
391+
const sim = fakeDuo(device, {
392+
lit: 'outer',
393+
picker: 'portrait',
394+
unresolvedGuestReads: 'all',
395+
});
396+
// Classify before injecting guest failures, including in a scoped set.
397+
const classification = fakeDuo(device, { lit: 'outer', picker: 'portrait' });
398+
await expect(rotateDuo(classification, 'portrait')).resolves.toEqual({
399+
orientation: 'portrait',
400+
});
401+
await expect(rotateDuo(sim, 'landscape-left')).resolves.toMatchObject({
402+
code: 'COMMAND_FAILED',
403+
details: { reason: 'orientation-panel-table-unavailable' },
404+
});
405+
expect(sim.helpers).toHaveLength(IOS_FOLDABLE_ORIENTATION_SETTLE_ATTEMPTS + 1);
406+
expect(sim.rotations()).toEqual([]);
407+
expect(mockRunner).not.toHaveBeenCalled();
408+
});
409+
357410
test('a foldable rotates on the first command, confirmed by the panel table', async () => {
358411
const scoped = simulator({ simulatorSetPath });
359412
// The inner panel lights at rot90 while the picker sits at portrait (K = +90, measured).
@@ -661,20 +714,3 @@ test('a rotation is not routed by a pending panel-table retry', async () => {
661714
expect(sim.rotations()).toEqual(['landscape-left']);
662715
expect(mockRunner).not.toHaveBeenCalled();
663716
});
664-
665-
test.each(['iPad Pro', 'Another Foldable', undefined])(
666-
'an unverified multi-panel model %s cannot use the Duo picker mapping',
667-
async (model) => {
668-
const sim = fakeDuo(simulator({ model, name: 'iPhone Duo' }), {
669-
lit: 'outer',
670-
picker: 'portrait',
671-
});
672-
await expect(sim.rotate('landscape-left')).rejects.toMatchObject({
673-
code: 'UNSUPPORTED_OPERATION',
674-
details: { reason: 'unsupported-foldable-model' },
675-
});
676-
expect(ensureFoldHelperBinary).not.toHaveBeenCalled();
677-
expect(sim.rotations()).toEqual([]);
678-
expect(mockRunner).not.toHaveBeenCalled();
679-
},
680-
);

‎packages/platform-apple/src/foldable/orientation.ts‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,6 @@ export async function setAppleFoldableOrientation(
125125
options: { signal?: AbortSignal } = {},
126126
): Promise<{ orientation: DeviceRotation }> {
127127
const { signal } = options;
128-
if (device.model !== 'iPhone Duo') {
129-
throw new AppError('UNSUPPORTED_OPERATION', 'Guest rotation requires an iPhone Duo model', {
130-
deviceId: device.id,
131-
reason: 'unsupported-foldable-model',
132-
hint: 'The guest orientation picker mapping is verified only for iPhone Duo. Use a simulator whose discovered model is iPhone Duo.',
133-
});
134-
}
135128
if (rotation === 'portrait-upside-down') {
136129
throw new AppError('UNSUPPORTED_OPERATION', `${device.name} cannot rotate to ${rotation}`, {
137130
deviceId: device.id,
@@ -145,7 +138,7 @@ export async function setAppleFoldableOrientation(
145138
let reading: LitPanel | undefined;
146139
for (let attempt = 1; attempt <= IOS_FOLDABLE_ORIENTATION_SETTLE_ATTEMPTS; attempt += 1) {
147140
signal?.throwIfAborted();
148-
reading = await readLitPanel(device, helper, signal);
141+
reading = await readLitPanel(device, helper, { signal });
149142
if (reading?.rotation === rotation) return { orientation: rotation };
150143
if (reading && reading.panel.displayId !== dispatchedDisplayId) {
151144
const mapping = reading.panel.primary ? PANEL_PICKERS.outer : PANEL_PICKERS.inner;
@@ -168,7 +161,7 @@ export async function setAppleFoldableOrientation(
168161
}
169162
await settleRotation();
170163
}
171-
reading = await readLitPanel(device, helper, signal);
164+
reading = await readLitPanel(device, helper, { signal, requireTable: true });
172165
if (reading?.rotation === rotation) return { orientation: rotation };
173166
throw unverifiedError(device, rotation, reading);
174167
}
@@ -213,14 +206,14 @@ function unverifiedError(
213206
async function readLitPanel(
214207
device: DeviceInfo,
215208
helper: FoldHelper,
216-
signal: AbortSignal | undefined,
209+
options: { signal?: AbortSignal; requireTable?: boolean },
217210
): Promise<LitPanel | undefined> {
218211
const inventory = await queryAppleDisplayInventory(device, {
219-
signal,
212+
signal: options.signal,
220213
source: 'guest',
221214
foldHelper: helper,
222215
});
223-
if (inventory.unresolved) {
216+
if (inventory.unresolved && options.requireTable) {
224217
throw new AppError('COMMAND_FAILED', 'The guest panel table could not be read', {
225218
deviceId: device.id,
226219
reason: 'orientation-panel-table-unavailable',

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Back, orientation, and hardware buttons:
125125
- `back` uses app-owned back navigation: visible in-app back UI on Apple targets, and the back keyevent on Android. `--in-app` names this default explicitly.
126126
- On iOS, a single-screen iPhone or an iPad whose app provably fills its screen, `back` can use a top-leading tap when the app tree is unreadable. It compares the screen before and after the tap. If the screen cannot be captured, the command reports an unknown outcome; inspect the screen before you retry.
127127
- `back --system` sends system back input: the Android back keyevent, the iOS back gesture, or the tvOS Siri Remote Menu button. macOS has no system back, so it reports unavailable.
128-
- `orientation portrait|portrait-upside-down|landscape-left|landscape-right` works on iOS and Android mobile targets only. On ADB-backed Android it turns auto-rotate off, and the lock persists after the session ends until something turns it back on. In both default and scoped simulator sets, an iPhone Duo classified with more than one integrated panel refuses `portrait-upside-down` and rotates through the fold helper. It succeeds only when the lit panel shows the requested orientation; if it never does, or the simulator never reports a single lit panel, the command fails with `orientation-unverified`. An unreadable guest table reports `orientation-panel-table-unavailable`. Other multi-panel simulator models are refused. A simulator classified with one panel, or whose first classification read is unresolved, uses the standard runner rotation.
128+
- `orientation portrait|portrait-upside-down|landscape-left|landscape-right` works on iOS and Android mobile targets only. On ADB-backed Android it turns auto-rotate off, and the lock persists after the session ends until something turns it back on. In both default and scoped simulator sets, an iPhone Duo classified with more than one integrated panel refuses `portrait-upside-down` and rotates through the fold helper. It succeeds only when the lit panel shows the requested orientation; if it never does, or the simulator never reports a single lit panel, the command fails with `orientation-unverified`. Unresolved guest reads are retried inside the settle loop; an unresolved final read reports `orientation-panel-table-unavailable`. The picker offsets were measured on iPhone Duo; a new multi-panel model needs its own mapping. A simulator classified with one panel, or whose first classification read is unresolved, uses the standard runner rotation.
129129
- `orientation auto` hands rotation back to the sensor on ADB-backed Android, local or Limrun (`accelerometer_rotation=1`, `user_rotation` untouched), and reports `orientation: "auto"`. It does not wait for the display to rotate, because there is no target rotation: the sensor decides, and an emulator usually settles on portrait. Every other backend refuses `auto` with `UNSUPPORTED_OPERATION`.
130130
- `action-button` presses the iPhone or iPad Action Button once. It takes no arguments, no hold duration, and no `--settle`, and it doesn't activate the session app or drive Camera Control. Other platforms refuse it, and a model without the button (an iPhone SE, most iPad simulators) fails with `UNSUPPORTED_OPERATION`.
131131
- `action-button` reports that the press was dispatched, not what it triggered. Simulators run no Shortcuts or App Intents, so verify the outcome on a physical iPhone. Expect a press to take about five seconds on a Simulator.

0 commit comments

Comments
 (0)