@tapflowio/ios-agent 0.28.1 → 0.30.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 (49) hide show
  1. package/NOTICE +1 -1
  2. package/dist/AudioCaptureStreamer.d.ts +3 -2
  3. package/dist/AudioCaptureStreamer.d.ts.map +1 -1
  4. package/dist/AudioCaptureStreamer.js +16 -10
  5. package/dist/AudioCaptureStreamer.js.map +1 -1
  6. package/dist/IOSAgent.d.ts +31 -5
  7. package/dist/IOSAgent.d.ts.map +1 -1
  8. package/dist/IOSAgent.js +203 -66
  9. package/dist/IOSAgent.js.map +1 -1
  10. package/dist/LeanStore.d.ts +24 -3
  11. package/dist/LeanStore.d.ts.map +1 -1
  12. package/dist/LeanStore.js +61 -2
  13. package/dist/LeanStore.js.map +1 -1
  14. package/dist/SimProcessTree.d.ts +3 -1
  15. package/dist/SimProcessTree.d.ts.map +1 -1
  16. package/dist/SimProcessTree.js +14 -4
  17. package/dist/SimProcessTree.js.map +1 -1
  18. package/dist/SimctlWrapper.d.ts +16 -2
  19. package/dist/SimctlWrapper.d.ts.map +1 -1
  20. package/dist/SimctlWrapper.js +56 -5
  21. package/dist/SimctlWrapper.js.map +1 -1
  22. package/dist/SimulatorNetwork.d.ts +3 -2
  23. package/dist/SimulatorNetwork.d.ts.map +1 -1
  24. package/dist/SimulatorNetwork.js +3 -2
  25. package/dist/SimulatorNetwork.js.map +1 -1
  26. package/dist/TouchHelper.d.ts +11 -1
  27. package/dist/TouchHelper.d.ts.map +1 -1
  28. package/dist/TouchHelper.js +53 -2
  29. package/dist/TouchHelper.js.map +1 -1
  30. package/dist/launchArgs.d.ts +6 -0
  31. package/dist/launchArgs.d.ts.map +1 -0
  32. package/dist/launchArgs.js +21 -0
  33. package/dist/launchArgs.js.map +1 -0
  34. package/dist/leanLabels.d.ts +5 -0
  35. package/dist/leanLabels.d.ts.map +1 -1
  36. package/dist/leanLabels.js +5 -1
  37. package/dist/leanLabels.js.map +1 -1
  38. package/dist/serviceTiers.d.ts +25 -0
  39. package/dist/serviceTiers.d.ts.map +1 -0
  40. package/dist/serviceTiers.js +251 -0
  41. package/dist/serviceTiers.js.map +1 -0
  42. package/dist/simctl.d.ts +3 -0
  43. package/dist/simctl.d.ts.map +1 -1
  44. package/dist/simctl.js +1 -1
  45. package/dist/simctl.js.map +1 -1
  46. package/dist/xcuiTree.d.ts.map +1 -1
  47. package/dist/xcuiTree.js +23 -6
  48. package/dist/xcuiTree.js.map +1 -1
  49. package/package.json +5 -5
package/dist/IOSAgent.js CHANGED
@@ -6,7 +6,10 @@ import { randomUUID } from 'crypto';
6
6
  import { spawn } from 'child_process';
7
7
  import { WebSocket } from 'ws';
8
8
  import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE, SHUTDOWN_NO_SESSION_STATE, downloadBuild } from '@tapflowio/agent-core';
9
+ import { appliedKeys, compactLaunchOptions, launchOptionsProblem } from '@tapflowio/protocol/launch';
9
10
  const logger = createLogger('ios-agent');
11
+ /** The run reset's pasteboard step, the wait for the clipboard queue included; one step's bound like the others. */
12
+ const RUN_RESET_PASTEBOARD_TIMEOUT_MS = 10_000;
10
13
  // Typed so a typo cannot ship silently — the viewer gates the whole clipboard bridge on this.
11
14
  // `full-reset` is honoured in `handleDeviceBoot`, which shuts a running device down before
12
15
  // `simctl erase` (which refuses anything but a shut-down device) and boots it again.
@@ -14,7 +17,7 @@ const logger = createLogger('ios-agent');
14
17
  // actually took is per device and per app, and `network:state.available` carries that — the split the
