@tapflowio/ios-agent 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/TapflowNetFilter.app/Contents/CodeResources +0 -0
- package/bin/TapflowNetFilter.app/Contents/Info.plist +48 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/Info.plist +58 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/MacOS/dev.tapflow.netfilter.ext +0 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/_CodeSignature/CodeResources +123 -0
- package/bin/TapflowNetFilter.app/Contents/Library/SystemExtensions/dev.tapflow.netfilter.ext.systemextension/Contents/embedded.provisionprofile +0 -0
- package/bin/TapflowNetFilter.app/Contents/MacOS/TapflowNetFilter +0 -0
- package/bin/TapflowNetFilter.app/Contents/PkgInfo +1 -0
- package/bin/TapflowNetFilter.app/Contents/_CodeSignature/CodeResources +151 -0
- package/bin/TapflowNetFilter.app/Contents/embedded.provisionprofile +0 -0
- package/bin/libtapflow-nethook.dylib +0 -0
- package/dist/IOSAgent.d.ts +131 -3
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +312 -20
- package/dist/IOSAgent.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +24 -0
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +32 -0
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/SimulatorNetwork.d.ts +369 -0
- package/dist/SimulatorNetwork.d.ts.map +1 -0
- package/dist/SimulatorNetwork.js +899 -0
- package/dist/SimulatorNetwork.js.map +1 -0
- package/package.json +9 -7
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|