@tapflowio/ios-agent 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/bin/TapflowNetFilter.app/Contents/CodeResources +0 -0
  2. package/bin/TapflowNetFilter.app/Contents/Info.plist +48 -0
  3. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/Info.plist +58 -0
  4. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/MacOS/dev.tapflow.netfilter.ext +0 -0
  5. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/_CodeSignature/CodeResources +123 -0
  6. package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/embedded.provisionprofile +0 -0
  7. package/bin/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter +0 -0
  8. package/bin/TapflowNetFilter.app/Contents/PkgInfo +1 -0
  9. package/bin/TapflowNetFilter.app/Contents/_CodeSignature/CodeResources +151 -0
  10. package/bin/TapflowNetFilter.app/Contents/embedded.provisionprofile +0 -0
  11. package/bin/libtapflow-nethook.dylib +0 -0
  12. package/dist/IOSAgent.d.ts +131 -3
  13. package/dist/IOSAgent.d.ts.map +1 -1
  14. package/dist/IOSAgent.js +312 -20
  15. package/dist/IOSAgent.js.map +1 -1
  16. package/dist/SimctlWrapper.d.ts +24 -0
  17. package/dist/SimctlWrapper.d.ts.map +1 -1
  18. package/dist/SimctlWrapper.js +32 -0
  19. package/dist/SimctlWrapper.js.map +1 -1
  20. package/dist/SimulatorNetwork.d.ts +414 -0
  21. package/dist/SimulatorNetwork.d.ts.map +1 -0
  22. package/dist/SimulatorNetwork.js +996 -0
  23. package/dist/SimulatorNetwork.js.map +1 -0
  24. package/package.json +10 -8
package/dist/IOSAgent.js CHANGED
@@ -8,7 +8,13 @@ import { WebSocket } from 'ws';
8
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'];
12
18
  // Human prose for each reason. Not in `@tapflowio/protocol`: that package's main entry must stay
13
19
  // runtime-free so it erases under `import type` and never reaches the dashboard bundle — a lookup
14
20
  // table is a runtime value. The `reason` on the wire is the machine-readable half; this is only what
@@ -35,6 +41,7 @@ export const IOS_BUTTON_ALIASES = {
35
41
  };
36
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';
37
43
  import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine } from './SimctlWrapper.js';
44
+ import { SimulatorNetwork } from './SimulatorNetwork.js';
38
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';
39
46
  import { ScreenCaptureStreamer } from './ScreenCaptureStreamer.js';
40
47
  import { AudioCaptureStreamer, readSimVolume, applyGain } from './AudioCaptureStreamer.js';
@@ -53,6 +60,7 @@ const AUDIO_POLL_MS = 1500;
53
60
  const EXTRACT_MAXBUFFER = 256 * 1024 * 1024; // 256 MB
54
61
  export class IOSAgent {
55
62
  simctl;
63
+ network;
56
64
  fps;
57
65
  intervalMs;
58
66
  reconnectDelays;
@@ -90,6 +98,26 @@ export class IOSAgent {
90
98
  ws.send(JSON.stringify(msg));
91
99
  }
92
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();
93
121
  // Last app launched per device (deviceId → bundleId). The XCUITest tree backend
94
122
  // queries by bundleId; kept outside DeviceState so it survives a relay reconnect
95
123
  // (which clears deviceStates) while the app keeps running in the simulator.
@@ -112,6 +140,22 @@ export class IOSAgent {
112
140
  throw new PlatformError('IOSAgent requires macOS (xcrun simctl is macOS-only)');
113
141
  }
114
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); });
115
159
  this.fps = options.fps ?? 30;
116
160
  this.intervalMs = options.intervalMs;
117
161
  this.reconnectDelays = options.reconnectDelays ?? [1000, 2000, 4000, 8000, 16000, 30000];
@@ -128,6 +172,9 @@ export class IOSAgent {
128
172
  }
129
173
  async connect(relayUrl) {
130
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();
131
178
  if (this._reconnectTimer) {
132
179
  clearTimeout(this._reconnectTimer);
133
180
  this._reconnectTimer = null;
@@ -266,6 +313,9 @@ export class IOSAgent {
266
313
  }
267
314
  this.deviceStates.clear();
268
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();
269
319
  this.sleepBlocker.release();
270
320
  this.ws?.close();
271
321
  this.ws = null;
@@ -328,6 +378,13 @@ export class IOSAgent {
328
378
  });
329
379
  }
330
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
+ });
331
388
  void state.streamReader?.cancel();
332
389
  state.streamReader = null;
333
390
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
@@ -622,6 +679,13 @@ export class IOSAgent {
622
679
  // twice a second against a device that is now deliberately off — for the rest of the deadline,
623
680
  // with nothing left that wants the answer.
624
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);
688
+ });
625
689
  // Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
626
690
  // shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
627
691
  // device it is taking down — and one that revives itself, so the stale reference outlives the
@@ -643,7 +707,16 @@ export class IOSAgent {
643
707
  if (this.audioEnabled())
644
708
  this.startAudioCapture(state, streamWs, deviceId);
645
709
  state.booted = true;
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);
646
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);
647
720
  // Sync AppleKeyboards after ready — fire-and-forget so streaming isn't delayed.