15
18
  // protocol documents. Added last, after the handler and the boot-time arming, because the string on
16
19
  // its own is what puts a control on screen.
17
- const AGENT_CAPABILITIES = ['clipboard', 'full-reset', 'network-control', 'build-download'];
20
+ const AGENT_CAPABILITIES = ['clipboard', 'full-reset', 'network-control', 'build-download', 'launch-options', 'launch-env'];
18
21
  // Human prose for each reason. Not in `@tapflowio/protocol`: that package's main entry must stay
19
22
  // runtime-free so it erases under `import type` and never reaches the dashboard bundle — a lookup
20
23
  // table is a runtime value. The `reason` on the wire is the machine-readable half; this is only what
@@ -39,11 +42,11 @@ export const IOS_BUTTON_ALIASES = {
39
42
  volume_up: 'volume-up',
40
43
  volume_down: 'volume-down',
41
44
  };
42
- import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, acquireLoopStallWatch, describeRelayLoss, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
45
+ import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, acquireLoopStallWatch, describeRelayLoss, getMachineId, sameServiceTier, resetApps, runResetSteps, RUN_RESET_BUDGET_MS, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
43
46
  import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine, launchFailureReason } from './SimctlWrapper.js';
44
47
  import { SimulatorNetwork } from './SimulatorNetwork.js';
45
48
  import { LeanStore } from './LeanStore.js';
46
- import { LEAN_LABELS } from './leanLabels.js';
49
+ import { MANAGED_LABELS, labelsFor } from './serviceTiers.js';
47
50
  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';
48
51
  import { ScreenCaptureStreamer } from './ScreenCaptureStreamer.js';
49
52
  import { AudioCaptureStreamer, readSimVolume, applyGain } from './AudioCaptureStreamer.js';
@@ -156,6 +159,7 @@ export class IOSAgent {
156
159
  * falling back to nothing would blank a form factor it already had until the next reconnect. */
157
160
  formFactors = new Map();
158
161
  resourcesTimer = null;
162
+ resourcesInFlight = null;
159
163
  resources = createResourceSampler();
160
164
  _stopping = false;
161
165
  _reconnectTimer = null;
@@ -182,7 +186,7 @@ export class IOSAgent {
182
186
  // reason, as `sleepBlocker` below.
183
187
  this.lean = options.lean ?? false;
184
188
  // Never the host's real store under vitest, for the reason given for `network` just below.
185
- this.leanStore = options.leanStore ?? new LeanStore(process.env.VITEST ? path.join(tmpdir(), 'tapflow-no-lean-store') : undefined);
189
+ this.leanStore = options.leanStore ?? new LeanStore(process.env.VITEST ? path.join(tmpdir(), 'tapflow-no-lean-store') : undefined, MANAGED_LABELS);
186
190
  this.network = options.network ?? new SimulatorNetwork(this.simctl, process.env.VITEST ? { filterHostBinary: path.join(tmpdir(), 'tapflow-no-filter-host') } : {});
187
191
  // **Wired here rather than in the constructor call above, and the difference is coverage.** An
188
192
  // injected `SimulatorNetwork` — which is what every test uses — would otherwise never carry the
@@ -264,6 +268,7 @@ export class IOSAgent {
264
268
  status: d.status,
265
269
  osVersion: d.osVersion,
266
270
  formFactor: d.typeId ? formFactors.get(d.typeId) : undefined,
271
+ serviceTier: this.reportedTier(d),
267
272
  })),
268
273
  });
269
274
  });
@@ -298,8 +303,8 @@ export class IOSAgent {
298
303
  }
299
304
  catch { /* ignore malformed */ }
300
305
  });
301
- this.reportResources();
302
- this.resourcesTimer = setInterval(() => this.reportResources(), 5000);
306
+ void this.reportResources();
307
+ this.resourcesTimer = setInterval(() => { void this.reportResources(); }, 5000);
303
308
  this.lastPingAt = null;
304
309
  this.lastSocketError = undefined;
305
310
  ws.on('ping', () => { this.lastPingAt = Date.now(); });
@@ -431,23 +436,41 @@ export class IOSAgent {
431
436
  });
432
437
  }, delay);
433
438
  }
