@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.
- package/NOTICE +1 -1
- package/dist/AudioCaptureStreamer.d.ts +3 -2
- package/dist/AudioCaptureStreamer.d.ts.map +1 -1
- package/dist/AudioCaptureStreamer.js +16 -10
- package/dist/AudioCaptureStreamer.js.map +1 -1
- package/dist/IOSAgent.d.ts +31 -5
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +203 -66
- package/dist/IOSAgent.js.map +1 -1
- package/dist/LeanStore.d.ts +24 -3
- package/dist/LeanStore.d.ts.map +1 -1
- package/dist/LeanStore.js +61 -2
- package/dist/LeanStore.js.map +1 -1
- package/dist/SimProcessTree.d.ts +3 -1
- package/dist/SimProcessTree.d.ts.map +1 -1
- package/dist/SimProcessTree.js +14 -4
- package/dist/SimProcessTree.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +16 -2
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +56 -5
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/SimulatorNetwork.d.ts +3 -2
- package/dist/SimulatorNetwork.d.ts.map +1 -1
- package/dist/SimulatorNetwork.js +3 -2
- package/dist/SimulatorNetwork.js.map +1 -1
- package/dist/TouchHelper.d.ts +11 -1
- package/dist/TouchHelper.d.ts.map +1 -1
- package/dist/TouchHelper.js +53 -2
- package/dist/TouchHelper.js.map +1 -1
- package/dist/launchArgs.d.ts +6 -0
- package/dist/launchArgs.d.ts.map +1 -0
- package/dist/launchArgs.js +21 -0
- package/dist/launchArgs.js.map +1 -0
- package/dist/leanLabels.d.ts +5 -0
- package/dist/leanLabels.d.ts.map +1 -1
- package/dist/leanLabels.js +5 -1
- package/dist/leanLabels.js.map +1 -1
- package/dist/serviceTiers.d.ts +25 -0
- package/dist/serviceTiers.d.ts.map +1 -0
- package/dist/serviceTiers.js +251 -0
- package/dist/serviceTiers.js.map +1 -0
- package/dist/simctl.d.ts +3 -0
- package/dist/simctl.d.ts.map +1 -1
- package/dist/simctl.js +1 -1
- package/dist/simctl.js.map +1 -1
- package/dist/xcuiTree.d.ts.map +1 -1
- package/dist/xcuiTree.js +23 -6
- package/dist/xcuiTree.js.map +1 -1
- 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 {
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 (
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
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.**
|
|
968
|
+
* **A failure here never stops the boot.** A tier is an optimisation and booting is the job.
|
|
906
969
|
*/
|
|
907
|
-
|
|
970
|
+
prepareTier(device, requested) {
|
|
971
|
+
const tier = requested ?? this.defaultTier();
|
|
908
972
|
try {
|
|
909
|
-
if (!
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2156
|
-
|
|
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;
|
|
2164
|
-
launchWholeSimTap(state,
|
|
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
|
-
|
|
2184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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);
|