@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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/bin/TapflowNetFilter.app/Contents/CodeResources +0 -0
  3. package/bin/TapflowNetFilter.app/Contents/Info.plist +48 -0
  4. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/Info.plist +58 -0
  5. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/MacOS/dev.tapflow.netfilter.ext +0 -0
  6. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/_CodeSignature/CodeResources +123 -0
  7. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/embedded.provisionprofile +0 -0
  8. package/bin/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter +0 -0
  9. package/bin/TapflowNetFilter.app/Contents/PkgInfo +1 -0
  10. package/bin/TapflowNetFilter.app/Contents/_CodeSignature/CodeResources +151 -0
  11. package/bin/TapflowNetFilter.app/Contents/embedded.provisionprofile +0 -0
  12. package/bin/libtapflow-nethook.dylib +0 -0
  13. package/dist/DeviceChromeLoader.d.ts +2 -45
  14. package/dist/DeviceChromeLoader.d.ts.map +1 -1
  15. package/dist/DeviceChromeLoader.js.map +1 -1
  16. package/dist/IOSAgent.d.ts +196 -3
  17. package/dist/IOSAgent.d.ts.map +1 -1
  18. package/dist/IOSAgent.js +782 -128
  19. package/dist/IOSAgent.js.map +1 -1
  20. package/dist/KeyboardHelperDaemon.d.ts.map +1 -1
  21. package/dist/KeyboardHelperDaemon.js +2 -1
  22. package/dist/KeyboardHelperDaemon.js.map +1 -1
  23. package/dist/MjpegStreamer.d.ts.map +1 -1
  24. package/dist/MjpegStreamer.js +9 -1
  25. package/dist/MjpegStreamer.js.map +1 -1
  26. package/dist/SimctlWrapper.d.ts +70 -1
  27. package/dist/SimctlWrapper.d.ts.map +1 -1
  28. package/dist/SimctlWrapper.js +118 -3
  29. package/dist/SimctlWrapper.js.map +1 -1
  30. package/dist/SimulatorNetwork.d.ts +369 -0
  31. package/dist/SimulatorNetwork.d.ts.map +1 -0
  32. package/dist/SimulatorNetwork.js +899 -0
  33. package/dist/SimulatorNetwork.js.map +1 -0
  34. package/dist/TouchHelper.d.ts +53 -14
  35. package/dist/TouchHelper.d.ts.map +1 -1
  36. package/dist/TouchHelper.js +237 -59
  37. package/dist/TouchHelper.js.map +1 -1
  38. package/dist/childProcessKill.d.ts +3 -0
  39. package/dist/childProcessKill.d.ts.map +1 -0
  40. package/dist/childProcessKill.js +19 -0
  41. package/dist/childProcessKill.js.map +1 -0
  42. 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
