@tapflowio/ios-agent 0.26.1 → 0.27.1
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/dist/DeviceChromeLoader.d.ts +29 -1
- package/dist/DeviceChromeLoader.d.ts.map +1 -1
- package/dist/DeviceChromeLoader.js +134 -73
- package/dist/DeviceChromeLoader.js.map +1 -1
- package/dist/IOSAgent.d.ts +46 -0
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +209 -37
- package/dist/IOSAgent.js.map +1 -1
- package/dist/MjpegStreamer.d.ts.map +1 -1
- package/dist/MjpegStreamer.js +6 -0
- package/dist/MjpegStreamer.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +12 -0
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +35 -1
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/SimulatorNetwork.d.ts +47 -12
- package/dist/SimulatorNetwork.d.ts.map +1 -1
- package/dist/SimulatorNetwork.js +111 -23
- package/dist/SimulatorNetwork.js.map +1 -1
- package/package.json +6 -6
package/dist/IOSAgent.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type SleepBlocker } from '@tapflowio/agent-core/utils';
|
|
|
4
4
|
import { SimctlWrapper } from './SimctlWrapper.js';
|
|
5
5
|
import { SimulatorNetwork } from './SimulatorNetwork.js';
|
|
6
6
|
import { LeanStore } from './LeanStore.js';
|
|
7
|
+
import { DeviceChromeLoader } from './DeviceChromeLoader.js';
|
|
7
8
|
/** Lean mode needs an iOS runtime of 18.5 or later, where launchd honours the host-side overrides
|
|
8
9
|
* across reboots. tvOS, watchOS and visionOS devices come through the same list and are left alone. */
|
|
9
10
|
export declare function leanSupported(osVersion: string | undefined): boolean;
|
|
@@ -27,6 +28,9 @@ export interface IOSAgentOptions {
|
|
|
27
28
|
lean?: boolean;
|
|
28
29
|
/** Where the launchd overrides are written. Injectable for the same reason as `network`. */
|
|
29
30
|
leanStore?: LeanStore;
|
|
31
|
+
/** Device frame loading. Under vitest the default points at directories that do not exist, so a suite never
|
|
32
|
+
* reads the developer's Xcode chrome or spends seconds rendering it. */
|
|
33
|
+
chromeLoader?: DeviceChromeLoader;
|
|
30
34
|
}
|
|
31
35
|
export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
|
|
32
36
|
private readonly simctl;
|
|
@@ -62,6 +66,14 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
|
|
|
62
66
|
* inside `onopen` on a local socket. */
|
|
63
67
|
private sendOn;
|
|
64
68
|
private deviceStates;
|
|
69
|
+
/**
|
|
70
|
+
* One queue of rotations per device (#910). Each is its own helper process, so two sent back to back
|
|
71
|
+
* can land in either order, and a held ⌘⇧O or the boot reset racing the first press would leave the
|
|
72
|
+
* simulator on the earlier target. Keyed here rather than on `DeviceState` because a re-register
|
|
73
|
+
* replaces the states while a rotation already in flight keeps running — the boot reset that follows
|
|
74
|
+
* has to queue behind it, not start fresh beside it.
|
|
75
|
+
*/
|
|
76
|
+
private readonly rotations;
|
|
65
77
|
/**
|
|
66
78
|
* Devices **this agent booted**, by device id.
|
|
67
79
|
*
|
|
@@ -86,7 +98,15 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
|
|
|
86
98
|
private lastBundleIds;
|
|
87
99
|
private readonly uiTreeReader;
|
|
88
100
|
private readonly sleepBlocker;
|
|
101
|
+
/** What the next "relay disconnected" line reports: when the relay last pinged, and the socket's last error. */
|
|
102
|
+
private lastPingAt;
|
|
103
|
+
private lastSocketError;
|
|
104
|
+
private releaseStallWatch;
|
|
89
105
|
private relayUrl;
|
|
106
|
+
/** The last device-type lookup that answered. A udid's type never changes, so on a reconnect whose
|
|
107
|
+
* lookup fails this is still right — and the relay overwrites device fields on every register, so
|
|
108
|
+
* falling back to nothing would blank a form factor it already had until the next reconnect. */
|
|
109
|
+
private formFactors;
|
|
90
110
|
private resourcesTimer;
|
|
91
111
|
private readonly resources;
|
|
92
112
|
private _stopping;
|
|
@@ -97,12 +117,25 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
|
|
|
97
117
|
constructor(options?: IOSAgentOptions, simctl?: SimctlWrapper);
|
|
98
118
|
get sessionId(): string | null;
|
|
99
119
|
connect(relayUrl: string): Promise<void>;
|
|
120
|
+
/**
|
|
121
|
+
* `listTimeoutMs` is set only by the reconnect loop. `simctl list` is left unbounded elsewhere on purpose
|
|
122
|
+
* (see `SimctlWrapper.listDevices`), but here a hang has a worse outcome than a failure: the attempt never
|
|
123
|
+
* settles, `_scheduleReconnect` is never reached, and the agent stays offline with nothing in its output
|
|
124
|
+
* even after CoreSimulator recovers. A bounded attempt fails and the loop tries again.
|
|
125
|
+
*/
|
|
126
|
+
private connectOnce;
|
|
100
127
|
private initDeviceStates;
|
|
101
128
|
disconnect(): void;
|
|
129
|
+
/** `loss` is absent when a reconnect attempt failed: there is no close to describe, only a retry. */
|
|
102
130
|
private _scheduleReconnect;
|
|
103
131
|
private reportResources;
|
|
104
132
|
private cleanupDeviceState;
|
|
133
|
+
/** Resolves `false` when a newer boot or a relay loss retired this one during the chrome load, in which case
|
|
134
|
+
* nothing was stored and the caller abandons the boot. A closed socket alone continues as it always did —
|
|
135
|
+
* the relay loss that follows bumps the seq. */
|
|
105
136
|
private sendChromeData;
|
|
137
|
+
/** `failures` counts the frameless runs before this one; only the restart in `pump` passes it, so a new
|
|
138
|
+
* boot starts from zero without any state to reset. */
|
|
106
139
|
private startBinaryStream;
|
|
107
140
|
private wsClientOptions;
|
|
108
141
|
private openStreamWs;
|
|
@@ -176,6 +209,9 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
|
|
|
176
209
|
private refusalReason;
|
|
177
210
|
private ackInput;
|
|
178
211
|
private isBooted;
|
|
212
|
+
/** Run `step` after every rotation already queued for this device. A failing step is logged and does
|
|
213
|
+
* not stop the ones behind it. */
|
|
214
|
+
private enqueueRotation;
|
|
179
215
|
private handleRelayMessage;
|
|
180
216
|
private readUITree;
|
|
181
217
|
/**
|
|
@@ -318,4 +354,14 @@ export declare class IOSAgent implements DeviceAgent, NetworkControlCapability {
|
|
|
318
354
|
touchEnd(): Promise<void>;
|
|
319
355
|
openUrl(url: string): Promise<void>;
|
|
320
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* When to restart a screen stream that just ended. Only a run that delivered a frame resets the count — not
|
|
359
|
+
* how long it lasted: the helper retries `device.io` and its ports for up to ~10s before it gives up, so a
|
|
360
|
+
* duration rule would call every first-boot failure healthy. A simulator shut down from outside makes the
|
|
361
|
+
* helper exit at once, which without this was a restart loop at full speed.
|
|
362
|
+
*/
|
|
363
|
+
export declare function streamRestartPlan(previousFailures: number, deliveredFrame: boolean): {
|
|
364
|
+
failures: number;
|
|
365
|
+
delayMs: number;
|
|
366
|
+
};
|
|
321
367
|
//# sourceMappingURL=IOSAgent.d.ts.map
|
package/dist/IOSAgent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IOSAgent.d.ts","sourceRoot":"","sources":["../src/IOSAgent.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAA4C,MAAM,EAAE,WAAW,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AAsCpK,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIrD,CAAA;AACD,OAAO,
|
|
1
|
+
{"version":3,"file":"IOSAgent.d.ts","sourceRoot":"","sources":["../src/IOSAgent.ts"],"names":[],"mappings":"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"}
|
package/dist/IOSAgent.js
CHANGED
|
@@ -3,9 +3,9 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
|
-
import {
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
|
-
import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE, downloadBuild } from '@tapflowio/agent-core';
|
|
8
|
+
import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE, SHUTDOWN_NO_SESSION_STATE, downloadBuild } from '@tapflowio/agent-core';
|
|
9
9
|
const logger = createLogger('ios-agent');
|
|
10
10
|
// Typed so a typo cannot ship silently — the viewer gates the whole clipboard bridge on this.
|
|
11
11
|
// `full-reset` is honoured in `handleDeviceBoot`, which shuts a running device down before
|
|
@@ -39,8 +39,8 @@ export const IOS_BUTTON_ALIASES = {
|
|
|
39
39
|
volume_up: 'volume-up',
|
|
40
40
|
volume_down: 'volume-down',
|
|
41
41
|
};
|
|
42
|
-
import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
|
|
43
|
-
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine } from './SimctlWrapper.js';
|
|
42
|
+
import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, acquireLoopStallWatch, describeRelayLoss, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
|
|
43
|
+
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine, launchFailureReason } from './SimctlWrapper.js';
|
|
44
44
|
import { SimulatorNetwork } from './SimulatorNetwork.js';
|
|
45
45
|
import { LeanStore } from './LeanStore.js';
|
|
46
46
|
import { LEAN_LABELS } from './leanLabels.js';
|
|
@@ -58,8 +58,8 @@ import { KEY_CODE_MAP, MODIFIER_BITS } from './KeyCodeMap.js';
|
|
|
58
58
|
// processes (launched apps, WebKit WebContent). Short enough that a tab's audio starts promptly,
|
|
59
59
|
// long enough to keep `ps` overhead negligible.
|
|
60
60
|
const AUDIO_POLL_MS = 1500;
|
|
61
|
-
|
|
62
|
-
const
|
|
61
|
+
/** Bound on `simctl list` during a reconnect attempt only; see `connectOnce`. */
|
|
62
|
+
const RECONNECT_LIST_TIMEOUT_MS = 10_000;
|
|
63
63
|
/** Lean mode needs an iOS runtime of 18.5 or later, where launchd honours the host-side overrides
|
|
64
64
|
* across reboots. tvOS, watchOS and visionOS devices come through the same list and are left alone. */
|
|
65
65
|
export function leanSupported(osVersion) {
|
|
@@ -109,6 +109,14 @@ export class IOSAgent {
|
|
|
109
109
|
ws.send(JSON.stringify(msg));
|
|
110
110
|
}
|
|
111
111
|
deviceStates = new Map();
|
|
112
|
+
/**
|
|
113
|
+
* One queue of rotations per device (#910). Each is its own helper process, so two sent back to back
|
|
114
|
+
* can land in either order, and a held ⌘⇧O or the boot reset racing the first press would leave the
|
|
115
|
+
* simulator on the earlier target. Keyed here rather than on `DeviceState` because a re-register
|
|
116
|
+
* replaces the states while a rotation already in flight keeps running — the boot reset that follows
|
|
117
|
+
* has to queue behind it, not start fresh beside it.
|
|
118
|
+
*/
|
|
119
|
+
rotations = new Map();
|
|
112
120
|
/**
|
|
113
121
|
* Devices **this agent booted**, by device id.
|
|
114
122
|
*
|
|
@@ -138,7 +146,15 @@ export class IOSAgent {
|
|
|
138
146
|
// Holds a macOS power assertion while connected so the host doesn't idle-throttle the
|
|
139
147
|
// simulator capture/encode when the Mac is unattended. No-op off macOS.
|
|
140
148
|
sleepBlocker;
|
|
149
|
+
/** What the next "relay disconnected" line reports: when the relay last pinged, and the socket's last error. */
|
|
150
|
+
lastPingAt = null;
|
|
151
|
+
lastSocketError;
|
|
152
|
+
releaseStallWatch = null;
|
|
141
153
|
relayUrl = null;
|
|
154
|
+
/** The last device-type lookup that answered. A udid's type never changes, so on a reconnect whose
|
|
155
|
+
* lookup fails this is still right — and the relay overwrites device fields on every register, so
|
|
156
|
+
* falling back to nothing would blank a form factor it already had until the next reconnect. */
|
|
157
|
+
formFactors = new Map();
|
|
142
158
|
resourcesTimer = null;
|
|
143
159
|
resources = createResourceSampler();
|
|
144
160
|
_stopping = false;
|
|
@@ -176,7 +192,9 @@ export class IOSAgent {
|
|
|
176
192
|
this.fps = options.fps ?? 30;
|
|
177
193
|
this.intervalMs = options.intervalMs;
|
|
178
194
|
this.reconnectDelays = options.reconnectDelays ?? [1000, 2000, 4000, 8000, 16000, 30000];
|
|
179
|
-
this.chromeLoader = new DeviceChromeLoader(
|
|
195
|
+
this.chromeLoader = options.chromeLoader ?? new DeviceChromeLoader(process.env.VITEST
|
|
196
|
+
? { run: () => Promise.reject(new Error('no chrome under vitest')) }
|
|
197
|
+
: {});
|
|
180
198
|
this.deviceFilter = options.deviceFilter;
|
|
181
199
|
this.token = options.token;
|
|
182
200
|
this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000;
|
|
@@ -188,6 +206,15 @@ export class IOSAgent {
|
|
|
188
206
|
return first?.sessionId ?? null;
|
|
189
207
|
}
|
|
190
208
|
async connect(relayUrl) {
|
|
209
|
+
return this.connectOnce(relayUrl);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* `listTimeoutMs` is set only by the reconnect loop. `simctl list` is left unbounded elsewhere on purpose
|
|
213
|
+
* (see `SimctlWrapper.listDevices`), but here a hang has a worse outcome than a failure: the attempt never
|
|
214
|
+
* settles, `_scheduleReconnect` is never reached, and the agent stays offline with nothing in its output
|
|
215
|
+
* even after CoreSimulator recovers. A bounded attempt fails and the loop tries again.
|
|
216
|
+
*/
|
|
217
|
+
async connectOnce(relayUrl, listTimeoutMs) {
|
|
191
218
|
this._stopping = false;
|
|
192
219
|
// Paired with the `dispose()` in `disconnect()`. This method is public and reuses the network, so
|
|
193
220
|
// without it a reconnect leaves the liveness watcher off for good — see `SimulatorNetwork.resume`.
|
|
@@ -197,11 +224,21 @@ export class IOSAgent {
|
|
|
197
224
|
this._reconnectTimer = null;
|
|
198
225
|
}
|
|
199
226
|
this.relayUrl = relayUrl;
|
|
200
|
-
const allDevices = await this.simctl.listDevices();
|
|
227
|
+
const allDevices = await this.simctl.listDevices(listTimeoutMs);
|
|
201
228
|
this.revertLeftoverLean(allDevices);
|
|
202
229
|
const devices = this.deviceFilter
|
|
203
230
|
? allDevices.filter((d) => d.name === this.deviceFilter || d.id === this.deviceFilter)
|
|
204
231
|
: allDevices;
|
|
232
|
+
// A `disconnect()` during this await or the one above does not cancel the connect. Every caller
|
|
233
|
+
// today exits the process on the next line (the CLI's SIGINT handlers); one that keeps running
|
|
234
|
+
// needs a generation check here before the socket is built.
|
|
235
|
+
// An answer the lookup cannot give costs the field, not the register: a viewer draws a device with
|
|
236
|
+
// no form factor as a phone, which is what it did before there was one. A reconnect falls back to
|
|
237
|
+
// the last answer rather than to none (see `formFactors`).
|
|
238
|
+
const formFactors = await this.simctl.formFactorsByType().then((map) => (this.formFactors = map), (e) => {
|
|
239
|
+
logger.warn(`device types unavailable, registering with the last known form factors: ${e instanceof Error ? e.message : String(e)}`);
|
|
240
|
+
return this.formFactors;
|
|
241
|
+
});
|
|
205
242
|
return new Promise((resolve, reject) => {
|
|
206
243
|
const ws = new WebSocket(relayUrl, this.wsClientOptions());
|
|
207
244
|
let registered = false;
|
|
@@ -226,6 +263,7 @@ export class IOSAgent {
|
|
|
226
263
|
platform: d.platform,
|
|
227
264
|
status: d.status,
|
|
228
265
|
osVersion: d.osVersion,
|
|
266
|
+
formFactor: d.typeId ? formFactors.get(d.typeId) : undefined,
|
|
229
267
|
})),
|
|
230
268
|
});
|
|
231
269
|
});
|
|
@@ -262,7 +300,18 @@ export class IOSAgent {
|
|
|
262
300
|
});
|
|
263
301
|
this.reportResources();
|
|
264
302
|
this.resourcesTimer = setInterval(() => this.reportResources(), 5000);
|
|
265
|
-
|
|
303
|
+
this.lastPingAt = null;
|
|
304
|
+
this.lastSocketError = undefined;
|
|
305
|
+
ws.on('ping', () => { this.lastPingAt = Date.now(); });
|
|
306
|
+
// After registration the handshake's `once('error', reject)` lands on a settled promise, so the
|
|
307
|
+
// code (ETIMEDOUT, ECONNRESET…) was dropped. It is the network half of the disconnect line.
|
|
308
|
+
ws.on('error', (e) => { this.lastSocketError = e.code ?? e.message; });
|
|
309
|
+
// `now` is taken here, at the close, not when the warning prints: the cleanup in between can run
|
|
310
|
+
// work that holds the event loop and would inflate the ping age.
|
|
311
|
+
ws.on('close', (code, reason) => this._scheduleReconnect({
|
|
312
|
+
code, reason: reason.toString(), errorCode: this.lastSocketError, lastPingAt: this.lastPingAt, now: Date.now(),
|
|
313
|
+
}));
|
|
314
|
+
this.releaseStallWatch ??= acquireLoopStallWatch();
|
|
266
315
|
resolve();
|
|
267
316
|
}
|
|
268
317
|
else {
|
|
@@ -335,12 +384,15 @@ export class IOSAgent {
|
|
|
335
384
|
// other things that tick. It is `unref`'d, which is why nobody noticed it had no caller.
|
|
336
385
|
this.network.dispose();
|
|
337
386
|
this.sleepBlocker.release();
|
|
387
|
+
this.releaseStallWatch?.();
|
|
388
|
+
this.releaseStallWatch = null;
|
|
338
389
|
this.ws?.close();
|
|
339
390
|
this.ws = null;
|
|
340
391
|
this.relayUrl = null;
|
|
341
392
|
this.simctl.stopKeyboardDaemon();
|
|
342
393
|
}
|
|
343
|
-
|
|
394
|
+
/** `loss` is absent when a reconnect attempt failed: there is no close to describe, only a retry. */
|
|
395
|
+
_scheduleReconnect(loss) {
|
|
344
396
|
if (this._stopping)
|
|
345
397
|
return;
|
|
346
398
|
if (this.resourcesTimer) {
|
|
@@ -362,12 +414,14 @@ export class IOSAgent {
|
|
|
362
414
|
const delays = this.reconnectDelays;
|
|
363
415
|
const delay = delays[Math.min(this._reconnectAttempt, delays.length - 1)];
|
|
364
416
|
this._reconnectAttempt++;
|
|
365
|
-
logger.warn(
|
|
417
|
+
logger.warn(loss
|
|
418
|
+
? `relay disconnected (${describeRelayLoss(loss)}) — reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`
|
|
419
|
+
: `reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`);
|
|
366
420
|
this._reconnectTimer = setTimeout(() => {
|
|
367
421
|
this._reconnectTimer = null;
|
|
368
422
|
if (this._stopping || !this.relayUrl)
|
|
369
423
|
return;
|
|
370
|
-
this.
|
|
424
|
+
this.connectOnce(this.relayUrl, RECONNECT_LIST_TIMEOUT_MS).then(() => {
|
|
371
425
|
this._reconnectAttempt = 0;
|
|
372
426
|
logger.info('reconnected to relay');
|
|
373
427
|
}).catch((e) => {
|
|
@@ -403,7 +457,7 @@ export class IOSAgent {
|
|
|
403
457
|
void this.network.forget(state.deviceId).catch((e) => {
|
|
404
458
|
logger.warn('could not clear the network rule for a retired device:', e.message);
|
|
405
459
|
});
|
|
406
|
-
|
|
460
|
+
state.streamReader?.cancel().catch(() => { });
|
|
407
461
|
state.streamReader = null;
|
|
408
462
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
409
463
|
if (state.audioPoll) {
|
|
@@ -420,12 +474,15 @@ export class IOSAgent {
|
|
|
420
474
|
state.streamWs?.close();
|
|
421
475
|
state.streamWs = null;
|
|
422
476
|
}
|
|
423
|
-
|
|
477
|
+
/** Resolves `false` when a newer boot or a relay loss retired this one during the chrome load, in which case
|
|
478
|
+
* nothing was stored and the caller abandons the boot. A closed socket alone continues as it always did —
|
|
479
|
+
* the relay loss that follows bumps the seq. */
|
|
480
|
+
async sendChromeData(state, device, seq) {
|
|
424
481
|
// `readyState`, not presence: this runs mid-boot, and a socket that closed since the entry guard
|
|
425
482
|
// takes the payload into a buffer nobody flushes while `device:ready` is dropped by `sendMsg`'s own
|
|
426
483
|
// check — leaving the caller with neither the data nor an answer.
|
|
427
484
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
428
|
-
return;
|
|
485
|
+
return true;
|
|
429
486
|
// Stop the outgoing helper before dropping the reference. This used to leak a child process;
|
|
430
487
|
// now that a helper revives itself on death, an orphan would also keep respawning with
|
|
431
488
|
// nobody left holding a reference to stop it.
|
|
@@ -440,16 +497,28 @@ export class IOSAgent {
|
|
|
440
497
|
osVersion: device.osVersion ?? '',
|
|
441
498
|
},
|
|
442
499
|
});
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
this.
|
|
500
|
+
// Awaited where it used to block: a cold load is seconds of swift renders. A newer boot or a shutdown can
|
|
501
|
+
// land meanwhile, so the result is stored only after the seq is checked — otherwise a slow stale load
|
|
502
|
+
// would overwrite the chrome a faster newer boot already set, and the button path reads it.
|
|
503
|
+
const chrome = await this.chromeLoader.load(device.typeId ?? device.name);
|
|
504
|
+
if (seq !== state.bootSeq)
|
|
505
|
+
return false;
|
|
506
|
+
const ws = this.ws;
|
|
507
|
+
if (ws?.readyState !== WebSocket.OPEN)
|
|
508
|
+
return true;
|
|
509
|
+
state.loadedChrome = chrome;
|
|
510
|
+
if (!chrome)
|
|
511
|
+
return true;
|
|
512
|
+
this.sendOn(ws, {
|
|
447
513
|
type: 'session:chrome',
|
|
448
514
|
sessionId: state.sessionId,
|
|
449
|
-
payload:
|
|
515
|
+
payload: chrome,
|
|
450
516
|
});
|
|
517
|
+
return true;
|
|
451
518
|
}
|
|
452
|
-
|
|
519
|
+
/** `failures` counts the frameless runs before this one; only the restart in `pump` passes it, so a new
|
|
520
|
+
* boot starts from zero without any state to reset. */
|
|
521
|
+
startBinaryStream(state, streamWs, failures = 0) {
|
|
453
522
|
// H.264 is the default; opt out per-agent with TAPFLOW_IOS_CODEC=jpeg. It also needs
|
|
454
523
|
// a browser that reported it can decode it (device:boot acceptH264) — otherwise JPEG.
|
|
455
524
|
// Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG, and now
|
|
@@ -497,11 +566,13 @@ export class IOSAgent {
|
|
|
497
566
|
state.captureStreamer?.requestKeyframe();
|
|
498
567
|
} };
|
|
499
568
|
const pump = async () => {
|
|
569
|
+
let delivered = false;
|
|
500
570
|
try {
|
|
501
571
|
while (true) {
|
|
502
572
|
const { value, done } = await reader.read();
|
|
503
573
|
if (done)
|
|
504
574
|
break;
|
|
575
|
+
delivered = true;
|
|
505
576
|
// Declare reorder=0 on the keyframe SPS so every decoder (WebCodecs, MSE,
|
|
506
577
|
// WASM) emits frames immediately instead of buffering the level's max DPB
|
|
507
578
|
// (~8 frames ≈ 250ms). Keyframe-only (SPS lives there); no-op otherwise.
|
|
@@ -520,7 +591,26 @@ export class IOSAgent {
|
|
|
520
591
|
if (metricsTimer)
|
|
521
592
|
clearInterval(metricsTimer);
|
|
522
593
|
if (state.streamReader === reader && streamWs.readyState === WebSocket.OPEN) {
|
|
523
|
-
|
|
594
|
+
const next = streamRestartPlan(failures, delivered);
|
|
595
|
+
if (next.failures === 2) {
|
|
596
|
+
logger.warn(`screen stream for ${state.deviceId} keeps ending before its first frame — backing off restarts`);
|
|
597
|
+
}
|
|
598
|
+
// Re-checked after the delay: a new boot, a shutdown, a relay loss or disconnect() all swap or clear
|
|
599
|
+
// the reader, or close the socket, in the meantime.
|
|
600
|
+
const restart = () => {
|
|
601
|
+
if (this._stopping || state.streamReader !== reader || streamWs.readyState !== WebSocket.OPEN)
|
|
602
|
+
return;
|
|
603
|
+
try {
|
|
604
|
+
this.startBinaryStream(state, streamWs, next.failures);
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
logger.error(`screen stream for ${state.deviceId} could not restart: ${e instanceof Error ? e.message : String(e)}`);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
if (next.delayMs === 0)
|
|
611
|
+
restart();
|
|
612
|
+
else
|
|
613
|
+
setTimeout(restart, next.delayMs).unref();
|
|
524
614
|
}
|
|
525
615
|
};
|
|
526
616
|
void pump();
|
|
@@ -608,7 +698,7 @@ export class IOSAgent {
|
|
|
608
698
|
// superseded was gone as well. The caller then waited out the very deadline this change exists to
|
|
609
699
|
// end. `finally` is what stops `bootAbandon` outliving the boot that would have read it.
|
|
610
700
|
try {
|
|
611
|
-
|
|
701
|
+
state.streamReader?.cancel().catch(() => { });
|
|
612
702
|
state.streamReader = null;
|
|
613
703
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
614
704
|
state.touchHelper?.stop();
|
|
@@ -710,6 +800,15 @@ export class IOSAgent {
|
|
|
710
800
|
await this.network.arm(deviceId).catch((e) => {
|
|
711
801
|
logger.warn('could not arm the network injection:', e.message);
|
|
712
802
|
});
|
|
803
|
+
// **Stand it up (#910).** A fresh viewer always starts portrait, and a simulator left turned —
|
|
804
|
+
// by a session whose undo never arrived, or one an agent restart reset the memory of — would
|
|
805
|
+
// otherwise stream sideways under an upright frame, with every later press inverted. Queued with
|
|
806
|
+
// the rotations, so one still in flight from before a re-register lands first and is overridden.
|
|
807
|
+
// Best-effort: a failure leaves the simulator where it was, and the boot goes on.
|
|
808
|
+
await this.enqueueRotation(deviceId, async () => {
|
|
809
|
+
await this.simctl.rotate(deviceId, 'portrait');
|
|
810
|
+
state.orientation = 'portrait';
|
|
811
|
+
});
|
|
713
812
|
// Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
|
|
714
813
|
// shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
|
|
715
814
|
// device it is taking down — and one that revives itself, so the stale reference outlives the
|
|
@@ -718,7 +817,10 @@ export class IOSAgent {
|
|
|
718
817
|
this.abandonBoot(state, seq, sessionId, requestId);
|
|
719
818
|
return;
|
|
720
819
|
}
|
|
721
|
-
this.sendChromeData(state, bootedDevice)
|
|
820
|
+
if (!(await this.sendChromeData(state, bootedDevice, seq))) {
|
|
821
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
722
824
|
const streamWs = await this.openStreamWs(state);
|
|
723
825
|
if (seq !== state.bootSeq) {
|
|
724
826
|
streamWs.close();
|
|
@@ -838,8 +940,15 @@ export class IOSAgent {
|
|
|
838
940
|
}
|
|
839
941
|
async handleDeviceShutdown(sessionId, deviceId, requestId) {
|
|
840
942
|
const state = this.deviceStates.get(sessionId);
|
|
841
|
-
if (!state)
|
|
943
|
+
if (!state) {
|
|
944
|
+
// Answered only when someone asked: nothing was attempted, and the idle timer's id-less shutdown has no
|
|
945
|
+
// waiter to tell. A failure below is answered either way — `requestId` is optional on `device:shutdown`,
|
|
946
|
+
// so an id-less sender that is waiting can only learn of the failure this way (protocol/AGENTS.md,
|
|
947
|
+
// 「The sender must hold the session」).
|
|
948
|
+
if (requestId)
|
|
949
|
+
this.sendMsg({ type: 'device:shutdown-error', sessionId, requestId, message: SHUTDOWN_NO_SESSION_STATE });
|
|
842
950
|
return;
|
|
951
|
+
}
|
|
843
952
|
this.bumpBootSeq(state, 'shut-down');
|
|
844
953
|
// **Said here as well as in `cleanupDeviceState`, because this path does not go through it.**
|
|
845
954
|
// The teardown below is a copy of that method's, and the copy is older than the network rule —
|
|
@@ -851,7 +960,7 @@ export class IOSAgent {
|
|
|
851
960
|
void this.network.forget(deviceId).catch((e) => {
|
|
852
961
|
logger.warn('could not clear the network rule for a shut-down device:', e.message);
|
|
853
962
|
});
|
|
854
|
-
|
|
963
|
+
state.streamReader?.cancel().catch(() => { });
|
|
855
964
|
state.streamReader = null;
|
|
856
965
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
857
966
|
state.touchHelper?.stop();
|
|
@@ -883,6 +992,15 @@ export class IOSAgent {
|
|
|
883
992
|
catch (e) {
|
|
884
993
|
const message = e instanceof Error ? e.message : String(e);
|
|
885
994
|
logger.error('shutdown failed:', message);
|
|
995
|
+
// "Already shut down" never reaches here — `SimctlWrapper.shutdown` absorbs it — so this is a device
|
|
996
|
+
// that is still running, with its stream already torn down above. Said, because silence left the
|
|
997
|
+
// caller waiting out its deadline (#455).
|
|
998
|
+
this.sendMsg({
|
|
999
|
+
type: 'device:shutdown-error',
|
|
1000
|
+
sessionId,
|
|
1001
|
+
requestId,
|
|
1002
|
+
message: `The simulator did not shut down, and its stream has been stopped: ${message}`,
|
|
1003
|
+
});
|
|
886
1004
|
}
|
|
887
1005
|
}
|
|
888
1006
|
// Lazily set up the touch channel: a reconnect re-issues the session with touchHelper=null while the sim stays booted, so input self-heals without a fresh device:boot. Sync so a tap's start+end stay paired (async would drop touchEnd → stuck finger).
|
|
@@ -1005,6 +1123,17 @@ export class IOSAgent {
|
|
|
1005
1123
|
return false;
|
|
1006
1124
|
}
|
|
1007
1125
|
}
|
|
1126
|
+
/** Run `step` after every rotation already queued for this device. A failing step is logged and does
|
|
1127
|
+
* not stop the ones behind it. */
|
|
1128
|
+
enqueueRotation(deviceId, step) {
|
|
1129
|
+
const next = (this.rotations.get(deviceId) ?? Promise.resolve())
|
|
1130
|
+
.then(step)
|
|
1131
|
+
.catch((e) => { logger.error('rotate failed:', e); });
|
|
1132
|
+
this.rotations.set(deviceId, next);
|
|
1133
|
+
void next.then(() => { if (this.rotations.get(deviceId) === next)
|
|
1134
|
+
this.rotations.delete(deviceId); });
|
|
1135
|
+
return next;
|
|
1136
|
+
}
|
|
1008
1137
|
handleRelayMessage(msg) {
|
|
1009
1138
|
switch (msg.type) {
|
|
1010
1139
|
case 'device:boot': {
|
|
@@ -1080,8 +1209,9 @@ export class IOSAgent {
|
|
|
1080
1209
|
respond({ type: 'app:launch-done' });
|
|
1081
1210
|
})
|
|
1082
1211
|
.catch((e) => {
|
|
1083
|
-
|
|
1084
|
-
|
|
1212
|
+
// The toast gets the reason alone; the full simctl output stays in this log.
|
|
1213
|
+
console.warn('[tapflow] app:launch failed:', e);
|
|
1214
|
+
respond({ type: 'app:launch-error', message: launchFailureReason(e) });
|
|
1085
1215
|
});
|
|
1086
1216
|
break;
|
|
1087
1217
|
}
|
|
@@ -1152,9 +1282,19 @@ export class IOSAgent {
|
|
|
1152
1282
|
const state = this.deviceStates.get(msg.sessionId);
|
|
1153
1283
|
if (!state)
|
|
1154
1284
|
break;
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1285
|
+
// **A target when the viewer sends one, a toggle when it does not** (#910) — an older
|
|
1286
|
+
// dashboard's. The memory is read when the step runs, not when it was queued, and written only
|
|
1287
|
+
// once the simulator took it: a step queued behind another toggles from what that one applied.
|
|
1288
|
+
const sessionId = msg.sessionId;
|
|
1289
|
+
const target = msg.payload?.orientation;
|
|
1290
|
+
void this.enqueueRotation(state.deviceId, async () => {
|
|
1291
|
+
const live = this.deviceStates.get(sessionId) ?? state;
|
|
1292
|
+
const next = target
|
|
1293
|
+
? (target === 'landscape' ? 'landscapeRight' : 'portrait')
|
|
1294
|
+
: (live.orientation === 'portrait' ? 'landscapeRight' : 'portrait');
|
|
1295
|
+
await this.simctl.rotate(live.deviceId, next);
|
|
1296
|
+
live.orientation = next;
|
|
1297
|
+
});
|
|
1158
1298
|
break;
|
|
1159
1299
|
}
|
|
1160
1300
|
case 'stream:request-idr': {
|
|
@@ -1709,7 +1849,8 @@ export class IOSAgent {
|
|
|
1709
1849
|
return await this.installFrom(udid, source, tmpDir);
|
|
1710
1850
|
}
|
|
1711
1851
|
finally {
|
|
1712
|
-
|
|
1852
|
+
// Async for the same reason as the extraction: a large .app is a lot of files to unlink.
|
|
1853
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
1713
1854
|
}
|
|
1714
1855
|
}
|
|
1715
1856
|
/** Extraction + `simctl install`, given a file this machine can actually open. */
|
|
@@ -1724,11 +1865,12 @@ export class IOSAgent {
|
|
|
1724
1865
|
// `tmpDir`, and extracting beside it would put the two in one listing.
|
|
1725
1866
|
const outDir = path.join(tmpDir, 'x');
|
|
1726
1867
|
fs.mkdirSync(outDir, { recursive: true });
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1868
|
+
// **Not `spawnSync`.** It held the event loop for as long as the extraction ran, which on a large build
|
|
1869
|
+
// or a busy Mac was long enough for the relay's heartbeat to end this agent's connection — and every
|
|
1870
|
+
// other session on the Mac froze with it.
|
|
1729
1871
|
const result = isTar
|
|
1730
|
-
?
|
|
1731
|
-
:
|
|
1872
|
+
? await runExtractor('tar', ['-xzf', filePath, '-C', outDir])
|
|
1873
|
+
: await runExtractor('unzip', ['-q', '-o', filePath, '-d', outDir]);
|
|
1732
1874
|
// 실행 자체 실패(tar/unzip 부재=ENOENT 등)는 아카이브 무효와 구분한다.
|
|
1733
1875
|
if (result.error) {
|
|
1734
1876
|
const code = result.error.code ?? result.error.message;
|
|
@@ -1739,7 +1881,7 @@ export class IOSAgent {
|
|
|
1739
1881
|
// evidence away, so a build that was fine read as corrupt — `unzip` had said
|
|
1740
1882
|
// `cannot find or open`, which names a missing file rather than a bad archive, and nobody
|
|
1741
1883
|
// could see it. Trimmed because the whole thing reaches a browser toast.
|
|
1742
|
-
const detail =
|
|
1884
|
+
const detail = result.stderr.trim().split('\n')[0]?.slice(0, 300);
|
|
1743
1885
|
throw new ValidationError((isTar
|
|
1744
1886
|
? 'tar.gz 압축 해제 실패 — 시뮬레이터용 .tar.gz(경로 탈출/심볼릭 링크 없는)인지 확인하세요.'
|
|
1745
1887
|
: 'zip 압축 해제 실패 — 시뮬레이터용 .app.zip 파일인지 확인하세요.')
|
|
@@ -2087,4 +2229,34 @@ export class IOSAgent {
|
|
|
2087
2229
|
return this.simctl.openUrl(await this.soleLiveDeviceId(), url);
|
|
2088
2230
|
}
|
|
2089
2231
|
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Runs an archive tool without blocking, in the shape `spawnSync` returned: a spawn failure (the tool is
|
|
2234
|
+
* missing) in `error`, otherwise the exit `status` and what it wrote to stderr. stdout is discarded — the
|
|
2235
|
+
* tools run quiet, and nothing reads it.
|
|
2236
|
+
*/
|
|
2237
|
+
function runExtractor(cmd, args) {
|
|
2238
|
+
return new Promise((resolve) => {
|
|
2239
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
2240
|
+
let stderr = '';
|
|
2241
|
+
// Decoded by the stream, not per chunk: a chunk boundary inside a multibyte character (a Korean file
|
|
2242
|
+
// name in unzip's message) would otherwise turn into replacement characters.
|
|
2243
|
+
child.stderr?.setEncoding('utf8');
|
|
2244
|
+
child.stderr?.on('data', (chunk) => { if (stderr.length < 64 * 1024)
|
|
2245
|
+
stderr += chunk; });
|
|
2246
|
+
child.once('error', (error) => resolve({ error, status: null, stderr }));
|
|
2247
|
+
child.once('close', (status) => resolve({ status, stderr }));
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* When to restart a screen stream that just ended. Only a run that delivered a frame resets the count — not
|
|
2252
|
+
* how long it lasted: the helper retries `device.io` and its ports for up to ~10s before it gives up, so a
|
|
2253
|
+
* duration rule would call every first-boot failure healthy. A simulator shut down from outside makes the
|
|
2254
|
+
* helper exit at once, which without this was a restart loop at full speed.
|
|
2255
|
+
*/
|
|
2256
|
+
export function streamRestartPlan(previousFailures, deliveredFrame) {
|
|
2257
|
+
if (deliveredFrame)
|
|
2258
|
+
return { failures: 0, delayMs: 0 };
|
|
2259
|
+
const failures = previousFailures + 1;
|
|
2260
|
+
return { failures, delayMs: failures <= 1 ? 0 : Math.min(250 * 2 ** (failures - 2), 5000) };
|
|
2261
|
+
}
|
|
2090
2262
|
//# sourceMappingURL=IOSAgent.js.map
|