434
- reportResources() {
435
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
439
+ /** Samples off the event loop, then reports. One sample at a time per socket, so a slow one (bounded by its
440
+ * 1 s timeout) is not stacked by the next tick; keyed by socket, so a reconnect is not skipped behind the
441
+ * old socket's sample. Slots and the clock are read after the sample, when the report leaves. */
442
+ async reportResources() {
443
+ const ws = this.ws;
444
+ if (!ws || ws.readyState !== WebSocket.OPEN || this.resourcesInFlight === ws)
436
445
  return;
437
- const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.streamReader !== null).length;
438
- const slotsTotal = this.deviceStates.size;
439
- const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
440
- this.sendOn(this.ws, {
441
- type: 'agent:resources',
442
- resources: {
443
- cpuPercent: this.resources.getCpuPercent(),
444
- memUsedMB,
445
- memTotalMB,
446
- slotsAvailable: Math.max(0, slotsTotal - bootedCount),
447
- slotsTotal,
448
- reportedAt: Date.now(),
449
- },
450
- });
446
+ this.resourcesInFlight = ws;
447
+ try {
448
+ const [{ memUsedMB, memTotalMB }, memoryPressure] = await Promise.all([
449
+ this.resources.getMemoryUsage(),
450
+ this.resources.getMemoryPressure(),
451
+ ]);
452
+ // A reconnect while sampling replaced the socket: this report has nowhere to go.
453
+ if (this.ws !== ws || ws.readyState !== WebSocket.OPEN)
454
+ return;
455
+ const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.streamReader !== null).length;
456
+ const slotsTotal = this.deviceStates.size;
457
+ this.sendOn(ws, {
458
+ type: 'agent:resources',
459
+ resources: {
460
+ cpuPercent: this.resources.getCpuPercent(),
461
+ memUsedMB,
462
+ memTotalMB,
463
+ slotsAvailable: Math.max(0, slotsTotal - bootedCount),
464
+ slotsTotal,
465
+ reportedAt: Date.now(),
466
+ memoryPressure,
467
+ },
468
+ });
469
+ }
470
+ finally {
471
+ if (this.resourcesInFlight === ws)
472
+ this.resourcesInFlight = null;
473
+ }
451
474
  }
