@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.
- package/bin/guest-helper +0 -0
- package/bin/screencapture-helper +0 -0
- package/dist/DeviceChromeLoader.d.ts +28 -1
- package/dist/DeviceChromeLoader.d.ts.map +1 -1
- package/dist/DeviceChromeLoader.js +118 -37
- package/dist/DeviceChromeLoader.js.map +1 -1
- package/dist/FoldableInput.d.ts +58 -0
- package/dist/FoldableInput.d.ts.map +1 -0
- package/dist/FoldableInput.js +166 -0
- package/dist/FoldableInput.js.map +1 -0
- package/dist/GuestHelper.d.ts +109 -0
- package/dist/GuestHelper.d.ts.map +1 -0
- package/dist/GuestHelper.js +437 -0
- package/dist/GuestHelper.js.map +1 -0
- package/dist/IOSAgent.d.ts +119 -2
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +586 -24
- package/dist/IOSAgent.js.map +1 -1
- package/dist/InputChannel.d.ts +38 -0
- package/dist/InputChannel.d.ts.map +1 -0
- package/dist/InputChannel.js +7 -0
- package/dist/InputChannel.js.map +1 -0
- package/dist/PanelState.d.ts +72 -0
- package/dist/PanelState.d.ts.map +1 -0
- package/dist/PanelState.js +192 -0
- package/dist/PanelState.js.map +1 -0
- package/dist/PhysicalOrientation.d.ts +58 -0
- package/dist/PhysicalOrientation.d.ts.map +1 -0
- package/dist/PhysicalOrientation.js +155 -0
- package/dist/PhysicalOrientation.js.map +1 -0
- package/dist/ScreenCaptureStreamer.d.ts +12 -1
- package/dist/ScreenCaptureStreamer.d.ts.map +1 -1
- package/dist/ScreenCaptureStreamer.js +28 -3
- package/dist/ScreenCaptureStreamer.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +18 -1
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +43 -1
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/TouchHelper.d.ts +3 -2
- package/dist/TouchHelper.d.ts.map +1 -1
- package/dist/TouchHelper.js +4 -6
- package/dist/TouchHelper.js.map +1 -1
- package/dist/TypePanels.d.ts +33 -0
- package/dist/TypePanels.d.ts.map +1 -0
- package/dist/TypePanels.js +68 -0
- package/dist/TypePanels.js.map +1 -0
- package/dist/panels.d.ts +52 -0
- package/dist/panels.d.ts.map +1 -0
- package/dist/panels.js +68 -0
- package/dist/panels.js.map +1 -0
- 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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// **
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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();
|