@tapflowio/ios-agent 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/bin/guest-helper +0 -0
  2. package/bin/screencapture-helper +0 -0
  3. package/dist/DeviceChromeLoader.d.ts +28 -1
  4. package/dist/DeviceChromeLoader.d.ts.map +1 -1
  5. package/dist/DeviceChromeLoader.js +118 -37
  6. package/dist/DeviceChromeLoader.js.map +1 -1
  7. package/dist/FoldableInput.d.ts +58 -0
  8. package/dist/FoldableInput.d.ts.map +1 -0
  9. package/dist/FoldableInput.js +166 -0
  10. package/dist/FoldableInput.js.map +1 -0
  11. package/dist/GuestHelper.d.ts +109 -0
  12. package/dist/GuestHelper.d.ts.map +1 -0
  13. package/dist/GuestHelper.js +437 -0
  14. package/dist/GuestHelper.js.map +1 -0
  15. package/dist/IOSAgent.d.ts +119 -2
  16. package/dist/IOSAgent.d.ts.map +1 -1
  17. package/dist/IOSAgent.js +586 -24
  18. package/dist/IOSAgent.js.map +1 -1
  19. package/dist/InputChannel.d.ts +38 -0
  20. package/dist/InputChannel.d.ts.map +1 -0
  21. package/dist/InputChannel.js +7 -0
  22. package/dist/InputChannel.js.map +1 -0
  23. package/dist/PanelState.d.ts +72 -0
  24. package/dist/PanelState.d.ts.map +1 -0
  25. package/dist/PanelState.js +192 -0
  26. package/dist/PanelState.js.map +1 -0
  27. package/dist/PhysicalOrientation.d.ts +58 -0
  28. package/dist/PhysicalOrientation.d.ts.map +1 -0
  29. package/dist/PhysicalOrientation.js +155 -0
  30. package/dist/PhysicalOrientation.js.map +1 -0
  31. package/dist/ScreenCaptureStreamer.d.ts +12 -1
  32. package/dist/ScreenCaptureStreamer.d.ts.map +1 -1
  33. package/dist/ScreenCaptureStreamer.js +28 -3
  34. package/dist/ScreenCaptureStreamer.js.map +1 -1
  35. package/dist/SimctlWrapper.d.ts +18 -1
  36. package/dist/SimctlWrapper.d.ts.map +1 -1
  37. package/dist/SimctlWrapper.js +43 -1
  38. package/dist/SimctlWrapper.js.map +1 -1
  39. package/dist/TouchHelper.d.ts +3 -2
  40. package/dist/TouchHelper.d.ts.map +1 -1
  41. package/dist/TouchHelper.js +4 -6
  42. package/dist/TouchHelper.js.map +1 -1
  43. package/dist/TypePanels.d.ts +33 -0
  44. package/dist/TypePanels.d.ts.map +1 -0
  45. package/dist/TypePanels.js +68 -0
  46. package/dist/TypePanels.js.map +1 -0
  47. package/dist/panels.d.ts +52 -0
  48. package/dist/panels.d.ts.map +1 -0
  49. package/dist/panels.js +68 -0
  50. package/dist/panels.js.map +1 -0
  51. package/package.json +6 -5
package/dist/IOSAgent.js CHANGED
@@ -54,8 +54,13 @@ import { ensureHelperApp, launchAudioHelper, isAudioSupported } from '@tapflowio
54
54
  import { enumerateSimPids } from './SimProcessTree.js';
55
55
  import { MjpegStreamer } from './MjpegStreamer.js';
56
56
  import { TouchHelper } from './TouchHelper.js';
57
+ import { FoldableInput } from './FoldableInput.js';
58
+ import { GuestHelper, defaultGuestHelperNative } from './GuestHelper.js';
59
+ import { PanelState, FOLD_SETTLE_MS } from './PanelState.js';
60
+ import { PhysicalOrientation, ORIENTATION_DEGREES, turned } from './PhysicalOrientation.js';
57
61
  import { XCUITreeReader } from './XCUITreeReader.js';
58
62
  import { DeviceChromeLoader } from './DeviceChromeLoader.js';
63
+ import { TypePanels, panelFrame } from './TypePanels.js';
59
64
  import { KEY_CODE_MAP, MODIFIER_BITS } from './KeyCodeMap.js';
60
65
  // whole-sim audio: how often to re-enumerate the simulator's process tree for new audio-producing
61
66
  // processes (launched apps, WebKit WebContent). Short enough that a tab's audio starts promptly,
@@ -72,6 +77,39 @@ export function leanSupported(osVersion) {
72
77
  const [major, minor] = [Number(m[1]), Number(m[2])];
73
78
  return major > 18 || (major === 18 && minor >= 5);
74
79
  }
