@tapflowio/ios-agent 0.18.0 → 0.20.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/README.md +1 -1
- package/bin/TapflowNetFilter.app/Contents/CodeResources +0 -0
- package/bin/TapflowNetFilter.app/Contents/Info.plist +48 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/Info.plist +58 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/MacOS/dev.tapflow.netfilter.ext +0 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/_CodeSignature/CodeResources +123 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/embedded.provisionprofile +0 -0
- package/bin/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter +0 -0
- package/bin/TapflowNetFilter.app/Contents/PkgInfo +1 -0
- package/bin/TapflowNetFilter.app/Contents/_CodeSignature/CodeResources +151 -0
- package/bin/TapflowNetFilter.app/Contents/embedded.provisionprofile +0 -0
- package/bin/libtapflow-nethook.dylib +0 -0
- package/dist/DeviceChromeLoader.d.ts +2 -45
- package/dist/DeviceChromeLoader.d.ts.map +1 -1
- package/dist/DeviceChromeLoader.js.map +1 -1
- package/dist/IOSAgent.d.ts +196 -3
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +782 -128
- package/dist/IOSAgent.js.map +1 -1
- package/dist/KeyboardHelperDaemon.d.ts.map +1 -1
- package/dist/KeyboardHelperDaemon.js +2 -1
- package/dist/KeyboardHelperDaemon.js.map +1 -1
- package/dist/MjpegStreamer.d.ts.map +1 -1
- package/dist/MjpegStreamer.js +9 -1
- package/dist/MjpegStreamer.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +70 -1
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +118 -3
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/SimulatorNetwork.d.ts +369 -0
- package/dist/SimulatorNetwork.d.ts.map +1 -0
- package/dist/SimulatorNetwork.js +899 -0
- package/dist/SimulatorNetwork.js.map +1 -0
- package/dist/TouchHelper.d.ts +53 -14
- package/dist/TouchHelper.d.ts.map +1 -1
- package/dist/TouchHelper.js +237 -59
- package/dist/TouchHelper.js.map +1 -1
- package/dist/childProcessKill.d.ts +3 -0
- package/dist/childProcessKill.d.ts.map +1 -0
- package/dist/childProcessKill.js +19 -0
- package/dist/childProcessKill.js.map +1 -0
- package/package.json +13 -10
package/dist/IOSAgent.js
CHANGED
|
@@ -5,10 +5,32 @@ import { tmpdir } from 'os';
|
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { spawnSync } from 'child_process';
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
|
-
import { createLogger, PlatformError, ValidationError } from '@tapflowio/agent-core';
|
|
8
|
+
import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE } 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
|
|
12
|
+
// `simctl erase` (which refuses anything but a shut-down device) and boots it again.
|
|
13
|
+
// `network-control` claims what the other two claim: this agent has the code. Whether the injection
|
|
14
|
+
// actually took is per device and per app, and `network:state.available` carries that — the split the
|
|
15
|
+
// protocol documents. Added last, after the handler and the boot-time arming, because the string on
|
|
16
|
+
// its own is what puts a control on screen.
|
|
17
|
+
const AGENT_CAPABILITIES = ['clipboard', 'full-reset', 'network-control'];
|
|
18
|
+
// Human prose for each reason. Not in `@tapflowio/protocol`: that package's main entry must stay
|
|
19
|
+
// runtime-free so it erases under `import type` and never reaches the dashboard bundle — a lookup
|
|
20
|
+
// table is a runtime value. The `reason` on the wire is the machine-readable half; this is only what
|
|
21
|
+
// a human reads, and each agent owns its own wording.
|
|
22
|
+
const INPUT_ERROR_MESSAGES = {
|
|
23
|
+
'not-booted': 'device not booted',
|
|
24
|
+
'channel-unavailable': 'input channel not ready',
|
|
25
|
+
'channel-starting': 'the input channel is still starting — retry in a moment',
|
|
26
|
+
'dispatch-failed': 'the device rejected the input',
|
|
27
|
+
unsupported: 'this input is not supported on the active connection to the device',
|
|
28
|
+
malformed: 'this input does not fit what the device is doing',
|
|
29
|
+
'no-gesture': 'no gesture is in progress to complete — start a new one',
|
|
30
|
+
// The relay is the only producer of this one — an agent has no way to know which socket asked. Present
|
|
31
|
+
// because the map is exhaustive, which is what makes a new reason a decision rather than an omission.
|
|
32
|
+
'not-session-owner': 'this input was addressed to a session the sender does not hold',
|
|
33
|
+
};
|
|
12
34
|
// Cross-platform button name → iOS device-chrome button name. Chrome uses
|
|
13
35
|
// hyphens and "power" (not "lock"); MCP's vocabulary uses underscores. Names
|
|
14
36
|
// not listed here (incl. the raw chrome names the dashboard sends) pass through.
|
|
@@ -18,7 +40,8 @@ export const IOS_BUTTON_ALIASES = {
|
|
|
18
40
|
volume_down: 'volume-down',
|
|
19
41
|
};
|
|
20
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';
|
|
21
|
-
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError } from './SimctlWrapper.js';
|
|
43
|
+
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine } from './SimctlWrapper.js';
|
|
44
|
+
import { SimulatorNetwork } from './SimulatorNetwork.js';
|
|
22
45
|
import { MAX_CLIPBOARD_BYTES, clipboardByteLength, CLIPBOARD_SENTINEL_PREFIX as SENTINEL_PREFIX, isClipboardSentinel as isSentinel, CLIPBOARD_COPY_DEADLINE_MS, CLIPBOARD_WRITE_DEADLINE_MS, CLIPBOARD_RESTORE_DEADLINE_MS, CLIPBOARD_POLL_MS, createKeyedSerialQueue, } from '@tapflowio/agent-core';
|
|
23
46
|
import { ScreenCaptureStreamer } from './ScreenCaptureStreamer.js';
|
|
24
47
|
import { AudioCaptureStreamer, readSimVolume, applyGain } from './AudioCaptureStreamer.js';
|
|
@@ -37,6 +60,7 @@ const AUDIO_POLL_MS = 1500;
|
|
|
37
60
|
const EXTRACT_MAXBUFFER = 256 * 1024 * 1024; // 256 MB
|
|
38
61
|
export class IOSAgent {
|
|
39
62
|
simctl;
|
|
63
|
+
network;
|
|
40
64
|
fps;
|
|
41
65
|
intervalMs;
|
|
42
66
|
reconnectDelays;
|
|
@@ -45,7 +69,55 @@ export class IOSAgent {
|
|
|
45
69
|
token;
|
|
46
70
|
handshakeTimeoutMs;
|
|
47
71
|
ws = null;
|
|
72
|
+
/** Send on the control socket, if there is one. The `?.` is the point: 66 call sites relied on a send
|
|
73
|
+
* being a no-op between reconnects, and this preserves that exactly.
|
|
74
|
+
*
|
|
75
|
+
* Typed with `AgentControlOutbound`, which is why this exists — an agent's literal used to reach `ws.send`
|
|
76
|
+
* with nothing checking it, and #489/#490 are what that cost. *
|
|
77
|
+
* **The guard is not defensive tidying.** `ws.send` on anything other than OPEN takes the `sendAfterClose`
|
|
78
|
+
* path, which adds the payload to a buffer nobody will flush and neither throws nor emits — so a reply
|
|
79
|
+
* sent to a closing socket is indistinguishable from a delivered one at the call site. `reportResources`
|
|
80
|
+
* has always checked; this did not, and the boot answers this file now owes a caller are exactly the
|
|
81
|
+
* messages whose purpose is to end someone's wait.
|
|
82
|
+
*
|
|
83
|
+
* The body is held character for character by `scripts/__tests__/agentSendTyped.test.mjs`, so the comment
|
|
84
|
+
* lives out here: that check strips comments and matches the body exactly, having watched three earlier
|
|
85
|
+
* drafts get bypassed by a renamed socket. */
|
|
86
|
+
sendMsg(msg) {
|
|
87
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
88
|
+
return;
|
|
89
|
+
this.ws.send(JSON.stringify(msg));
|
|
90
|
+
}
|
|
91
|
+
/** Send on a socket the caller has already established. Takes the socket **as an argument** rather than
|
|
92
|
+
* reading `this.ws`, and that is deliberate: the call sites that use it sit behind an entry guard
|
|
93
|
+
* (`if (!this.ws) return`), and today deleting one of those guards is a compile error. Reading
|
|
94
|
+
* `this.ws` here — or asserting it with `!` — would make the guard optional to the compiler and turn
|
|
95
|
+
* its removal into a runtime `TypeError` instead. It also serves the `agent:register` send, which runs
|
|
96
|
+
* inside `onopen` on a local socket. */
|
|
97
|
+
sendOn(ws, msg) {
|
|
98
|
+
ws.send(JSON.stringify(msg));
|
|
99
|
+
}
|
|
48
100
|
deviceStates = new Map();
|
|
101
|
+
/**
|
|
102
|
+
* Devices **this agent booted**, by device id.
|
|
103
|
+
*
|
|
104
|
+
* Separate from `DeviceState.booted` because the two answer different questions and have different
|
|
105
|
+
* lifetimes. `booted` is liveness and is a cache: `initDeviceStates` rebuilds the whole map on
|
|
106
|
+
* every `agent:registered`, so it does not survive a reconnect. This does — the agent process is
|
|
107
|
+
* the same one across a reconnect — and it is what says *which* device is tapflow's when a
|
|
108
|
+
* developer has a second simulator of their own open.
|
|
109
|
+
*
|
|
110
|
+
* Without it, a reconnect on a two-simulator desk left the capability entry points asking simctl,
|
|
111
|
+
* getting two live devices, and refusing both. Written only where a boot succeeds; never by
|
|
112
|
+
* `ackInput`, which verifies liveness and proves nothing about ownership.
|
|
113
|
+
*
|
|
114
|
+
* **The `DeviceAgent.boot`/`shutdown` delegates do not touch this**, and that is deliberate: they
|
|
115
|
+
* hand a device id straight to simctl without opening a session, so a device they start is not one
|
|
116
|
+
* this agent is driving. Nothing in the repo calls either — the same position `stream()` is in — so
|
|
117
|
+
* wiring ownership through them would be a mechanism built for a caller that does not exist. A
|
|
118
|
+
* future caller that wants both should go through the boot handler rather than around it.
|
|
119
|
+
*/
|
|
120
|
+
ownedDevices = new Set();
|
|
49
121
|
// Last app launched per device (deviceId → bundleId). The XCUITest tree backend
|
|
50
122
|
// queries by bundleId; kept outside DeviceState so it survives a relay reconnect
|
|
51
123
|
// (which clears deviceStates) while the app keeps running in the simulator.
|
|
@@ -68,6 +140,22 @@ export class IOSAgent {
|
|
|
68
140
|
throw new PlatformError('IOSAgent requires macOS (xcrun simctl is macOS-only)');
|
|
69
141
|
}
|
|
70
142
|
this.simctl = simctl ?? new SimctlWrapper();
|
|
143
|
+
// **Never the real filter under vitest.** Layer 1 is a system extension installed on the
|
|
144
|
+
// developer's Mac, and `arm()` runs on every boot — so a suite that booted a mock device was
|
|
145
|
+
// launching the notarized container app and rewriting the host's live filter configuration, once
|
|
146
|
+
// per boot test, on any machine where tapflow's own filter is installed. Nothing in the suite
|
|
147
|
+
// said so, because the class reports a missing container app rather than failing.
|
|
148
|
+
//
|
|
149
|
+
// Pointed at a path that does not exist rather than stubbed out: that is the same state a machine
|
|
150
|
+
// without the app is in, so the tests exercise the real class along its real reporting path
|
|
151
|
+
// (`filter-unavailable`) instead of a double that could drift from it. Same shape, and the same
|
|
152
|
+
// reason, as `sleepBlocker` below.
|
|
153
|
+
this.network = options.network ?? new SimulatorNetwork(this.simctl, process.env.VITEST ? { filterHostBinary: path.join(tmpdir(), 'tapflow-no-filter-host') } : {});
|
|
154
|
+
// **Wired here rather than in the constructor call above, and the difference is coverage.** An
|
|
155
|
+
// injected `SimulatorNetwork` — which is what every test uses — would otherwise never carry the
|
|
156
|
+
// handler, so the only path that tells a tester enforcement was lost was reachable in production
|
|
157
|
+
// and in nothing else.
|
|
158
|
+
this.network.setEnforcementLostHandler((deviceId) => { this.reportEnforcementLost(deviceId); });
|
|
71
159
|
this.fps = options.fps ?? 30;
|
|
72
160
|
this.intervalMs = options.intervalMs;
|
|
73
161
|
this.reconnectDelays = options.reconnectDelays ?? [1000, 2000, 4000, 8000, 16000, 30000];
|
|
@@ -84,6 +172,9 @@ export class IOSAgent {
|
|
|
84
172
|
}
|
|
85
173
|
async connect(relayUrl) {
|
|
86
174
|
this._stopping = false;
|
|
175
|
+
// Paired with the `dispose()` in `disconnect()`. This method is public and reuses the network, so
|
|
176
|
+
// without it a reconnect leaves the liveness watcher off for good — see `SimulatorNetwork.resume`.
|
|
177
|
+
this.network.resume();
|
|
87
178
|
if (this._reconnectTimer) {
|
|
88
179
|
clearTimeout(this._reconnectTimer);
|
|
89
180
|
this._reconnectTimer = null;
|
|
@@ -103,7 +194,7 @@ export class IOSAgent {
|
|
|
103
194
|
}, this.handshakeTimeoutMs);
|
|
104
195
|
ws.once('open', () => {
|
|
105
196
|
disableNagle(ws);
|
|
106
|
-
|
|
197
|
+
this.sendOn(ws, {
|
|
107
198
|
type: 'agent:register',
|
|
108
199
|
platform: 'ios',
|
|
109
200
|
// Lets a viewer tell a clipboard-capable agent from one that predates the
|
|
@@ -118,7 +209,7 @@ export class IOSAgent {
|
|
|
118
209
|
status: d.status,
|
|
119
210
|
osVersion: d.osVersion,
|
|
120
211
|
})),
|
|
121
|
-
})
|
|
212
|
+
});
|
|
122
213
|
});
|
|
123
214
|
ws.once('message', (data) => {
|
|
124
215
|
let msg;
|
|
@@ -140,7 +231,14 @@ export class IOSAgent {
|
|
|
140
231
|
this.initDeviceStates(msg.registeredSessions);
|
|
141
232
|
ws.on('message', (d) => {
|
|
142
233
|
try {
|
|
143
|
-
|
|
234
|
+
const m = JSON.parse(d.toString());
|
|
235
|
+
// Every type `handleRelayMessage` dispatches is session-scoped, and the relay resolves the
|
|
236
|
+
// session before forwarding — so a message without one did not come from that path. Rejecting
|
|
237
|
+
// it here is what lets the dispatcher declare `sessionId: string` instead of threading an
|
|
238
|
+
// optional through 30 sends and asserting it with `!` at each one.
|
|
239
|
+
if (typeof m.type !== 'string' || typeof m.sessionId !== 'string')
|
|
240
|
+
return;
|
|
241
|
+
this.handleRelayMessage(m);
|
|
144
242
|
}
|
|
145
243
|
catch { /* ignore malformed */ }
|
|
146
244
|
});
|
|
@@ -183,6 +281,8 @@ export class IOSAgent {
|
|
|
183
281
|
streamReader: null,
|
|
184
282
|
captureStreamer: null,
|
|
185
283
|
bootSeq: 0,
|
|
284
|
+
bootAbandon: new Map(),
|
|
285
|
+
bootsInFlight: new Set(),
|
|
186
286
|
orientation: 'portrait',
|
|
187
287
|
loadedChrome: null,
|
|
188
288
|
softKeyboardVisible: false,
|
|
@@ -208,10 +308,14 @@ export class IOSAgent {
|
|
|
208
308
|
this.resourcesTimer = null;
|
|
209
309
|
}
|
|
210
310
|
for (const state of this.deviceStates.values()) {
|
|
311
|
+
this.bumpBootSeq(state, 'relay-lost');
|
|
211
312
|
this.cleanupDeviceState(state);
|
|
212
313
|
}
|
|
213
314
|
this.deviceStates.clear();
|
|
214
315
|
this.uiTreeReader.stop();
|
|
316
|
+
// The liveness watcher outlives every device it was started for, so it is stopped here with the
|
|
317
|
+
// other things that tick. It is `unref`'d, which is why nobody noticed it had no caller.
|
|
318
|
+
this.network.dispose();
|
|
215
319
|
this.sleepBlocker.release();
|
|
216
320
|
this.ws?.close();
|
|
217
321
|
this.ws = null;
|
|
@@ -226,6 +330,13 @@ export class IOSAgent {
|
|
|
226
330
|
this.resourcesTimer = null;
|
|
227
331
|
}
|
|
228
332
|
for (const state of this.deviceStates.values()) {
|
|
333
|
+
// Invalidate any boot still in flight against this state. The map is dropped just below, but a
|
|
334
|
+
// `handleDeviceBoot` awaiting simctl holds its own reference and its seq would still match, so it
|
|
335
|
+
// would go on to build a TouchHelper on a state nobody owns. That used to leak one child process;
|
|
336
|
+
// a self-reviving helper would respawn for the life of the agent with no reference left to stop it.
|
|
337
|
+
// Bumped here rather than inside `cleanupDeviceState` so that all three retirements name a reason —
|
|
338
|
+
// and so Android, whose boot path calls its own cleanup mid-boot, can hold the same shape.
|
|
339
|
+
this.bumpBootSeq(state, 'relay-lost');
|
|
229
340
|
this.cleanupDeviceState(state);
|
|
230
341
|
}
|
|
231
342
|
this.deviceStates.clear();
|
|
@@ -254,7 +365,7 @@ export class IOSAgent {
|
|
|
254
365
|
const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.streamReader !== null).length;
|
|
255
366
|
const slotsTotal = this.deviceStates.size;
|
|
256
367
|
const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
|
|
257
|
-
this.
|
|
368
|
+
this.sendOn(this.ws, {
|
|
258
369
|
type: 'agent:resources',
|
|
259
370
|
resources: {
|
|
260
371
|
cpuPercent: this.resources.getCpuPercent(),
|
|
@@ -264,9 +375,16 @@ export class IOSAgent {
|
|
|
264
375
|
slotsTotal,
|
|
265
376
|
reportedAt: Date.now(),
|
|
266
377
|
},
|
|
267
|
-
})
|
|
378
|
+
});
|
|
268
379
|
}
|
|
269
380
|
cleanupDeviceState(state) {
|
|
381
|
+
// Drop this device out of the filter rule. The rule lives on the host and is keyed only by udid,
|
|
382
|
+
// so a simulator that goes away while offline would otherwise keep its udid named there for the
|
|
383
|
+
// rest of the Mac's uptime — and the next boot of that same device would come up offline with
|
|
384
|
+
// nothing on screen saying why.
|
|
385
|
+
void this.network.forget(state.deviceId).catch((e) => {
|
|
386
|
+
logger.warn('could not clear the network rule for a retired device:', e.message);
|
|
387
|
+
});
|
|
270
388
|
void state.streamReader?.cancel();
|
|
271
389
|
state.streamReader = null;
|
|
272
390
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
@@ -285,31 +403,39 @@ export class IOSAgent {
|
|
|
285
403
|
state.streamWs = null;
|
|
286
404
|
}
|
|
287
405
|
sendChromeData(state, device) {
|
|
288
|
-
|
|
406
|
+
// `readyState`, not presence: this runs mid-boot, and a socket that closed since the entry guard
|
|
407
|
+
// takes the payload into a buffer nobody flushes while `device:ready` is dropped by `sendMsg`'s own
|
|
408
|
+
// check — leaving the caller with neither the data nor an answer.
|
|
409
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
289
410
|
return;
|
|
411
|
+
// Stop the outgoing helper before dropping the reference. This used to leak a child process;
|
|
412
|
+
// now that a helper revives itself on death, an orphan would also keep respawning with
|
|
413
|
+
// nobody left holding a reference to stop it.
|
|
414
|
+
state.touchHelper?.stop();
|
|
290
415
|
state.touchHelper = new TouchHelper(device.id);
|
|
291
416
|
state.touchHelper.start();
|
|
292
|
-
this.
|
|
417
|
+
this.sendOn(this.ws, {
|
|
293
418
|
type: 'session:deviceInfo',
|
|
294
419
|
sessionId: state.sessionId,
|
|
295
420
|
payload: {
|
|
296
421
|
deviceName: device.name,
|
|
297
422
|
osVersion: device.osVersion ?? '',
|
|
298
423
|
},
|
|
299
|
-
})
|
|
424
|
+
});
|
|
300
425
|
state.loadedChrome = this.chromeLoader.load(device.typeId ?? device.name);
|
|
301
426
|
if (!state.loadedChrome)
|
|
302
427
|
return;
|
|
303
|
-
this.
|
|
428
|
+
this.sendOn(this.ws, {
|
|
304
429
|
type: 'session:chrome',
|
|
305
430
|
sessionId: state.sessionId,
|
|
306
431
|
payload: state.loadedChrome,
|
|
307
|
-
})
|
|
432
|
+
});
|
|
308
433
|
}
|
|
309
434
|
startBinaryStream(state, streamWs) {
|
|
310
435
|
// H.264 is the default; opt out per-agent with TAPFLOW_IOS_CODEC=jpeg. It also needs
|
|
311
436
|
// a browser that reported it can decode it (device:boot acceptH264) — otherwise JPEG.
|
|
312
|
-
// Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG
|
|
437
|
+
// Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG, and now
|
|
438
|
+
// genuinely produces it rather than only being stamped that way.
|
|
313
439
|
const envAllowsH264 = process.env.TAPFLOW_IOS_CODEC !== 'jpeg';
|
|
314
440
|
const useH264 = this.intervalMs === undefined && envAllowsH264 && state.acceptH264;
|
|
315
441
|
const codec = useH264 ? CODEC_H264 : CODEC_JPEG;
|
|
@@ -398,29 +524,86 @@ export class IOSAgent {
|
|
|
398
524
|
await registerStreamWs(streamWs, state.sessionId);
|
|
399
525
|
return streamWs;
|
|
400
526
|
}
|
|
401
|
-
|
|
527
|
+
/** Retire the boot that holds the current seq, recording what retired it, and return the new seq.
|
|
528
|
+
*
|
|
529
|
+
* **Three callers, and the third is why this is a method.** A new boot supersedes the one in flight, a
|
|
530
|
+
* shutdown abandons it, and losing the relay invalidates it — that last one lives in the reconnect path
|
|
531
|
+
* (`cleanupDeviceState`'s callers) and is easy to miss when reading `handleDeviceBoot` alone. Spreading
|
|
532
|
+
* the reason across three `state.bootSeq++` lines makes forgetting one silent; here it is a parameter. */
|
|
533
|
+
bumpBootSeq(state, reason) {
|
|
534
|
+
if (state.bootsInFlight.has(state.bootSeq))
|
|
535
|
+
state.bootAbandon.set(state.bootSeq, reason);
|
|
536
|
+
return ++state.bootSeq;
|
|
537
|
+
}
|
|
538
|
+
/** Answer a boot this agent has stopped running. Called at every point that abandons one.
|
|
539
|
+
*
|
|
540
|
+
* **Only when a correlator exists**, which is the same rule the input path settled on (#489): a reply
|
|
541
|
+
* nobody is waiting for is not an answer, and the dashboard reports every *uncorrelated*
|
|
542
|
+
* `device:boot-error` as a failure — deliberately, since Android's dead-stream report has no id it could
|
|
543
|
+
* carry (#426). So sending one for a boot with no correlator would put an error toast on a tester's
|
|
544
|
+
* screen for a device that is booting normally. */
|
|
545
|
+
abandonBoot(state, seq, sessionId, requestId) {
|
|
546
|
+
const reason = state.bootAbandon.get(seq) ?? 'superseded';
|
|
547
|
+
state.bootAbandon.delete(seq);
|
|
548
|
+
// **A state the agent has stopped holding cannot be answered, and trying is worse than silence.**
|
|
549
|
+
// `relay-lost` retires a boot by dropping the whole map, so by the time the parked `await` resumes this
|
|
550
|
+
// `state` is an object nobody is registered against — and whether the socket is back yet decides
|
|
551
|
+
// between dropping the reply and sending it to a *new* relay naming a session id it has never heard
|
|
552
|
+
// of. Neither is an answer, and which one happens is a race between a poll interval and a backoff.
|
|
553
|
+
if (this.deviceStates.get(sessionId) !== state) {
|
|
554
|
+
logger.warn(`boot for ${sessionId} abandoned (${reason}) after the session was dropped — nothing to answer`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (!requestId) {
|
|
558
|
+
logger.warn(`boot for ${sessionId} abandoned (${reason}) with no requestId — nothing to answer`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: bootAbandonMessage(reason) });
|
|
562
|
+
}
|
|
563
|
+
// `requestId` is a parameter, never a field on `state`: `bootSeq` exists because two boots overlap,
|
|
564
|
+
// and a correlator hoisted onto shared state would answer the first request with the second's id.
|
|
565
|
+
// Optional because the relay's idle timer boots nothing — but every *browser* boot carries one.
|
|
566
|
+
async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier, requestId) {
|
|
402
567
|
const state = this.deviceStates.get(sessionId);
|
|
403
|
-
|
|
568
|
+
// Split, because the two halves are not the same kind of nothing. No open control channel means the
|
|
569
|
+
// answer itself has nowhere to go, so this is the one abandonment that stays silent — and the caller
|
|
570
|
+
// learns from the relay instead, which declares the agent away and terminates the session. A missing
|
|
571
|
+
// `state` is answerable and now answered (#489's reasoning, on the boot path).
|
|
572
|
+
const ws = this.ws;
|
|
573
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
574
|
+
return;
|
|
575
|
+
if (!state) {
|
|
576
|
+
if (requestId)
|
|
577
|
+
this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: BOOT_NO_SESSION_STATE });
|
|
404
578
|
return;
|
|
579
|
+
}
|
|
405
580
|
state.acceptH264 = acceptH264;
|
|
406
581
|
if (tier) {
|
|
407
582
|
state.secureContext = tier.secureContext;
|
|
408
583
|
state.external = tier.external;
|
|
409
584
|
}
|
|
410
|
-
const seq =
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
state.streamWs?.close();
|
|
418
|
-
state.streamWs = null;
|
|
419
|
-
this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
|
|
585
|
+
const seq = this.bumpBootSeq(state, 'superseded');
|
|
586
|
+
state.bootsInFlight.add(seq);
|
|
587
|
+
// **The teardown runs inside the try, and that is a fix rather than tidying.** It ran before it, so a
|
|
588
|
+
// throw there rejected this handler into the bare `.catch(logger.error)` at its dispatch site: no
|
|
589
|
+
// `device:booting`, no ready, no error — and the seq was already bumped, so the boot this one
|
|
590
|
+
// superseded was gone as well. The caller then waited out the very deadline this change exists to
|
|
591
|
+
// end. `finally` is what stops `bootAbandon` outliving the boot that would have read it.
|
|
420
592
|
try {
|
|
593
|
+
void state.streamReader?.cancel();
|
|
594
|
+
state.streamReader = null;
|
|
595
|
+
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
596
|
+
state.touchHelper?.stop();
|
|
597
|
+
state.touchHelper = null;
|
|
598
|
+
state.booted = false;
|
|
599
|
+
state.streamWs?.close();
|
|
600
|
+
state.streamWs = null;
|
|
601
|
+
this.sendOn(ws, { type: 'device:booting', sessionId });
|
|
421
602
|
const devices = await this.simctl.listDevices();
|
|
422
|
-
if (seq !== state.bootSeq)
|
|
603
|
+
if (seq !== state.bootSeq) {
|
|
604
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
423
605
|
return;
|
|
606
|
+
}
|
|
424
607
|
const target = devices.find((d) => d.id === deviceId);
|
|
425
608
|
if (!target)
|
|
426
609
|
throw new PlatformError(`Device not found: ${deviceId}`);
|
|
@@ -443,8 +626,10 @@ export class IOSAgent {
|
|
|
443
626
|
// The shutdown is an await, so a newer boot for this device may have overtaken us while it
|
|
444
627
|
// ran. Without this check the superseded boot goes on to erase a device the tester has
|
|
445
628
|
// since re-picked with the toggle off — the exact wipe-with-no-click this issue is about.
|
|
446
|
-
if (seq !== state.bootSeq)
|
|
629
|
+
if (seq !== state.bootSeq) {
|
|
630
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
447
631
|
return;
|
|
632
|
+
}
|
|
448
633
|
try {
|
|
449
634
|
await this.simctl.erase(deviceId);
|
|
450
635
|
}
|
|
@@ -469,20 +654,51 @@ export class IOSAgent {
|
|
|
469
654
|
}
|
|
470
655
|
await this.simctl.boot(deviceId);
|
|
471
656
|
}
|
|
472
|
-
else
|
|
657
|
+
else {
|
|
658
|
+
// Unconditionally, including when the list above said `booted`. That reading is already
|
|
659
|
+
// stale by the time we act on it — a tester can ⌘Q the simulator, or run `simctl shutdown`,
|
|
660
|
+
// in the width of one `xcrun` round trip — and skipping the boot on it is the only way to
|
|
661
|
+
// reach the wait below with *nothing bringing the device up*, which then costs the full
|
|
662
|
+
// deadline to discover. `SimctlWrapper.boot` swallows `Unable to boot device in current
|
|
663
|
+
// state: Booted`, so re-issuing it for a device that really is up costs one no-op
|
|
664
|
+
// subprocess and makes "the wait only ever runs after a boot was accepted" true.
|
|
473
665
|
await this.bootWithZombieRecovery(deviceId);
|
|
474
666
|
}
|
|
475
|
-
if (seq !== state.bootSeq)
|
|
667
|
+
if (seq !== state.bootSeq) {
|
|
668
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
476
669
|
return;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
670
|
+
}
|
|
671
|
+
// `simctl boot` returns on initiation, so everything below used to run while the device was
|
|
672
|
+
// still coming up — measured 7.6s early (#486). The status sent with the chrome data was
|
|
673
|
+
// hardcoded `'booted'` over a value that had just been fetched and discarded, and
|
|
674
|
+
// `device:ready` went out a few lines later. A human is slower than the gap and rarely
|
|
675
|
+
// notices; `mcp-server` installs and taps the moment it sees ready, which is what #440's
|
|
676
|
+
// "No devices are booted" was.
|
|
677
|
+
// `isStale` rather than a check on the far side alone: this handler is fire-and-forget, so a
|
|
678
|
+
// shutdown arriving mid-wait bumps `bootSeq` and leaves the poll spawning `xcrun simctl list`
|
|
679
|
+
// twice a second against a device that is now deliberately off — for the rest of the deadline,
|
|
680
|
+
// with nothing left that wants the answer.
|
|
681
|
+
const bootedDevice = await this.simctl.waitUntilBooted(deviceId, { isStale: () => seq !== state.bootSeq });
|
|
682
|
+
// Arm the network injection while nothing is running on the device yet — dyld reads the
|
|
683
|
+
// environment at process start, so anything launched before this is unhooked for its whole life.
|
|
684
|
+
// Best-effort: the network toggle is opt-in and a device that cannot be armed says so through
|
|
685
|
+
// `network:state.available`, which is a better answer than a boot that fails for it.
|
|
686
|
+
await this.network.arm(deviceId).catch((e) => {
|
|
687
|
+
logger.warn('could not arm the network injection:', e.message);
|
|
482
688
|
});
|
|
689
|
+
// Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
|
|
690
|
+
// shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
|
|
691
|
+
// device it is taking down — and one that revives itself, so the stale reference outlives the
|
|
692
|
+
// boot that returns just below.
|
|
693
|
+
if (seq !== state.bootSeq) {
|
|
694
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
this.sendChromeData(state, bootedDevice);
|
|
483
698
|
const streamWs = await this.openStreamWs(state);
|
|
484
699
|
if (seq !== state.bootSeq) {
|
|
485
700
|
streamWs.close();
|
|
701
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
486
702
|
return;
|
|
487
703
|
}
|
|
488
704
|
this.startBinaryStream(state, streamWs);
|
|
@@ -491,7 +707,16 @@ export class IOSAgent {
|
|
|
491
707
|
if (this.audioEnabled())
|
|
492
708
|
this.startAudioCapture(state, streamWs, deviceId);
|
|
493
709
|
state.booted = true;
|
|
494
|
-
|
|
710
|
+
// **"Driving", not "transitioned from off to on".** A tester who picks a simulator that was
|
|
711
|
+
// already running is choosing the device tapflow should act on, and `simctl boot` is issued on
|
|
712
|
+
// every path precisely so that case reaches here. Requiring an off→on transition would leave
|
|
713
|
+
// that device unowned and put the resolvers back to refusing between two live simulators.
|
|
714
|
+
this.ownedDevices.add(deviceId);
|
|
715
|
+
this.sendMsg({ type: 'device:ready', sessionId, requestId, payload: { deviceId } });
|
|
716
|
+
// The unsolicited report the protocol names on `device:ready`. It follows the ready rather than
|
|
717
|
+
// riding inside it: a tester whose device just came up can arm the toggle in the same breath, and
|
|
718
|
+
// a boot that armed nothing still has to say so rather than leaving the control blank.
|
|
719
|
+
void this.reportNetworkState(sessionId);
|
|
495
720
|
// Sync AppleKeyboards after ready — fire-and-forget so streaming isn't delayed.
|
|
496
721
|
// hw=Automatic lets the hardware layout follow the active input source on LANG1/CapsLock.
|
|
497
722
|
// By the time the user navigates to a text field the sync has already completed.
|
|
@@ -500,10 +725,22 @@ export class IOSAgent {
|
|
|
500
725
|
});
|
|
501
726
|
}
|
|
502
727
|
catch (e) {
|
|
503
|
-
if (seq !== state.bootSeq)
|
|
728
|
+
if (seq !== state.bootSeq) {
|
|
729
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
504
730
|
return;
|
|
505
|
-
|
|
506
|
-
|
|
731
|
+
}
|
|
732
|
+
// `firstLine`, not `e.message`: this reaches a toast, and node's first line is
|
|
733
|
+
// `Command failed: xcrun simctl boot <UDID>`, which says nothing and echoes the udid. That
|
|
734
|
+
// matters more now that the boot is issued unconditionally — a device caught mid `Shutting
|
|
735
|
+
// Down` refuses the boot, and refusing is correct (swallowing it would put us back to waiting
|
|
736
|
+
// on a device nobody is bringing up), so the refusal is what the tester reads.
|
|
737
|
+
this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: firstLine(e) });
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
state.bootsInFlight.delete(seq);
|
|
741
|
+
// Its own key, in the one case nothing reads it: a newer boot arriving after this boot's last
|
|
742
|
+
// checkpoint but before it returns.
|
|
743
|
+
state.bootAbandon.delete(seq);
|
|
507
744
|
}
|
|
508
745
|
}
|
|
509
746
|
// Boot a device, auto-recovering from a vanished data dir. simctl lists the device
|
|
@@ -521,11 +758,21 @@ export class IOSAgent {
|
|
|
521
758
|
await this.simctl.boot(deviceId);
|
|
522
759
|
}
|
|
523
760
|
}
|
|
524
|
-
async handleDeviceShutdown(sessionId, deviceId) {
|
|
761
|
+
async handleDeviceShutdown(sessionId, deviceId, requestId) {
|
|
525
762
|
const state = this.deviceStates.get(sessionId);
|
|
526
763
|
if (!state)
|
|
527
764
|
return;
|
|
528
|
-
state
|
|
765
|
+
this.bumpBootSeq(state, 'shut-down');
|
|
766
|
+
// **Said here as well as in `cleanupDeviceState`, because this path does not go through it.**
|
|
767
|
+
// The teardown below is a copy of that method's, and the copy is older than the network rule —
|
|
768
|
+
// so a device shut down from the dashboard while offline kept its udid in the host filter for
|
|
769
|
+
// the rest of the Mac's uptime, and its next boot came up with traffic dead and nothing on
|
|
770
|
+
// screen saying why. The two bodies have already diverged in another respect (this one does not
|
|
771
|
+
// stop the audio tap), which is why this is a call rather than a merge: unifying them changes
|
|
772
|
+
// audio teardown on a path nobody asked about.
|
|
773
|
+
void this.network.forget(deviceId).catch((e) => {
|
|
774
|
+
logger.warn('could not clear the network rule for a shut-down device:', e.message);
|
|
775
|
+
});
|
|
529
776
|
void state.streamReader?.cancel();
|
|
530
777
|
state.streamReader = null;
|
|
531
778
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
@@ -540,11 +787,19 @@ export class IOSAgent {
|
|
|
540
787
|
this.uiTreeReader.stopIfDevice(deviceId);
|
|
541
788
|
try {
|
|
542
789
|
await this.simctl.shutdown(deviceId);
|
|
543
|
-
this
|
|
790
|
+
// **After the shutdown lands, not with the teardown above.** Everything before this point is
|
|
791
|
+
// the session's state, which is correct to drop the moment a shutdown is asked for. Ownership
|
|
792
|
+
// is about the *device*, and a shutdown that throws leaves it running — forgetting it was ours
|
|
793
|
+
// there would hand an ambiguous choice back to the resolvers for a simulator tapflow is still
|
|
794
|
+
// driving. `cleanupDeviceState` deliberately does not clear it at all: that path is a relay
|
|
795
|
+
// disconnect, where the device outlives the session and ownership is the thing worth keeping.
|
|
796
|
+
this.ownedDevices.delete(deviceId);
|
|
797
|
+
this.sendMsg({
|
|
544
798
|
type: 'device:shutdown-done',
|
|
545
799
|
sessionId,
|
|
800
|
+
requestId,
|
|
546
801
|
payload: { deviceId },
|
|
547
|
-
})
|
|
802
|
+
});
|
|
548
803
|
}
|
|
549
804
|
catch (e) {
|
|
550
805
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -578,14 +833,89 @@ export class IOSAgent {
|
|
|
578
833
|
sentinelParked(deviceId) {
|
|
579
834
|
return (this.parkedSentinels.get(deviceId) ?? 0) > 0;
|
|
580
835
|
}
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
836
|
+
// A terminal input naming a session this agent holds no state for. Reachable in a way that is
|
|
837
|
+
// genuinely disputed: `sessionRebind.test.ts` records that a restarted agent is re-seeded from
|
|
838
|
+
// `agent:registered`, so `!state` should never fire — but `registeredSessions` carries one entry
|
|
839
|
+
// per *device* (`RelayServer.ts`, `byDeviceId`), and the relay's own comment there notes that one
|
|
840
|
+
// device can now sit behind two sessions, which leaves the second unseeded. Answering costs four
|
|
841
|
+
// lines; staying silent costs a terminal input swallowed and the caller's own fallback reporting
|
|
842
|
+
// success. Android already answers, and `channel-unavailable` is what it maps this to.
|
|
843
|
+
/** The correlator on an input an ack answers, or `null` if the frame cannot be attributed.
|
|
844
|
+
*
|
|
845
|
+
* A local capture rather than a guard at the top of the dispatcher, and that is the point: a guard there
|
|
846
|
+
* would not narrow `msg.requestId` inside the case, so the shortest way to satisfy the reply's required
|
|
847
|
+
* field would be `msg.requestId!` — the assertion removed from `open-url` and then from clipboard. This
|
|
848
|
+
* hands back a `string` the case can close over.
|
|
849
|
+
*
|
|
850
|
+
* It is unvalidated JSON, so the check is real work rather than ceremony: the declaration is required and
|
|
851
|
+
* every in-repo sender is typed against it, and #444 made the relay refuse an empty one — but this agent may
|
|
852
|
+
* be talking to a relay older than that, and `mcp-server`'s tool
|
|
853
|
+
* schemas are bare `z.string()` so a model can produce `''`. */
|
|
854
|
+
correlatorOf(msg) {
|
|
855
|
+
if (typeof msg.requestId === 'string' && msg.requestId !== '')
|
|
856
|
+
return msg.requestId;
|
|
857
|
+
logger.warn(`${msg.type} without a usable requestId — dropped, its ack could not be attributed`);
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
ackNoSession(sessionId, requestId) {
|
|
861
|
+
// The `if (!sessionId) return` this used to open with is gone: the dispatcher now declares
|
|
862
|
+
// `sessionId: string`, so there is no undefined to guard against and the guard would have been a
|
|
863
|
+
// silent drop with nothing left that could reach it.
|
|
864
|
+
this.sendMsg({
|
|
865
|
+
type: 'input:error', sessionId, requestId,
|
|
866
|
+
message: INPUT_ERROR_MESSAGES['channel-unavailable'],
|
|
867
|
+
reason: 'channel-unavailable',
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Why a write was refused. The helper's boolean says only "no".
|
|
872
|
+
*
|
|
873
|
+
* `kind` matters because the two questions were decided at different times. Readiness is about now;
|
|
874
|
+
* a continuation frame's fate was decided when the gesture opened. A gesture whose opening frame
|
|
875
|
+
* was refused inside the start-up window owns nothing, so its terminal frame can never land no
|
|
876
|
+
* matter how ready the helper has since become — and reading readiness first answered `malformed`
|
|
877
|
+
* ("never retry") for precisely the case `channel-starting` exists to serve. Ownership is therefore
|
|
878
|
+
* checked first for continuations, which is also the right answer when the helper died mid-gesture
|
|
879
|
+
* and its replacement is still starting: waiting would not help either.
|
|
880
|
+
*
|
|
881
|
+
* Safe to read at the ack site rather than inside the write, because iOS writes are synchronous —
|
|
882
|
+
* but only for the question readiness answers. That is why ownership is asked separately.
|
|
883
|
+
*/
|
|
884
|
+
refusalReason(helper, kind) {
|
|
885
|
+
const state = helper?.inputState() ?? 'unavailable';
|
|
886
|
+
// No channel at all dominates: the caller must reconnect, and telling it to re-open a gesture
|
|
887
|
+
// would send it round a loop that cannot succeed.
|
|
888
|
+
if (state === 'unavailable')
|
|
889
|
+
return 'channel-unavailable';
|
|
890
|
+
// Then ownership, and only for a continuation. A gesture whose opening frame never landed owns
|
|
891
|
+
// nothing, so its terminal frame can never be delivered however ready the helper has become —
|
|
892
|
+
// which is why readiness must not be consulted first here.
|
|
893
|
+
//
|
|
894
|
+
// A consequence worth stating: `channel-starting` is unreachable for a continuation. Owning a
|
|
895
|
+
// gesture requires an opening frame to have landed, which requires the helper to have been ready,
|
|
896
|
+
// so a continuation is never refused *merely* because the channel is still coming up. Standalone
|
|
897
|
+
// inputs — a key, a button — are the ones that get that answer.
|
|
898
|
+
if (kind === 'continuation' && !helper?.ownsGesture())
|
|
899
|
+
return 'no-gesture';
|
|
900
|
+
return state === 'starting' ? 'channel-starting' : 'no-gesture';
|
|
901
|
+
}
|
|
902
|
+
// Ack a terminal input. `input:done` = dispatched to a booted device (not a landing guarantee —
|
|
903
|
+
// HID is fire-and-forget). Otherwise `input:error` carries a machine-readable `reason` so a caller
|
|
904
|
+
// can tell "retry in a moment" from "reconnect" from "never retry"; `message` stays human prose.
|
|
905
|
+
// Off the sync inject path, so start/end pairing is unaffected.
|
|
906
|
+
//
|
|
907
|
+
// `requestId` is a parameter, never read from shared state: a gesture is dozens of frames and two can
|
|
908
|
+
// overlap, so a correlator hoisted out of per-request scope would answer one input with another's id —
|
|
909
|
+
// which is #499 rebuilt inside the agent. Same reason `seq` is passed in on the Android side.
|
|
910
|
+
async ackInput(state, outcome, requestId) {
|
|
911
|
+
const reason = outcome !== 'delivered'
|
|
912
|
+
? outcome
|
|
913
|
+
: (state.booted || (await this.isBooted(state.deviceId))) ? null : 'not-booted';
|
|
914
|
+
if (reason === null)
|
|
585
915
|
state.booted = true; // cache the post-reconnect verify so later inputs skip simctl
|
|
586
|
-
this.
|
|
587
|
-
? { type: 'input:done', sessionId: state.sessionId }
|
|
588
|
-
: { type: 'input:error', sessionId: state.sessionId, message:
|
|
916
|
+
this.sendMsg(reason === null
|
|
917
|
+
? { type: 'input:done', sessionId: state.sessionId, requestId }
|
|
918
|
+
: { type: 'input:error', sessionId: state.sessionId, requestId, message: INPUT_ERROR_MESSAGES[reason], reason });
|
|
589
919
|
}
|
|
590
920
|
async isBooted(deviceId) {
|
|
591
921
|
try {
|
|
@@ -601,52 +931,78 @@ export class IOSAgent {
|
|
|
601
931
|
case 'device:boot': {
|
|
602
932
|
const { deviceId, resetMode, acceptH264, secureContext, external } = msg.payload;
|
|
603
933
|
const sessionId = msg.sessionId;
|
|
604
|
-
this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external })
|
|
934
|
+
this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external }, msg.requestId)
|
|
605
935
|
.catch((e) => logger.error('handleDeviceBoot failed:', e));
|
|
606
936
|
break;
|
|
607
937
|
}
|
|
608
938
|
case 'device:shutdown': {
|
|
609
939
|
const { deviceId } = msg.payload;
|
|
610
940
|
const sessionId = msg.sessionId;
|
|
611
|
-
this.handleDeviceShutdown(sessionId, deviceId)
|
|
941
|
+
this.handleDeviceShutdown(sessionId, deviceId, msg.requestId)
|
|
612
942
|
.catch((e) => logger.error('handleDeviceShutdown failed:', e));
|
|
613
943
|
break;
|
|
614
944
|
}
|
|
615
945
|
case 'app:install': {
|
|
616
946
|
const { filePath, bundleId } = msg.payload;
|
|
617
947
|
const sessionId = msg.sessionId;
|
|
948
|
+
const { requestId } = msg;
|
|
949
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
950
|
+
console.warn('[tapflow] app:install without a requestId — dropped, cannot correlate a reply');
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
// `...body` first: with the ids last, a body *variable* carrying one would override the real
|
|
954
|
+
// correlator, because excess-property checking does not fire on variables. See `open-url`.
|
|
955
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
618
956
|
const installState = this.deviceStates.get(sessionId);
|
|
619
957
|
if (!installState) {
|
|
620
|
-
|
|
958
|
+
respond({ type: 'app:install-error', message: 'No booted device' });
|
|
621
959
|
break;
|
|
622
960
|
}
|
|
623
961
|
this.installBuild(installState.deviceId, filePath, bundleId)
|
|
624
|
-
.then(() =>
|
|
962
|
+
.then(() => respond({ type: 'app:install-done' }))
|
|
625
963
|
.catch((e) => {
|
|
626
964
|
const message = e instanceof Error ? e.message : String(e);
|
|
627
|
-
|
|
965
|
+
respond({ type: 'app:install-error', message });
|
|
628
966
|
});
|
|
629
967
|
break;
|
|
630
968
|
}
|
|
631
969
|
case 'app:launch': {
|
|
632
970
|
const { bundleId } = msg.payload;
|
|
633
971
|
const sessionId = msg.sessionId;
|
|
972
|
+
const { requestId } = msg;
|
|
973
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
974
|
+
console.warn('[tapflow] app:launch without a requestId — dropped, cannot correlate a reply');
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
// `...body` first: with the ids last, a body *variable* carrying one would override the real
|
|
978
|
+
// correlator, because excess-property checking does not fire on variables. See `open-url`.
|
|
979
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
634
980
|
const launchState = this.deviceStates.get(sessionId);
|
|
635
981
|
if (!launchState) {
|
|
636
|
-
|
|
982
|
+
respond({ type: 'app:launch-error', message: 'No booted device' });
|
|
637
983
|
break;
|
|
638
984
|
}
|
|
639
|
-
|
|
640
|
-
|
|
985
|
+
// The target is named BEFORE the launch, and the order is the whole point: dyld reads the
|
|
986
|
+
// environment when the process starts, so naming it afterwards arms the next launch and leaves
|
|
987
|
+
// this one unhooked while `available` would still say true.
|
|
988
|
+
this.network.target(launchState.deviceId, bundleId)
|
|
989
|
+
.catch((e) => logger.warn('could not name the network target:', e.message))
|
|
990
|
+
.then(() => this.simctl.launchApp(launchState.deviceId, bundleId))
|
|
991
|
+
.then((pid) => {
|
|
992
|
+
// **After the launch, with the pid it returned.** The network control measures how long a
|
|
993
|
+
// started process has gone without reporting its hooks; opening that window before
|
|
994
|
+
// `simctl launch` had run put the launch itself inside the budget, and left it open when
|
|
995
|
+
// the launch failed. A null or unchanged pid means no new process, so nothing is owed.
|
|
996
|
+
this.network.markLaunched(launchState.deviceId, pid);
|
|
641
997
|
// Track the foreground app so ui:tree:request can query it via XCUITest.
|
|
642
998
|
this.lastBundleIds.set(launchState.deviceId, bundleId);
|
|
643
999
|
// Audio: the whole-sim tap's poll picks up the launched app process within one interval;
|
|
644
1000
|
// no per-launch helper needed.
|
|
645
|
-
|
|
1001
|
+
respond({ type: 'app:launch-done' });
|
|
646
1002
|
})
|
|
647
1003
|
.catch((e) => {
|
|
648
1004
|
const message = e instanceof Error ? e.message : String(e);
|
|
649
|
-
|
|
1005
|
+
respond({ type: 'app:launch-error', message });
|
|
650
1006
|
});
|
|
651
1007
|
break;
|
|
652
1008
|
}
|
|
@@ -668,11 +1024,19 @@ export class IOSAgent {
|
|
|
668
1024
|
break;
|
|
669
1025
|
}
|
|
670
1026
|
case 'input:touch:end': {
|
|
1027
|
+
const requestId = this.correlatorOf(msg);
|
|
1028
|
+
if (requestId === null)
|
|
1029
|
+
break;
|
|
671
1030
|
const state = this.deviceStates.get(msg.sessionId);
|
|
672
|
-
if (!state)
|
|
1031
|
+
if (!state) {
|
|
1032
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
673
1033
|
break;
|
|
674
|
-
|
|
675
|
-
|
|
1034
|
+
}
|
|
1035
|
+
// The helper's answer, not its existence: a helper whose process has died reports every
|
|
1036
|
+
// write as dropped, and that is what the caller needs to hear (#482).
|
|
1037
|
+
const helper = state.touchHelper;
|
|
1038
|
+
// terminal of a tap/swipe → ack the gesture
|
|
1039
|
+
void this.ackInput(state, helper?.touchEnd() ? 'delivered' : this.refusalReason(helper, 'continuation'), requestId);
|
|
676
1040
|
break;
|
|
677
1041
|
}
|
|
678
1042
|
case 'input:pinch:start': {
|
|
@@ -693,11 +1057,16 @@ export class IOSAgent {
|
|
|
693
1057
|
break;
|
|
694
1058
|
}
|
|
695
1059
|
case 'input:pinch:end': {
|
|
1060
|
+
const requestId = this.correlatorOf(msg);
|
|
1061
|
+
if (requestId === null)
|
|
1062
|
+
break;
|
|
696
1063
|
const state = this.deviceStates.get(msg.sessionId);
|
|
697
|
-
if (!state)
|
|
1064
|
+
if (!state) {
|
|
1065
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
698
1066
|
break;
|
|
699
|
-
|
|
700
|
-
|
|
1067
|
+
}
|
|
1068
|
+
const pinchHelper = state.touchHelper;
|
|
1069
|
+
void this.ackInput(state, pinchHelper?.pinchEnd() ? 'delivered' : this.refusalReason(pinchHelper, 'continuation'), requestId);
|
|
701
1070
|
break;
|
|
702
1071
|
}
|
|
703
1072
|
case 'input:rotate': {
|
|
@@ -724,24 +1093,27 @@ export class IOSAgent {
|
|
|
724
1093
|
: this.simctl.hideSoftwareKeyboard(state.deviceId);
|
|
725
1094
|
op.then(() => {
|
|
726
1095
|
state.softKeyboardVisible = showing;
|
|
727
|
-
this.
|
|
1096
|
+
this.sendMsg({
|
|
728
1097
|
type: 'keyboard:toggled',
|
|
729
1098
|
sessionId: state.sessionId,
|
|
730
1099
|
payload: { visible: showing },
|
|
731
|
-
})
|
|
1100
|
+
});
|
|
732
1101
|
}).catch((e) => {
|
|
733
1102
|
logger.error('keyboard toggle failed:', e);
|
|
734
1103
|
});
|
|
735
1104
|
break;
|
|
736
1105
|
}
|
|
737
1106
|
case 'input:type': {
|
|
1107
|
+
const requestId = this.correlatorOf(msg);
|
|
1108
|
+
if (requestId === null)
|
|
1109
|
+
break;
|
|
738
1110
|
const sessionId = msg.sessionId;
|
|
739
1111
|
const state = this.deviceStates.get(sessionId);
|
|
740
1112
|
if (state)
|
|
741
1113
|
this.ensureTouchHelper(state);
|
|
742
1114
|
const { text } = (msg.payload ?? {});
|
|
743
1115
|
if (!state?.touchHelper) {
|
|
744
|
-
this.
|
|
1116
|
+
this.sendMsg({ type: 'input:type-error', sessionId, requestId, message: 'No booted device' });
|
|
745
1117
|
break;
|
|
746
1118
|
}
|
|
747
1119
|
// simctl pbcopy → Cmd+V paste. Works for arbitrary Unicode (unlike a
|
|
@@ -758,92 +1130,155 @@ export class IOSAgent {
|
|
|
758
1130
|
state.softKeyboardVisible = false;
|
|
759
1131
|
await this.simctl.hideSoftwareKeyboard(state.deviceId).catch(() => { });
|
|
760
1132
|
}
|
|
761
|
-
|
|
1133
|
+
// Throwing routes into the input:type-error below. Without it a dropped chord still
|
|
1134
|
+
// answers input:type-done: the text sits on the device pasteboard, nothing was pasted
|
|
1135
|
+
// into the app, and the caller moves on. Unlike the copy path in clipboard:read there
|
|
1136
|
+
// is no pasteboard deadline here to fail loudly in its place.
|
|
1137
|
+
if (!state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft'])) {
|
|
1138
|
+
throw new PlatformError('Cannot paste the text — no input channel to the device');
|
|
1139
|
+
}
|
|
762
1140
|
};
|
|
763
1141
|
// Ack on completion so a following input step (e.g. pressKey Enter) is
|
|
764
1142
|
// only sent after the paste has actually landed.
|
|
765
1143
|
// Shares the clipboard queue: this writes the pasteboard, so running it alongside a
|
|
766
1144
|
// clipboard:read would overwrite that read's sentinel and be returned as "copied".
|
|
767
1145
|
this.clipboardQueue(state.deviceId, doType)
|
|
768
|
-
.then(() => this.
|
|
1146
|
+
.then(() => this.sendMsg({ type: 'input:type-done', sessionId, requestId }))
|
|
769
1147
|
.catch((e) => {
|
|
770
1148
|
const message = e instanceof Error ? e.message : String(e);
|
|
771
1149
|
logger.error('input:type (pbcopy+paste) failed:', e);
|
|
772
|
-
this.
|
|
1150
|
+
this.sendMsg({ type: 'input:type-error', sessionId, requestId, message });
|
|
773
1151
|
});
|
|
774
1152
|
break;
|
|
775
1153
|
}
|
|
776
1154
|
case 'input:key': {
|
|
1155
|
+
const requestId = this.correlatorOf(msg);
|
|
1156
|
+
if (requestId === null)
|
|
1157
|
+
break;
|
|
777
1158
|
const state = this.deviceStates.get(msg.sessionId);
|
|
778
|
-
if (!state)
|
|
1159
|
+
if (!state) {
|
|
1160
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
779
1161
|
break;
|
|
1162
|
+
}
|
|
780
1163
|
this.ensureTouchHelper(state);
|
|
781
1164
|
const { code, modifiers } = msg.payload;
|
|
782
1165
|
const usage = KEY_CODE_MAP[code];
|
|
783
1166
|
if (usage === undefined) {
|
|
784
|
-
|
|
1167
|
+
// Prose preserved (it names the code), reason added: `unsupported` tells a caller never to
|
|
1168
|
+
// retry, which the message alone could not.
|
|
1169
|
+
this.sendMsg({
|
|
1170
|
+
type: 'input:error', sessionId: msg.sessionId, requestId,
|
|
1171
|
+
message: `unknown key code: ${code}`, reason: 'unsupported',
|
|
1172
|
+
});
|
|
785
1173
|
break;
|
|
786
1174
|
}
|
|
787
1175
|
if (state.softKeyboardVisible) {
|
|
788
1176
|
// Hide the SW keyboard first so iOS re-initialises the HW keyboard
|
|
789
1177
|
// context. Skipping this causes input-source desync (qks / ㅂㅏㄴ symptoms).
|
|
790
1178
|
state.softKeyboardVisible = false;
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1179
|
+
// The ack belongs after the deferred chord, not beside it. Acking out here ran before
|
|
1180
|
+
// the key was ever written, so the answer could only be a guess — and the guess was
|
|
1181
|
+
// "delivered". The hide failing is not fatal (the key still goes out), so the catch
|
|
1182
|
+
// resolves and the send happens exactly once either way.
|
|
1183
|
+
void this.simctl.hideSoftwareKeyboard(state.deviceId)
|
|
1184
|
+
.catch((e) => { logger.error('hideSoftwareKeyboard (on key) failed:', e); })
|
|
1185
|
+
.then(() => {
|
|
1186
|
+
const h = state.touchHelper;
|
|
1187
|
+
return this.ackInput(state, h?.sendKey(usage, modifiers ?? 0) ? 'delivered' : this.refusalReason(h, 'standalone'), requestId);
|
|
796
1188
|
});
|
|
797
1189
|
}
|
|
798
1190
|
else {
|
|
799
|
-
state.touchHelper
|
|
1191
|
+
const h = state.touchHelper;
|
|
1192
|
+
void this.ackInput(state, h?.sendKey(usage, modifiers ?? 0) ? 'delivered' : this.refusalReason(h, 'standalone'), requestId);
|
|
800
1193
|
}
|
|
801
|
-
void this.ackInput(state, state.touchHelper !== null);
|
|
802
1194
|
break;
|
|
803
1195
|
}
|
|
804
1196
|
case 'input:button': {
|
|
1197
|
+
const requestId = this.correlatorOf(msg);
|
|
1198
|
+
if (requestId === null)
|
|
1199
|
+
break;
|
|
805
1200
|
const state = this.deviceStates.get(msg.sessionId);
|
|
806
|
-
if (!state)
|
|
1201
|
+
if (!state) {
|
|
1202
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
807
1203
|
break;
|
|
1204
|
+
}
|
|
808
1205
|
this.ensureTouchHelper(state);
|
|
809
1206
|
const { name, phase } = msg.payload;
|
|
810
1207
|
// Map the cross-platform button vocabulary (used by MCP) onto this
|
|
811
1208
|
// device's actual chrome button names. Dashboard already sends the raw
|
|
812
1209
|
// chrome names (e.g. "volume-up"), which pass through unchanged.
|
|
813
1210
|
const chromeName = IOS_BUTTON_ALIASES[name] ?? name;
|
|
1211
|
+
// Two branches below deliberately write nothing — a home press-down, and a button this
|
|
1212
|
+
// device's chrome has no HID mapping for. Neither is a dropped input, so they answer
|
|
1213
|
+
// with the channel's health instead of a write result; reporting an error there would
|
|
1214
|
+
// trade this issue's false success for a false failure.
|
|
1215
|
+
const btnHelper = state.touchHelper;
|
|
1216
|
+
const wrote = (ok) => ok ? 'delivered' : this.refusalReason(btnHelper, 'standalone');
|
|
1217
|
+
// The two branches that write nothing answer from the channel's state, which is `delivered`
|
|
1218
|
+
// when it is ready. A button this device's chrome lacks stays a *success* — the device
|
|
1219
|
+
// genuinely has no such button, so an error would be a false failure (#484). That is a
|
|
1220
|
+
// decision, and it is why iOS never sends `unsupported` for a button while Android does.
|
|
1221
|
+
let outcome = btnHelper?.inputState() === 'ready'
|
|
1222
|
+
? 'delivered'
|
|
1223
|
+
: this.refusalReason(btnHelper, 'standalone');
|
|
814
1224
|
if (chromeName === 'home') {
|
|
815
1225
|
// Home has no HID down/up split — always a single legacy press. Send once on release
|
|
816
1226
|
// (or on a phase-less legacy message) so a down+up pair doesn't fire it twice.
|
|
817
1227
|
if (phase !== 'down')
|
|
818
|
-
|
|
1228
|
+
outcome = wrote(btnHelper?.pressLegacyButton(0) ?? false);
|
|
819
1229
|
}
|
|
820
1230
|
else {
|
|
821
1231
|
const btn = state.loadedChrome?.buttons.find((b) => b.name === chromeName);
|
|
822
1232
|
if (btn && btn.usagePage > 0 && btn.usage > 0) {
|
|
823
1233
|
if (phase === 'down')
|
|
824
|
-
|
|
1234
|
+
outcome = wrote(btnHelper?.pressButtonDown(btn.usagePage, btn.usage) ?? false);
|
|
825
1235
|
else if (phase === 'up')
|
|
826
|
-
|
|
1236
|
+
outcome = wrote(btnHelper?.pressButtonUp(btn.usagePage, btn.usage) ?? false);
|
|
827
1237
|
else
|
|
828
|
-
|
|
1238
|
+
outcome = wrote(btnHelper?.pressButton(btn.usagePage, btn.usage) ?? false);
|
|
829
1239
|
}
|
|
830
1240
|
}
|
|
831
|
-
void this.ackInput(state,
|
|
1241
|
+
void this.ackInput(state, outcome, requestId);
|
|
832
1242
|
break;
|
|
833
1243
|
}
|
|
834
1244
|
case 'open-url': {
|
|
835
1245
|
const { url } = msg.payload;
|
|
836
1246
|
const sessionId = msg.sessionId;
|
|
1247
|
+
const { requestId } = msg;
|
|
1248
|
+
// No fallback by design (see the note above `OpenUrl`), so an uncorrelatable request cannot be
|
|
1249
|
+
// answered correlatably either — and inventing an id would make this agent's reply look like a
|
|
1250
|
+
// response to a request nobody made. Every in-repo sender supplies one, and the `fixed` version
|
|
1251
|
+
// group means there is no in-repo skew window; validating third-party frames at the relay's door
|
|
1252
|
+
// is #444, which will take this over. Until then a drop with a log beats a reply that lies.
|
|
1253
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
1254
|
+
console.warn('[tapflow] open-url without a requestId — dropped, cannot correlate a reply');
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
// Every exit merges the correlation ids here. What that buys, precisely — review measured 13
|
|
1258
|
+
// attacks and the type caught 3:
|
|
1259
|
+
//
|
|
1260
|
+
// - **Omitting the correlator is a compile error.** That comes from `requestId: string` being
|
|
1261
|
+
// required on `OpenUrlDone`/`OpenUrlError`, reached through `sendMsg`'s `AgentControlOutbound`
|
|
1262
|
+
// — not from `OpenUrlReplyBody`, whose one contribution is rejecting a fresh id written as a
|
|
1263
|
+
// literal at the `respond(...)` call.
|
|
1264
|
+
// - **`...body` goes first on purpose.** With the ids last, a body *variable* carrying a
|
|
1265
|
+
// `requestId` cannot override them — excess-property checking does not fire on variables, so
|
|
1266
|
+
// the earlier `{ sessionId, requestId, ...body }` let a wrong id win.
|
|
1267
|
+
//
|
|
1268
|
+
// What it does **not** buy: `sendMsg` accepts any `string` here, so a site that bypasses
|
|
1269
|
+
// `respond` type-checks. The echo tests in both agents' suites are what catch that, and each
|
|
1270
|
+
// remaining correlation pair needs its own — this helper does not remove that work.
|
|
1271
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
837
1272
|
const state = this.deviceStates.get(sessionId);
|
|
838
1273
|
if (!state) {
|
|
839
|
-
|
|
1274
|
+
respond({ type: 'open-url:error', message: 'no booted device' });
|
|
840
1275
|
break;
|
|
841
1276
|
}
|
|
842
1277
|
this.simctl.openUrl(state.deviceId, url)
|
|
843
|
-
.then(() =>
|
|
1278
|
+
.then(() => respond({ type: 'open-url:done' }))
|
|
844
1279
|
.catch((e) => {
|
|
845
1280
|
const message = e instanceof Error ? e.message : String(e);
|
|
846
|
-
|
|
1281
|
+
respond({ type: 'open-url:error', message });
|
|
847
1282
|
});
|
|
848
1283
|
break;
|
|
849
1284
|
}
|
|
@@ -853,7 +1288,7 @@ export class IOSAgent {
|
|
|
853
1288
|
const sessionId = msg.sessionId;
|
|
854
1289
|
const shotState = this.deviceStates.get(sessionId);
|
|
855
1290
|
if (!shotState) {
|
|
856
|
-
this.
|
|
1291
|
+
this.sendMsg({ type: 'screenshot:error', sessionId, requestId, message: 'No booted device' });
|
|
857
1292
|
break;
|
|
858
1293
|
}
|
|
859
1294
|
// Note for anyone widening this signature again: before the udid parameter existed this
|
|
@@ -861,32 +1296,38 @@ export class IOSAgent {
|
|
|
861
1296
|
// type error — the format string simply became the device id. The compiler cannot help
|
|
862
1297
|
// here; the test that scans exec arguments is what does.
|
|
863
1298
|
this.simctl.screenshot(shotState.deviceId, format ?? 'png')
|
|
864
|
-
.then((buf) => this.
|
|
1299
|
+
.then((buf) => this.sendMsg({
|
|
865
1300
|
type: 'screenshot:done',
|
|
866
1301
|
sessionId,
|
|
867
1302
|
requestId,
|
|
868
1303
|
format: format ?? 'png',
|
|
869
1304
|
data: buf.toString('base64'),
|
|
870
|
-
}))
|
|
1305
|
+
}))
|
|
871
1306
|
.catch((e) => {
|
|
872
1307
|
const message = e instanceof Error ? e.message : String(e);
|
|
873
|
-
this.
|
|
1308
|
+
this.sendMsg({ type: 'screenshot:error', sessionId, requestId, message });
|
|
874
1309
|
});
|
|
875
1310
|
break;
|
|
876
1311
|
}
|
|
877
1312
|
case 'app:clear-state': {
|
|
878
1313
|
const { bundleId } = (msg.payload ?? {});
|
|
879
1314
|
const sessionId = msg.sessionId;
|
|
1315
|
+
const { requestId } = msg;
|
|
1316
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
1317
|
+
console.warn('[tapflow] app:clear-state without a requestId — dropped, cannot correlate a reply');
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
880
1321
|
const state = this.deviceStates.get(sessionId);
|
|
881
1322
|
if (!state || !bundleId) {
|
|
882
|
-
|
|
1323
|
+
respond({ type: 'app:clear-state-error', message: !state ? 'No booted device' : 'bundleId missing' });
|
|
883
1324
|
break;
|
|
884
1325
|
}
|
|
885
1326
|
this.simctl.clearAppData(state.deviceId, bundleId)
|
|
886
|
-
.then(() =>
|
|
1327
|
+
.then(() => respond({ type: 'app:clear-state-done' }))
|
|
887
1328
|
.catch((e) => {
|
|
888
1329
|
const message = e instanceof Error ? e.message : String(e);
|
|
889
|
-
|
|
1330
|
+
respond({ type: 'app:clear-state-error', message });
|
|
890
1331
|
});
|
|
891
1332
|
break;
|
|
892
1333
|
}
|
|
@@ -894,15 +1335,46 @@ export class IOSAgent {
|
|
|
894
1335
|
// cannot know when the key actually lands (a visible software keyboard makes this path
|
|
895
1336
|
// await hideSoftwareKeyboard first), and reading too early returns the PREVIOUS
|
|
896
1337
|
// pasteboard — a stale value the user would never notice.
|
|
1338
|
+
case 'network:set': {
|
|
1339
|
+
const requestId = this.correlatorOf(msg);
|
|
1340
|
+
if (requestId === null)
|
|
1341
|
+
break;
|
|
1342
|
+
// `?? {}` and a re-check of a field the relay's schema already requires: this case owes a
|
|
1343
|
+
// reply, and a cast that dereferences a missing payload throws into a dispatch that swallows
|
|
1344
|
+
// it — answering nothing at all, the one failure a requester cannot tell from a hung device.
|
|
1345
|
+
const { offline } = (msg.payload ?? {});
|
|
1346
|
+
if (typeof offline !== 'boolean') {
|
|
1347
|
+
this.sendMsg({
|
|
1348
|
+
type: 'network:error', sessionId: msg.sessionId, requestId,
|
|
1349
|
+
message: 'network:set payload must carry a boolean `offline`.',
|
|
1350
|
+
});
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
void this.handleNetworkSet(msg.sessionId, offline, requestId);
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
// The relay asks on a viewer's re-join (#614). Uncorrelated both ways: the reply is a report and
|
|
1357
|
+
// nothing is waiting on it, so a session with no booted device answers nothing at all.
|
|
1358
|
+
case 'network:request-state':
|
|
1359
|
+
void this.reportNetworkState(msg.sessionId);
|
|
1360
|
+
break;
|
|
897
1361
|
case 'clipboard:read': {
|
|
1362
|
+
// `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
|
|
1363
|
+
// the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
|
|
1364
|
+
// required and the only requester goes through a typed `send()`, so nothing in-repo omits it.
|
|
1365
|
+
//
|
|
1366
|
+
// It is still an assertion about unvalidated JSON, as those other two are: a third-party client
|
|
1367
|
+
// could omit it, and then the reply would carry `undefined` and be uncorrelatable. That is inbound
|
|
1368
|
+
// validation (#444), not producer typing, and making it optional here instead would just move the
|
|
1369
|
+
// same hole into every reply this case sends.
|
|
898
1370
|
const { requestId } = msg;
|
|
899
1371
|
const sessionId = msg.sessionId;
|
|
900
1372
|
const state = this.deviceStates.get(sessionId);
|
|
901
1373
|
if (!state) {
|
|
902
|
-
this.
|
|
1374
|
+
this.sendMsg({
|
|
903
1375
|
type: 'clipboard:error', sessionId, requestId, message: 'No booted device',
|
|
904
1376
|
payload: { sentinelParked: false },
|
|
905
|
-
})
|
|
1377
|
+
});
|
|
906
1378
|
break;
|
|
907
1379
|
}
|
|
908
1380
|
const { press } = (msg.payload ?? {});
|
|
@@ -910,7 +1382,7 @@ export class IOSAgent {
|
|
|
910
1382
|
// executes AFTER this — so the viewer hears back as soon as the outcome is known and
|
|
911
1383
|
// does not wait out the restore window. The queue is still held until the restore
|
|
912
1384
|
// finishes, which is what actually matters: the next operation must not see a sentinel.
|
|
913
|
-
const respond = (body) => this.
|
|
1385
|
+
const respond = (body) => this.sendMsg({ sessionId, requestId, ...body });
|
|
914
1386
|
const read = async () => {
|
|
915
1387
|
if (!press) {
|
|
916
1388
|
respond({ type: 'clipboard:data', payload: { text: await this.simctl.getPasteboard(state.deviceId) } });
|
|
@@ -1023,23 +1495,31 @@ export class IOSAgent {
|
|
|
1023
1495
|
break;
|
|
1024
1496
|
}
|
|
1025
1497
|
case 'clipboard:write': {
|
|
1498
|
+
// `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
|
|
1499
|
+
// the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
|
|
1500
|
+
// required and the only requester goes through a typed `send()`, so nothing in-repo omits it.
|
|
1501
|
+
//
|
|
1502
|
+
// It is still an assertion about unvalidated JSON, as those other two are: a third-party client
|
|
1503
|
+
// could omit it, and then the reply would carry `undefined` and be uncorrelatable. That is inbound
|
|
1504
|
+
// validation (#444), not producer typing, and making it optional here instead would just move the
|
|
1505
|
+
// same hole into every reply this case sends.
|
|
1026
1506
|
const { requestId } = msg;
|
|
1027
1507
|
const sessionId = msg.sessionId;
|
|
1028
1508
|
const state = this.deviceStates.get(sessionId);
|
|
1029
1509
|
if (!state) {
|
|
1030
|
-
this.
|
|
1510
|
+
this.sendMsg({
|
|
1031
1511
|
type: 'clipboard:error', sessionId, requestId, message: 'No booted device',
|
|
1032
1512
|
payload: { sentinelParked: false },
|
|
1033
|
-
})
|
|
1513
|
+
});
|
|
1034
1514
|
break;
|
|
1035
1515
|
}
|
|
1036
1516
|
const { text, pasteAfter } = (msg.payload ?? {});
|
|
1037
1517
|
if (clipboardByteLength(text ?? '') > MAX_CLIPBOARD_BYTES) {
|
|
1038
|
-
this.
|
|
1518
|
+
this.sendMsg({
|
|
1039
1519
|
type: 'clipboard:error', sessionId, requestId,
|
|
1040
1520
|
message: `Clipboard is too large (max ${Math.floor(MAX_CLIPBOARD_BYTES / 1024)} KB)`,
|
|
1041
1521
|
payload: { sentinelParked: this.sentinelParked(state.deviceId) },
|
|
1042
|
-
})
|
|
1522
|
+
});
|
|
1043
1523
|
break;
|
|
1044
1524
|
}
|
|
1045
1525
|
const write = async () => {
|
|
@@ -1064,17 +1544,22 @@ export class IOSAgent {
|
|
|
1064
1544
|
state.softKeyboardVisible = false;
|
|
1065
1545
|
await this.simctl.hideSoftwareKeyboard(state.deviceId).catch(() => { });
|
|
1066
1546
|
}
|
|
1067
|
-
|
|
1547
|
+
// Same reason as input:type: the deadline above proves the pasteboard took the text,
|
|
1548
|
+
// not that the chord reached the device, so a dead channel here would answer
|
|
1549
|
+
// clipboard:write-done having pasted nothing.
|
|
1550
|
+
if (!state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft'])) {
|
|
1551
|
+
throw new PlatformError('Cannot press paste — no input channel to the device');
|
|
1552
|
+
}
|
|
1068
1553
|
};
|
|
1069
1554
|
// Ack only once the write (and the paste, when asked for) actually landed.
|
|
1070
1555
|
this.clipboardQueue(state.deviceId, write)
|
|
1071
|
-
.then(() => this.
|
|
1556
|
+
.then(() => this.sendMsg({ type: 'clipboard:write-done', sessionId, requestId }))
|
|
1072
1557
|
.catch((e) => {
|
|
1073
1558
|
const message = e instanceof Error ? e.message : String(e);
|
|
1074
|
-
this.
|
|
1559
|
+
this.sendMsg({
|
|
1075
1560
|
type: 'clipboard:error', sessionId, requestId, message,
|
|
1076
1561
|
payload: { sentinelParked: this.sentinelParked(state.deviceId) },
|
|
1077
|
-
})
|
|
1562
|
+
});
|
|
1078
1563
|
});
|
|
1079
1564
|
break;
|
|
1080
1565
|
}
|
|
@@ -1084,14 +1569,14 @@ export class IOSAgent {
|
|
|
1084
1569
|
const sessionId = msg.sessionId;
|
|
1085
1570
|
const state = this.deviceStates.get(sessionId);
|
|
1086
1571
|
if (!state) {
|
|
1087
|
-
this.
|
|
1572
|
+
this.sendMsg({ type: 'ui:tree:error', sessionId, requestId, message: 'No booted device' });
|
|
1088
1573
|
break;
|
|
1089
1574
|
}
|
|
1090
1575
|
this.readUITree(state)
|
|
1091
|
-
.then((elements) => this.
|
|
1576
|
+
.then((elements) => this.sendMsg({ type: 'ui:tree:response', sessionId, requestId, elements }))
|
|
1092
1577
|
.catch((e) => {
|
|
1093
1578
|
const message = e instanceof Error ? e.message : String(e);
|
|
1094
|
-
this.
|
|
1579
|
+
this.sendMsg({ type: 'ui:tree:error', sessionId, requestId, message });
|
|
1095
1580
|
});
|
|
1096
1581
|
break;
|
|
1097
1582
|
}
|
|
@@ -1161,10 +1646,122 @@ export class IOSAgent {
|
|
|
1161
1646
|
* (`AndroidAgent.ts:1429`); these do the same. Throwing beats falling back to simctl's `booted`
|
|
1162
1647
|
* alias, which would quietly act on whichever simulator happened to be up. */
|
|
1163
1648
|
async installApp(appPath) {
|
|
1164
|
-
await this.simctl.installApp(this.
|
|
1649
|
+
await this.simctl.installApp(await this.soleLiveDeviceId(), appPath);
|
|
1650
|
+
}
|
|
1651
|
+
// ── network control (#607) ─────────────────────────────────────────────────
|
|
1652
|
+
//
|
|
1653
|
+
// iOS has no airplane mode to ask for, so "offline" is three mechanisms applied together — see
|
|
1654
|
+
// `SimulatorNetwork`, which owns the ordering. What lives here is the wire: which device a request
|
|
1655
|
+
// means, and what the session is told afterwards.
|
|
1656
|
+
/** The device a session is driving, or undefined when nothing is booted. */
|
|
1657
|
+
/**
|
|
1658
|
+
* The session's device, **only while it is up**.
|
|
1659
|
+
*
|
|
1660
|
+
* `deviceStates` holds one entry per *registered* simulator, not per running one, so this used to
|
|
1661
|
+
* answer for a device that was shut down — or never booted. The network path is where that costs
|
|
1662
|
+
* something: a toggle after a shutdown writes the kernel rule for a dead udid, and `arm()` is the
|
|
1663
|
+
* only thing that clears it, which now happens on the *next* boot of that device. Until then the
|
|
1664
|
+
* host is dropping flows for nothing, and a simulator that reuses the udid comes up offline.
|
|
1665
|
+
*
|
|
1666
|
+
* **`booted` alone is not the check, and reading it as one is a regression this shipped once.**
|
|
1667
|
+
* The flag is a cache, not the truth: `initDeviceStates` runs on `agent:registered`, which is every
|
|
1668
|
+
* *reconnect* and not only the first connection, so it is `false` for a simulator that has been up
|
|
1669
|
+
* the whole time. Gated on the flag alone, a relay restart left the toggle answering
|
|
1670
|
+
* `No booted device` for a running device and left `network:request-state` — the message a viewer's
|
|
1671
|
+
* re-join sends, which exists precisely for this moment — silently unanswered, so the control never
|
|
1672
|
+
* left `waiting`.
|
|
1673
|
+
*
|
|
1674
|
+
* So it falls back to asking simctl and caches the answer, which is what `ackInput` already does
|
|
1675
|
+
* with the same flag and for the same reason. That is also why this is async.
|
|
1676
|
+
*/
|
|
1677
|
+
async deviceFor(sessionId) {
|
|
1678
|
+
const state = this.deviceStates.get(sessionId);
|
|
1679
|
+
if (!state)
|
|
1680
|
+
return undefined;
|
|
1681
|
+
if (state.booted)
|
|
1682
|
+
return state.deviceId;
|
|
1683
|
+
if (!(await this.isBooted(state.deviceId)))
|
|
1684
|
+
return undefined;
|
|
1685
|
+
state.booted = true;
|
|
1686
|
+
return state.deviceId;
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Send the current state to a session.
|
|
1690
|
+
*
|
|
1691
|
+
* Uncorrelated on purpose: this answers `device:ready` and a viewer's re-join, neither of which is a
|
|
1692
|
+
* request anyone is waiting on. Silent with no device, for the same reason `network:error` would be
|
|
1693
|
+
* addressed to nobody.
|
|
1694
|
+
*/
|
|
1695
|
+
async reportNetworkState(sessionId) {
|
|
1696
|
+
const deviceId = await this.deviceFor(sessionId);
|
|
1697
|
+
if (!deviceId)
|
|
1698
|
+
return;
|
|
1699
|
+
this.sendMsg({ type: 'network:state', sessionId, payload: this.network.state(deviceId) });
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* A device that was offline stopped being enforced — tell whoever is watching it.
|
|
1703
|
+
*
|
|
1704
|
+
* **Uncorrelated, and it has to be**: nobody asked. `network:error` is the wrong carrier for the
|
|
1705
|
+
* same reason — that type is *"a `network:set` that could not be dispatched"*, and this is not a
|
|
1706
|
+
* request failing. `network:state` already supports an unsolicited frame; the viewer re-join path
|
|
1707
|
+
* has been sending one since #614.
|
|
1708
|
+
*
|
|
1709
|
+
* **Every session holding the device, not one.** A device can sit behind two sessions, and the one
|
|
1710
|
+
* that happened to make the last request is not necessarily the one whose tester is looking at it.
|
|
1711
|
+
*
|
|
1712
|
+
* Synchronous on purpose: `deviceFor` asks simctl before believing a device is up, and this runs
|
|
1713
|
+
* from a timer while something has just gone wrong with that device. The state being reported is
|
|
1714
|
+
* about the filter, not about whether the simulator is still booted.
|
|
1715
|
+
*/
|
|
1716
|
+
reportEnforcementLost(deviceId) {
|
|
1717
|
+
for (const [sessionId, state] of this.deviceStates) {
|
|
1718
|
+
if (state.deviceId !== deviceId)
|
|
1719
|
+
continue;
|
|
1720
|
+
this.sendMsg({ type: 'network:state', sessionId, payload: this.network.state(deviceId) });
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
async handleNetworkSet(sessionId, offline, requestId) {
|
|
1724
|
+
const deviceId = await this.deviceFor(sessionId);
|
|
1725
|
+
if (!deviceId) {
|
|
1726
|
+
this.sendMsg({
|
|
1727
|
+
type: 'network:error', sessionId, requestId,
|
|
1728
|
+
message: 'No booted device — boot one before changing its network.',
|
|
1729
|
+
});
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
try {
|
|
1733
|
+
const payload = await this.network.setOffline(deviceId, offline);
|
|
1734
|
+
this.sendMsg({ type: 'network:state', sessionId, requestId, payload });
|
|
1735
|
+
}
|
|
1736
|
+
catch (e) {
|
|
1737
|
+
this.sendMsg({
|
|
1738
|
+
type: 'network:error', sessionId, requestId,
|
|
1739
|
+
message: e instanceof Error ? e.message : String(e),
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* The two capability entry points, which resolve their device the way every other one on this class
|
|
1745
|
+
* does — **`soleDeviceState`, not the first key in the map.**
|
|
1746
|
+
*
|
|
1747
|
+
* `deviceStates` holds one entry per *registered* simulator and this Mac reports dozens, so
|
|
1748
|
+
* `keys().next()` is whichever simctl listed first, almost always a shut-down one. That was wrong
|
|
1749
|
+
* before the liveness check and became a hard failure after it: `deviceFor` answers `undefined` for
|
|
1750
|
+
* a shut-down device, so both of these threw `No booted device` while a booted device sat right
|
|
1751
|
+
* there. `soleDeviceState` filters on `booted` and refuses to guess between two, which is the
|
|
1752
|
+
* policy this class already settled and wrote down — these were simply not using it.
|
|
1753
|
+
*
|
|
1754
|
+
* `deviceFor` stays for the wire path, where the caller names a session and the only open question
|
|
1755
|
+
* is whether that device is up.
|
|
1756
|
+
*/
|
|
1757
|
+
async setNetworkOffline(offline) {
|
|
1758
|
+
return this.network.setOffline(await this.soleLiveDeviceId(), offline);
|
|
1759
|
+
}
|
|
1760
|
+
async networkState() {
|
|
1761
|
+
return this.network.state(await this.soleLiveDeviceId());
|
|
1165
1762
|
}
|
|
1166
1763
|
async launchApp(bundleId) {
|
|
1167
|
-
await this.simctl.launchApp(this.
|
|
1764
|
+
await this.simctl.launchApp(await this.soleLiveDeviceId(), bundleId);
|
|
1168
1765
|
}
|
|
1169
1766
|
/** The one booted session, or undefined when there is no unambiguous answer. Callers that can
|
|
1170
1767
|
* reasonably do nothing (input) use this; callers that owe the user a result use
|
|
@@ -1173,15 +1770,9 @@ export class IOSAgent {
|
|
|
1173
1770
|
const live = [...this.deviceStates.values()].filter((s) => s.booted);
|
|
1174
1771
|
return live.length === 1 ? live[0] : undefined;
|
|
1175
1772
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
// first entry would pick whichever simulator simctl listed first, almost always a shut-down
|
|
1180
|
-
// one, which is worse than the `booted` alias it replaced: the alias at least found the device
|
|
1181
|
-
// that was actually up. `booted` is set by handleDeviceBoot, so filtering on it is the liveness
|
|
1182
|
-
// check `AndroidAgent` gets for free from `getSerial` (its serial map is only populated on
|
|
1183
|
-
// launch).
|
|
1184
|
-
const live = [...this.deviceStates.values()].filter((s) => s.booted);
|
|
1773
|
+
/** The one device out of `live`, or the refusal. Factored so the async resolver below answers
|
|
1774
|
+
* ambiguity with the same words rather than its own. */
|
|
1775
|
+
soleOf(live) {
|
|
1185
1776
|
if (live.length === 0)
|
|
1186
1777
|
throw new ValidationError('no booted device — call connect() first');
|
|
1187
1778
|
// Refusing beats guessing: this interface has no way to say which device is meant, and picking
|
|
@@ -1191,9 +1782,73 @@ export class IOSAgent {
|
|
|
1191
1782
|
}
|
|
1192
1783
|
return live[0];
|
|
1193
1784
|
}
|
|
1194
|
-
|
|
1785
|
+
/**
|
|
1786
|
+
* **The synchronous resolver, and `stream()` is the only caller left.**
|
|
1787
|
+
*
|
|
1788
|
+
* Everything else that resolves a device for a capability call awaits `soleLiveDeviceState`,
|
|
1789
|
+
* which asks simctl rather than trusting `booted` — a flag `initDeviceStates` clears on every
|
|
1790
|
+
* reconnect. `stream()` returns a `ReadableStream` and is part of `DeviceAgent`, so it cannot
|
|
1791
|
+
* await without changing that interface across both agents.
|
|
1792
|
+
*
|
|
1793
|
+
* **It is deliberately left with the stale flag, and nothing was added to paper over that.** A
|
|
1794
|
+
* refresh that marked every simulator simctl reports as booted was written and removed: it also
|
|
1795
|
+
* marked the ones a developer had open in Simulator.app, which destroys the very thing `booted`
|
|
1796
|
+
* disambiguates by — *the device this agent booted* — and made all the callers above refuse with
|
|
1797
|
+
* "2 booted devices" on the common two-simulator desk. Fire-and-forget, it could also resurrect a
|
|
1798
|
+
* flag a `device:shutdown` had just cleared, with no path back.
|
|
1799
|
+
*
|
|
1800
|
+
* That was a mechanism for a method **nothing in this repo calls**. A future caller needs the
|
|
1801
|
+
* interface decision, not a guess: make `stream()` async across `DeviceAgent`, or give it an
|
|
1802
|
+
* explicit device argument.
|
|
1803
|
+
*/
|
|
1804
|
+
soleDeviceState() {
|
|
1805
|
+
// `deviceStates` holds one entry per *registered* simulator, not per running one — the relay
|
|
1806
|
+
// opens a session for every device in `agent:register` and this Mac reports dozens. Taking the
|
|
1807
|
+
// first entry would pick whichever simulator simctl listed first, almost always a shut-down
|
|
1808
|
+
// one, which is worse than the `booted` alias it replaced: the alias at least found the device
|
|
1809
|
+
// that was actually up. `booted` is set by handleDeviceBoot, so filtering on it is the liveness
|
|
1810
|
+
// check `AndroidAgent` gets for free from `getSerial` (its serial map is only populated on
|
|
1811
|
+
// launch).
|
|
1812
|
+
return this.soleOf([...this.deviceStates.values()].filter((s) => s.booted));
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* `soleDeviceState`, but **it asks simctl before believing that nothing is booted**.
|
|
1816
|
+
*
|
|
1817
|
+
* `booted` is a cache and `initDeviceStates` clears it on `agent:registered` — every reconnect,
|
|
1818
|
+
* not only the first connection. So after a relay restart every flag reads false while the
|
|
1819
|
+
* simulators are still running, and a resolver that trusts the cache refuses a live device until
|
|
1820
|
+
* something else happens to refresh it. On the wire path that is `deviceFor`; these two entry
|
|
1821
|
+
* points have no session id to work from, so without this they stay broken.
|
|
1822
|
+
*
|
|
1823
|
+
* Only the empty case pays for the `listDevices` call: a cache that already names a booted device
|
|
1824
|
+
* is not wrong, just possibly incomplete, and the ambiguity refusal is stricter for having fewer
|
|
1825
|
+
* candidates rather than looser.
|
|
1826
|
+
*
|
|
1827
|
+
* **Every entry point that can await now comes through here** — the network pair, `installApp`,
|
|
1828
|
+
* `launchApp`, `queryUITree`, `screenshot`, `openUrl`. `stream()` cannot, and `soleDeviceState`
|
|
1829
|
+
* above says what that costs and why nothing was bolted on to hide it.
|
|
1830
|
+
*/
|
|
1831
|
+
async soleLiveDeviceState() {
|
|
1832
|
+
const cached = [...this.deviceStates.values()].filter((s) => s.booted);
|
|
1833
|
+
if (cached.length > 0)
|
|
1834
|
+
return this.soleOf(cached);
|
|
1835
|
+
const booted = new Set((await this.simctl.listDevices()).filter((d) => d.status === 'booted').map((d) => d.id));
|
|
1836
|
+
const up = [...this.deviceStates.values()].filter((s) => booted.has(s.deviceId));
|
|
1837
|
+
// **Ownership narrows liveness, and only when it can.** A developer with their own simulator open
|
|
1838
|
+
// makes two devices live, and asking simctl alone would find both and refuse both — the reconnect
|
|
1839
|
+
// case this whole path exists for. `ownedDevices` survives the reconnect and says which one is
|
|
1840
|
+
// tapflow's. When it says nothing — a fresh agent process, or devices booted entirely outside
|
|
1841
|
+
// tapflow — there is no ownership to apply and the refusal is the honest answer.
|
|
1842
|
+
const mine = up.filter((s) => this.ownedDevices.has(s.deviceId));
|
|
1843
|
+
const live = this.soleOf(mine.length > 0 ? mine : up);
|
|
1844
|
+
live.booted = true; // same cache write `ackInput` makes, so the next call skips simctl
|
|
1845
|
+
return live;
|
|
1846
|
+
}
|
|
1847
|
+
async soleLiveDeviceId() {
|
|
1848
|
+
return (await this.soleLiveDeviceState()).deviceId;
|
|
1849
|
+
}
|
|
1195
1850
|
async queryUITree() {
|
|
1196
|
-
const state = this.
|
|
1851
|
+
const state = await this.soleLiveDeviceState();
|
|
1197
1852
|
return this.readUITree(state);
|
|
1198
1853
|
}
|
|
1199
1854
|
// ── Audio output (opt-in, macOS 14.2+ Core Audio process taps) ───────────────────────────────
|
|
@@ -1289,7 +1944,7 @@ export class IOSAgent {
|
|
|
1289
1944
|
}
|
|
1290
1945
|
// async, not a bare `return`: `soleDeviceId` throws, and a synchronous throw out of a method
|
|
1291
1946
|
// typed `Promise<T>` skips every caller's `.catch`.
|
|
1292
|
-
async screenshot() { return this.simctl.screenshot(this.
|
|
1947
|
+
async screenshot() { return this.simctl.screenshot(await this.soleLiveDeviceId()); }
|
|
1293
1948
|
stream() {
|
|
1294
1949
|
const first = this.soleDeviceState();
|
|
1295
1950
|
// DeviceAgent.stream() is the platform-neutral Buffer contract; unwrap StreamFrame payloads.
|
|
@@ -1312,9 +1967,8 @@ export class IOSAgent {
|
|
|
1312
1967
|
first?.touchHelper?.touchEnd();
|
|
1313
1968
|
return Promise.resolve();
|
|
1314
1969
|
}
|
|
1315
|
-
openUrl(url) {
|
|
1316
|
-
|
|
1317
|
-
return this.simctl.openUrl(first.deviceId, url);
|
|
1970
|
+
async openUrl(url) {
|
|
1971
|
+
return this.simctl.openUrl(await this.soleLiveDeviceId(), url);
|
|
1318
1972
|
}
|
|
1319
1973
|
}
|
|
1320
1974
|
//# sourceMappingURL=IOSAgent.js.map
|