648
721
  // hw=Automatic lets the hardware layout follow the active input source on LANG1/CapsLock.
649
722
  // By the time the user navigates to a text field the sync has already completed.
@@ -690,6 +763,16 @@ export class IOSAgent {
690
763
  if (!state)
691
764
  return;
692
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
+ });
693
776
  void state.streamReader?.cancel();
694
777
  state.streamReader = null;
695
778
  state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
@@ -704,6 +787,13 @@ export class IOSAgent {
704
787
  this.uiTreeReader.stopIfDevice(deviceId);
705
788
  try {
706
789
  await this.simctl.shutdown(deviceId);
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);
707
797
  this.sendMsg({
708
798
  type: 'device:shutdown-done',
709
799
  sessionId,
@@ -892,8 +982,18 @@ export class IOSAgent {
892
982
  respond({ type: 'app:launch-error', message: 'No booted device' });
893
983
  break;
894
984
  }
895
- this.simctl.launchApp(launchState.deviceId, bundleId)
896
- .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);
897
997
  // Track the foreground app so ui:tree:request can query it via XCUITest.
898
998
  this.lastBundleIds.set(launchState.deviceId, bundleId);
899
999
  // Audio: the whole-sim tap's poll picks up the launched app process within one interval;
@@ -1235,6 +1335,29 @@ export class IOSAgent {
1235
1335
  // cannot know when the key actually lands (a visible software keyboard makes this path
1236
1336
  // await hideSoftwareKeyboard first), and reading too early returns the PREVIOUS
1237
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;
1238
1361
  case 'clipboard:read': {
1239
1362
  // `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
1240
1363
  // the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
@@ -1523,10 +1646,122 @@ export class IOSAgent {
1523
1646
  * (`AndroidAgent.ts:1429`); these do the same. Throwing beats falling back to simctl's `booted`
1524
1647
  * alias, which would quietly act on whichever simulator happened to be up. */
1525
1648
  async installApp(appPath) {
1526
- 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());
1527
1762
  }
1528
1763
  async launchApp(bundleId) {
1529
- await this.simctl.launchApp(this.soleDeviceId(), bundleId);
1764
+ await this.simctl.launchApp(await this.soleLiveDeviceId(), bundleId);
1530
1765
  }
1531
1766
  /** The one booted session, or undefined when there is no unambiguous answer. Callers that can
1532
1767
  * reasonably do nothing (input) use this; callers that owe the user a result use
@@ -1535,15 +1770,9 @@ export class IOSAgent {
1535
1770
  const live = [...this.deviceStates.values()].filter((s) => s.booted);
1536
1771
  return live.length === 1 ? live[0] : undefined;
1537
1772
  }
1538
- soleDeviceState() {
1539
- // `deviceStates` holds one entry per *registered* simulator, not per running one — the relay
1540
- // opens a session for every device in `agent:register` and this Mac reports dozens. Taking the
1541
- // first entry would pick whichever simulator simctl listed first, almost always a shut-down
1542
- // one, which is worse than the `booted` alias it replaced: the alias at least found the device
1543
- // that was actually up. `booted` is set by handleDeviceBoot, so filtering on it is the liveness
1544
- // check `AndroidAgent` gets for free from `getSerial` (its serial map is only populated on
1545
- // launch).
1546
- 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) {
1547
1776
  if (live.length === 0)
1548
1777
  throw new ValidationError('no booted device — call connect() first');
1549
1778
  // Refusing beats guessing: this interface has no way to say which device is meant, and picking
@@ -1553,9 +1782,73 @@ export class IOSAgent {
1553
1782
  }
1554
1783
  return live[0];
1555
1784
  }
1556
- 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
+ }
1557
1850
  async queryUITree() {
1558
- const state = this.soleDeviceState();
1851
+ const state = await this.soleLiveDeviceState();
1559
1852
  return this.readUITree(state);
1560
1853
  }
1561
1854
  // ── Audio output (opt-in, macOS 14.2+ Core Audio process taps) ───────────────────────────────
@@ -1651,7 +1944,7 @@ export class IOSAgent {
1651
1944
  }
1652
1945
  // async, not a bare `return`: `soleDeviceId` throws, and a synchronous throw out of a method
1653
1946
  // typed `Promise<T>` skips every caller's `.catch`.
1654
- async screenshot() { return this.simctl.screenshot(this.soleDeviceId()); }
1947
+ async screenshot() { return this.simctl.screenshot(await this.soleLiveDeviceId()); }
1655
1948
  stream() {
1656
1949
  const first = this.soleDeviceState();
1657
1950
  // DeviceAgent.stream() is the platform-neutral Buffer contract; unwrap StreamFrame payloads.
@@ -1674,9 +1967,8 @@ export class IOSAgent {
1674
1967
  first?.touchHelper?.touchEnd();
1675
1968
  return Promise.resolve();
1676
1969
  }
1677
- openUrl(url) {
1678
- const first = this.soleDeviceState();
1679
- return this.simctl.openUrl(first.deviceId, url);
1970
+ async openUrl(url) {
1971
+ return this.simctl.openUrl(await this.soleLiveDeviceId(), url);
1680
1972
  }
1681
1973
  }
1682
1974
  //# sourceMappingURL=IOSAgent.js.map