80
+ // The postures of a multi-panel device (iPhone Duo), most closed first as the protocol requires. Two, not
81
+ // DeviceHub's three: its `flat` (180°) lights the same panel as `open`, and a step between them would read
82
+ // as a press that did nothing — the reason Android's list leaves out HALF_OPENED's twin. Labels match Android's.
83
+ const FOLDABLE_POSTURES = [{ id: 'closed', label: 'Folded' }, { id: 'open', label: 'Unfolded' }];
84
+ // The angles DeviceHub's pose picker sweeps to (closed reads ≈3° after it). Unfolded is its flat pose, 180°, not
85
+ // its half-open 130°: both light the same panel, and unfolded means opened all the way — 130 left DeviceHub
86
+ // showing the device half open after tapflow unfolded it.
87
+ const POSTURE_DEGREES = { closed: 0, open: 180 };
88
+ // devicectl costs ~0.23 s a read, so this is a fold made outside tapflow showing up within a few seconds,
89
+ // not a frame-rate watch. And only while something is moving: each read also costs ~0.2 s of CPU, so a 2 s
90
+ // poll held ~18% of a core for as long as a session was open (measured, 8 GB Mac mini). Once nothing has
91
+ // changed for PANEL_QUIET_MS it reads every PANEL_IDLE_POLL_MS — a fold in DeviceHub on a device left alone
92
+ // shows within 6 s rather than 2 — and the first change it sees, or a fold or turn sent from tapflow, brings
93
+ // it back.
94
+ const PANEL_POLL_MS = 2_000;
95
+ const PANEL_IDLE_POLL_MS = 6_000;
96
+ const PANEL_QUIET_MS = 30_000;
97
+ // How long a boot waits for a multi-panel device's helper. A helper that dies without saying `fatal` is
98
+ // relaunched on a rolling budget for ever, which is right for a session already on the device and wrong for a
99
+ // boot, which would otherwise never answer. Four of the helper's own 15 s start-up deadlines.
100
+ const HELPER_BOOT_READY_MS = 60_000;
101
+ // How long a turn sent through the helper has to show up in `devicectl device orientation get`. M1 read each
102
+ // one back 2.5 s after sending and every one had landed; the wait ends as soon as it does.
103
+ const ROTATE_SETTLE_MS = 3_000;
104
+ // `rotation-helper` takes UIInterfaceOrientation's names, whose landscapes are the device's the other way round:
105
+ // what it calls `landscapeRight` devicectl reads back as `landscapeLeft` (measured, Xcode 27 host, iOS 26.5).
106
+ const PURPLE_ARGS = {
107
+ portrait: 'portrait', landscapeRight: 'landscapeLeft', portraitUpsideDown: 'portraitUpsideDown', landscapeLeft: 'landscapeRight',
108
+ };
109
+ // The helper's labels are the physical orientations' own names, on both of the Duo's panels (measured).
110
+ const ORIENT_LABELS = {
111
+ portrait: 'portrait', landscapeRight: 'landscape-right', portraitUpsideDown: 'pud', landscapeLeft: 'landscape-left',
112
+ };
75
113
  export class IOSAgent {
76
114
  simctl;
77
115
  network;
@@ -145,6 +183,37 @@ export class IOSAgent {
145
183
  // queries by bundleId; kept outside DeviceState so it survives a relay reconnect
146
184
  // (which clears deviceStates) while the app keeps running in the simulator.
147
185
  lastBundleIds = new Map();
186
+ // **A multi-panel device (iPhone Duo) is known per device id, from the connect, not from a boot.** A
187
+ // reconnect re-seeds sessions with no `device:boot`, and `ensureTouchHelper` then builds the channel with
188
+ // nothing else to go on — so the answer must already be here, or an agent restart would hand the Duo an
189
+ // Indigo channel that answers success and lands nowhere.
190
+ multiPanelDevices = new Set();
191
+ // Each device's type, from the connect: re-sending a panel's frame after a fold has no boot to read it from.
192
+ deviceTypeIds = new Map();
193
+ // The guest helper and the panels' display UUIDs are keyed by device and live outside `DeviceState`:
194
+ // every relay reconnect rebuilds that map, and stopping the helper there would hide the fold control
195
+ // (no helper, no postures) until something respawned it. They are stopped by the device's own teardown
196
+ // — `stopGuest` — and nowhere else.
197
+ guests = new Map();
198
+ guestScreens = new Map();
199
+ panelState;
200
+ physical;
201
+ // A multi-panel device type's panels: each one's native turn, and the scale every panel is drawn at.
202
+ typePanels;
203
+ guestHelperNative;
204
+ foldSettleMs;
205
+ panelPollMs;
206
+ panelIdlePollMs;
207
+ panelQuietMs;
208
+ helperBootReadyMs;
209
+ panelPolls = new Map();
210
+ // When each device's panel or orientation last moved, or tapflow last moved it: the poll is fast after that.
211
+ panelActivity = new Map();
212
+ panelRechecks = new Map();
213
+ // A posture change in flight per device: a second press during a fold is a double-press, dropped like Android's.
214
+ posturing = new Set();
215
+ // The posture last reported per device, so the poll reports only a change.
216
+ reportedPosture = new Map();
148
217
  uiTreeReader = new XCUITreeReader();
149
218
  // Holds a macOS power assertion while connected so the host doesn't idle-throttle the
150
219
  // simulator capture/encode when the Mac is unattended. No-op off macOS.
@@ -186,6 +255,18 @@ export class IOSAgent {
186
255
  // reason, as `sleepBlocker` below.
187
256
  this.lean = options.lean ?? false;
188
257
  // Never the host's real store under vitest, for the reason given for `network` just below.
258
+ this.panelState = options.panelState ?? new PanelState();
259
+ // Never the host's devicectl under vitest: every single-panel boot reads the orientation, and on an Xcode 27
260
+ // host a real call would read whatever simulator the developer has under that udid. A suite gets the
261
+ // Xcode 26 answer — unreadable, so tapflow remembers — unless it injects one.
262
+ this.physical = options.physicalOrientation ?? new PhysicalOrientation(process.env.VITEST
263
+ ? { readOrientation: () => Promise.reject(new Error('no devicectl under vitest')) } : undefined);
264
+ this.guestHelperNative = options.guestHelperNative ?? defaultGuestHelperNative;
265
+ this.foldSettleMs = options.foldSettleMs ?? FOLD_SETTLE_MS;
266
+ this.panelPollMs = options.panelPollMs ?? PANEL_POLL_MS;
267
+ this.panelIdlePollMs = options.panelIdlePollMs ?? PANEL_IDLE_POLL_MS;
268
+ this.panelQuietMs = options.panelQuietMs ?? PANEL_QUIET_MS;
269
+ this.helperBootReadyMs = options.helperBootReadyMs ?? HELPER_BOOT_READY_MS;
189
270
  this.leanStore = options.leanStore ?? new LeanStore(process.env.VITEST ? path.join(tmpdir(), 'tapflow-no-lean-store') : undefined, MANAGED_LABELS);
190
271
  this.network = options.network ?? new SimulatorNetwork(this.simctl, process.env.VITEST ? { filterHostBinary: path.join(tmpdir(), 'tapflow-no-filter-host') } : {});
191
272
  // **Wired here rather than in the constructor call above, and the difference is coverage.** An
@@ -199,6 +280,7 @@ export class IOSAgent {
199
280
  this.chromeLoader = options.chromeLoader ?? new DeviceChromeLoader(process.env.VITEST
200
281
  ? { run: () => Promise.reject(new Error('no chrome under vitest')) }
201
282
  : {});
283
+ this.typePanels = new TypePanels(this.simctl, this.chromeLoader);
202
284
  this.deviceFilter = options.deviceFilter;
203
285
  this.token = options.token;
204
286
  this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000;
@@ -243,6 +325,25 @@ export class IOSAgent {
243
325
  logger.warn(`device types unavailable, registering with the last known form factors: ${e instanceof Error ? e.message : String(e)}`);
244
326
  return this.formFactors;
245
327
  });
328
+ // Whether each device has more than one integrated panel. A failed lookup keeps the last answer, like the
329
+ // form factors above; with no answer at all every device stays on the Indigo path, which is how a
330
+ // multi-panel device behaved before this existed. Wrapped so a simctl without the method cannot throw here.
331
+ await Promise.resolve()
332
+ .then(() => this.simctl.integratedPanelCounts([...new Set(allDevices.flatMap((d) => (d.typeId ? [d.typeId] : [])))]))
333
+ .then((counts) => {
334
+ this.multiPanelDevices = new Set(allDevices.filter((d) => d.typeId && (counts.get(d.typeId) ?? 1) > 1).map((d) => d.id));
335
+ this.deviceTypeIds = new Map(allDevices.flatMap((d) => (d.typeId ? [[d.id, d.typeId]] : [])));
336
+ }, (e) => logger.warn(`could not read the devices' panels, keeping the last answer: ${e instanceof Error ? e.message : String(e)}`));
337
+ // **A multi-panel device this Mac cannot drive is not offered.** Its input has no other path, so listing it
338
+ // would let a tester — or an MCP agent's `list_devices` — pick a device that boots and then refuses.
339
+ let offered = devices;
340
+ if (devices.some((d) => this.multiPanelDevices.has(d.id))) {
341
+ const reason = await this.guestHelperNative.unavailableReason().catch((e) => String(e));
342
+ if (reason) {
343
+ logger.warn(`not offering ${devices.filter((d) => this.multiPanelDevices.has(d.id)).map((d) => d.name).join(', ')}: ${reason}`);
344
+ offered = devices.filter((d) => !this.multiPanelDevices.has(d.id));
345
+ }
346
+ }
246
347
  return new Promise((resolve, reject) => {
247
348
  const ws = new WebSocket(relayUrl, this.wsClientOptions());
248
349
  let registered = false;
@@ -261,13 +362,14 @@ export class IOSAgent {
261
362
  capabilities: AGENT_CAPABILITIES,
262
363
  agentId: getMachineId(),
263
364
  agentName: os.hostname(),
264
- devices: devices.map((d) => ({
365
+ devices: offered.map((d) => ({
265
366
  id: d.id,
266
367
  name: d.name,
267
368
  platform: d.platform,
268
369
  status: d.status,
269
370
  osVersion: d.osVersion,
270
- formFactor: d.typeId ? formFactors.get(d.typeId) : undefined,
371
+ // A device with more than one panel folds, whatever its product family says (the Duo is an iPhone).
372
+ formFactor: this.multiPanelDevices.has(d.id) ? 'foldable' : d.typeId ? formFactors.get(d.typeId) : undefined,
271
373
  serviceTier: this.reportedTier(d),
272
374
  })),
273
375
  });
@@ -355,8 +457,10 @@ export class IOSAgent {
355
457
  bootSeq: 0,
356
458
  bootAbandon: new Map(),
357
459
  bootsInFlight: new Set(),
358
- orientation: 'portrait',
359
460
  loadedChrome: null,
461
+ chromeSettled: false,
462
+ sentRotation: undefined,
463
+ sentBasis: undefined,
360
464
  softKeyboardVisible: false,
361
465
  acceptH264: false,
362
466
  secureContext: false,
@@ -384,6 +488,8 @@ export class IOSAgent {
384
488
  this.cleanupDeviceState(state);
385
489
  }
386
490
  this.deviceStates.clear();
491
+ for (const deviceId of [...this.guests.keys()])
492
+ this.stopGuest(deviceId);
387
493
  this.uiTreeReader.stop();
388
494
  // The liveness watcher outlives every device it was started for, so it is stopped here with the
389
495
  // other things that tick. It is `unref`'d, which is why nobody noticed it had no caller.
@@ -513,7 +619,7 @@ export class IOSAgent {
513
619
  // now that a helper revives itself on death, an orphan would also keep respawning with
514
620
  // nobody left holding a reference to stop it.
515
621
  state.touchHelper?.stop();
516
- state.touchHelper = new TouchHelper(device.id);
622
+ state.touchHelper = this.createInputChannel(device.id);
517
623
  state.touchHelper.start();
518
624
  this.sendOn(this.ws, {
519
625
  type: 'session:deviceInfo',
@@ -526,20 +632,39 @@ export class IOSAgent {
526
632
  // Awaited where it used to block: a cold load is seconds of swift renders. A newer boot or a shutdown can
527
633
  // land meanwhile, so the result is stored only after the seq is checked — otherwise a slow stale load
528
634
  // would overwrite the chrome a faster newer boot already set, and the button path reads it.
529
- const chrome = await this.chromeLoader.load(device.typeId ?? device.name);
635
+ // A multi-panel device shows its lit panel's frame; until the panels are known it gets the model's, and
636
+ // `applyLitPanel` replaces it once they are.
637
+ // A multi-panel device's scale comes with its first frame, from its type, so a viewer sizes it once.
638
+ const multiPanel = this.multiPanelDevices.has(device.id) && device.typeId;
639
+ const [chrome, basis] = await Promise.all([
640
+ this.chromeLoader.load(device.typeId ?? device.name, this.chromePanel(device.id)),
641
+ multiPanel ? this.typePanels.scaleBasis(multiPanel) : undefined,
642
+ ]);
530
643
  if (seq !== state.bootSeq)
531
644
  return false;
532
645
  const ws = this.ws;
533
646
  if (ws?.readyState !== WebSocket.OPEN)
534
647
  return true;
535
648
  state.loadedChrome = chrome;
536
- if (!chrome)
649
+ state.chromeSettled = true;
650
+ if (!chrome) {
651
+ // The model's frame did not load; the lit panel's has its own and may.
652
+ if (this.multiPanelDevices.has(device.id))
653
+ void this.resendChrome(device.id);
537
654
  return true;
655
+ }
656
+ state.sentRotation = this.chromeRotation(device.id);
657
+ state.sentBasis = basis;
538
658
  this.sendOn(ws, {
539
659
  type: 'session:chrome',
540
660
  sessionId: state.sessionId,
541
- payload: chrome,
661
+ payload: this.forViewer(chrome, state.sentRotation, state.sentBasis),
542
662
  });
663
+ // A multi-panel device's panels can become known while the load above was running — the helper reads them
664
+ // in parallel with the boot — and the frame just sent may be the model's. Settled here, from whichever
665
+ // side finishes last: `resendChrome` replaces it only if the lit panel's frame differs.
666
+ if (this.multiPanelDevices.has(device.id))
667
+ void this.resendChrome(device.id);
543
668
  return true;
544
669
  }
545
670
  /** `failures` counts the frameless runs before this one; only the restart in `pump` passes it, so a new
@@ -563,7 +688,10 @@ export class IOSAgent {
563
688
  external: state.external,
564
689
  override: process.env.TAPFLOW_IOS_MAX_SIZE ?? process.env.TAPFLOW_MAX_SIZE,
565
690
  });
566
- const capture = new ScreenCaptureStreamer(state.deviceId, this.fps, useH264 ? 'h264' : 'jpeg', maxSize);
691
+ // A multi-panel device is captured at its lit panel; a restart re-applies it here, since a stdin switch
692
+ // lives only as long as the helper it was written to.
693
+ const panel = this.multiPanelDevices.has(state.deviceId) ? this.litPanel(state.deviceId) : null;
694
+ const capture = new ScreenCaptureStreamer(state.deviceId, this.fps, useH264 ? 'h264' : 'jpeg', maxSize, panel ? { width: panel.width, height: panel.height } : null);
567
695
  state.captureStreamer = capture;
568
696
  stream = capture.start();
569
697
  }
@@ -751,6 +879,11 @@ export class IOSAgent {
751
879
  // Read before anything below changes it: a Full reset shuts the device down itself, and `unknown`
752
880
  // (booting, shutting down) is a device paying a boot's cost now.
753
881
  const cold = fullErase || retier || target.status !== 'booted';
882
+ // A device this boot brings up gets a new helper and fresh readings. One left from before was serving a
883
+ // simulator that is gone — shut down outside tapflow (DeviceHub quitting takes every one), or about to be
884
+ // — and kept, the boot waited out its respawn cooldown and started from the panel lit before.
885
+ if (cold)
886
+ this.stopGuest(deviceId);
754
887
  if (fullErase) {
755
888
  // `simctl erase` only accepts a shut-down device, and a device is often already running —
756
889
  // it survives agent restarts and sessions that ended without a clean shutdown. Without this
@@ -860,15 +993,18 @@ export class IOSAgent {
860
993
  await this.network.arm(deviceId).catch((e) => {
861
994
  logger.warn('could not arm the network injection:', e.message);
862
995
  });
863
- // **Stand it up (#910).** A fresh viewer always starts portrait, and a simulator left turned —
864
- // by a session whose undo never arrived, or one an agent restart reset the memory of — would
865
- // otherwise stream sideways under an upright frame, with every later press inverted. Queued with
866
- // the rotations, so one still in flight from before a re-register lands first and is overridden.
867
- // Best-effort: a failure leaves the simulator where it was, and the boot goes on.
868
- await this.enqueueRotation(deviceId, async () => {
869
- await this.simctl.rotate(deviceId, 'portrait');
870
- state.orientation = 'portrait';
871
- });
996
+ // **Where it is held, before the first frame says so** (#910). The orientation is the device's, shared with
997
+ // DeviceHub and every viewer, so it is read and reported as it is, and a reload or a second viewer joins it
998
+ // there rather than turning it under the first. It is stood up only when there is nothing to report — no
999
+ // reading and nothing remembered — and **for a run**: a flow replays from the same orientation every time,
1000
+ // whatever a person left the device in (`runReset` is a run's boot; review finding). Queued with the
1001
+ // rotations, so one still in flight lands first. A multi-panel device reads its own through its helper.
1002
+ if (!this.multiPanelDevices.has(deviceId)) {
1003
+ await this.enqueueRotation(deviceId, async () => {
1004
+ if (runReset || !(await this.heldSingle(deviceId)))
1005
+ await this.standUp(deviceId);
1006
+ });
1007
+ }
872
1008
  // Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
873
1009
  // shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
874
1010
  // device it is taking down — and one that revives itself, so the stale reference outlives the
@@ -881,6 +1017,54 @@ export class IOSAgent {
881
1017
  this.abandonBoot(state, seq, sessionId, requestId);
882
1018
  return;
883
1019
  }
1020
+ // **A session with no frame cannot be told the turn**: no `session:chrome` goes out, and its viewer draws
1021
+ // the device upright, as one always started. So that device is stood up, the old guarantee for the one
1022
+ // viewer that still relies on it (review finding).
1023
+ if (!this.multiPanelDevices.has(deviceId) && !state.loadedChrome && this.physical.last(deviceId) !== 'portrait') {
1024
+ await this.enqueueRotation(deviceId, async () => { await this.standUp(deviceId); });
1025
+ }
1026
+ // **A multi-panel device is usable only through its helper**, so the boot is not ready until the helper
1027
+ // is, and a runtime that refuses it fails the boot with the reason. The device is shut down only if this
1028
+ // boot brought it up: one that was already running (DeviceHub, another session) is left as it was found.
1029
+ //
1030
+ // No helper at all is a socket that closed before `sendChromeData` built the channel: the boot carries on
1031
+ // as a single-panel one does, and the relay loss that follows retires it — not a refusal to shut down for.
1032
+ const guest = this.multiPanelDevices.has(deviceId) ? this.guests.get(deviceId) : undefined;
1033
+ if (guest) {
1034
+ let limit;
1035
+ const ready = await Promise.race([
1036
+ guest.whenReady(),
1037
+ new Promise((resolve) => { limit = setTimeout(() => resolve(false), this.helperBootReadyMs); }),
1038
+ ]).finally(() => clearTimeout(limit));
1039
+ if (seq !== state.bootSeq) {
1040
+ this.abandonBoot(state, seq, sessionId, requestId);
1041
+ return;
1042
+ }
1043
+ if (!ready) {
1044
+ const reason = guest.unavailableReason() ?? `the input helper was not ready within ${this.helperBootReadyMs / 1000}s`;
1045
+ this.stopGuest(deviceId);
1046
+ if (cold)
1047
+ await this.simctl.shutdown(deviceId).catch(() => { });
1048
+ throw new PlatformError(`${bootedDevice.name} cannot be used on this Mac: ${reason}`);
1049
+ }
1050
+ // A run replays from the same orientation, as a single-panel device's does (the stand-up above); the Duo
1051
+ // turns through its helper, so only once the helper is up.
1052
+ // Re-described afterwards, as `input:rotate` does: the chrome sent while the helper came up carried the old
1053
+ // turn. The panel poll the turn re-arms would correct it too, but up to its interval later.
1054
+ // A newer boot can arrive while the turn waits in the queue: it neither turns for a boot already superseded
1055
+ // nor goes on to open a stream over the newer one's.
1056
+ if (runReset) {
1057
+ await this.enqueueRotation(deviceId, async () => {
1058
+ if (seq === state.bootSeq)
1059
+ await this.turnHeld(deviceId, undefined, 'portrait', () => seq !== state.bootSeq);
1060
+ });
1061
+ if (seq !== state.bootSeq) {
1062
+ this.abandonBoot(state, seq, sessionId, requestId);
1063
+ return;
1064
+ }
1065
+ this.applyLitPanel(deviceId);
1066
+ }
1067
+ }
884
1068
  const streamWs = await this.openStreamWs(state);
885
1069
  if (seq !== state.bootSeq) {
886
1070
  streamWs.close();
@@ -903,6 +1087,8 @@ export class IOSAgent {
903
1087
  // riding inside it: a tester whose device just came up can arm the toggle in the same breath, and
904
1088
  // a boot that armed nothing still has to say so rather than leaving the control blank.
905
1089
  void this.reportNetworkState(sessionId);
1090
+ if (this.multiPanelDevices.has(deviceId))
1091
+ void this.sendPostures(state);
906
1092
  // An automation client's first selector step would otherwise spend the runner's cold start inside its timeout.
907
1093
  if (automation)
908
1094
  this.uiTreeReader.warm(deviceId);
@@ -1038,6 +1224,7 @@ export class IOSAgent {
1038
1224
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
1039
1225
  state.touchHelper?.stop();
1040
1226
  state.touchHelper = null;
1227
+ this.stopGuest(deviceId);
1041
1228
  state.booted = false;
1042
1229
  state.streamWs?.close();
1043
1230
  state.streamWs = null;
@@ -1122,9 +1309,363 @@ export class IOSAgent {
1122
1309
  ensureTouchHelper(state) {
1123
1310
  if (state.touchHelper)
1124
1311
  return;
1125
- state.touchHelper = new TouchHelper(state.deviceId);
1312
+ state.touchHelper = this.createInputChannel(state.deviceId);
1126
1313
  state.touchHelper.start();
1127
1314
  }
1315
+ /**
1316
+ * **The one place a device's input path is chosen** (see `InputChannel`). A device with more than one
1317
+ * integrated panel gets `FoldableInput` over its in-simulator helper; every other device gets the Indigo
1318
+ * `TouchHelper` it always had. A multi-panel device is never given a `TouchHelper`: its writes would
1319
+ * answer success and land on a dark panel or nowhere.
1320
+ */
1321
+ createInputChannel(deviceId) {
1322
+ if (!this.multiPanelDevices.has(deviceId))
1323
+ return new TouchHelper(deviceId);
1324
+ this.ensureGuest(deviceId);
1325
+ return new FoldableInput({
1326
+ guest: () => this.guests.get(deviceId) ?? null,
1327
+ litScreen: () => this.litScreen(deviceId),
1328
+ });
1329
+ }
1330
+ ensureGuest(deviceId) {
1331
+ if (this.guests.has(deviceId))
1332
+ return;
1333
+ const guest = new GuestHelper(deviceId, async () => {
1334
+ const screens = await this.simctl.integratedScreens(deviceId);
1335
+ this.guestScreens.set(deviceId, new Map(screens.map((s) => [s.screenId, s])));
1336
+ const typeId = this.deviceTypeIds.get(deviceId);
1337
+ if (typeId)
1338
+ await this.typePanels.load(typeId);
1339
+ return screens.map((s) => s.uuid);
1340
+ }, this.guestHelperNative, {
1341
+ onUnavailable: (reason) => {
1342
+ logger.error(`input for ${deviceId} is unavailable: ${reason}`);
1343
+ // Hide the fold control everywhere, and in the relay's replay cache: a control left up would do nothing.
1344
+ for (const state of this.deviceStates.values())
1345
+ if (state.deviceId === deviceId)
1346
+ void this.sendPostures(state);
1347
+ },
1348
+ // Back after an outage (the respawn window cleared): show it again, from the device's real posture.
1349
+ onAvailable: () => {
1350
+ for (const state of this.deviceStates.values())
1351
+ if (state.deviceId === deviceId)
1352
+ void this.sendPostures(state);
1353
+ },
1354
+ });
1355
+ this.guests.set(deviceId, guest);
1356
+ void guest.start();
1357
+ this.startPanelPoll(deviceId);
1358
+ // Which panel is lit decides where every touch goes and which panel is captured; until a reading arrives
1359
+ // the channel says `starting`. The panels are known once the helper has read them (`whenReady`), and a
1360
+ // stream that started before that captured the largest panel — dark while folded — so it is corrected here.
1361
+ // The way it is held is read alongside, so the first frame a viewer gets after the helper is up is
1362
+ // already turned rather than corrected a poll later.
1363
+ void this.panelState.read(deviceId);
1364
+ void this.physical.read(deviceId);
1365
+ void guest.whenReady()
1366
+ .then((ready) => (ready ? Promise.all([this.panelState.read(deviceId), this.physical.read(deviceId)]) : null))
1367
+ .then(() => this.applyLitPanel(deviceId));
1368
+ }
1369
+ /** The lit panel of a multi-panel device, from the last devicectl reading and this boot's panels. */
1370
+ litPanel(deviceId) {
1371
+ const lit = this.panelState.last(deviceId)?.lit;
1372
+ return lit ? this.guestScreens.get(deviceId)?.get(lit.displayId) ?? null : null;
1373
+ }
1374
+ /**
1375
+ * How far the viewer turns a multi-panel device (`ChromeData.streamRotation`): the way it is held, plus the
1376
+ * lit panel's own native turn — the Duo's unfolded panel is drawn turned 270, so a device held upright
1377
+ * shows it turned 90 (measured: portrait → rot90 … landscapeLeft → rot0). Undefined for a single-panel
1378
+ * device, and until both the orientation and the lit panel have been read.
1379
+ */
1380
+ chromeRotation(deviceId) {
1381
+ if (!this.multiPanelDevices.has(deviceId)) {
1382
+ const single = this.physical.last(deviceId);
1383
+ return single ? ORIENTATION_DEGREES[single] : undefined;
1384
+ }
1385
+ const held = this.physical.last(deviceId);
1386
+ const panel = this.litPanel(deviceId);
1387
+ if (!held || !panel)
1388
+ return undefined;
1389
+ const typeId = this.deviceTypeIds.get(deviceId);
1390
+ const native = typeId ? this.typePanels.nativeOrientation(typeId, panel.deviceName) : 0;
1391
+ return ((ORIENTATION_DEGREES[held] + 360 - native) % 360);
1392
+ }
1393
+ /** The cached frame with this send's turn and scale — never written into the loader's shared object. */
1394
+ forViewer(chrome, streamRotation, scaleBasis) {
1395
+ if (streamRotation === undefined && scaleBasis === undefined)
1396
+ return chrome;
1397
+ return {
1398
+ ...chrome,
1399
+ ...(streamRotation === undefined ? {} : { streamRotation }),
1400
+ ...(scaleBasis === undefined ? {} : { scaleBasis }),
1401
+ };
1402
+ }
1403
+ /** The lit panel's frame for `DeviceChromeLoader`, or undefined for a single-panel device or an unknown panel. */
1404
+ chromePanel(deviceId) {
1405
+ if (!this.multiPanelDevices.has(deviceId))
1406
+ return undefined;
1407
+ const panel = this.litPanel(deviceId);
1408
+ return panel ? panelFrame(panel) : undefined;
1409
+ }
1410
+ /** Point every stream of this device at its lit panel, and give its viewers that panel's frame. */
1411
+ applyLitPanel(deviceId) {
1412
+ const panel = this.litPanel(deviceId);
1413
+ if (!panel)
1414
+ return;
1415
+ for (const state of this.deviceStates.values()) {
1416
+ if (state.deviceId === deviceId)
1417
+ state.captureStreamer?.selectPanel({ width: panel.width, height: panel.height });
1418
+ }
1419
+ void this.resendChrome(deviceId);
1420
+ }
1421
+ /**
1422
+ * Load the lit panel's frame and send it as `session:chrome` to every session on this device whose frame
1423
+ * it is not already. `session:chrome` is re-sendable by contract (protocol `InputPosture`), and the relay
1424
+ * caches the last one for a re-joining viewer.
1425
+ */
1426
+ async resendChrome(deviceId) {
1427
+ const typeId = this.deviceTypeIds.get(deviceId);
1428
+ const panel = this.chromePanel(deviceId);
1429
+ if (!typeId || !panel)
1430
+ return;
1431
+ const [chrome, basis] = await Promise.all([this.chromeLoader.load(typeId, panel), this.typePanels.scaleBasis(typeId)]);
1432
+ const ws = this.ws;
1433
+ if (!chrome || ws?.readyState !== WebSocket.OPEN)
1434
+ return;
1435
+ for (const state of this.deviceStates.values()) {
1436
+ // Only a session whose boot has finished loading its first frame: one still loading gets the lit panel's
1437
+ // frame from the tail of `sendChromeData`, and sending it here first would be overwritten by the model's.
1438
+ const rotation = this.chromeRotation(deviceId);
1439
+ if (state.deviceId !== deviceId || !state.chromeSettled)
1440
+ continue;
1441
+ if (state.loadedChrome === chrome && state.sentRotation === rotation && state.sentBasis === basis)
1442
+ continue;
1443
+ state.loadedChrome = chrome;
1444
+ state.sentRotation = rotation;
1445
+ state.sentBasis = basis;
1446
+ this.sendOn(ws, { type: 'session:chrome', sessionId: state.sessionId, payload: this.forViewer(chrome, rotation, basis) });
1447
+ }
1448
+ }
1449
+ /** The display UUID of the panel a multi-panel device is showing, or null if not known yet. */
1450
+ litScreen(deviceId) {
1451
+ return this.litPanel(deviceId)?.uuid ?? null;
1452
+ }
1453
+ /**
1454
+ * Follow a fold made outside tapflow (DeviceHub's picker) while a session is on the device: read the lit
1455
+ * panel, re-point the capture and the frame, and report the posture if it moved. Skipped while tapflow's own
1456
+ * fold is in flight, which reports for itself.
1457
+ */
1458
+ startPanelPoll(deviceId) {
1459
+ if (this.panelPolls.has(deviceId))
1460
+ return;
1461
+ this.panelActivity.set(deviceId, Date.now());
1462
+ this.schedulePanelPoll(deviceId);
1463
+ }
1464
+ schedulePanelPoll(deviceId) {
1465
+ const quiet = Date.now() - (this.panelActivity.get(deviceId) ?? 0) >= this.panelQuietMs;
1466
+ const timer = setTimeout(() => {
1467
+ this.followPanel(deviceId);
1468
+ // Stopped while it ran (`stopGuest`) is stopped for good.
1469
+ if (this.panelPolls.get(deviceId) === timer)
1470
+ this.schedulePanelPoll(deviceId);
1471
+ }, quiet ? this.panelIdlePollMs : this.panelPollMs);
1472
+ timer.unref?.();
1473
+ this.panelPolls.set(deviceId, timer);
1474
+ }
1475
+ /** The device's panel or orientation is moving, or about to: the poll goes back to its fast rate now, not
1476
+ * after the slow wait already scheduled. */
1477
+ panelMoved(deviceId) {
1478
+ if (!this.panelPolls.has(deviceId))
1479
+ return;
1480
+ this.panelActivity.set(deviceId, Date.now());
1481
+ clearTimeout(this.panelPolls.get(deviceId));
1482
+ this.schedulePanelPoll(deviceId);
1483
+ }
1484
+ followPanel(deviceId) {
1485
+ if (this.posturing.has(deviceId) || this.guests.get(deviceId)?.inputState() !== 'ready')
1486
+ return;
1487
+ if (![...this.deviceStates.values()].some((s) => s.deviceId === deviceId && s.booted))
1488
+ return;
1489
+ const pose = () => `${this.panelState.last(deviceId)?.lit?.displayId}:${this.physical.last(deviceId)}`;
1490
+ const before = pose();
1491
+ void Promise.all([this.panelState.read(deviceId), this.physical.read(deviceId)]).then(() => {
1492
+ if (pose() !== before)
1493
+ this.panelMoved(deviceId);
1494
+ // The panel it has settled on, not the one just read: one lit only for a moment is not followed.
1495
+ const settled = this.panelState.last(deviceId);
1496
+ if (!settled?.lit || this.posturing.has(deviceId))
1497
+ return;
1498
+ // A new panel is read again once it has had time to hold, rather than at the next poll or two.
1499
+ if (this.panelState.changing(deviceId) && !this.panelRechecks.has(deviceId)) {
1500
+ const recheck = setTimeout(() => { this.panelRechecks.delete(deviceId); this.followPanel(deviceId); }, this.panelState.holdMs);
1501
+ recheck.unref?.();
1502
+ this.panelRechecks.set(deviceId, recheck);
1503
+ }
1504
+ this.applyLitPanel(deviceId);
1505
+ const posture = this.postureOf(deviceId, settled);
1506
+ if (posture !== this.reportedPosture.get(deviceId)) {
1507
+ for (const state of this.deviceStates.values())
1508
+ if (state.deviceId === deviceId)
1509
+ void this.sendPostures(state);
1510
+ }
1511
+ });
1512
+ }
1513
+ /** The device's teardown: shut down, erased, or the agent leaving. Not a relay reconnect — see `guests`. */
1514
+ stopGuest(deviceId) {
1515
+ clearTimeout(this.panelPolls.get(deviceId));
1516
+ this.panelPolls.delete(deviceId);
1517
+ this.panelActivity.delete(deviceId);
1518
+ clearTimeout(this.panelRechecks.get(deviceId));
1519
+ this.panelRechecks.delete(deviceId);
1520
+ this.reportedPosture.delete(deviceId);
1521
+ this.guests.get(deviceId)?.stop();
1522
+ this.guests.delete(deviceId);
1523
+ this.guestScreens.delete(deviceId);
1524
+ this.panelState.forget(deviceId);
1525
+ this.physical.forget(deviceId);
1526
+ }
1527
+ // ── PosturableAgent ────────────────────────────────────────────────────────
1528
+ // A multi-panel device (iPhone Duo) folds through its in-simulator helper's hinge, and which panel is lit
1529
+ // is read back from devicectl — never inferred from the angle sent.
1530
+ async listPostures(deviceId) {
1531
+ // The list's length is the capability's real gate: a device that cannot fold, or whose helper is not up
1532
+ // to fold it, answers none, and the viewer shows no control.
1533
+ return this.multiPanelDevices.has(deviceId) && this.guests.get(deviceId)?.inputState() === 'ready' ? FOLDABLE_POSTURES : [];
1534
+ }
1535
+ async getPosture(deviceId) {
1536
+ if ((await this.listPostures(deviceId)).length === 0)
1537
+ return null;
1538
+ const reading = (await this.panelState.read(deviceId)) ?? this.panelState.last(deviceId);
1539
+ const id = reading ? this.postureOf(deviceId, reading) : null;
1540
+ return FOLDABLE_POSTURES.find((p) => p.id === id) ?? null;
1541
+ }
1542
+ async setPosture(deviceId, postureId) {
1543
+ if (!(await this.listPostures(deviceId)).some((p) => p.id === postureId)) {
1544
+ throw new PlatformError(`Unknown posture "${postureId}" for ${deviceId}`);
1545
+ }
1546
+ const guest = this.guests.get(deviceId);
1547
+ this.panelMoved(deviceId);
1548
+ const target = [...(this.guestScreens.get(deviceId)?.values() ?? [])]
1549
+ .find((s) => s.deviceName === (postureId === 'closed' ? 'primary' : 'primary-1'));
1550
+ if (!target)
1551
+ throw new PlatformError(`${deviceId} has no ${postureId === 'closed' ? 'cover' : 'unfolded'} panel`);
1552
+ const from = POSTURE_DEGREES[(await this.getPosture(deviceId))?.id ?? (postureId === 'closed' ? 'open' : 'closed')];
1553
+ // **The panel is the answer, not the helper's.** The sweep ending is not the panel changing — measured, the
1554
+ // new panel lights 4–6 s after the command — and on a loaded Mac the helper's answer came after the panel
1555
+ // had lit. So both are watched at once, and a lit panel is success however late the answer.
1556
+ const swept = guest.hinge(from, POSTURE_DEGREES[postureId], 800);
1557
+ const settled = await this.panelState.awaitLit(deviceId, (r) => r.lit?.displayId === target.screenId, this.foldSettleMs);
1558
+ if (!settled) {
1559
+ if (!(await swept))
1560
+ throw new PlatformError('the hinge did not move');
1561
+ throw new PlatformError(`the ${postureId === 'closed' ? 'cover' : 'unfolded panel'} did not light within ${this.foldSettleMs}ms`);
1562
+ }
1563
+ // Nothing is turned: a fold does not change the way the device is held (measured), so only the frame and
1564
+ // the lit panel's native turn change — which is what DeviceHub shows.
1565
+ this.applyLitPanel(deviceId);
1566
+ }
1567
+ /**
1568
+ * Turn a multi-panel device the way it is held, through the helper (Purple and `devicectl orientation set`
1569
+ * do nothing on the Duo — measured). From the orientation read back now, never one remembered: a quarter
1570
+ * turn `direction`, or an older viewer's `target` — where either landscape meets landscape, so asking for
1571
+ * it never flips one into the other, and portrait means upright. Then waits for devicectl to say so, which
1572
+ * is what the re-sent chrome reports; a turn that does not land is logged and the chrome says where the
1573
+ * device really is.
1574
+ */
1575
+ async turnHeld(deviceId, direction, target, isStale) {
1576
+ const guest = this.guests.get(deviceId);
1577
+ this.panelMoved(deviceId);
1578
+ const held = (await this.physical.read(deviceId)) ?? this.physical.last(deviceId);
1579
+ // The read takes a moment, and a caller whose boot was superseded in it must not turn the shared device.
1580
+ if (isStale?.())
1581
+ return;
1582
+ if (!guest || !held) {
1583
+ logger.warn(`rotate ${deviceId}: ${guest ? 'its orientation could not be read' : 'no helper'}, not turning`);
1584
+ return;
1585
+ }
1586
+ const landscape = held === 'landscapeLeft' || held === 'landscapeRight';
1587
+ const want = direction ? turned(held, direction)
1588
+ : target === 'portrait' ? (held === 'portrait' ? null : 'portrait')
1589
+ : target === 'landscape' ? (landscape ? null : 'landscapeRight')
1590
+ : (landscape ? 'portrait' : 'landscapeRight');
1591
+ if (!want)
1592
+ return;
1593
+ if (!(await guest.orient(ORIENT_LABELS[want]))) {
1594
+ logger.warn(`rotate ${deviceId}: the helper did not take ${want}`);
1595
+ return;
1596
+ }
1597
+ if (!(await this.physical.await(deviceId, (o) => o === want, ROTATE_SETTLE_MS))) {
1598
+ logger.warn(`rotate ${deviceId}: asked for ${want}, still ${this.physical.last(deviceId) ?? 'unread'} after ${ROTATE_SETTLE_MS}ms`);
1599
+ }
1600
+ }
1601
+ /**
1602
+ * Where a single-panel device is held: read (devicectl on Xcode 27, the guest helper before — see
1603
+ * `defaultPhysicalOrientationNative`), else the last known — the last reading, or what tapflow last set on a
1604
+ * host where neither source answers. Read every time, never marked unreadable: one failure right after a
1605
+ * boot would otherwise have left the device on memory for the life of the agent (review finding).
1606
+ */
1607
+ async heldSingle(deviceId) {
1608
+ return (await this.physical.read(deviceId)) ?? this.physical.last(deviceId);
1609
+ }
1610
+ /** Turn a device nobody can say the orientation of upright, so there is one to turn from. */
1611
+ async standUp(deviceId) {
1612
+ await this.simctl.rotate(deviceId, PURPLE_ARGS.portrait);
1613
+ this.physical.note(deviceId, 'portrait');
1614
+ return 'portrait';
1615
+ }
1616
+ /**
1617
+ * Turn a single-panel device, through Purple: a quarter turn `direction` from where it is held, or an older
1618
+ * viewer's `target`. **That viewer draws landscape as 270**, so `landscape` means exactly `landscapeLeft` —
1619
+ * the other landscape would show it upside down — and is met only there; `portrait` means upright. A device
1620
+ * whose orientation cannot be read or remembered (an agent restarted under it) is stood up first.
1621
+ */
1622
+ async turnSingle(deviceId, direction, target) {
1623
+ const held = (await this.heldSingle(deviceId)) ?? (await this.standUp(deviceId));
1624
+ const want = direction ? turned(held, direction)
1625
+ : target === 'portrait' ? (held === 'portrait' ? null : 'portrait')
1626
+ : target === 'landscape' ? (held === 'landscapeLeft' ? null : 'landscapeLeft')
1627
+ : (held === 'portrait' ? 'landscapeLeft' : 'portrait');
1628
+ if (!want)
1629
+ return;
1630
+ await this.simctl.rotate(deviceId, PURPLE_ARGS[want]);
1631
+ // Read back once; a host that cannot read at all remembers what it set instead of waiting out the settle.
1632
+ const back = await this.physical.read(deviceId);
1633
+ if (!back) {
1634
+ this.physical.note(deviceId, want);
1635
+ return;
1636
+ }
1637
+ if (back !== want && !(await this.physical.await(deviceId, (o) => o === want, ROTATE_SETTLE_MS))) {
1638
+ logger.warn(`rotate ${deviceId}: asked for ${want}, still ${this.physical.last(deviceId) ?? 'unread'} after ${ROTATE_SETTLE_MS}ms`);
1639
+ }
1640
+ }
1641
+ /** Re-send the frame each session on this device already has, with the turn it is held at now. */
1642
+ resendTurn(deviceId) {
1643
+ const ws = this.ws;
1644
+ if (ws?.readyState !== WebSocket.OPEN)
1645
+ return;
1646
+ const rotation = this.chromeRotation(deviceId);
1647
+ for (const state of this.deviceStates.values()) {
1648
+ // Not to a session whose boot is still running: its frame is the last boot's, and that boot sends its own.
1649
+ if (state.deviceId !== deviceId || !state.loadedChrome || state.bootsInFlight.size > 0 || state.sentRotation === rotation)
1650
+ continue;
1651
+ state.sentRotation = rotation;
1652
+ this.sendOn(ws, { type: 'session:chrome', sessionId: state.sessionId, payload: this.forViewer(state.loadedChrome, rotation, state.sentBasis) });
1653
+ }
1654
+ }
1655
+ /** `closed` while the cover is lit, `open` while the unfolded panel is. */
1656
+ postureOf(deviceId, reading) {
1657
+ const lit = reading.lit && this.guestScreens.get(deviceId)?.get(reading.lit.displayId);
1658
+ if (!lit)
1659
+ return null;
1660
+ return lit.deviceName === 'primary' ? 'closed' : 'open';
1661
+ }
1662
+ /** Tell the viewer which postures exist and which one the device is in — read from the device, so a fold
1663
+ * made outside tapflow shows too. Sent at boot, after a change (failed or not), and on the helper dying. */
1664
+ async sendPostures(state) {
1665
+ const [postures, current] = await Promise.all([this.listPostures(state.deviceId), this.getPosture(state.deviceId)]);
1666
+ this.reportedPosture.set(state.deviceId, current?.id ?? null);
1667
+ this.sendMsg({ type: 'device:postures', sessionId: state.sessionId, payload: { postures, currentId: current?.id ?? null } });
1668
+ }
1128
1669
  // Clipboard work parks a sentinel on the device while it waits, so two operations on the same
1129
1670
  // device must not interleave — each would read the other's marker instead of the real
1130
1671
  // clipboard. Keyed by device, not session: several sessions (and MCP) can address one device.
@@ -1414,17 +1955,38 @@ export class IOSAgent {
1414
1955
  // dashboard's. The memory is read when the step runs, not when it was queued, and written only
1415
1956
  // once the simulator took it: a step queued behind another toggles from what that one applied.
1416
1957
  const sessionId = msg.sessionId;
1417
- const target = msg.payload?.orientation;
1958
+ const payload = msg.payload;
1959
+ const target = payload?.orientation;
1418
1960
  void this.enqueueRotation(state.deviceId, async () => {
1419
1961
  const live = this.deviceStates.get(sessionId) ?? state;
1420
- const next = target
1421
- ? (target === 'landscape' ? 'landscapeRight' : 'portrait')
1422
- : (live.orientation === 'portrait' ? 'landscapeRight' : 'portrait');
1423
- await this.simctl.rotate(live.deviceId, next);
1424
- live.orientation = next;
1962
+ if (this.multiPanelDevices.has(live.deviceId)) {
1963
+ await this.turnHeld(live.deviceId, payload?.direction, target);
1964
+ this.applyLitPanel(live.deviceId);
1965
+ return;
1966
+ }
1967
+ await this.turnSingle(live.deviceId, payload?.direction, target);
1968
+ this.resendTurn(live.deviceId);
1425
1969
  });
1426
1970
  break;
1427
1971
  }
1972
+ case 'input:posture': {
1973
+ const state = this.deviceStates.get(msg.sessionId);
1974
+ if (!state)
1975
+ break;
1976
+ const { postureId } = msg.payload;
1977
+ // No ack by declaration (like `input:rotate`): a fold that fails is reported by the posture list that
1978
+ // follows saying where the device really is. One at a time per device, like Android's.
1979
+ if (this.posturing.has(state.deviceId)) {
1980
+ logger.info(`posture ${postureId}: a change is already in flight, ignoring`);
1981
+ break;
1982
+ }
1983
+ this.posturing.add(state.deviceId);
1984
+ void this.setPosture(state.deviceId, postureId)
1985
+ .catch((e) => logger.warn(`setPosture(${postureId}): ${e.message}`))
1986
+ .then(() => this.sendPostures(state))
1987
+ .finally(() => this.posturing.delete(state.deviceId));
1988
+ break;
1989
+ }
1428
1990
  case 'stream:request-idr': {
1429
1991
  // Relay drop-to-keyframe recovery: force an IDR so the stream resyncs fast.
1430
1992
  this.deviceStates.get(msg.sessionId)?.captureStreamer?.requestKeyframe();