452
475
  cleanupDeviceState(state) {
453
476
  // Drop this device out of the filter rule. The rule lives on the host and is keyed only by udid,
@@ -674,7 +697,7 @@ export class IOSAgent {
674
697
  // `requestId` is a parameter, never a field on `state`: `bootSeq` exists because two boots overlap,
675
698
  // and a correlator hoisted onto shared state would answer the first request with the second's id.
676
699
  // Optional because the relay's idle timer boots nothing — but every *browser* boot carries one.
677
- async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier, requestId, automation = false) {
700
+ async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier, requestId, automation = false, serviceTier, runReset) {
678
701
  const state = this.deviceStates.get(sessionId);
679
702
  // Split, because the two halves are not the same kind of nothing. No open control channel means the
680
703
  // answer itself has nowhere to go, so this is the one abandonment that stays silent — and the caller
@@ -721,6 +744,13 @@ export class IOSAgent {
721
744
  const target = devices.find((d) => d.id === deviceId);
722
745
  if (!target)
723
746
  throw new PlatformError(`Device not found: ${deviceId}`);
747
+ // A running device on another tier than the run asked for is restarted onto it: the services have to be off
748
+ // from launch, and replaying on whatever the last boot left is what the tier exists to stop.
749
+ const retier = !fullErase && target.status === 'booted' && serviceTier !== undefined && leanSupported(target.osVersion)
750
+ && !sameServiceTier(this.leanStore.currentTier(deviceId), serviceTier);
751
+ // Read before anything below changes it: a Full reset shuts the device down itself, and `unknown`
752
+ // (booting, shutting down) is a device paying a boot's cost now.
753
+ const cold = fullErase || retier || target.status !== 'booted';
724
754
  if (fullErase) {
725
755
  // `simctl erase` only accepts a shut-down device, and a device is often already running —
726
756
  // it survives agent restarts and sessions that ended without a clean shutdown. Without this
@@ -747,7 +777,6 @@ export class IOSAgent {
747
777
  this.abandonBoot(state, seq, sessionId, requestId);
748
778
  return;
749
779
  }
750
- this.prepareLean(target);
751
780
  try {
752
781
  await this.simctl.erase(deviceId);
753
782
  }
@@ -770,6 +799,9 @@ export class IOSAgent {
770
799
  }
771
800
  throw err;
772
801
  }
802
+ // After the erase, not before: a failed erase boots the device back as it was found, and the tier is part of
803
+ // that. The overrides live on the host, so the erase does not touch them either way.
804
+ this.prepareTier(target, serviceTier);
773
805
  await this.simctl.boot(deviceId);
774
806
  }
775
807
  else {
@@ -780,10 +812,22 @@ export class IOSAgent {
780
812
  // deadline to discover. `SimctlWrapper.boot` swallows `Unable to boot device in current
781
813
  // state: Booted`, so re-issuing it for a device that really is up costs one no-op
782
814
  // subprocess and makes "the wait only ever runs after a boot was accepted" true.
783
- if (target.status === 'shutdown')
784
- this.prepareLean(target);
785
- else if (this.lean && !this.leanStore.isApplied(deviceId)) {
786
- logger.info(`Lean mode: ${target.name} is already running, so it applies from its next boot`);
815
+ if (retier) {
816
+ logger.info(`Service tier: restarting ${target.name} onto ${serviceTier.name}`);
817
+ // As for a Full reset: a runner starting for this device would boot it back up mid-change.
818
+ this.uiTreeReader.stopIfDevice(deviceId);
819
+ await this.simctl.shutdown(deviceId);
820
+ if (seq !== state.bootSeq) {
821
+ this.abandonBoot(state, seq, sessionId, requestId);
822
+ return;
823
+ }
824
+ this.prepareTier(target, serviceTier);
825
+ }
826
+ else if (target.status === 'shutdown') {
827
+ this.prepareTier(target, serviceTier);
828
+ }
829
+ else if (leanSupported(target.osVersion) && !sameServiceTier(this.leanStore.currentTier(deviceId), this.defaultTier())) {
830
+ logger.info(`Service tier: ${target.name} is already running, so ${this.defaultTier().name} applies from its next boot`);
787
831
  }
788
832
  await this.bootWithZombieRecovery(deviceId);
789
833
  }
@@ -802,6 +846,13 @@ export class IOSAgent {
802
846
  // twice a second against a device that is now deliberately off — for the rest of the deadline,
803
847
  // with nothing left that wants the answer.
804
848
  const bootedDevice = await this.simctl.waitUntilBooted(deviceId, { isStale: () => seq !== state.bootSeq });
849
+ // Before anything runs on the device: the network arm below starts nothing, and the run installs its app after the
850
+ // ready. Clearing an app now and installing it after leaves a clean container.
851
+ const runResetReport = runReset ? await this.resetForRun(deviceId, runReset, () => seq !== state.bootSeq) : undefined;
852
+ if (seq !== state.bootSeq) {
853
+ this.abandonBoot(state, seq, sessionId, requestId);
854
+ return;
855
+ }
805
856
  // Arm the network injection while nothing is running on the device yet — dyld reads the
806
857
  // environment at process start, so anything launched before this is unhooked for its whole life.
807
858
  // Best-effort: the network toggle is opt-in and a device that cannot be armed says so through
@@ -847,7 +898,7 @@ export class IOSAgent {
847
898
  // every path precisely so that case reaches here. Requiring an off→on transition would leave
848
899
  // that device unowned and put the resolvers back to refusing between two live simulators.
849
900
  this.ownedDevices.add(deviceId);
850
- this.sendMsg({ type: 'device:ready', sessionId, requestId, payload: { deviceId } });
901
+ this.sendMsg({ type: 'device:ready', sessionId, requestId, payload: { deviceId, cold, serviceTier: this.reportedTier(target), ...(runResetReport ? { runReset: runResetReport } : {}) } });
851
902
  // The unsolicited report the protocol names on `device:ready`. It follows the ready rather than
852
903
  // riding inside it: a tester whose device just came up can arm the toggle in the same breath, and
853
904
  // a boot that armed nothing still has to say so rather than leaving the control blank.
@@ -897,27 +948,37 @@ export class IOSAgent {
897
948
  }
898
949
  }
899
950
  // ── Lean mode ──────────────────────────────────────────────────────────────
951
+ /** The tier a boot gets when it does not ask for one: Lean mode's setting. A person's boot is always this. */
952
+ defaultTier() {
953
+ return { name: this.lean ? 'lean' : 'stock', keep: [] };
954
+ }
955
+ /** What the device boots with now, for the relay: the marker's tier, or `unsupported` on a runtime that cannot take one. */
956
+ reportedTier(device) {
957
+ return leanSupported(device.osVersion) ? this.leanStore.currentTier(device.id) : 'unsupported';
958
+ }
900
959
  /**
901
960
  * Called only when the device is known to be shut down: `launchd_sim` reads the store when it
902
961
  * starts, and a write while it runs is not known to survive. So a device that was already up when
903
- * asked for is booted as it is, and becomes lean from its next boot through tapflow.
962
+ * asked for is booted as it is, and takes the default tier from its next boot through tapflow —
963
+ * unless a run asked for a tier, which restarts it (`retier` in `handleDeviceBoot`).
964
+ *
965
+ * **Every boot sets an exact tier**, the asked-for one or the default. Adding to whatever the last boot left
966
+ * is how a device a run left slim reached a person still slim (L2 review R4-S1).
904
967
  *
905
- * **A failure here never stops the boot.** Lean mode is an optimisation and booting is the job.
968
+ * **A failure here never stops the boot.** A tier is an optimisation and booting is the job.
906
969
  */
907
- prepareLean(device) {
970
+ prepareTier(device, requested) {
971
+ const tier = requested ?? this.defaultTier();
908
972
  try {
909
- if (!this.lean) {
910
- this.revertLean(device.id);
911
- }
912
- else if (!leanSupported(device.osVersion)) {
913
- logger.info(`Lean mode: not applied to ${device.name} (${device.osVersion ?? 'unknown runtime'}) — iOS 18.5 or later only`);
914
- }
915
- else {
916
- this.leanStore.apply(device.id, LEAN_LABELS);
973
+ if (!leanSupported(device.osVersion)) {
974
+ if (tier.name !== 'stock')
975
+ logger.info(`Service tier: ${tier.name} not applied to ${device.name} (${device.osVersion ?? 'unknown runtime'}) — iOS 18.5 or later only`);
976
+ return;
917
977
  }
978
+ this.leanStore.setTier(device.id, tier, labelsFor(tier));
918
979
  }
919
980
  catch (e) {
920
- logger.warn(`Lean mode: could not update ${device.name}, booting it as it is:`, e.message);
981
+ logger.warn(`Service tier: could not set ${device.name} to ${tier.name}, booting it as it is:`, e.message);
921
982
  }
922
983
  }
923
984
  /** Put back what Lean mode disabled on a device that is now shut down. A no-op for devices it never touched. */
@@ -1014,6 +1075,49 @@ export class IOSAgent {
1014
1075
  });
1015
1076
  }
1016
1077
  }
1078
+ /**
1079
+ * What `flow run` asks reset before a run, carried out on a booted device. Only the agent's report says it happened.
1080
+ * It waits for the system services first: `Booted` shows before tccd and securityd serve, and not only on a boot this
1081
+ * agent started — a device someone else just booted reads `booted` too. On a settled device the wait returns at once.
1082
+ * The wait does not watch `isStale`; a shutdown meanwhile holds this boot's abandonment until its deadline.
1083
+ */
1084
+ async resetForRun(deviceId, request, isStale) {
1085
+ // One budget for the wait and the steps: a device whose services never come up spends it waiting, and the steps
1086
+ // that would have failed against it are reported failed instead of each running out its own timeout.
1087
+ const deadline = Date.now() + RUN_RESET_BUDGET_MS;
1088
+ const stop = () => isStale() || Date.now() >= deadline;
1089
+ await this.simctl.waitForBootStatus(deviceId).catch((e) => logger.warn(`run reset: boot status unknown: ${firstLine(e)}`));
1090
+ const steps = [];
1091
+ if (request.uninstall.length > 0) {
1092
+ steps.push({ item: 'app-uninstall', run: () => resetApps(request.uninstall, (id) => this.simctl.uninstallAppForReset(deviceId, id), stop) });
1093
+ }
1094
+ if (request.clear.length > 0) {
1095
+ steps.push({ item: 'app-files', run: () => resetApps(request.clear, (id) => this.simctl.clearAppDataIfInstalled(deviceId, id), stop) });
1096
+ }
1097
+ steps.push({ item: 'keychain', run: () => this.simctl.resetKeychain(deviceId) }, { item: 'privacy-services', run: () => this.simctl.resetPrivacy(deviceId) },
1098
+ // Through the clipboard queue: a clipboard read in flight parks a sentinel on this pasteboard.
1099
+ { item: 'pasteboard', run: () => this.clearPasteboardQueued(deviceId, stop) }, { item: 'location', run: () => this.simctl.clearLocation(deviceId) });
1100
+ return runResetSteps(steps, isStale, (item, e) => logger.warn(`run reset: ${item} failed: ${firstLine(e)}`), deadline);
1101
+ }
1102
+ /**
1103
+ * The pasteboard step of a run reset. The queue can be held by a clipboard operation already running, so the wait for
1104
+ * it is bounded too; a clear that reaches the front after the step gave up does nothing, because by then the run may
1105
+ * have put something of its own there.
1106
+ */
1107
+ clearPasteboardQueued(deviceId, stop) {
1108
+ let abandoned = false;
1109
+ let timer;
1110
+ const queued = this.clipboardQueue(deviceId, async () => {
1111
+ if (abandoned || stop())
1112
+ throw new PlatformError('the clipboard was busy for the whole reset');
1113
+ await this.simctl.setPasteboard(deviceId, '');
1114
+ });
1115
+ const late = new Promise((_, reject) => {
1116
+ timer = setTimeout(() => { abandoned = true; reject(new PlatformError('pasteboard clear timed out')); }, RUN_RESET_PASTEBOARD_TIMEOUT_MS);
1117
+ });
1118
+ queued.catch(() => { });
1119
+ return Promise.race([queued, late]).finally(() => clearTimeout(timer));
1120
+ }
1017
1121
  // Lazily set up the touch channel: a reconnect re-issues the session with touchHelper=null while the sim stays booted, so input self-heals without a fresh device:boot. Sync so a tap's start+end stay paired (async would drop touchEnd → stuck finger).
1018
1122
  ensureTouchHelper(state) {
1019
1123
  if (state.touchHelper)
@@ -1149,9 +1253,9 @@ export class IOSAgent {
1149
1253
  switch (msg.type) {
1150
1254
  case 'device:boot': {
1151
1255
  // `external` and `automation` are added by the relay, never by the browser (see `DeviceBoot`).
1152
- const { deviceId, resetMode, acceptH264, secureContext, external, automation } = msg.payload;
1256
+ const { deviceId, resetMode, acceptH264, secureContext, external, automation, serviceTier, runReset } = msg.payload;
1153
1257
  const sessionId = msg.sessionId;
1154
- this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external }, msg.requestId, automation === true)
1258
+ this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external }, msg.requestId, automation === true, serviceTier, runReset)
1155
1259
  .catch((e) => logger.error('handleDeviceBoot failed:', e));
1156
1260
  break;
1157
1261
  }
@@ -1187,7 +1291,7 @@ export class IOSAgent {
1187
1291
  break;
1188
1292
  }
1189
1293
  case 'app:launch': {
1190
- const { bundleId } = msg.payload;
1294
+ const { bundleId, arguments: args, env } = msg.payload;
1191
1295
  const sessionId = msg.sessionId;
1192
1296
  const { requestId } = msg;
1193
1297
  if (typeof requestId !== 'string' || requestId === '') {
@@ -1202,12 +1306,20 @@ export class IOSAgent {
1202
1306
  respond({ type: 'app:launch-error', message: 'No booted device' });
1203
1307
  break;
1204
1308
  }
1309
+ // Checked again here rather than trusted: the relay checks with the same function, but an agent is the last
1310
+ // door before a command line, and a relay of another version may check less.
1311
+ const problem = launchOptionsProblem({ arguments: args, env });
1312
+ if (problem) {
1313
+ respond({ type: 'app:launch-error', message: problem });
1314
+ break;
1315
+ }
1316
+ const options = compactLaunchOptions({ arguments: args, env });
1205
1317
  // The target is named BEFORE the launch, and the order is the whole point: dyld reads the
1206
1318
  // environment when the process starts, so naming it afterwards arms the next launch and leaves
1207
1319
  // this one unhooked while `available` would still say true.
1208
1320
  this.network.target(launchState.deviceId, bundleId)
1209
1321
  .catch((e) => logger.warn('could not name the network target:', e.message))
1210
- .then(() => this.simctl.launchApp(launchState.deviceId, bundleId))
1322
+ .then(() => this.simctl.launchApp(launchState.deviceId, bundleId, options))
1211
1323
  .then((pid) => {
1212
1324
  // **After the launch, with the pid it returned.** The network control measures how long a
1213
1325
  // started process has gone without reporting its hooks; opening that window before
@@ -1218,7 +1330,7 @@ export class IOSAgent {
1218
1330
  this.lastBundleIds.set(launchState.deviceId, bundleId);
1219
1331
  // Audio: the whole-sim tap's poll picks up the launched app process within one interval;
1220
1332
  // no per-launch helper needed.
1221
- respond({ type: 'app:launch-done' });
1333
+ respond(options ? { type: 'app:launch-done', applied: appliedKeys(options) } : { type: 'app:launch-done' });
1222
1334
  })
1223
1335
  .catch((e) => {
1224
1336
  // The toast gets the reason alone; the full simctl output stays in this log.
@@ -1256,8 +1368,12 @@ export class IOSAgent {
1256
1368
  // The helper's answer, not its existence: a helper whose process has died reports every
1257
1369
  // write as dropped, and that is what the caller needs to hear (#482).
1258
1370
  const helper = state.touchHelper;
1259
- // terminal of a tap/swipe → ack the gesture
1260
- void this.ackInput(state, helper?.touchEnd() ? 'delivered' : this.refusalReason(helper, 'continuation'), requestId);
1371
+ // terminal of a tap/swipe → ack the gesture, once the up is written (it may wait for the
1372
+ // touch's minimum contact). The refusal is read then too, from the helper the state holds *then*:
1373
+ // one replaced during the wait (a chrome reload, a reboot) is up and owns nothing, which is
1374
+ // `no-gesture`, where the stopped one it replaced would say the channel is gone.
1375
+ void (helper ? helper.touchEndAfterMinContact() : Promise.resolve(false))
1376
+ .then((sent) => this.ackInput(state, sent ? 'delivered' : this.refusalReason(state.touchHelper, 'continuation'), requestId));
1261
1377
  break;
1262
1378
  }
1263
1379
  case 'input:pinch:start': {
@@ -2030,8 +2146,11 @@ export class IOSAgent {
2030
2146
  async networkState() {
2031
2147
  return this.network.state(await this.soleLiveDeviceId());
2032
2148
  }
2033
- async launchApp(bundleId) {
2034
- await this.simctl.launchApp(await this.soleLiveDeviceId(), bundleId);
2149
+ async launchApp(bundleId, options) {
2150
+ const problem = launchOptionsProblem(options ?? {});
2151
+ if (problem)
2152
+ throw new ValidationError(problem);
2153
+ await this.simctl.launchApp(await this.soleLiveDeviceId(), bundleId, compactLaunchOptions(options));
2035
2154
  }
2036
2155
  /** The one booted session, or undefined when there is no unambiguous answer. Callers that can
2037
2156
  * reasonably do nothing (input) use this; callers that owe the user a result use
@@ -2139,7 +2258,7 @@ export class IOSAgent {
2139
2258
  const seq = state.bootSeq;
2140
2259
  const streamer = new AudioCaptureStreamer();
2141
2260
  streamer.listen()
2142
- .then((port) => {
2261
+ .then(async (port) => {
2143
2262
  // A reboot/shutdown/disconnect may have superseded this boot while listen() was binding —
2144
2263
  // discard so we don't leave an orphan helper/poll/server behind the current lifecycle.
2145
2264
  if (seq !== state.bootSeq || streamWs.readyState !== WebSocket.OPEN) {
@@ -2148,21 +2267,37 @@ export class IOSAgent {
2148
2267
  }
2149
2268
  state.audioStreamer = streamer;
2150
2269
  state.audioPort = port;
2151
- state.audioVolume = readSimVolume(udid);
2270
+ // Read before launching, so the first frames carry the sim's volume rather than full.
2271
+ const [pids, volume] = await Promise.all([enumerateSimPids(udid), readSimVolume(udid)]);
2272
+ // A stop or reboot while reading retired this capture: `stopAudioCapture` already stopped `streamer`,
2273
+ // and launching the helper now would tap a device nobody is streaming.
2274
+ if (state.audioStreamer !== streamer)
2275
+ return;
2276
+ // Only the control socket's close tears audio down; a stream socket that closed while reading would
2277
+ // leave a helper and a poll running for nothing until the next reboot.
2278
+ if (streamWs.readyState !== WebSocket.OPEN) {
2279
+ this.stopAudioCapture(state);
2280
+ return;
2281
+ }
2282
+ state.audioVolume = volume;
2152
2283
  void this.pumpAudio(streamWs, streamer.frames(), state);
2153
- this.launchWholeSimTap(state, udid);
2284
+ this.launchWholeSimTap(state, pids);
2285
+ // Started after the launch, so the first poll never races the launch's own pid set. One poll at a
2286
+ // time: `ps` is bounded by its timeout, and a slow one is not stacked by the next tick.
2287
+ let polling = false;
2154
2288
  state.audioPoll = setInterval(() => {
2155
- this.refreshAudioPids(state, udid);
2156
- state.audioVolume = readSimVolume(udid); // track live sim-volume changes
2289
+ if (polling)
2290
+ return;
2291
+ polling = true;
2292
+ void this.refreshAudio(state, udid, streamer).finally(() => { polling = false; });
2157
2293
  }, AUDIO_POLL_MS);
2158
2294
  })
2159
2295
  .catch((e) => logger.warn(`audio capture server failed to start: ${e instanceof Error ? e.message : String(e)}`));
2160
2296
  }
2161
2297
  // Launch the tap helper for the simulator's whole process tree. Swallows errors — audio must never
2162
2298
  // break the launch/video path (e.g. helper build fails, or the user hasn't granted the audio
2163
- // permission). The helper holds the socket open; refreshAudioPids() pushes later updates to it.
2164
- launchWholeSimTap(state, udid) {
2165
- const pids = enumerateSimPids(udid);
2299
+ // permission). The helper holds the socket open; refreshAudio() pushes later updates to it.
2300
+ launchWholeSimTap(state, pids) {
2166
2301
  if (!pids.length) {
2167
2302
  logger.warn('no simulator processes to tap (audio idle until first poll)');
2168
2303
  return;
@@ -2179,18 +2314,21 @@ export class IOSAgent {
2179
2314
  // freshly spawned WebKit WebContent), push the updated set so the helper rebuilds its tap. Dead pids
2180
2315
  // need no rebuild — they resolve to no audio object, so the helper just stops mixing them; we still
2181
2316
  // refresh the baseline so a later reappearance is detected. Rebuilding only on additions keeps
2182
- // short-lived daemon churn from causing constant tap teardown (audio glitches).
2183
- refreshAudioPids(state, udid) {
2184
- if (!state.audioStreamer)
2317
+ // short-lived daemon churn from causing constant tap teardown (audio glitches). Also tracks live sim-volume
2318
+ // changes. Both reads are asynchronous, so the capture may have been retired by the time they land; `streamer`
2319
+ // is the capture this poll belongs to.
2320
+ async refreshAudio(state, udid, streamer) {
2321
+ const [pids, volume] = await Promise.all([enumerateSimPids(udid), readSimVolume(udid)]);
2322
+ if (state.audioStreamer !== streamer)
2185
2323
  return;
2186
- const pids = enumerateSimPids(udid);
2324
+ state.audioVolume = volume;
2187
2325
  if (!pids.length)
2188
2326
  return;
2189
2327
  const prev = state.audioPids;
2190
2328
  const hasNew = !prev || pids.some((p) => !prev.has(p));
2191
2329
  state.audioPids = new Set(pids);
2192
2330
  if (hasNew)
2193
- state.audioStreamer.updatePids(pids);
2331
+ streamer.updatePids(pids);
2194
2332
  }
2195
2333
  // Forward captured PCM to the relay on the shared stream socket via the yielding sender (audio must
2196
2334
  // never inflate the socket buffer enough to trip video's backpressure). Mirrors android-agent.
@@ -2232,10 +2370,9 @@ export class IOSAgent {
2232
2370
  first?.touchHelper?.touchMove(x, y);
2233
2371
  return Promise.resolve();
2234
2372
  }
2235
- touchEnd() {
2373
+ async touchEnd() {
2236
2374
  const first = this.liveDeviceState();
2237
- first?.touchHelper?.touchEnd();
2238
- return Promise.resolve();
2375
+ await first?.touchHelper?.touchEndAfterMinContact();
2239
2376
  }
2240
2377
  async openUrl(url) {
2241
2378
  return this.simctl.openUrl(await this.soleLiveDeviceId(), url);