- const AGENT_CAPABILITIES = ['clipboard'];
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
- ws.send(JSON.stringify({
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
- this.handleRelayMessage(JSON.parse(d.toString()));
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.ws.send(JSON.stringify({
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
- if (!this.ws)
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.ws.send(JSON.stringify({
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.ws.send(JSON.stringify({
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
- async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier) {
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
- if (!state || !this.ws)
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 = ++state.bootSeq;
411
- void state.streamReader?.cancel();
412
- state.streamReader = null;
413
- state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
414
- state.touchHelper?.stop();
415
- state.touchHelper = null;
416
- state.booted = false;
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 if (target.status !== 'booted') {
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
- const refreshed = await this.simctl.listDevices();
478
- const refreshedDevice = refreshed.find((d) => d.id === deviceId) ?? target;
479
- this.sendChromeData(state, {
480
- ...refreshedDevice,
481
- status: 'booted',
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
- this.ws?.send(JSON.stringify({ type: 'device:ready', sessionId, payload: { deviceId } }));
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
- const message = e instanceof Error ? e.message : String(e);
506
- this.ws?.send(JSON.stringify({ type: 'device:boot-error', sessionId, message }));
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.bootSeq++;
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.ws?.send(JSON.stringify({
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
- // Ack a terminal input: input:done = dispatched to a booted device (not a landing guarantee — HID is fire-and-forget); input:error = no live channel / not booted. Off the sync inject path, so start/end pairing is unaffected.
582
- async ackInput(state, dispatched) {
583
- const booted = dispatched && (state.booted || (await this.isBooted(state.deviceId)));
584
- if (booted)
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.ws?.send(JSON.stringify(booted
587
- ? { type: 'input:done', sessionId: state.sessionId }
588
- : { type: 'input:error', sessionId: state.sessionId, message: dispatched ? 'device not booted' : 'input channel not ready' }));
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
- this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message: 'No booted device' }));
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(() => this.ws?.send(JSON.stringify({ type: 'app:install-done', sessionId })))
962
+ .then(() => respond({ type: 'app:install-done' }))
625
963
  .catch((e) => {
626
964
  const message = e instanceof Error ? e.message : String(e);
627
- this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message }));
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
- this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message: 'No booted device' }));
982
+ respond({ type: 'app:launch-error', message: 'No booted device' });
637
983
  break;
638
984
  }
639
- this.simctl.launchApp(launchState.deviceId, bundleId)
640
- .then(() => {
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
- this.ws?.send(JSON.stringify({ type: 'app:launch-done', sessionId }));
1001
+ respond({ type: 'app:launch-done' });
646
1002
  })
647
1003
  .catch((e) => {
648
1004
  const message = e instanceof Error ? e.message : String(e);
649
- this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message }));
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
- state.touchHelper?.touchEnd();
675
- void this.ackInput(state, state.touchHelper !== null); // terminal of a tap/swipe → ack the gesture
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
- state.touchHelper?.pinchEnd();
700
- void this.ackInput(state, state.touchHelper !== null);
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.ws?.send(JSON.stringify({
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.ws?.send(JSON.stringify({ type: 'input:type-error', sessionId, message: 'No booted device' }));
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
- state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft']);
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.ws?.send(JSON.stringify({ type: 'input:type-done', sessionId })))
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.ws?.send(JSON.stringify({ type: 'input:type-error', sessionId, message }));
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
- this.ws?.send(JSON.stringify({ type: 'input:error', sessionId: msg.sessionId, message: `unknown key code: ${code}` }));
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
- this.simctl.hideSoftwareKeyboard(state.deviceId)
792
- .then(() => state.touchHelper?.sendKey(usage, modifiers ?? 0))
793
- .catch((e) => {
794
- logger.error('hideSoftwareKeyboard (on key) failed:', e);
795
- state.touchHelper?.sendKey(usage, modifiers ?? 0);
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?.sendKey(usage, modifiers ?? 0);
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
- state.touchHelper?.pressLegacyButton(0);
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
- state.touchHelper?.pressButtonDown(btn.usagePage, btn.usage);
1234
+ outcome = wrote(btnHelper?.pressButtonDown(btn.usagePage, btn.usage) ?? false);
825
1235
  else if (phase === 'up')
826
- state.touchHelper?.pressButtonUp(btn.usagePage, btn.usage);
1236
+ outcome = wrote(btnHelper?.pressButtonUp(btn.usagePage, btn.usage) ?? false);
827
1237
  else
828
- state.touchHelper?.pressButton(btn.usagePage, btn.usage);
1238
+ outcome = wrote(btnHelper?.pressButton(btn.usagePage, btn.usage) ?? false);
829
1239
  }
830
1240
  }
831
- void this.ackInput(state, state.touchHelper !== null);
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
- this.ws?.send(JSON.stringify({ type: 'open-url:error', sessionId, message: 'no booted device' }));
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(() => this.ws?.send(JSON.stringify({ type: 'open-url:done', sessionId })))
1278
+ .then(() => respond({ type: 'open-url:done' }))
844
1279
  .catch((e) => {
845
1280
  const message = e instanceof Error ? e.message : String(e);
846
- this.ws?.send(JSON.stringify({ type: 'open-url:error', sessionId, message }));
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.ws?.send(JSON.stringify({ type: 'screenshot:error', sessionId, requestId, message: 'No booted device' }));
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.ws?.send(JSON.stringify({
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.ws?.send(JSON.stringify({ type: 'screenshot:error', sessionId, requestId, message }));
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
- this.ws?.send(JSON.stringify({ type: 'app:clear-state-error', sessionId, message: !state ? 'No booted device' : 'bundleId missing' }));
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(() => this.ws?.send(JSON.stringify({ type: 'app:clear-state-done', sessionId })))
1327
+ .then(() => respond({ type: 'app:clear-state-done' }))
887
1328
  .catch((e) => {
888
1329
  const message = e instanceof Error ? e.message : String(e);
889
- this.ws?.send(JSON.stringify({ type: 'app:clear-state-error', sessionId, message }));
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.ws?.send(JSON.stringify({
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.ws?.send(JSON.stringify({ sessionId, requestId, ...body }));
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.ws?.send(JSON.stringify({
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.ws?.send(JSON.stringify({
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
- state.touchHelper.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft']);
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.ws?.send(JSON.stringify({ type: 'clipboard:write-done', sessionId, requestId })))
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.ws?.send(JSON.stringify({
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.ws?.send(JSON.stringify({ type: 'ui:tree:error', sessionId, requestId, message: 'No booted device' }));
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.ws?.send(JSON.stringify({ type: 'ui:tree:response', sessionId, requestId, elements })))
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.ws?.send(JSON.stringify({ type: 'ui:tree:error', sessionId, requestId, message }));
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.soleDeviceId(), appPath);
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.soleDeviceId(), bundleId);
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
- soleDeviceState() {
1177
- // `deviceStates` holds one entry per *registered* simulator, not per running one — the relay
1178
- // opens a session for every device in `agent:register` and this Mac reports dozens. Taking the
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
- soleDeviceId() { return this.soleDeviceState().deviceId; }
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.soleDeviceState();
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.soleDeviceId()); }
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
- const first = this.soleDeviceState();
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