@tapflowio/ios-agent 0.17.0 → 0.19.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/README.md +1 -1
- package/dist/DeviceChromeLoader.d.ts +2 -45
- package/dist/DeviceChromeLoader.d.ts.map +1 -1
- package/dist/DeviceChromeLoader.js.map +1 -1
- package/dist/IOSAgent.d.ts +76 -0
- package/dist/IOSAgent.d.ts.map +1 -1
- package/dist/IOSAgent.js +585 -134
- package/dist/IOSAgent.js.map +1 -1
- package/dist/KeyboardHelperDaemon.d.ts.map +1 -1
- package/dist/KeyboardHelperDaemon.js +2 -1
- package/dist/KeyboardHelperDaemon.js.map +1 -1
- package/dist/MjpegStreamer.d.ts +2 -1
- package/dist/MjpegStreamer.d.ts.map +1 -1
- package/dist/MjpegStreamer.js +12 -2
- package/dist/MjpegStreamer.js.map +1 -1
- package/dist/ScreenCaptureStreamer.d.ts +2 -2
- package/dist/ScreenCaptureStreamer.d.ts.map +1 -1
- package/dist/ScreenCaptureStreamer.js +6 -3
- package/dist/ScreenCaptureStreamer.js.map +1 -1
- package/dist/SimctlWrapper.d.ts +51 -6
- package/dist/SimctlWrapper.d.ts.map +1 -1
- package/dist/SimctlWrapper.js +113 -18
- package/dist/SimctlWrapper.js.map +1 -1
- package/dist/TouchHelper.d.ts +53 -14
- package/dist/TouchHelper.d.ts.map +1 -1
- package/dist/TouchHelper.js +237 -59
- package/dist/TouchHelper.js.map +1 -1
- package/dist/childProcessKill.d.ts +3 -0
- package/dist/childProcessKill.d.ts.map +1 -0
- package/dist/childProcessKill.js +19 -0
- package/dist/childProcessKill.js.map +1 -0
- package/package.json +10 -8
package/dist/IOSAgent.js
CHANGED
|
@@ -5,10 +5,26 @@ import { tmpdir } from 'os';
|
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { spawnSync } from 'child_process';
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
|
-
import { createLogger, PlatformError, ValidationError } from '@tapflowio/agent-core';
|
|
8
|
+
import { createLogger, PlatformError, ValidationError, bootAbandonMessage, BOOT_NO_SESSION_STATE } from '@tapflowio/agent-core';
|
|
9
9
|
const logger = createLogger('ios-agent');
|
|
10
10
|
// Typed so a typo cannot ship silently — the viewer gates the whole clipboard bridge on this.
|
|
11
11
|
const AGENT_CAPABILITIES = ['clipboard'];
|
|
12
|
+
// Human prose for each reason. Not in `@tapflowio/protocol`: that package's main entry must stay
|
|
13
|
+
// runtime-free so it erases under `import type` and never reaches the dashboard bundle — a lookup
|
|
14
|
+
// table is a runtime value. The `reason` on the wire is the machine-readable half; this is only what
|
|
15
|
+
// a human reads, and each agent owns its own wording.
|
|
16
|
+
const INPUT_ERROR_MESSAGES = {
|
|
17
|
+
'not-booted': 'device not booted',
|
|
18
|
+
'channel-unavailable': 'input channel not ready',
|
|
19
|
+
'channel-starting': 'the input channel is still starting — retry in a moment',
|
|
20
|
+
'dispatch-failed': 'the device rejected the input',
|
|
21
|
+
unsupported: 'this input is not supported on the active connection to the device',
|
|
22
|
+
malformed: 'this input does not fit what the device is doing',
|
|
23
|
+
'no-gesture': 'no gesture is in progress to complete — start a new one',
|
|
24
|
+
// The relay is the only producer of this one — an agent has no way to know which socket asked. Present
|
|
25
|
+
// because the map is exhaustive, which is what makes a new reason a decision rather than an omission.
|
|
26
|
+
'not-session-owner': 'this input was addressed to a session the sender does not hold',
|
|
27
|
+
};
|
|
12
28
|
// Cross-platform button name → iOS device-chrome button name. Chrome uses
|
|
13
29
|
// hyphens and "power" (not "lock"); MCP's vocabulary uses underscores. Names
|
|
14
30
|
// not listed here (incl. the raw chrome names the dashboard sends) pass through.
|
|
@@ -18,7 +34,7 @@ export const IOS_BUTTON_ALIASES = {
|
|
|
18
34
|
volume_down: 'volume-down',
|
|
19
35
|
};
|
|
20
36
|
import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, getMachineId, isLocalhostWss, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_JPEG, CODEC_H264, CODEC_AUDIO, sendAudioYieldingToVideo, } from '@tapflowio/agent-core/utils';
|
|
21
|
-
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError } from './SimctlWrapper.js';
|
|
37
|
+
import { SimctlWrapper, isDeviceMissingError, ClipboardTooLargeError, firstLine } from './SimctlWrapper.js';
|
|
22
38
|
import { MAX_CLIPBOARD_BYTES, clipboardByteLength, CLIPBOARD_SENTINEL_PREFIX as SENTINEL_PREFIX, isClipboardSentinel as isSentinel, CLIPBOARD_COPY_DEADLINE_MS, CLIPBOARD_WRITE_DEADLINE_MS, CLIPBOARD_RESTORE_DEADLINE_MS, CLIPBOARD_POLL_MS, createKeyedSerialQueue, } from '@tapflowio/agent-core';
|
|
23
39
|
import { ScreenCaptureStreamer } from './ScreenCaptureStreamer.js';
|
|
24
40
|
import { AudioCaptureStreamer, readSimVolume, applyGain } from './AudioCaptureStreamer.js';
|
|
@@ -45,6 +61,34 @@ export class IOSAgent {
|
|
|
45
61
|
token;
|
|
46
62
|
handshakeTimeoutMs;
|
|
47
63
|
ws = null;
|
|
64
|
+
/** Send on the control socket, if there is one. The `?.` is the point: 66 call sites relied on a send
|
|
65
|
+
* being a no-op between reconnects, and this preserves that exactly.
|
|
66
|
+
*
|
|
67
|
+
* Typed with `AgentControlOutbound`, which is why this exists — an agent's literal used to reach `ws.send`
|
|
68
|
+
* with nothing checking it, and #489/#490 are what that cost. *
|
|
69
|
+
* **The guard is not defensive tidying.** `ws.send` on anything other than OPEN takes the `sendAfterClose`
|
|
70
|
+
* path, which adds the payload to a buffer nobody will flush and neither throws nor emits — so a reply
|
|
71
|
+
* sent to a closing socket is indistinguishable from a delivered one at the call site. `reportResources`
|
|
72
|
+
* has always checked; this did not, and the boot answers this file now owes a caller are exactly the
|
|
73
|
+
* messages whose purpose is to end someone's wait.
|
|
74
|
+
*
|
|
75
|
+
* The body is held character for character by `scripts/__tests__/agentSendTyped.test.mjs`, so the comment
|
|
76
|
+
* lives out here: that check strips comments and matches the body exactly, having watched three earlier
|
|
77
|
+
* drafts get bypassed by a renamed socket. */
|
|
78
|
+
sendMsg(msg) {
|
|
79
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
80
|
+
return;
|
|
81
|
+
this.ws.send(JSON.stringify(msg));
|
|
82
|
+
}
|
|
83
|
+
/** Send on a socket the caller has already established. Takes the socket **as an argument** rather than
|
|
84
|
+
* reading `this.ws`, and that is deliberate: the call sites that use it sit behind an entry guard
|
|
85
|
+
* (`if (!this.ws) return`), and today deleting one of those guards is a compile error. Reading
|
|
86
|
+
* `this.ws` here — or asserting it with `!` — would make the guard optional to the compiler and turn
|
|
87
|
+
* its removal into a runtime `TypeError` instead. It also serves the `agent:register` send, which runs
|
|
88
|
+
* inside `onopen` on a local socket. */
|
|
89
|
+
sendOn(ws, msg) {
|
|
90
|
+
ws.send(JSON.stringify(msg));
|
|
91
|
+
}
|
|
48
92
|
deviceStates = new Map();
|
|
49
93
|
// Last app launched per device (deviceId → bundleId). The XCUITest tree backend
|
|
50
94
|
// queries by bundleId; kept outside DeviceState so it survives a relay reconnect
|
|
@@ -103,7 +147,7 @@ export class IOSAgent {
|
|
|
103
147
|
}, this.handshakeTimeoutMs);
|
|
104
148
|
ws.once('open', () => {
|
|
105
149
|
disableNagle(ws);
|
|
106
|
-
|
|
150
|
+
this.sendOn(ws, {
|
|
107
151
|
type: 'agent:register',
|
|
108
152
|
platform: 'ios',
|
|
109
153
|
// Lets a viewer tell a clipboard-capable agent from one that predates the
|
|
@@ -118,7 +162,7 @@ export class IOSAgent {
|
|
|
118
162
|
status: d.status,
|
|
119
163
|
osVersion: d.osVersion,
|
|
120
164
|
})),
|
|
121
|
-
})
|
|
165
|
+
});
|
|
122
166
|
});
|
|
123
167
|
ws.once('message', (data) => {
|
|
124
168
|
let msg;
|
|
@@ -140,7 +184,14 @@ export class IOSAgent {
|
|
|
140
184
|
this.initDeviceStates(msg.registeredSessions);
|
|
141
185
|
ws.on('message', (d) => {
|
|
142
186
|
try {
|
|
143
|
-
|
|
187
|
+
const m = JSON.parse(d.toString());
|
|
188
|
+
// Every type `handleRelayMessage` dispatches is session-scoped, and the relay resolves the
|
|
189
|
+
// session before forwarding — so a message without one did not come from that path. Rejecting
|
|
190
|
+
// it here is what lets the dispatcher declare `sessionId: string` instead of threading an
|
|
191
|
+
// optional through 30 sends and asserting it with `!` at each one.
|
|
192
|
+
if (typeof m.type !== 'string' || typeof m.sessionId !== 'string')
|
|
193
|
+
return;
|
|
194
|
+
this.handleRelayMessage(m);
|
|
144
195
|
}
|
|
145
196
|
catch { /* ignore malformed */ }
|
|
146
197
|
});
|
|
@@ -183,6 +234,8 @@ export class IOSAgent {
|
|
|
183
234
|
streamReader: null,
|
|
184
235
|
captureStreamer: null,
|
|
185
236
|
bootSeq: 0,
|
|
237
|
+
bootAbandon: new Map(),
|
|
238
|
+
bootsInFlight: new Set(),
|
|
186
239
|
orientation: 'portrait',
|
|
187
240
|
loadedChrome: null,
|
|
188
241
|
softKeyboardVisible: false,
|
|
@@ -208,6 +261,7 @@ export class IOSAgent {
|
|
|
208
261
|
this.resourcesTimer = null;
|
|
209
262
|
}
|
|
210
263
|
for (const state of this.deviceStates.values()) {
|
|
264
|
+
this.bumpBootSeq(state, 'relay-lost');
|
|
211
265
|
this.cleanupDeviceState(state);
|
|
212
266
|
}
|
|
213
267
|
this.deviceStates.clear();
|
|
@@ -226,6 +280,13 @@ export class IOSAgent {
|
|
|
226
280
|
this.resourcesTimer = null;
|
|
227
281
|
}
|
|
228
282
|
for (const state of this.deviceStates.values()) {
|
|
283
|
+
// Invalidate any boot still in flight against this state. The map is dropped just below, but a
|
|
284
|
+
// `handleDeviceBoot` awaiting simctl holds its own reference and its seq would still match, so it
|
|
285
|
+
// would go on to build a TouchHelper on a state nobody owns. That used to leak one child process;
|
|
286
|
+
// a self-reviving helper would respawn for the life of the agent with no reference left to stop it.
|
|
287
|
+
// Bumped here rather than inside `cleanupDeviceState` so that all three retirements name a reason —
|
|
288
|
+
// and so Android, whose boot path calls its own cleanup mid-boot, can hold the same shape.
|
|
289
|
+
this.bumpBootSeq(state, 'relay-lost');
|
|
229
290
|
this.cleanupDeviceState(state);
|
|
230
291
|
}
|
|
231
292
|
this.deviceStates.clear();
|
|
@@ -254,7 +315,7 @@ export class IOSAgent {
|
|
|
254
315
|
const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.streamReader !== null).length;
|
|
255
316
|
const slotsTotal = this.deviceStates.size;
|
|
256
317
|
const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
|
|
257
|
-
this.
|
|
318
|
+
this.sendOn(this.ws, {
|
|
258
319
|
type: 'agent:resources',
|
|
259
320
|
resources: {
|
|
260
321
|
cpuPercent: this.resources.getCpuPercent(),
|
|
@@ -264,7 +325,7 @@ export class IOSAgent {
|
|
|
264
325
|
slotsTotal,
|
|
265
326
|
reportedAt: Date.now(),
|
|
266
327
|
},
|
|
267
|
-
})
|
|
328
|
+
});
|
|
268
329
|
}
|
|
269
330
|
cleanupDeviceState(state) {
|
|
270
331
|
void state.streamReader?.cancel();
|
|
@@ -285,38 +346,46 @@ export class IOSAgent {
|
|
|
285
346
|
state.streamWs = null;
|
|
286
347
|
}
|
|
287
348
|
sendChromeData(state, device) {
|
|
288
|
-
|
|
349
|
+
// `readyState`, not presence: this runs mid-boot, and a socket that closed since the entry guard
|
|
350
|
+
// takes the payload into a buffer nobody flushes while `device:ready` is dropped by `sendMsg`'s own
|
|
351
|
+
// check — leaving the caller with neither the data nor an answer.
|
|
352
|
+
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
289
353
|
return;
|
|
354
|
+
// Stop the outgoing helper before dropping the reference. This used to leak a child process;
|
|
355
|
+
// now that a helper revives itself on death, an orphan would also keep respawning with
|
|
356
|
+
// nobody left holding a reference to stop it.
|
|
357
|
+
state.touchHelper?.stop();
|
|
290
358
|
state.touchHelper = new TouchHelper(device.id);
|
|
291
359
|
state.touchHelper.start();
|
|
292
|
-
this.
|
|
360
|
+
this.sendOn(this.ws, {
|
|
293
361
|
type: 'session:deviceInfo',
|
|
294
362
|
sessionId: state.sessionId,
|
|
295
363
|
payload: {
|
|
296
364
|
deviceName: device.name,
|
|
297
365
|
osVersion: device.osVersion ?? '',
|
|
298
366
|
},
|
|
299
|
-
})
|
|
367
|
+
});
|
|
300
368
|
state.loadedChrome = this.chromeLoader.load(device.typeId ?? device.name);
|
|
301
369
|
if (!state.loadedChrome)
|
|
302
370
|
return;
|
|
303
|
-
this.
|
|
371
|
+
this.sendOn(this.ws, {
|
|
304
372
|
type: 'session:chrome',
|
|
305
373
|
sessionId: state.sessionId,
|
|
306
374
|
payload: state.loadedChrome,
|
|
307
|
-
})
|
|
375
|
+
});
|
|
308
376
|
}
|
|
309
377
|
startBinaryStream(state, streamWs) {
|
|
310
378
|
// H.264 is the default; opt out per-agent with TAPFLOW_IOS_CODEC=jpeg. It also needs
|
|
311
379
|
// a browser that reported it can decode it (device:boot acceptH264) — otherwise JPEG.
|
|
312
|
-
// Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG
|
|
380
|
+
// Only on the ScreenCaptureStreamer path — the MjpegStreamer fallback is always JPEG, and now
|
|
381
|
+
// genuinely produces it rather than only being stamped that way.
|
|
313
382
|
const envAllowsH264 = process.env.TAPFLOW_IOS_CODEC !== 'jpeg';
|
|
314
383
|
const useH264 = this.intervalMs === undefined && envAllowsH264 && state.acceptH264;
|
|
315
384
|
const codec = useH264 ? CODEC_H264 : CODEC_JPEG;
|
|
316
385
|
let stream;
|
|
317
386
|
if (this.intervalMs !== undefined) {
|
|
318
387
|
state.captureStreamer = null;
|
|
319
|
-
stream = new MjpegStreamer(this.simctl, this.intervalMs).start();
|
|
388
|
+
stream = new MjpegStreamer(this.simctl, state.deviceId, this.intervalMs).start();
|
|
320
389
|
}
|
|
321
390
|
else {
|
|
322
391
|
const maxSize = pickMaxSize({
|
|
@@ -324,7 +393,7 @@ export class IOSAgent {
|
|
|
324
393
|
external: state.external,
|
|
325
394
|
override: process.env.TAPFLOW_IOS_MAX_SIZE ?? process.env.TAPFLOW_MAX_SIZE,
|
|
326
395
|
});
|
|
327
|
-
const capture = new ScreenCaptureStreamer(
|
|
396
|
+
const capture = new ScreenCaptureStreamer(state.deviceId, this.fps, useH264 ? 'h264' : 'jpeg', maxSize);
|
|
328
397
|
state.captureStreamer = capture;
|
|
329
398
|
stream = capture.start();
|
|
330
399
|
}
|
|
@@ -398,50 +467,174 @@ export class IOSAgent {
|
|
|
398
467
|
await registerStreamWs(streamWs, state.sessionId);
|
|
399
468
|
return streamWs;
|
|
400
469
|
}
|
|
401
|
-
|
|
470
|
+
/** Retire the boot that holds the current seq, recording what retired it, and return the new seq.
|
|
471
|
+
*
|
|
472
|
+
* **Three callers, and the third is why this is a method.** A new boot supersedes the one in flight, a
|
|
473
|
+
* shutdown abandons it, and losing the relay invalidates it — that last one lives in the reconnect path
|
|
474
|
+
* (`cleanupDeviceState`'s callers) and is easy to miss when reading `handleDeviceBoot` alone. Spreading
|
|
475
|
+
* the reason across three `state.bootSeq++` lines makes forgetting one silent; here it is a parameter. */
|
|
476
|
+
bumpBootSeq(state, reason) {
|
|
477
|
+
if (state.bootsInFlight.has(state.bootSeq))
|
|
478
|
+
state.bootAbandon.set(state.bootSeq, reason);
|
|
479
|
+
return ++state.bootSeq;
|
|
480
|
+
}
|
|
481
|
+
/** Answer a boot this agent has stopped running. Called at every point that abandons one.
|
|
482
|
+
*
|
|
483
|
+
* **Only when a correlator exists**, which is the same rule the input path settled on (#489): a reply
|
|
484
|
+
* nobody is waiting for is not an answer, and the dashboard reports every *uncorrelated*
|
|
485
|
+
* `device:boot-error` as a failure — deliberately, since Android's dead-stream report has no id it could
|
|
486
|
+
* carry (#426). So sending one for a boot with no correlator would put an error toast on a tester's
|
|
487
|
+
* screen for a device that is booting normally. */
|
|
488
|
+
abandonBoot(state, seq, sessionId, requestId) {
|
|
489
|
+
const reason = state.bootAbandon.get(seq) ?? 'superseded';
|
|
490
|
+
state.bootAbandon.delete(seq);
|
|
491
|
+
// **A state the agent has stopped holding cannot be answered, and trying is worse than silence.**
|
|
492
|
+
// `relay-lost` retires a boot by dropping the whole map, so by the time the parked `await` resumes this
|
|
493
|
+
// `state` is an object nobody is registered against — and whether the socket is back yet decides
|
|
494
|
+
// between dropping the reply and sending it to a *new* relay naming a session id it has never heard
|
|
495
|
+
// of. Neither is an answer, and which one happens is a race between a poll interval and a backoff.
|
|
496
|
+
if (this.deviceStates.get(sessionId) !== state) {
|
|
497
|
+
logger.warn(`boot for ${sessionId} abandoned (${reason}) after the session was dropped — nothing to answer`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!requestId) {
|
|
501
|
+
logger.warn(`boot for ${sessionId} abandoned (${reason}) with no requestId — nothing to answer`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: bootAbandonMessage(reason) });
|
|
505
|
+
}
|
|
506
|
+
// `requestId` is a parameter, never a field on `state`: `bootSeq` exists because two boots overlap,
|
|
507
|
+
// and a correlator hoisted onto shared state would answer the first request with the second's id.
|
|
508
|
+
// Optional because the relay's idle timer boots nothing — but every *browser* boot carries one.
|
|
509
|
+
async handleDeviceBoot(sessionId, deviceId, fullErase = false, acceptH264 = false, tier, requestId) {
|
|
402
510
|
const state = this.deviceStates.get(sessionId);
|
|
403
|
-
|
|
511
|
+
// Split, because the two halves are not the same kind of nothing. No open control channel means the
|
|
512
|
+
// answer itself has nowhere to go, so this is the one abandonment that stays silent — and the caller
|
|
513
|
+
// learns from the relay instead, which declares the agent away and terminates the session. A missing
|
|
514
|
+
// `state` is answerable and now answered (#489's reasoning, on the boot path).
|
|
515
|
+
const ws = this.ws;
|
|
516
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
404
517
|
return;
|
|
518
|
+
if (!state) {
|
|
519
|
+
if (requestId)
|
|
520
|
+
this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: BOOT_NO_SESSION_STATE });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
405
523
|
state.acceptH264 = acceptH264;
|
|
406
524
|
if (tier) {
|
|
407
525
|
state.secureContext = tier.secureContext;
|
|
408
526
|
state.external = tier.external;
|
|
409
527
|
}
|
|
410
|
-
const seq =
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
state.streamWs?.close();
|
|
418
|
-
state.streamWs = null;
|
|
419
|
-
this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
|
|
528
|
+
const seq = this.bumpBootSeq(state, 'superseded');
|
|
529
|
+
state.bootsInFlight.add(seq);
|
|
530
|
+
// **The teardown runs inside the try, and that is a fix rather than tidying.** It ran before it, so a
|
|
531
|
+
// throw there rejected this handler into the bare `.catch(logger.error)` at its dispatch site: no
|
|
532
|
+
// `device:booting`, no ready, no error — and the seq was already bumped, so the boot this one
|
|
533
|
+
// superseded was gone as well. The caller then waited out the very deadline this change exists to
|
|
534
|
+
// end. `finally` is what stops `bootAbandon` outliving the boot that would have read it.
|
|
420
535
|
try {
|
|
536
|
+
void state.streamReader?.cancel();
|
|
537
|
+
state.streamReader = null;
|
|
538
|
+
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
539
|
+
state.touchHelper?.stop();
|
|
540
|
+
state.touchHelper = null;
|
|
541
|
+
state.booted = false;
|
|
542
|
+
state.streamWs?.close();
|
|
543
|
+
state.streamWs = null;
|
|
544
|
+
this.sendOn(ws, { type: 'device:booting', sessionId });
|
|
421
545
|
const devices = await this.simctl.listDevices();
|
|
422
|
-
if (seq !== state.bootSeq)
|
|
546
|
+
if (seq !== state.bootSeq) {
|
|
547
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
423
548
|
return;
|
|
549
|
+
}
|
|
424
550
|
const target = devices.find((d) => d.id === deviceId);
|
|
425
551
|
if (!target)
|
|
426
552
|
throw new PlatformError(`Device not found: ${deviceId}`);
|
|
427
553
|
if (fullErase) {
|
|
428
|
-
|
|
554
|
+
// `simctl erase` only accepts a shut-down device, and a device is often already running —
|
|
555
|
+
// it survives agent restarts and sessions that ended without a clean shutdown. Without this
|
|
556
|
+
// the boot fails outright (#439).
|
|
557
|
+
//
|
|
558
|
+
// Anything that is not `shutdown` gets the shutdown, not just `booted`: `toDeviceStatus`
|
|
559
|
+
// collapses `Booting` / `Shutting Down` / `Creating` into `unknown`, and `erase` refuses
|
|
560
|
+
// every one of them. Re-picking a device while its `device:shutdown` is still draining
|
|
561
|
+
// lands exactly there. `SimctlWrapper.shutdown` already tolerates an off device, so
|
|
562
|
+
// widening the condition costs nothing.
|
|
563
|
+
//
|
|
564
|
+
// This makes an unwanted reset *succeed* rather than fail, so it is only safe alongside the
|
|
565
|
+
// dashboard change that consumes the toggle after one use. The erase failing is what used
|
|
566
|
+
// to stand between a stale toggle and a wiped device.
|
|
567
|
+
if (target.status !== 'shutdown')
|
|
568
|
+
await this.simctl.shutdown(deviceId);
|
|
569
|
+
// The shutdown is an await, so a newer boot for this device may have overtaken us while it
|
|
570
|
+
// ran. Without this check the superseded boot goes on to erase a device the tester has
|
|
571
|
+
// since re-picked with the toggle off — the exact wipe-with-no-click this issue is about.
|
|
572
|
+
if (seq !== state.bootSeq) {
|
|
573
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
await this.simctl.erase(deviceId);
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
// We powered the device off to make the erase possible. If the erase then fails, leaving
|
|
581
|
+
// it off strands the tester with a dead device on top of the error message, so put it
|
|
582
|
+
// back the way we found it and report the original failure.
|
|
583
|
+
//
|
|
584
|
+
// `=== 'booted'`, deliberately narrower than the `!== 'shutdown'` above: that one asks
|
|
585
|
+
// "does this need shutting down", this one asks "did we take it down". `unknown` covers
|
|
586
|
+
// `Shutting Down` (someone else was already stopping it) and `Creating` (never ran) —
|
|
587
|
+
// booting either would not be putting it back.
|
|
588
|
+
//
|
|
589
|
+
// The seq check matters as much: erase takes seconds, and a shutdown arriving mid-flight
|
|
590
|
+
// bumps bootSeq. Recovering then would power a device back up right after the tester
|
|
591
|
+
// asked for it to stop — and the throw below is swallowed by the outer catch on the same
|
|
592
|
+
// seq mismatch, so nothing would say why.
|
|
593
|
+
if (seq === state.bootSeq && target.status === 'booted') {
|
|
594
|
+
await this.simctl.boot(deviceId).catch(() => { });
|
|
595
|
+
}
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
429
598
|
await this.simctl.boot(deviceId);
|
|
430
599
|
}
|
|
431
|
-
else
|
|
600
|
+
else {
|
|
601
|
+
// Unconditionally, including when the list above said `booted`. That reading is already
|
|
602
|
+
// stale by the time we act on it — a tester can ⌘Q the simulator, or run `simctl shutdown`,
|
|
603
|
+
// in the width of one `xcrun` round trip — and skipping the boot on it is the only way to
|
|
604
|
+
// reach the wait below with *nothing bringing the device up*, which then costs the full
|
|
605
|
+
// deadline to discover. `SimctlWrapper.boot` swallows `Unable to boot device in current
|
|
606
|
+
// state: Booted`, so re-issuing it for a device that really is up costs one no-op
|
|
607
|
+
// subprocess and makes "the wait only ever runs after a boot was accepted" true.
|
|
432
608
|
await this.bootWithZombieRecovery(deviceId);
|
|
433
609
|
}
|
|
434
|
-
if (seq !== state.bootSeq)
|
|
610
|
+
if (seq !== state.bootSeq) {
|
|
611
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
435
612
|
return;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
613
|
+
}
|
|
614
|
+
// `simctl boot` returns on initiation, so everything below used to run while the device was
|
|
615
|
+
// still coming up — measured 7.6s early (#486). The status sent with the chrome data was
|
|
616
|
+
// hardcoded `'booted'` over a value that had just been fetched and discarded, and
|
|
617
|
+
// `device:ready` went out a few lines later. A human is slower than the gap and rarely
|
|
618
|
+
// notices; `mcp-server` installs and taps the moment it sees ready, which is what #440's
|
|
619
|
+
// "No devices are booted" was.
|
|
620
|
+
// `isStale` rather than a check on the far side alone: this handler is fire-and-forget, so a
|
|
621
|
+
// shutdown arriving mid-wait bumps `bootSeq` and leaves the poll spawning `xcrun simctl list`
|
|
622
|
+
// twice a second against a device that is now deliberately off — for the rest of the deadline,
|
|
623
|
+
// with nothing left that wants the answer.
|
|
624
|
+
const bootedDevice = await this.simctl.waitUntilBooted(deviceId, { isStale: () => seq !== state.bootSeq });
|
|
625
|
+
// Another await — a multi-second one — and `sendChromeData` below starts a helper process. A
|
|
626
|
+
// shutdown or a newer boot arriving in this gap would otherwise get a helper installed for the
|
|
627
|
+
// device it is taking down — and one that revives itself, so the stale reference outlives the
|
|
628
|
+
// boot that returns just below.
|
|
629
|
+
if (seq !== state.bootSeq) {
|
|
630
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.sendChromeData(state, bootedDevice);
|
|
442
634
|
const streamWs = await this.openStreamWs(state);
|
|
443
635
|
if (seq !== state.bootSeq) {
|
|
444
636
|
streamWs.close();
|
|
637
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
445
638
|
return;
|
|
446
639
|
}
|
|
447
640
|
this.startBinaryStream(state, streamWs);
|
|
@@ -450,7 +643,7 @@ export class IOSAgent {
|
|
|
450
643
|
if (this.audioEnabled())
|
|
451
644
|
this.startAudioCapture(state, streamWs, deviceId);
|
|
452
645
|
state.booted = true;
|
|
453
|
-
this.
|
|
646
|
+
this.sendMsg({ type: 'device:ready', sessionId, requestId, payload: { deviceId } });
|
|
454
647
|
// Sync AppleKeyboards after ready — fire-and-forget so streaming isn't delayed.
|
|
455
648
|
// hw=Automatic lets the hardware layout follow the active input source on LANG1/CapsLock.
|
|
456
649
|
// By the time the user navigates to a text field the sync has already completed.
|
|
@@ -459,10 +652,22 @@ export class IOSAgent {
|
|
|
459
652
|
});
|
|
460
653
|
}
|
|
461
654
|
catch (e) {
|
|
462
|
-
if (seq !== state.bootSeq)
|
|
655
|
+
if (seq !== state.bootSeq) {
|
|
656
|
+
this.abandonBoot(state, seq, sessionId, requestId);
|
|
463
657
|
return;
|
|
464
|
-
|
|
465
|
-
|
|
658
|
+
}
|
|
659
|
+
// `firstLine`, not `e.message`: this reaches a toast, and node's first line is
|
|
660
|
+
// `Command failed: xcrun simctl boot <UDID>`, which says nothing and echoes the udid. That
|
|
661
|
+
// matters more now that the boot is issued unconditionally — a device caught mid `Shutting
|
|
662
|
+
// Down` refuses the boot, and refusing is correct (swallowing it would put us back to waiting
|
|
663
|
+
// on a device nobody is bringing up), so the refusal is what the tester reads.
|
|
664
|
+
this.sendMsg({ type: 'device:boot-error', sessionId, requestId, message: firstLine(e) });
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
state.bootsInFlight.delete(seq);
|
|
668
|
+
// Its own key, in the one case nothing reads it: a newer boot arriving after this boot's last
|
|
669
|
+
// checkpoint but before it returns.
|
|
670
|
+
state.bootAbandon.delete(seq);
|
|
466
671
|
}
|
|
467
672
|
}
|
|
468
673
|
// Boot a device, auto-recovering from a vanished data dir. simctl lists the device
|
|
@@ -480,11 +685,11 @@ export class IOSAgent {
|
|
|
480
685
|
await this.simctl.boot(deviceId);
|
|
481
686
|
}
|
|
482
687
|
}
|
|
483
|
-
async handleDeviceShutdown(sessionId, deviceId) {
|
|
688
|
+
async handleDeviceShutdown(sessionId, deviceId, requestId) {
|
|
484
689
|
const state = this.deviceStates.get(sessionId);
|
|
485
690
|
if (!state)
|
|
486
691
|
return;
|
|
487
|
-
state
|
|
692
|
+
this.bumpBootSeq(state, 'shut-down');
|
|
488
693
|
void state.streamReader?.cancel();
|
|
489
694
|
state.streamReader = null;
|
|
490
695
|
state.captureStreamer = null; // reader.cancel() kills the helper proc; drop the ref so a stale requestKeyframe() no-ops
|
|
@@ -499,11 +704,12 @@ export class IOSAgent {
|
|
|
499
704
|
this.uiTreeReader.stopIfDevice(deviceId);
|
|
500
705
|
try {
|
|
501
706
|
await this.simctl.shutdown(deviceId);
|
|
502
|
-
this.
|
|
707
|
+
this.sendMsg({
|
|
503
708
|
type: 'device:shutdown-done',
|
|
504
709
|
sessionId,
|
|
710
|
+
requestId,
|
|
505
711
|
payload: { deviceId },
|
|
506
|
-
})
|
|
712
|
+
});
|
|
507
713
|
}
|
|
508
714
|
catch (e) {
|
|
509
715
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -537,14 +743,89 @@ export class IOSAgent {
|
|
|
537
743
|
sentinelParked(deviceId) {
|
|
538
744
|
return (this.parkedSentinels.get(deviceId) ?? 0) > 0;
|
|
539
745
|
}
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
746
|
+
// A terminal input naming a session this agent holds no state for. Reachable in a way that is
|
|
747
|
+
// genuinely disputed: `sessionRebind.test.ts` records that a restarted agent is re-seeded from
|
|
748
|
+
// `agent:registered`, so `!state` should never fire — but `registeredSessions` carries one entry
|
|
749
|
+
// per *device* (`RelayServer.ts`, `byDeviceId`), and the relay's own comment there notes that one
|
|
750
|
+
// device can now sit behind two sessions, which leaves the second unseeded. Answering costs four
|
|
751
|
+
// lines; staying silent costs a terminal input swallowed and the caller's own fallback reporting
|
|
752
|
+
// success. Android already answers, and `channel-unavailable` is what it maps this to.
|
|
753
|
+
/** The correlator on an input an ack answers, or `null` if the frame cannot be attributed.
|
|
754
|
+
*
|
|
755
|
+
* A local capture rather than a guard at the top of the dispatcher, and that is the point: a guard there
|
|
756
|
+
* would not narrow `msg.requestId` inside the case, so the shortest way to satisfy the reply's required
|
|
757
|
+
* field would be `msg.requestId!` — the assertion removed from `open-url` and then from clipboard. This
|
|
758
|
+
* hands back a `string` the case can close over.
|
|
759
|
+
*
|
|
760
|
+
* It is unvalidated JSON, so the check is real work rather than ceremony: the declaration is required and
|
|
761
|
+
* every in-repo sender is typed against it, and #444 made the relay refuse an empty one — but this agent may
|
|
762
|
+
* be talking to a relay older than that, and `mcp-server`'s tool
|
|
763
|
+
* schemas are bare `z.string()` so a model can produce `''`. */
|
|
764
|
+
correlatorOf(msg) {
|
|
765
|
+
if (typeof msg.requestId === 'string' && msg.requestId !== '')
|
|
766
|
+
return msg.requestId;
|
|
767
|
+
logger.warn(`${msg.type} without a usable requestId — dropped, its ack could not be attributed`);
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
ackNoSession(sessionId, requestId) {
|
|
771
|
+
// The `if (!sessionId) return` this used to open with is gone: the dispatcher now declares
|
|
772
|
+
// `sessionId: string`, so there is no undefined to guard against and the guard would have been a
|
|
773
|
+
// silent drop with nothing left that could reach it.
|
|
774
|
+
this.sendMsg({
|
|
775
|
+
type: 'input:error', sessionId, requestId,
|
|
776
|
+
message: INPUT_ERROR_MESSAGES['channel-unavailable'],
|
|
777
|
+
reason: 'channel-unavailable',
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Why a write was refused. The helper's boolean says only "no".
|
|
782
|
+
*
|
|
783
|
+
* `kind` matters because the two questions were decided at different times. Readiness is about now;
|
|
784
|
+
* a continuation frame's fate was decided when the gesture opened. A gesture whose opening frame
|
|
785
|
+
* was refused inside the start-up window owns nothing, so its terminal frame can never land no
|
|
786
|
+
* matter how ready the helper has since become — and reading readiness first answered `malformed`
|
|
787
|
+
* ("never retry") for precisely the case `channel-starting` exists to serve. Ownership is therefore
|
|
788
|
+
* checked first for continuations, which is also the right answer when the helper died mid-gesture
|
|
789
|
+
* and its replacement is still starting: waiting would not help either.
|
|
790
|
+
*
|
|
791
|
+
* Safe to read at the ack site rather than inside the write, because iOS writes are synchronous —
|
|
792
|
+
* but only for the question readiness answers. That is why ownership is asked separately.
|
|
793
|
+
*/
|
|
794
|
+
refusalReason(helper, kind) {
|
|
795
|
+
const state = helper?.inputState() ?? 'unavailable';
|
|
796
|
+
// No channel at all dominates: the caller must reconnect, and telling it to re-open a gesture
|
|
797
|
+
// would send it round a loop that cannot succeed.
|
|
798
|
+
if (state === 'unavailable')
|
|
799
|
+
return 'channel-unavailable';
|
|
800
|
+
// Then ownership, and only for a continuation. A gesture whose opening frame never landed owns
|
|
801
|
+
// nothing, so its terminal frame can never be delivered however ready the helper has become —
|
|
802
|
+
// which is why readiness must not be consulted first here.
|
|
803
|
+
//
|
|
804
|
+
// A consequence worth stating: `channel-starting` is unreachable for a continuation. Owning a
|
|
805
|
+
// gesture requires an opening frame to have landed, which requires the helper to have been ready,
|
|
806
|
+
// so a continuation is never refused *merely* because the channel is still coming up. Standalone
|
|
807
|
+
// inputs — a key, a button — are the ones that get that answer.
|
|
808
|
+
if (kind === 'continuation' && !helper?.ownsGesture())
|
|
809
|
+
return 'no-gesture';
|
|
810
|
+
return state === 'starting' ? 'channel-starting' : 'no-gesture';
|
|
811
|
+
}
|
|
812
|
+
// Ack a terminal input. `input:done` = dispatched to a booted device (not a landing guarantee —
|
|
813
|
+
// HID is fire-and-forget). Otherwise `input:error` carries a machine-readable `reason` so a caller
|
|
814
|
+
// can tell "retry in a moment" from "reconnect" from "never retry"; `message` stays human prose.
|
|
815
|
+
// Off the sync inject path, so start/end pairing is unaffected.
|
|
816
|
+
//
|
|
817
|
+
// `requestId` is a parameter, never read from shared state: a gesture is dozens of frames and two can
|
|
818
|
+
// overlap, so a correlator hoisted out of per-request scope would answer one input with another's id —
|
|
819
|
+
// which is #499 rebuilt inside the agent. Same reason `seq` is passed in on the Android side.
|
|
820
|
+
async ackInput(state, outcome, requestId) {
|
|
821
|
+
const reason = outcome !== 'delivered'
|
|
822
|
+
? outcome
|
|
823
|
+
: (state.booted || (await this.isBooted(state.deviceId))) ? null : 'not-booted';
|
|
824
|
+
if (reason === null)
|
|
544
825
|
state.booted = true; // cache the post-reconnect verify so later inputs skip simctl
|
|
545
|
-
this.
|
|
546
|
-
? { type: 'input:done', sessionId: state.sessionId }
|
|
547
|
-
: { type: 'input:error', sessionId: state.sessionId, message:
|
|
826
|
+
this.sendMsg(reason === null
|
|
827
|
+
? { type: 'input:done', sessionId: state.sessionId, requestId }
|
|
828
|
+
: { type: 'input:error', sessionId: state.sessionId, requestId, message: INPUT_ERROR_MESSAGES[reason], reason });
|
|
548
829
|
}
|
|
549
830
|
async isBooted(deviceId) {
|
|
550
831
|
try {
|
|
@@ -560,44 +841,68 @@ export class IOSAgent {
|
|
|
560
841
|
case 'device:boot': {
|
|
561
842
|
const { deviceId, resetMode, acceptH264, secureContext, external } = msg.payload;
|
|
562
843
|
const sessionId = msg.sessionId;
|
|
563
|
-
this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external })
|
|
844
|
+
this.handleDeviceBoot(sessionId, deviceId, resetMode === 'full-erase', acceptH264 === true, { secureContext: !!secureContext, external: !!external }, msg.requestId)
|
|
564
845
|
.catch((e) => logger.error('handleDeviceBoot failed:', e));
|
|
565
846
|
break;
|
|
566
847
|
}
|
|
567
848
|
case 'device:shutdown': {
|
|
568
849
|
const { deviceId } = msg.payload;
|
|
569
850
|
const sessionId = msg.sessionId;
|
|
570
|
-
this.handleDeviceShutdown(sessionId, deviceId)
|
|
851
|
+
this.handleDeviceShutdown(sessionId, deviceId, msg.requestId)
|
|
571
852
|
.catch((e) => logger.error('handleDeviceShutdown failed:', e));
|
|
572
853
|
break;
|
|
573
854
|
}
|
|
574
855
|
case 'app:install': {
|
|
575
856
|
const { filePath, bundleId } = msg.payload;
|
|
576
857
|
const sessionId = msg.sessionId;
|
|
577
|
-
|
|
578
|
-
|
|
858
|
+
const { requestId } = msg;
|
|
859
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
860
|
+
console.warn('[tapflow] app:install without a requestId — dropped, cannot correlate a reply');
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
// `...body` first: with the ids last, a body *variable* carrying one would override the real
|
|
864
|
+
// correlator, because excess-property checking does not fire on variables. See `open-url`.
|
|
865
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
866
|
+
const installState = this.deviceStates.get(sessionId);
|
|
867
|
+
if (!installState) {
|
|
868
|
+
respond({ type: 'app:install-error', message: 'No booted device' });
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
this.installBuild(installState.deviceId, filePath, bundleId)
|
|
872
|
+
.then(() => respond({ type: 'app:install-done' }))
|
|
579
873
|
.catch((e) => {
|
|
580
874
|
const message = e instanceof Error ? e.message : String(e);
|
|
581
|
-
|
|
875
|
+
respond({ type: 'app:install-error', message });
|
|
582
876
|
});
|
|
583
877
|
break;
|
|
584
878
|
}
|
|
585
879
|
case 'app:launch': {
|
|
586
880
|
const { bundleId } = msg.payload;
|
|
587
881
|
const sessionId = msg.sessionId;
|
|
882
|
+
const { requestId } = msg;
|
|
883
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
884
|
+
console.warn('[tapflow] app:launch without a requestId — dropped, cannot correlate a reply');
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
// `...body` first: with the ids last, a body *variable* carrying one would override the real
|
|
888
|
+
// correlator, because excess-property checking does not fire on variables. See `open-url`.
|
|
889
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
588
890
|
const launchState = this.deviceStates.get(sessionId);
|
|
589
|
-
|
|
891
|
+
if (!launchState) {
|
|
892
|
+
respond({ type: 'app:launch-error', message: 'No booted device' });
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
this.simctl.launchApp(launchState.deviceId, bundleId)
|
|
590
896
|
.then(() => {
|
|
591
897
|
// Track the foreground app so ui:tree:request can query it via XCUITest.
|
|
592
|
-
|
|
593
|
-
this.lastBundleIds.set(launchState.deviceId, bundleId);
|
|
898
|
+
this.lastBundleIds.set(launchState.deviceId, bundleId);
|
|
594
899
|
// Audio: the whole-sim tap's poll picks up the launched app process within one interval;
|
|
595
900
|
// no per-launch helper needed.
|
|
596
|
-
|
|
901
|
+
respond({ type: 'app:launch-done' });
|
|
597
902
|
})
|
|
598
903
|
.catch((e) => {
|
|
599
904
|
const message = e instanceof Error ? e.message : String(e);
|
|
600
|
-
|
|
905
|
+
respond({ type: 'app:launch-error', message });
|
|
601
906
|
});
|
|
602
907
|
break;
|
|
603
908
|
}
|
|
@@ -619,11 +924,19 @@ export class IOSAgent {
|
|
|
619
924
|
break;
|
|
620
925
|
}
|
|
621
926
|
case 'input:touch:end': {
|
|
927
|
+
const requestId = this.correlatorOf(msg);
|
|
928
|
+
if (requestId === null)
|
|
929
|
+
break;
|
|
622
930
|
const state = this.deviceStates.get(msg.sessionId);
|
|
623
|
-
if (!state)
|
|
931
|
+
if (!state) {
|
|
932
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
624
933
|
break;
|
|
625
|
-
|
|
626
|
-
|
|
934
|
+
}
|
|
935
|
+
// The helper's answer, not its existence: a helper whose process has died reports every
|
|
936
|
+
// write as dropped, and that is what the caller needs to hear (#482).
|
|
937
|
+
const helper = state.touchHelper;
|
|
938
|
+
// terminal of a tap/swipe → ack the gesture
|
|
939
|
+
void this.ackInput(state, helper?.touchEnd() ? 'delivered' : this.refusalReason(helper, 'continuation'), requestId);
|
|
627
940
|
break;
|
|
628
941
|
}
|
|
629
942
|
case 'input:pinch:start': {
|
|
@@ -644,11 +957,16 @@ export class IOSAgent {
|
|
|
644
957
|
break;
|
|
645
958
|
}
|
|
646
959
|
case 'input:pinch:end': {
|
|
960
|
+
const requestId = this.correlatorOf(msg);
|
|
961
|
+
if (requestId === null)
|
|
962
|
+
break;
|
|
647
963
|
const state = this.deviceStates.get(msg.sessionId);
|
|
648
|
-
if (!state)
|
|
964
|
+
if (!state) {
|
|
965
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
649
966
|
break;
|
|
650
|
-
|
|
651
|
-
|
|
967
|
+
}
|
|
968
|
+
const pinchHelper = state.touchHelper;
|
|
969
|
+
void this.ackInput(state, pinchHelper?.pinchEnd() ? 'delivered' : this.refusalReason(pinchHelper, 'continuation'), requestId);
|
|
652
970
|
break;
|
|
653
971
|
}
|
|
654
972
|
case 'input:rotate': {
|
|
@@ -675,24 +993,27 @@ export class IOSAgent {
|
|
|
675
993
|
: this.simctl.hideSoftwareKeyboard(state.deviceId);
|
|
676
994
|
op.then(() => {
|
|
677
995
|
state.softKeyboardVisible = showing;
|
|
678
|
-
this.
|
|
996
|
+
this.sendMsg({
|
|
679
997
|
type: 'keyboard:toggled',
|
|
680
998
|
sessionId: state.sessionId,
|
|
681
999
|
payload: { visible: showing },
|
|
682
|
-
})
|
|
1000
|
+
});
|
|
683
1001
|
}).catch((e) => {
|
|
684
1002
|
logger.error('keyboard toggle failed:', e);
|
|
685
1003
|
});
|
|
686
1004
|
break;
|
|
687
1005
|
}
|
|
688
1006
|
case 'input:type': {
|
|
1007
|
+
const requestId = this.correlatorOf(msg);
|
|
1008
|
+
if (requestId === null)
|
|
1009
|
+
break;
|
|
689
1010
|
const sessionId = msg.sessionId;
|
|
690
1011
|
const state = this.deviceStates.get(sessionId);
|
|
691
1012
|
if (state)
|
|
692
1013
|
this.ensureTouchHelper(state);
|
|
693
1014
|
const { text } = (msg.payload ?? {});
|
|
694
1015
|
if (!state?.touchHelper) {
|
|
695
|
-
this.
|
|
1016
|
+
this.sendMsg({ type: 'input:type-error', sessionId, requestId, message: 'No booted device' });
|
|
696
1017
|
break;
|
|
697
1018
|
}
|
|
698
1019
|
// simctl pbcopy → Cmd+V paste. Works for arbitrary Unicode (unlike a
|
|
@@ -709,92 +1030,155 @@ export class IOSAgent {
|
|
|
709
1030
|
state.softKeyboardVisible = false;
|
|
710
1031
|
await this.simctl.hideSoftwareKeyboard(state.deviceId).catch(() => { });
|
|
711
1032
|
}
|
|
712
|
-
|
|
1033
|
+
// Throwing routes into the input:type-error below. Without it a dropped chord still
|
|
1034
|
+
// answers input:type-done: the text sits on the device pasteboard, nothing was pasted
|
|
1035
|
+
// into the app, and the caller moves on. Unlike the copy path in clipboard:read there
|
|
1036
|
+
// is no pasteboard deadline here to fail loudly in its place.
|
|
1037
|
+
if (!state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft'])) {
|
|
1038
|
+
throw new PlatformError('Cannot paste the text — no input channel to the device');
|
|
1039
|
+
}
|
|
713
1040
|
};
|
|
714
1041
|
// Ack on completion so a following input step (e.g. pressKey Enter) is
|
|
715
1042
|
// only sent after the paste has actually landed.
|
|
716
1043
|
// Shares the clipboard queue: this writes the pasteboard, so running it alongside a
|
|
717
1044
|
// clipboard:read would overwrite that read's sentinel and be returned as "copied".
|
|
718
1045
|
this.clipboardQueue(state.deviceId, doType)
|
|
719
|
-
.then(() => this.
|
|
1046
|
+
.then(() => this.sendMsg({ type: 'input:type-done', sessionId, requestId }))
|
|
720
1047
|
.catch((e) => {
|
|
721
1048
|
const message = e instanceof Error ? e.message : String(e);
|
|
722
1049
|
logger.error('input:type (pbcopy+paste) failed:', e);
|
|
723
|
-
this.
|
|
1050
|
+
this.sendMsg({ type: 'input:type-error', sessionId, requestId, message });
|
|
724
1051
|
});
|
|
725
1052
|
break;
|
|
726
1053
|
}
|
|
727
1054
|
case 'input:key': {
|
|
1055
|
+
const requestId = this.correlatorOf(msg);
|
|
1056
|
+
if (requestId === null)
|
|
1057
|
+
break;
|
|
728
1058
|
const state = this.deviceStates.get(msg.sessionId);
|
|
729
|
-
if (!state)
|
|
1059
|
+
if (!state) {
|
|
1060
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
730
1061
|
break;
|
|
1062
|
+
}
|
|
731
1063
|
this.ensureTouchHelper(state);
|
|
732
1064
|
const { code, modifiers } = msg.payload;
|
|
733
1065
|
const usage = KEY_CODE_MAP[code];
|
|
734
1066
|
if (usage === undefined) {
|
|
735
|
-
|
|
1067
|
+
// Prose preserved (it names the code), reason added: `unsupported` tells a caller never to
|
|
1068
|
+
// retry, which the message alone could not.
|
|
1069
|
+
this.sendMsg({
|
|
1070
|
+
type: 'input:error', sessionId: msg.sessionId, requestId,
|
|
1071
|
+
message: `unknown key code: ${code}`, reason: 'unsupported',
|
|
1072
|
+
});
|
|
736
1073
|
break;
|
|
737
1074
|
}
|
|
738
1075
|
if (state.softKeyboardVisible) {
|
|
739
1076
|
// Hide the SW keyboard first so iOS re-initialises the HW keyboard
|
|
740
1077
|
// context. Skipping this causes input-source desync (qks / ㅂㅏㄴ symptoms).
|
|
741
1078
|
state.softKeyboardVisible = false;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1079
|
+
// The ack belongs after the deferred chord, not beside it. Acking out here ran before
|
|
1080
|
+
// the key was ever written, so the answer could only be a guess — and the guess was
|
|
1081
|
+
// "delivered". The hide failing is not fatal (the key still goes out), so the catch
|
|
1082
|
+
// resolves and the send happens exactly once either way.
|
|
1083
|
+
void this.simctl.hideSoftwareKeyboard(state.deviceId)
|
|
1084
|
+
.catch((e) => { logger.error('hideSoftwareKeyboard (on key) failed:', e); })
|
|
1085
|
+
.then(() => {
|
|
1086
|
+
const h = state.touchHelper;
|
|
1087
|
+
return this.ackInput(state, h?.sendKey(usage, modifiers ?? 0) ? 'delivered' : this.refusalReason(h, 'standalone'), requestId);
|
|
747
1088
|
});
|
|
748
1089
|
}
|
|
749
1090
|
else {
|
|
750
|
-
state.touchHelper
|
|
1091
|
+
const h = state.touchHelper;
|
|
1092
|
+
void this.ackInput(state, h?.sendKey(usage, modifiers ?? 0) ? 'delivered' : this.refusalReason(h, 'standalone'), requestId);
|
|
751
1093
|
}
|
|
752
|
-
void this.ackInput(state, state.touchHelper !== null);
|
|
753
1094
|
break;
|
|
754
1095
|
}
|
|
755
1096
|
case 'input:button': {
|
|
1097
|
+
const requestId = this.correlatorOf(msg);
|
|
1098
|
+
if (requestId === null)
|
|
1099
|
+
break;
|
|
756
1100
|
const state = this.deviceStates.get(msg.sessionId);
|
|
757
|
-
if (!state)
|
|
1101
|
+
if (!state) {
|
|
1102
|
+
this.ackNoSession(msg.sessionId, requestId);
|
|
758
1103
|
break;
|
|
1104
|
+
}
|
|
759
1105
|
this.ensureTouchHelper(state);
|
|
760
1106
|
const { name, phase } = msg.payload;
|
|
761
1107
|
// Map the cross-platform button vocabulary (used by MCP) onto this
|
|
762
1108
|
// device's actual chrome button names. Dashboard already sends the raw
|
|
763
1109
|
// chrome names (e.g. "volume-up"), which pass through unchanged.
|
|
764
1110
|
const chromeName = IOS_BUTTON_ALIASES[name] ?? name;
|
|
1111
|
+
// Two branches below deliberately write nothing — a home press-down, and a button this
|
|
1112
|
+
// device's chrome has no HID mapping for. Neither is a dropped input, so they answer
|
|
1113
|
+
// with the channel's health instead of a write result; reporting an error there would
|
|
1114
|
+
// trade this issue's false success for a false failure.
|
|
1115
|
+
const btnHelper = state.touchHelper;
|
|
1116
|
+
const wrote = (ok) => ok ? 'delivered' : this.refusalReason(btnHelper, 'standalone');
|
|
1117
|
+
// The two branches that write nothing answer from the channel's state, which is `delivered`
|
|
1118
|
+
// when it is ready. A button this device's chrome lacks stays a *success* — the device
|
|
1119
|
+
// genuinely has no such button, so an error would be a false failure (#484). That is a
|
|
1120
|
+
// decision, and it is why iOS never sends `unsupported` for a button while Android does.
|
|
1121
|
+
let outcome = btnHelper?.inputState() === 'ready'
|
|
1122
|
+
? 'delivered'
|
|
1123
|
+
: this.refusalReason(btnHelper, 'standalone');
|
|
765
1124
|
if (chromeName === 'home') {
|
|
766
1125
|
// Home has no HID down/up split — always a single legacy press. Send once on release
|
|
767
1126
|
// (or on a phase-less legacy message) so a down+up pair doesn't fire it twice.
|
|
768
1127
|
if (phase !== 'down')
|
|
769
|
-
|
|
1128
|
+
outcome = wrote(btnHelper?.pressLegacyButton(0) ?? false);
|
|
770
1129
|
}
|
|
771
1130
|
else {
|
|
772
1131
|
const btn = state.loadedChrome?.buttons.find((b) => b.name === chromeName);
|
|
773
1132
|
if (btn && btn.usagePage > 0 && btn.usage > 0) {
|
|
774
1133
|
if (phase === 'down')
|
|
775
|
-
|
|
1134
|
+
outcome = wrote(btnHelper?.pressButtonDown(btn.usagePage, btn.usage) ?? false);
|
|
776
1135
|
else if (phase === 'up')
|
|
777
|
-
|
|
1136
|
+
outcome = wrote(btnHelper?.pressButtonUp(btn.usagePage, btn.usage) ?? false);
|
|
778
1137
|
else
|
|
779
|
-
|
|
1138
|
+
outcome = wrote(btnHelper?.pressButton(btn.usagePage, btn.usage) ?? false);
|
|
780
1139
|
}
|
|
781
1140
|
}
|
|
782
|
-
void this.ackInput(state,
|
|
1141
|
+
void this.ackInput(state, outcome, requestId);
|
|
783
1142
|
break;
|
|
784
1143
|
}
|
|
785
1144
|
case 'open-url': {
|
|
786
1145
|
const { url } = msg.payload;
|
|
787
1146
|
const sessionId = msg.sessionId;
|
|
1147
|
+
const { requestId } = msg;
|
|
1148
|
+
// No fallback by design (see the note above `OpenUrl`), so an uncorrelatable request cannot be
|
|
1149
|
+
// answered correlatably either — and inventing an id would make this agent's reply look like a
|
|
1150
|
+
// response to a request nobody made. Every in-repo sender supplies one, and the `fixed` version
|
|
1151
|
+
// group means there is no in-repo skew window; validating third-party frames at the relay's door
|
|
1152
|
+
// is #444, which will take this over. Until then a drop with a log beats a reply that lies.
|
|
1153
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
1154
|
+
console.warn('[tapflow] open-url without a requestId — dropped, cannot correlate a reply');
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
// Every exit merges the correlation ids here. What that buys, precisely — review measured 13
|
|
1158
|
+
// attacks and the type caught 3:
|
|
1159
|
+
//
|
|
1160
|
+
// - **Omitting the correlator is a compile error.** That comes from `requestId: string` being
|
|
1161
|
+
// required on `OpenUrlDone`/`OpenUrlError`, reached through `sendMsg`'s `AgentControlOutbound`
|
|
1162
|
+
// — not from `OpenUrlReplyBody`, whose one contribution is rejecting a fresh id written as a
|
|
1163
|
+
// literal at the `respond(...)` call.
|
|
1164
|
+
// - **`...body` goes first on purpose.** With the ids last, a body *variable* carrying a
|
|
1165
|
+
// `requestId` cannot override them — excess-property checking does not fire on variables, so
|
|
1166
|
+
// the earlier `{ sessionId, requestId, ...body }` let a wrong id win.
|
|
1167
|
+
//
|
|
1168
|
+
// What it does **not** buy: `sendMsg` accepts any `string` here, so a site that bypasses
|
|
1169
|
+
// `respond` type-checks. The echo tests in both agents' suites are what catch that, and each
|
|
1170
|
+
// remaining correlation pair needs its own — this helper does not remove that work.
|
|
1171
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
788
1172
|
const state = this.deviceStates.get(sessionId);
|
|
789
1173
|
if (!state) {
|
|
790
|
-
|
|
1174
|
+
respond({ type: 'open-url:error', message: 'no booted device' });
|
|
791
1175
|
break;
|
|
792
1176
|
}
|
|
793
1177
|
this.simctl.openUrl(state.deviceId, url)
|
|
794
|
-
.then(() =>
|
|
1178
|
+
.then(() => respond({ type: 'open-url:done' }))
|
|
795
1179
|
.catch((e) => {
|
|
796
1180
|
const message = e instanceof Error ? e.message : String(e);
|
|
797
|
-
|
|
1181
|
+
respond({ type: 'open-url:error', message });
|
|
798
1182
|
});
|
|
799
1183
|
break;
|
|
800
1184
|
}
|
|
@@ -802,33 +1186,48 @@ export class IOSAgent {
|
|
|
802
1186
|
const raw = msg;
|
|
803
1187
|
const { requestId, format } = raw;
|
|
804
1188
|
const sessionId = msg.sessionId;
|
|
805
|
-
this.
|
|
806
|
-
|
|
1189
|
+
const shotState = this.deviceStates.get(sessionId);
|
|
1190
|
+
if (!shotState) {
|
|
1191
|
+
this.sendMsg({ type: 'screenshot:error', sessionId, requestId, message: 'No booted device' });
|
|
1192
|
+
break;
|
|
1193
|
+
}
|
|
1194
|
+
// Note for anyone widening this signature again: before the udid parameter existed this
|
|
1195
|
+
// read `screenshot(format ?? 'png')`, and adding a leading `udid: string` did NOT make it a
|
|
1196
|
+
// type error — the format string simply became the device id. The compiler cannot help
|
|
1197
|
+
// here; the test that scans exec arguments is what does.
|
|
1198
|
+
this.simctl.screenshot(shotState.deviceId, format ?? 'png')
|
|
1199
|
+
.then((buf) => this.sendMsg({
|
|
807
1200
|
type: 'screenshot:done',
|
|
808
1201
|
sessionId,
|
|
809
1202
|
requestId,
|
|
810
1203
|
format: format ?? 'png',
|
|
811
1204
|
data: buf.toString('base64'),
|
|
812
|
-
}))
|
|
1205
|
+
}))
|
|
813
1206
|
.catch((e) => {
|
|
814
1207
|
const message = e instanceof Error ? e.message : String(e);
|
|
815
|
-
this.
|
|
1208
|
+
this.sendMsg({ type: 'screenshot:error', sessionId, requestId, message });
|
|
816
1209
|
});
|
|
817
1210
|
break;
|
|
818
1211
|
}
|
|
819
1212
|
case 'app:clear-state': {
|
|
820
1213
|
const { bundleId } = (msg.payload ?? {});
|
|
821
1214
|
const sessionId = msg.sessionId;
|
|
1215
|
+
const { requestId } = msg;
|
|
1216
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
1217
|
+
console.warn('[tapflow] app:clear-state without a requestId — dropped, cannot correlate a reply');
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
const respond = (body) => this.sendMsg({ ...body, sessionId, requestId });
|
|
822
1221
|
const state = this.deviceStates.get(sessionId);
|
|
823
1222
|
if (!state || !bundleId) {
|
|
824
|
-
|
|
1223
|
+
respond({ type: 'app:clear-state-error', message: !state ? 'No booted device' : 'bundleId missing' });
|
|
825
1224
|
break;
|
|
826
1225
|
}
|
|
827
|
-
this.simctl.clearAppData(bundleId)
|
|
828
|
-
.then(() =>
|
|
1226
|
+
this.simctl.clearAppData(state.deviceId, bundleId)
|
|
1227
|
+
.then(() => respond({ type: 'app:clear-state-done' }))
|
|
829
1228
|
.catch((e) => {
|
|
830
1229
|
const message = e instanceof Error ? e.message : String(e);
|
|
831
|
-
|
|
1230
|
+
respond({ type: 'app:clear-state-error', message });
|
|
832
1231
|
});
|
|
833
1232
|
break;
|
|
834
1233
|
}
|
|
@@ -837,14 +1236,22 @@ export class IOSAgent {
|
|
|
837
1236
|
// await hideSoftwareKeyboard first), and reading too early returns the PREVIOUS
|
|
838
1237
|
// pasteboard — a stale value the user would never notice.
|
|
839
1238
|
case 'clipboard:read': {
|
|
1239
|
+
// `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
|
|
1240
|
+
// the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
|
|
1241
|
+
// required and the only requester goes through a typed `send()`, so nothing in-repo omits it.
|
|
1242
|
+
//
|
|
1243
|
+
// It is still an assertion about unvalidated JSON, as those other two are: a third-party client
|
|
1244
|
+
// could omit it, and then the reply would carry `undefined` and be uncorrelatable. That is inbound
|
|
1245
|
+
// validation (#444), not producer typing, and making it optional here instead would just move the
|
|
1246
|
+
// same hole into every reply this case sends.
|
|
840
1247
|
const { requestId } = msg;
|
|
841
1248
|
const sessionId = msg.sessionId;
|
|
842
1249
|
const state = this.deviceStates.get(sessionId);
|
|
843
1250
|
if (!state) {
|
|
844
|
-
this.
|
|
1251
|
+
this.sendMsg({
|
|
845
1252
|
type: 'clipboard:error', sessionId, requestId, message: 'No booted device',
|
|
846
1253
|
payload: { sentinelParked: false },
|
|
847
|
-
})
|
|
1254
|
+
});
|
|
848
1255
|
break;
|
|
849
1256
|
}
|
|
850
1257
|
const { press } = (msg.payload ?? {});
|
|
@@ -852,7 +1259,7 @@ export class IOSAgent {
|
|
|
852
1259
|
// executes AFTER this — so the viewer hears back as soon as the outcome is known and
|
|
853
1260
|
// does not wait out the restore window. The queue is still held until the restore
|
|
854
1261
|
// finishes, which is what actually matters: the next operation must not see a sentinel.
|
|
855
|
-
const respond = (body) => this.
|
|
1262
|
+
const respond = (body) => this.sendMsg({ sessionId, requestId, ...body });
|
|
856
1263
|
const read = async () => {
|
|
857
1264
|
if (!press) {
|
|
858
1265
|
respond({ type: 'clipboard:data', payload: { text: await this.simctl.getPasteboard(state.deviceId) } });
|
|
@@ -965,23 +1372,31 @@ export class IOSAgent {
|
|
|
965
1372
|
break;
|
|
966
1373
|
}
|
|
967
1374
|
case 'clipboard:write': {
|
|
1375
|
+
// `requestId: string`, matching the screenshot and ui:tree casts a few cases below — clipboard was
|
|
1376
|
+
// the only one reading it as optional, for the same wire guarantee. `ClipboardRequest.requestId` is
|
|
1377
|
+
// required and the only requester goes through a typed `send()`, so nothing in-repo omits it.
|
|
1378
|
+
//
|
|
1379
|
+
// It is still an assertion about unvalidated JSON, as those other two are: a third-party client
|
|
1380
|
+
// could omit it, and then the reply would carry `undefined` and be uncorrelatable. That is inbound
|
|
1381
|
+
// validation (#444), not producer typing, and making it optional here instead would just move the
|
|
1382
|
+
// same hole into every reply this case sends.
|
|
968
1383
|
const { requestId } = msg;
|
|
969
1384
|
const sessionId = msg.sessionId;
|
|
970
1385
|
const state = this.deviceStates.get(sessionId);
|
|
971
1386
|
if (!state) {
|
|
972
|
-
this.
|
|
1387
|
+
this.sendMsg({
|
|
973
1388
|
type: 'clipboard:error', sessionId, requestId, message: 'No booted device',
|
|
974
1389
|
payload: { sentinelParked: false },
|
|
975
|
-
})
|
|
1390
|
+
});
|
|
976
1391
|
break;
|
|
977
1392
|
}
|
|
978
1393
|
const { text, pasteAfter } = (msg.payload ?? {});
|
|
979
1394
|
if (clipboardByteLength(text ?? '') > MAX_CLIPBOARD_BYTES) {
|
|
980
|
-
this.
|
|
1395
|
+
this.sendMsg({
|
|
981
1396
|
type: 'clipboard:error', sessionId, requestId,
|
|
982
1397
|
message: `Clipboard is too large (max ${Math.floor(MAX_CLIPBOARD_BYTES / 1024)} KB)`,
|
|
983
1398
|
payload: { sentinelParked: this.sentinelParked(state.deviceId) },
|
|
984
|
-
})
|
|
1399
|
+
});
|
|
985
1400
|
break;
|
|
986
1401
|
}
|
|
987
1402
|
const write = async () => {
|
|
@@ -1006,17 +1421,22 @@ export class IOSAgent {
|
|
|
1006
1421
|
state.softKeyboardVisible = false;
|
|
1007
1422
|
await this.simctl.hideSoftwareKeyboard(state.deviceId).catch(() => { });
|
|
1008
1423
|
}
|
|
1009
|
-
|
|
1424
|
+
// Same reason as input:type: the deadline above proves the pasteboard took the text,
|
|
1425
|
+
// not that the chord reached the device, so a dead channel here would answer
|
|
1426
|
+
// clipboard:write-done having pasted nothing.
|
|
1427
|
+
if (!state.touchHelper?.sendKey(KEY_CODE_MAP['KeyV'], MODIFIER_BITS['MetaLeft'])) {
|
|
1428
|
+
throw new PlatformError('Cannot press paste — no input channel to the device');
|
|
1429
|
+
}
|
|
1010
1430
|
};
|
|
1011
1431
|
// Ack only once the write (and the paste, when asked for) actually landed.
|
|
1012
1432
|
this.clipboardQueue(state.deviceId, write)
|
|
1013
|
-
.then(() => this.
|
|
1433
|
+
.then(() => this.sendMsg({ type: 'clipboard:write-done', sessionId, requestId }))
|
|
1014
1434
|
.catch((e) => {
|
|
1015
1435
|
const message = e instanceof Error ? e.message : String(e);
|
|
1016
|
-
this.
|
|
1436
|
+
this.sendMsg({
|
|
1017
1437
|
type: 'clipboard:error', sessionId, requestId, message,
|
|
1018
1438
|
payload: { sentinelParked: this.sentinelParked(state.deviceId) },
|
|
1019
|
-
})
|
|
1439
|
+
});
|
|
1020
1440
|
});
|
|
1021
1441
|
break;
|
|
1022
1442
|
}
|
|
@@ -1026,14 +1446,14 @@ export class IOSAgent {
|
|
|
1026
1446
|
const sessionId = msg.sessionId;
|
|
1027
1447
|
const state = this.deviceStates.get(sessionId);
|
|
1028
1448
|
if (!state) {
|
|
1029
|
-
this.
|
|
1449
|
+
this.sendMsg({ type: 'ui:tree:error', sessionId, requestId, message: 'No booted device' });
|
|
1030
1450
|
break;
|
|
1031
1451
|
}
|
|
1032
1452
|
this.readUITree(state)
|
|
1033
|
-
.then((elements) => this.
|
|
1453
|
+
.then((elements) => this.sendMsg({ type: 'ui:tree:response', sessionId, requestId, elements }))
|
|
1034
1454
|
.catch((e) => {
|
|
1035
1455
|
const message = e instanceof Error ? e.message : String(e);
|
|
1036
|
-
this.
|
|
1456
|
+
this.sendMsg({ type: 'ui:tree:error', sessionId, requestId, message });
|
|
1037
1457
|
});
|
|
1038
1458
|
break;
|
|
1039
1459
|
}
|
|
@@ -1054,15 +1474,15 @@ export class IOSAgent {
|
|
|
1054
1474
|
* 직접 설치. tar 추출은 실행 비트·심볼릭 링크를 보존하고(재압축이 아니라 네이티브 보관),
|
|
1055
1475
|
* macOS tar(libarchive)가 path traversal·symlink 탈출을 기본 차단한다. 완료 후 임시 정리.
|
|
1056
1476
|
*/
|
|
1057
|
-
async installBuild(filePath, bundleId) {
|
|
1477
|
+
async installBuild(udid, filePath, bundleId) {
|
|
1058
1478
|
if (bundleId) {
|
|
1059
|
-
await this.simctl.uninstallApp(bundleId).catch(() => { });
|
|
1479
|
+
await this.simctl.uninstallApp(udid, bundleId).catch(() => { });
|
|
1060
1480
|
}
|
|
1061
1481
|
const lower = filePath.toLowerCase();
|
|
1062
1482
|
const isTar = lower.endsWith('.tar.gz') || lower.endsWith('.tgz');
|
|
1063
1483
|
const isZip = lower.endsWith('.zip');
|
|
1064
1484
|
if (!isTar && !isZip) {
|
|
1065
|
-
return this.simctl.installApp(filePath);
|
|
1485
|
+
return this.simctl.installApp(udid, filePath);
|
|
1066
1486
|
}
|
|
1067
1487
|
const tmpDir = path.join(tmpdir(), `tapflow-install-${randomUUID()}`);
|
|
1068
1488
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
@@ -1087,7 +1507,7 @@ export class IOSAgent {
|
|
|
1087
1507
|
if (!appDir) {
|
|
1088
1508
|
throw new ValidationError('.app 디렉토리를 찾을 수 없습니다. iphonesimulator 로 빌드한 .app 을 .app.zip 또는 .tar.gz 로 업로드하세요.');
|
|
1089
1509
|
}
|
|
1090
|
-
await this.simctl.installApp(path.join(tmpDir, appDir));
|
|
1510
|
+
await this.simctl.installApp(udid, path.join(tmpDir, appDir));
|
|
1091
1511
|
}
|
|
1092
1512
|
finally {
|
|
1093
1513
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -1097,12 +1517,45 @@ export class IOSAgent {
|
|
|
1097
1517
|
listDevices() { return this.simctl.listDevices(); }
|
|
1098
1518
|
boot(deviceId) { return this.simctl.boot(deviceId); }
|
|
1099
1519
|
shutdown(deviceId) { return this.simctl.shutdown(deviceId); }
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1520
|
+
/** The `DeviceAgent` interface has no device parameter — it predates multi-session agents and is
|
|
1521
|
+
* shared with Android, so widening it would be a breaking change for every implementation and
|
|
1522
|
+
* caller. `AndroidAgent` resolves the single live session internally instead
|
|
1523
|
+
* (`AndroidAgent.ts:1429`); these do the same. Throwing beats falling back to simctl's `booted`
|
|
1524
|
+
* alias, which would quietly act on whichever simulator happened to be up. */
|
|
1525
|
+
async installApp(appPath) {
|
|
1526
|
+
await this.simctl.installApp(this.soleDeviceId(), appPath);
|
|
1527
|
+
}
|
|
1528
|
+
async launchApp(bundleId) {
|
|
1529
|
+
await this.simctl.launchApp(this.soleDeviceId(), bundleId);
|
|
1530
|
+
}
|
|
1531
|
+
/** The one booted session, or undefined when there is no unambiguous answer. Callers that can
|
|
1532
|
+
* reasonably do nothing (input) use this; callers that owe the user a result use
|
|
1533
|
+
* `soleDeviceState`, which throws instead. */
|
|
1534
|
+
liveDeviceState() {
|
|
1535
|
+
const live = [...this.deviceStates.values()].filter((s) => s.booted);
|
|
1536
|
+
return live.length === 1 ? live[0] : undefined;
|
|
1537
|
+
}
|
|
1538
|
+
soleDeviceState() {
|
|
1539
|
+
// `deviceStates` holds one entry per *registered* simulator, not per running one — the relay
|
|
1540
|
+
// opens a session for every device in `agent:register` and this Mac reports dozens. Taking the
|
|
1541
|
+
// first entry would pick whichever simulator simctl listed first, almost always a shut-down
|
|
1542
|
+
// one, which is worse than the `booted` alias it replaced: the alias at least found the device
|
|
1543
|
+
// that was actually up. `booted` is set by handleDeviceBoot, so filtering on it is the liveness
|
|
1544
|
+
// check `AndroidAgent` gets for free from `getSerial` (its serial map is only populated on
|
|
1545
|
+
// launch).
|
|
1546
|
+
const live = [...this.deviceStates.values()].filter((s) => s.booted);
|
|
1547
|
+
if (live.length === 0)
|
|
1105
1548
|
throw new ValidationError('no booted device — call connect() first');
|
|
1549
|
+
// Refusing beats guessing: this interface has no way to say which device is meant, and picking
|
|
1550
|
+
// one silently is the whole defect being fixed here.
|
|
1551
|
+
if (live.length > 1) {
|
|
1552
|
+
throw new ValidationError(`${live.length} booted devices — this entry point cannot choose between them`);
|
|
1553
|
+
}
|
|
1554
|
+
return live[0];
|
|
1555
|
+
}
|
|
1556
|
+
soleDeviceId() { return this.soleDeviceState().deviceId; }
|
|
1557
|
+
async queryUITree() {
|
|
1558
|
+
const state = this.soleDeviceState();
|
|
1106
1559
|
return this.readUITree(state);
|
|
1107
1560
|
}
|
|
1108
1561
|
// ── Audio output (opt-in, macOS 14.2+ Core Audio process taps) ───────────────────────────────
|
|
@@ -1196,35 +1649,33 @@ export class IOSAgent {
|
|
|
1196
1649
|
// stream cancelled or ws closed — expected on teardown/restart
|
|
1197
1650
|
}
|
|
1198
1651
|
}
|
|
1199
|
-
|
|
1652
|
+
// async, not a bare `return`: `soleDeviceId` throws, and a synchronous throw out of a method
|
|
1653
|
+
// typed `Promise<T>` skips every caller's `.catch`.
|
|
1654
|
+
async screenshot() { return this.simctl.screenshot(this.soleDeviceId()); }
|
|
1200
1655
|
stream() {
|
|
1201
|
-
const first = this.
|
|
1202
|
-
if (!first)
|
|
1203
|
-
throw new ValidationError('no booted device — call connect() first');
|
|
1656
|
+
const first = this.soleDeviceState();
|
|
1204
1657
|
// DeviceAgent.stream() is the platform-neutral Buffer contract; unwrap StreamFrame payloads.
|
|
1205
|
-
return new ScreenCaptureStreamer(
|
|
1658
|
+
return new ScreenCaptureStreamer(first.deviceId, this.fps).start()
|
|
1206
1659
|
.pipeThrough(new TransformStream({
|
|
1207
1660
|
transform(frame, controller) { controller.enqueue(frame.payload); },
|
|
1208
1661
|
}));
|
|
1209
1662
|
}
|
|
1210
1663
|
touchStart(x, y) {
|
|
1211
|
-
const first = this.
|
|
1664
|
+
const first = this.liveDeviceState();
|
|
1212
1665
|
first?.touchHelper?.touchStart(x, y);
|
|
1213
1666
|
}
|
|
1214
1667
|
touchMove(x, y) {
|
|
1215
|
-
const first = this.
|
|
1668
|
+
const first = this.liveDeviceState();
|
|
1216
1669
|
first?.touchHelper?.touchMove(x, y);
|
|
1217
1670
|
return Promise.resolve();
|
|
1218
1671
|
}
|
|
1219
1672
|
touchEnd() {
|
|
1220
|
-
const first = this.
|
|
1673
|
+
const first = this.liveDeviceState();
|
|
1221
1674
|
first?.touchHelper?.touchEnd();
|
|
1222
1675
|
return Promise.resolve();
|
|
1223
1676
|
}
|
|
1224
1677
|
openUrl(url) {
|
|
1225
|
-
const first = this.
|
|
1226
|
-
if (!first)
|
|
1227
|
-
throw new ValidationError('no booted device — call connect() first');
|
|
1678
|
+
const first = this.soleDeviceState();
|
|
1228
1679
|
return this.simctl.openUrl(first.deviceId, url);
|
|
1229
1680
|
}
|
|
1230
1681
|
}
|