@tapflowio/relay 0.16.0 → 0.18.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/dist/RelayServer.d.ts +50 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +406 -81
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +45 -7
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +95 -8
- package/dist/SessionManager.js.map +1 -1
- package/dist/types.d.ts +6 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -3
- package/public/assets/{AppCenter-DHKav0MA.js → AppCenter-B3FO67aw.js} +2 -2
- package/public/assets/AppCenter-B3FO67aw.js.br +0 -0
- package/public/assets/{Default-FOVo8D6L.js → Default-BlNUv5dw.js} +1 -1
- package/public/assets/Default-BlNUv5dw.js.br +0 -0
- package/public/assets/{Invite-DdjwJ4DV.js → Invite-CKYFmy7S.js} +1 -1
- package/public/assets/Invite-CKYFmy7S.js.br +0 -0
- package/public/assets/{MacResources-Dl1w6E1h.js → MacResources-BK4VQnFc.js} +1 -1
- package/public/assets/MacResources-BK4VQnFc.js.br +0 -0
- package/public/assets/{NotFound-BMUavI-G.js → NotFound-BMmi1paB.js} +1 -1
- package/public/assets/NotFound-BMmi1paB.js.br +0 -0
- package/public/assets/QASession-CVTlqW3d.js +125 -0
- package/public/assets/QASession-CVTlqW3d.js.br +0 -0
- package/public/assets/{ResetPassword-Yn2hnB_M.js → ResetPassword-VPPBWzSI.js} +1 -1
- package/public/assets/ResetPassword-VPPBWzSI.js.br +0 -0
- package/public/assets/{Setup-BaGV0xx6.js → Setup-CRdnCl41.js} +1 -1
- package/public/assets/Setup-CRdnCl41.js.br +0 -0
- package/public/assets/{Team-BOwoos5_.js → Team-CBsjuUwh.js} +2 -2
- package/public/assets/Team-CBsjuUwh.js.br +0 -0
- package/public/assets/{Tokens-Bu1FtE_n.js → Tokens-CU9HB9WD.js} +1 -1
- package/public/assets/Tokens-CU9HB9WD.js.br +0 -0
- package/public/assets/alert-dialog-DWqGDQTB.js +1 -0
- package/public/assets/alert-dialog-DWqGDQTB.js.br +0 -0
- package/public/assets/{badge-CNtq_oBK.js → badge-OToVq1dK.js} +1 -1
- package/public/assets/badge-OToVq1dK.js.br +0 -0
- package/public/assets/{build-format-D6egYX-U.js → build-format-B78hbKY9.js} +1 -1
- package/public/assets/build-format-B78hbKY9.js.br +3 -0
- package/public/assets/dialog-BxC9v2BL.js +11 -0
- package/public/assets/dialog-BxC9v2BL.js.br +0 -0
- package/public/assets/index-DXjMx9hQ.js +274 -0
- package/public/assets/index-DXjMx9hQ.js.br +0 -0
- package/public/assets/index-uGJGo6ac.css +1 -0
- package/public/assets/index-uGJGo6ac.css.br +0 -0
- package/public/assets/{pencil-CzKUUBmE.js → pencil-Cq64I9LV.js} +1 -1
- package/public/assets/pencil-Cq64I9LV.js.br +0 -0
- package/public/assets/{table-CU0HMyju.js → table-CbATN5Pi.js} +1 -1
- package/public/assets/table-CbATN5Pi.js.br +0 -0
- package/public/assets/tabs-BVumgDA4.js +1 -0
- package/public/assets/tabs-BVumgDA4.js.br +0 -0
- package/public/assets/{trash-2-Bw_edyHa.js → trash-2-CszwJHEh.js} +1 -1
- package/public/assets/trash-2-CszwJHEh.js.br +0 -0
- package/public/index.html +2 -2
- package/public/index.html.br +0 -0
- package/public/assets/AppCenter-DHKav0MA.js.br +0 -0
- package/public/assets/Default-FOVo8D6L.js.br +0 -0
- package/public/assets/Invite-DdjwJ4DV.js.br +0 -0
- package/public/assets/MacResources-Dl1w6E1h.js.br +0 -0
- package/public/assets/NotFound-BMUavI-G.js.br +0 -0
- package/public/assets/QASession-BzfWUo9F.js +0 -130
- package/public/assets/QASession-BzfWUo9F.js.br +0 -0
- package/public/assets/ResetPassword-Yn2hnB_M.js.br +0 -0
- package/public/assets/Setup-BaGV0xx6.js.br +0 -0
- package/public/assets/Team-BOwoos5_.js.br +0 -0
- package/public/assets/Tokens-Bu1FtE_n.js.br +0 -0
- package/public/assets/alert-dialog-dsCcQ1NM.js +0 -1
- package/public/assets/alert-dialog-dsCcQ1NM.js.br +0 -0
- package/public/assets/badge-CNtq_oBK.js.br +0 -0
- package/public/assets/build-format-D6egYX-U.js.br +0 -0
- package/public/assets/dialog-Blqnc3tq.js +0 -11
- package/public/assets/dialog-Blqnc3tq.js.br +0 -0
- package/public/assets/index-BI7E3MD-.css +0 -1
- package/public/assets/index-BI7E3MD-.css.br +0 -0
- package/public/assets/index-IvDXNTVd.js +0 -274
- package/public/assets/index-IvDXNTVd.js.br +0 -0
- package/public/assets/pencil-CzKUUBmE.js.br +0 -0
- package/public/assets/table-CU0HMyju.js.br +0 -0
- package/public/assets/tabs-C9nZOMC5.js +0 -1
- package/public/assets/tabs-C9nZOMC5.js.br +0 -0
- package/public/assets/trash-2-Bw_edyHa.js.br +0 -0
package/dist/RelayServer.js
CHANGED
|
@@ -41,6 +41,11 @@ export function isExternalAddress(addr) {
|
|
|
41
41
|
const IDR_REQUEST_THROTTLE_MS = 500;
|
|
42
42
|
// Ping every socket each interval; a missed pong window (~2× this) terminates the dead socket.
|
|
43
43
|
const HEARTBEAT_MS = 30_000;
|
|
44
|
+
// How long a session outlives its agent's socket, waiting for that agent to come back (#426).
|
|
45
|
+
// An agent registers ~1s after its process starts, so this covers an automatic respawn several
|
|
46
|
+
// times over and about half of a hand-typed restart. What bounds it is the other direction: the
|
|
47
|
+
// device stays claimed by a session nobody can use until the window closes.
|
|
48
|
+
const DEFAULT_AGENT_GRACE_MS = 15_000;
|
|
44
49
|
import { handleVerifyReset, handleDoReset, handleSendMemberReset } from './api/passwordReset.js';
|
|
45
50
|
import { handleListBuilds, handleGetBuild, handleUpdateBuild, handleUploadBuild, handleScheduleBuildDeletion, handleCancelBuildDeletion, purgeExpiredBuilds } from './api/builds.js';
|
|
46
51
|
import { handleListApps, handleCreateApp, handleUpdateApp, handleDeleteApp } from './api/apps.js';
|
|
@@ -70,6 +75,10 @@ const _parsedThreshold = parseInt(process.env['TAPFLOW_RESOURCE_THRESHOLD_PERCEN
|
|
|
70
75
|
const RESOURCE_THRESHOLD = Number.isFinite(_parsedThreshold) ? _parsedThreshold : 80;
|
|
71
76
|
// Messages that only agents are allowed to send. Authenticated browser sockets
|
|
72
77
|
// that send any of these are disconnected immediately.
|
|
78
|
+
// Terminal input messages the MCP client awaits an ack for — if the agent is offline
|
|
79
|
+
// the relay replies input:error so the client fails truthfully (non-terminal moves/starts
|
|
80
|
+
// expect no ack and are dropped silently).
|
|
81
|
+
const TERMINAL_INPUT_TYPES = new Set(['input:touch:end', 'input:pinch:end', 'input:key', 'input:button']);
|
|
73
82
|
const AGENT_MSG_TYPES = new Set([
|
|
74
83
|
'agent:register', 'agent:resources', 'screenshot:done', 'screenshot:error',
|
|
75
84
|
'ui:tree:response', 'ui:tree:error',
|
|
@@ -78,7 +87,10 @@ const AGENT_MSG_TYPES = new Set([
|
|
|
78
87
|
'session:chrome', 'session:deviceInfo',
|
|
79
88
|
'app:install-done', 'app:install-error', 'app:launch-done', 'app:launch-error',
|
|
80
89
|
'open-url:done', 'open-url:error', 'keyboard:toggled',
|
|
81
|
-
'input:type-done', 'input:type-error',
|
|
90
|
+
'input:type-done', 'input:type-error', 'input:done', 'input:error',
|
|
91
|
+
// clipboard:data carries the simulator's clipboard — agent-authenticated, so a
|
|
92
|
+
// browser socket can never inject it into another viewer.
|
|
93
|
+
'clipboard:data', 'clipboard:write-done', 'clipboard:error',
|
|
82
94
|
// stream:register binds a session's stream socket — agent-only, or a browser
|
|
83
95
|
// (view PAT / cookie) could hijack an existing session's video feed.
|
|
84
96
|
'stream:register',
|
|
@@ -109,11 +121,22 @@ export class RelayServer {
|
|
|
109
121
|
// Per-session throttled "request an IDR from the agent" callbacks (drop recovery).
|
|
110
122
|
idrRequesters = new Map();
|
|
111
123
|
wsRoles = new Map();
|
|
124
|
+
// Agent sockets whose sessions are being held open, and the timer that gives up on each.
|
|
125
|
+
// Keyed by the dead socket, never by session id: a rebind moves sessions off that socket, so an
|
|
126
|
+
// expiry that fires late has nothing left to evict. That is the invariant — releasing the hold
|
|
127
|
+
// on the way back is hygiene on top of it, not a second thing holding the property up. The late
|
|
128
|
+
// expiry is not a complete no-op either: it still drops the socket's resource entry.
|
|
129
|
+
agentHolds = new Map();
|
|
130
|
+
// Set by `stop()`. Its own `terminate()` loop fires a close for every socket, and a hold armed
|
|
131
|
+
// from there would outlive the server it belongs to — the exact hazard the clearing above it is
|
|
132
|
+
// for, arriving a few lines later.
|
|
133
|
+
stopping = false;
|
|
112
134
|
// True when the connection's remote IP is public (not loopback / private LAN) — the agent uses
|
|
113
135
|
// this to downscale harder for bandwidth on external viewers.
|
|
114
136
|
wsExternal = new Map();
|
|
115
137
|
backpressureBytes;
|
|
116
138
|
screenshotTimeoutMs;
|
|
139
|
+
agentGraceMs;
|
|
117
140
|
corsAllowed;
|
|
118
141
|
// One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
|
|
119
142
|
warnedProxyMisconfig = false;
|
|
@@ -128,6 +151,24 @@ export class RelayServer {
|
|
|
128
151
|
// itself may take up to 10s before it errors out.
|
|
129
152
|
this.uiTreeTimeoutMs = options.uiTreeTimeoutMs ?? 15_000;
|
|
130
153
|
this.corsAllowed = new Set(options.corsOrigins ?? []);
|
|
154
|
+
// Constructor option first, then env. It cannot be env-only: `IDLE_TIMEOUT_MS` is read at module
|
|
155
|
+
// load, which is why no test can set it, and the tests that need a different window here run in
|
|
156
|
+
// the same process as the default.
|
|
157
|
+
// Validated, not just parsed, and blank treated as unset rather than as a number. Three ways
|
|
158
|
+
// this silently switches the feature off if read naively: `parseInt('15s')` is 15 — and the
|
|
159
|
+
// documented default reads "15000 (15 s)", so that is the typo the docs invite; a non-numeric
|
|
160
|
+
// value is NaN, which `setTimeout` rounds to ~1ms; and `Number('')` is 0, not NaN, so an empty
|
|
161
|
+
// `.env` line would pass a `>= 0` check and give a zero-length window.
|
|
162
|
+
const graceRaw = process.env['TAPFLOW_AGENT_GRACE_MS']?.trim();
|
|
163
|
+
const graceEnv = graceRaw ? Number(graceRaw) : NaN;
|
|
164
|
+
const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
|
|
165
|
+
this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
|
|
166
|
+
// Say so rather than only documenting it. Both times this parsing was wrong the symptom was
|
|
167
|
+
// the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
|
|
168
|
+
// reading their terminal, not the configuration table.
|
|
169
|
+
if (options.agentGraceMs === undefined && graceRaw && !graceUsable) {
|
|
170
|
+
logger.warn(`TAPFLOW_AGENT_GRACE_MS="${graceRaw}" is not a usable number of milliseconds — using ${DEFAULT_AGENT_GRACE_MS}`);
|
|
171
|
+
}
|
|
131
172
|
this.sessions = new SessionManager({ idleTimeoutMs: options.idleTimeoutMs });
|
|
132
173
|
this.publicDir = options.publicDir ?? path.join(import.meta.dirname, '../public');
|
|
133
174
|
this.uploadsDir = options.uploadsDir ?? path.join(import.meta.dirname, '../uploads');
|
|
@@ -281,6 +322,12 @@ export class RelayServer {
|
|
|
281
322
|
clearInterval(this.heartbeatTimer);
|
|
282
323
|
this.heartbeatTimer = null;
|
|
283
324
|
}
|
|
325
|
+
// `unref()` would not do: the test runner's process outlives the server, so an un-cleared hold
|
|
326
|
+
// still fires — against a server that has already stopped.
|
|
327
|
+
this.stopping = true;
|
|
328
|
+
for (const timer of this.agentHolds.values())
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
this.agentHolds.clear();
|
|
284
331
|
return new Promise((resolve, reject) => {
|
|
285
332
|
this.wss.clients.forEach((ws) => ws.terminate());
|
|
286
333
|
this.wss.close(() => {
|
|
@@ -319,7 +366,7 @@ export class RelayServer {
|
|
|
319
366
|
lastAt = now;
|
|
320
367
|
const session = this.sessions.get(sessionId);
|
|
321
368
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
322
|
-
session.agentSocket
|
|
369
|
+
this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId });
|
|
323
370
|
}
|
|
324
371
|
};
|
|
325
372
|
}
|
|
@@ -416,19 +463,35 @@ export class RelayServer {
|
|
|
416
463
|
}
|
|
417
464
|
return;
|
|
418
465
|
}
|
|
466
|
+
let parsed;
|
|
419
467
|
try {
|
|
420
|
-
|
|
421
|
-
this.route(ws, msg);
|
|
468
|
+
parsed = JSON.parse(data.toString());
|
|
422
469
|
}
|
|
423
470
|
catch {
|
|
424
|
-
//
|
|
471
|
+
return; // genuinely malformed — there is no type to answer on
|
|
472
|
+
}
|
|
473
|
+
// `JSON.parse` returns bare `null`, numbers and strings without throwing, and `route` reads
|
|
474
|
+
// `.type` off whatever it is handed. Rejecting non-objects here is what lets the catch below
|
|
475
|
+
// name the message safely.
|
|
476
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
477
|
+
return;
|
|
478
|
+
const msg = parsed;
|
|
479
|
+
try {
|
|
480
|
+
this.route(ws, msg);
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
// A throw inside a handler used to land in the same catch as a parse failure and vanish.
|
|
484
|
+
// Anything reaching here is a bug in routing, not a bad message, and the caller is left
|
|
485
|
+
// waiting either way — so at least say so once instead of dropping it silently.
|
|
486
|
+
logger.error(`route failed for ${String(msg.type)}:`, e);
|
|
425
487
|
}
|
|
426
488
|
});
|
|
427
489
|
ws.on('close', () => {
|
|
428
490
|
this.wsRoles.delete(ws);
|
|
429
491
|
this.wsExternal.delete(ws);
|
|
430
|
-
// Agent main socket disconnected →
|
|
431
|
-
|
|
492
|
+
// Agent main socket disconnected → hold its sessions open for a moment in case the agent is
|
|
493
|
+
// coming back (#426), rather than ending them where they stand.
|
|
494
|
+
if (this.holdAgentSocket(ws))
|
|
432
495
|
return;
|
|
433
496
|
// Stream socket disconnected → clear the streamSocket reference
|
|
434
497
|
const streamSession = this.sessions.getByStreamSocket(ws);
|
|
@@ -442,11 +505,11 @@ export class RelayServer {
|
|
|
442
505
|
this.sessions.clearBrowser(browserSession.id, () => {
|
|
443
506
|
const session = this.sessions.get(browserSession.id);
|
|
444
507
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
445
|
-
session.agentSocket
|
|
508
|
+
this.sendTo(session.agentSocket, {
|
|
446
509
|
type: 'device:shutdown',
|
|
447
510
|
sessionId: session.id,
|
|
448
511
|
payload: { deviceId: session.deviceId },
|
|
449
|
-
})
|
|
512
|
+
});
|
|
450
513
|
}
|
|
451
514
|
});
|
|
452
515
|
}
|
|
@@ -493,7 +556,7 @@ export class RelayServer {
|
|
|
493
556
|
this.handleUITreeError(msg);
|
|
494
557
|
break;
|
|
495
558
|
case 'agents:list': {
|
|
496
|
-
|
|
559
|
+
this.sendTo(ws, { type: 'agents:listed', sessions: this.sessions.list() });
|
|
497
560
|
break;
|
|
498
561
|
}
|
|
499
562
|
// ── Session / Stream lifecycle ─────────────────────────────────────────
|
|
@@ -524,7 +587,7 @@ export class RelayServer {
|
|
|
524
587
|
const session = this.sessions.get(msg.sessionId);
|
|
525
588
|
if (session) {
|
|
526
589
|
this.sessions.setStreamSocket(session.id, ws);
|
|
527
|
-
|
|
590
|
+
this.sendTo(ws, { type: 'stream:registered' });
|
|
528
591
|
}
|
|
529
592
|
break;
|
|
530
593
|
}
|
|
@@ -533,6 +596,10 @@ export class RelayServer {
|
|
|
533
596
|
const session = this.sessions.get(msg.sessionId);
|
|
534
597
|
if (!session)
|
|
535
598
|
break;
|
|
599
|
+
// Cast, not a check: nothing validates inbound messages, so the relay takes the agent's
|
|
600
|
+
// word for the shape. It only stores and forwards this, so a wrong shape surfaces in the
|
|
601
|
+
// viewer rather than here — but this is where a runtime validator belongs when one exists
|
|
602
|
+
// (see packages/protocol/AGENTS.md).
|
|
536
603
|
this.sessions.setChromeData(session.id, msg.payload);
|
|
537
604
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
538
605
|
session.browserSocket.send(JSON.stringify(msg));
|
|
@@ -572,6 +639,7 @@ export class RelayServer {
|
|
|
572
639
|
if (!session)
|
|
573
640
|
break;
|
|
574
641
|
this.sessions.updateDeviceStatus(session.id, 'shutdown');
|
|
642
|
+
this.sessions.setReadySent(session.id, false);
|
|
575
643
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
576
644
|
session.browserSocket.send(JSON.stringify(msg));
|
|
577
645
|
}
|
|
@@ -582,6 +650,7 @@ export class RelayServer {
|
|
|
582
650
|
if (!session)
|
|
583
651
|
break;
|
|
584
652
|
this.sessions.updateDeviceStatus(session.id, 'booted');
|
|
653
|
+
this.sessions.setReadySent(session.id, true);
|
|
585
654
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
586
655
|
session.browserSocket.send(JSON.stringify(msg));
|
|
587
656
|
}
|
|
@@ -597,6 +666,8 @@ export class RelayServer {
|
|
|
597
666
|
case 'app:clear-state-error':
|
|
598
667
|
case 'input:type-done':
|
|
599
668
|
case 'input:type-error':
|
|
669
|
+
case 'input:done':
|
|
670
|
+
case 'input:error':
|
|
600
671
|
case 'keyboard:toggled': {
|
|
601
672
|
const session = this.sessions.get(msg.sessionId);
|
|
602
673
|
if (session?.browserSocket?.readyState === WebSocket.OPEN) {
|
|
@@ -604,10 +675,38 @@ export class RelayServer {
|
|
|
604
675
|
}
|
|
605
676
|
break;
|
|
606
677
|
}
|
|
678
|
+
// Bound to the session's own agent socket, unlike the replies above: this payload
|
|
679
|
+
// lands on the viewer's host OS clipboard, so a second agent (another Mac on the
|
|
680
|
+
// same relay) must not be able to address someone else's session.
|
|
681
|
+
case 'clipboard:data':
|
|
682
|
+
case 'clipboard:write-done':
|
|
683
|
+
case 'clipboard:error': {
|
|
684
|
+
const session = this.sessions.get(msg.sessionId);
|
|
685
|
+
if (session?.agentSocket !== ws)
|
|
686
|
+
break;
|
|
687
|
+
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
688
|
+
session.browserSocket.send(JSON.stringify(msg));
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
607
692
|
// ── Browser → Agent ────────────────────────────────────────────────────
|
|
608
693
|
case 'device:boot':
|
|
609
694
|
case 'device:shutdown': {
|
|
610
695
|
const session = this.sessions.get(msg.sessionId);
|
|
696
|
+
if (session?.agentSocket.readyState !== WebSocket.OPEN && msg.type === 'device:boot') {
|
|
697
|
+
// A boot the agent never receives leaves the viewer on "Waiting for first frame…" with
|
|
698
|
+
// nothing said. `device:shutdown` gets no such reply: nothing waits on it, and inventing
|
|
699
|
+
// a `device:shutdown-error` would grow the contract for a message no one reads.
|
|
700
|
+
//
|
|
701
|
+
// The two are worth telling apart: `bootDevice` is the first call an MCP caller makes, so
|
|
702
|
+
// reporting a stale session id as a dead Mac sends the reader after the wrong problem.
|
|
703
|
+
this.sendTo(ws, {
|
|
704
|
+
type: 'device:boot-error',
|
|
705
|
+
sessionId: msg.sessionId,
|
|
706
|
+
message: session ? 'agent offline' : 'Session not found',
|
|
707
|
+
});
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
611
710
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
612
711
|
// Tag the boot with whether the viewer is external (public IP) so the agent can pick the
|
|
613
712
|
// downscale tier. The browser already reports secureContext in the payload.
|
|
@@ -630,7 +729,7 @@ export class RelayServer {
|
|
|
630
729
|
session.agentSocket.send(JSON.stringify(msg));
|
|
631
730
|
}
|
|
632
731
|
else {
|
|
633
|
-
|
|
732
|
+
this.sendTo(ws, { type: 'open-url:error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
634
733
|
}
|
|
635
734
|
break;
|
|
636
735
|
}
|
|
@@ -640,7 +739,7 @@ export class RelayServer {
|
|
|
640
739
|
session.agentSocket.send(JSON.stringify(msg));
|
|
641
740
|
}
|
|
642
741
|
else {
|
|
643
|
-
|
|
742
|
+
this.sendTo(ws, { type: 'app:clear-state-error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
644
743
|
}
|
|
645
744
|
break;
|
|
646
745
|
}
|
|
@@ -659,6 +758,28 @@ export class RelayServer {
|
|
|
659
758
|
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
660
759
|
session.agentSocket.send(JSON.stringify(msg));
|
|
661
760
|
}
|
|
761
|
+
else if (TERMINAL_INPUT_TYPES.has(msg.type) && ws.readyState === WebSocket.OPEN) {
|
|
762
|
+
// Agent offline or session evicted: a terminal input can't be dispatched.
|
|
763
|
+
// Reply to the sender (the MCP/browser socket) so it fails truthfully
|
|
764
|
+
// instead of falling through to its optimistic 2s timeout.
|
|
765
|
+
this.sendTo(ws, { type: 'input:error', sessionId: msg.sessionId, message: 'agent offline' });
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
// Kept out of the input:* chain above: these need their own error type, and the caller
|
|
770
|
+
// is waiting on a bounded deadline, so an undeliverable request fails now rather than
|
|
771
|
+
// hanging until the browser gives up.
|
|
772
|
+
case 'clipboard:read':
|
|
773
|
+
case 'clipboard:write': {
|
|
774
|
+
const session = this.sessions.get(msg.sessionId);
|
|
775
|
+
if (session?.agentSocket.readyState === WebSocket.OPEN) {
|
|
776
|
+
session.agentSocket.send(JSON.stringify(msg));
|
|
777
|
+
}
|
|
778
|
+
else if (ws.readyState === WebSocket.OPEN) {
|
|
779
|
+
this.sendTo(ws, {
|
|
780
|
+
type: 'clipboard:error', sessionId: msg.sessionId, requestId: msg.requestId, message: 'agent offline',
|
|
781
|
+
});
|
|
782
|
+
}
|
|
662
783
|
break;
|
|
663
784
|
}
|
|
664
785
|
}
|
|
@@ -678,28 +799,124 @@ export class RelayServer {
|
|
|
678
799
|
// Removes an agent socket's sessions + resources and rejects its in-flight
|
|
679
800
|
// screenshot / ui-tree requests. Shared by socket close and re-register
|
|
680
801
|
// eviction. Returns true if `ws` had agent sessions.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
802
|
+
/**
|
|
803
|
+
* Every message the relay *originates* goes through here. The union checks the shape where the
|
|
804
|
+
* literal is written — a mistyped `type`, a missing field or one that does not belong are all
|
|
805
|
+
* compile errors — and the readyState guard replaces the check that was repeated (and sometimes
|
|
806
|
+
* omitted) at each call site.
|
|
807
|
+
*
|
|
808
|
+
* Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
|
|
809
|
+
* they keep their inbound type and there is nothing new to check.
|
|
810
|
+
*/
|
|
811
|
+
sendTo(socket, msg) {
|
|
812
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
813
|
+
return;
|
|
814
|
+
socket.send(JSON.stringify(msg));
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Settle every in-flight screenshot / UI-tree request belonging to these sessions. Both callers
|
|
818
|
+
* are moments where the agent that was going to answer them no longer exists — it disconnected,
|
|
819
|
+
* or it restarted and its session moved to the new process. Left alone these reject on their own
|
|
820
|
+
* timeout, minutes later, having told the caller nothing in between.
|
|
821
|
+
*/
|
|
822
|
+
rejectPending(sessionIds, reason) {
|
|
823
|
+
for (const pendings of [this.pendingScreenshots, this.pendingUITrees]) {
|
|
824
|
+
for (const [reqId, pending] of pendings.entries()) {
|
|
825
|
+
if (!sessionIds.has(pending.sessionId))
|
|
826
|
+
continue;
|
|
688
827
|
clearTimeout(pending.timer);
|
|
689
|
-
|
|
690
|
-
pending.reject(new Error(
|
|
828
|
+
pendings.delete(reqId);
|
|
829
|
+
pending.reject(new Error(reason));
|
|
691
830
|
}
|
|
692
831
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
|
|
835
|
+
* of ending them here — a restarting agent registers about a second later, and until #426 stage 3
|
|
836
|
+
* the close always won that race, so a restart cost the tester their place.
|
|
837
|
+
*
|
|
838
|
+
* The sessions stay exactly where they are. They are NOT moved to a holding structure of their
|
|
839
|
+
* own: `getAgentSocketsByIdentity` finds the returning agent's previous socket by walking
|
|
840
|
+
* `sessions` and reading `agentSocket`, so a session parked anywhere else is a session that can
|
|
841
|
+
* never be reclaimed.
|
|
842
|
+
*
|
|
843
|
+
* @returns whether this was an agent socket, matching `evictAgentSocket`'s contract — the close
|
|
844
|
+
* handler uses it to decide whether to keep looking.
|
|
845
|
+
*/
|
|
846
|
+
holdAgentSocket(ws) {
|
|
847
|
+
// Shutting down: nothing is coming back, and arming a timer here would leave it running after
|
|
848
|
+
// `stop()` resolved.
|
|
849
|
+
if (this.stopping)
|
|
850
|
+
return this.evictAgentSocket(ws);
|
|
851
|
+
const sessions = this.sessions.getAllByAgentSocket(ws);
|
|
852
|
+
if (sessions.length === 0) {
|
|
853
|
+
// No sessions to hold, but the socket may still own a resource entry — `evictAgentSocket`
|
|
854
|
+
// returns before dropping it in exactly this case.
|
|
855
|
+
this.sessions.removeResources(ws);
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
// Not held for the window: these were addressed to a process that is gone, and a returning
|
|
859
|
+
// agent is a new one that never saw them. Waiting would only delay the same failure.
|
|
860
|
+
this.rejectPending(new Set(sessions.map((s) => s.id)), 'Agent disconnected');
|
|
861
|
+
for (const s of sessions) {
|
|
862
|
+
if (s.browserSocket)
|
|
863
|
+
this.sendTo(s.browserSocket, { type: 'session:agent-away', sessionId: s.id });
|
|
864
|
+
}
|
|
865
|
+
const timer = setTimeout(() => {
|
|
866
|
+
this.agentHolds.delete(ws);
|
|
867
|
+
// Whatever is still on this socket never got reclaimed. A rebind moves sessions off it, so
|
|
868
|
+
// after a successful one this evicts nothing.
|
|
869
|
+
if (this.evictAgentSocket(ws))
|
|
870
|
+
logger.info(`agent did not come back within ${this.agentGraceMs}ms — session(s) ended`);
|
|
871
|
+
// Not inside that branch: `evictAgentSocket` returns before dropping resources when the
|
|
872
|
+
// socket has no sessions left, and the sessions can leave by routes other than a rebind —
|
|
873
|
+
// `session:end` among them. This is the last moment anything holds a reference to the dead
|
|
874
|
+
// socket, so it is the last chance to drop its entry.
|
|
875
|
+
this.sessions.removeResources(ws);
|
|
876
|
+
}, this.agentGraceMs);
|
|
877
|
+
this.agentHolds.set(ws, timer);
|
|
878
|
+
logger.info(`agent socket lost — holding ${sessions.length} session(s) for ${this.agentGraceMs}ms`);
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
/** Stop waiting on a socket, whether or not anything was reclaimed from it. */
|
|
882
|
+
releaseHold(ws) {
|
|
883
|
+
const timer = this.agentHolds.get(ws);
|
|
884
|
+
if (!timer)
|
|
885
|
+
return;
|
|
886
|
+
clearTimeout(timer);
|
|
887
|
+
this.agentHolds.delete(ws);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* @param cause `'disconnect'` — the socket closed. `'replaced'` — the same agent re-registered
|
|
891
|
+
* and this is its previous socket. Only affects the log line: a restart otherwise reads as a
|
|
892
|
+
* crash followed by a recovery, which is not what happened.
|
|
893
|
+
*/
|
|
894
|
+
evictAgentSocket(ws, cause = 'disconnect') {
|
|
895
|
+
const agentSessions = this.sessions.getAllByAgentSocket(ws);
|
|
896
|
+
if (agentSessions.length === 0)
|
|
897
|
+
return false;
|
|
898
|
+
this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
|
|
899
|
+
// Tell whoever is attached before the session stops existing — after `remove()` the socket
|
|
900
|
+
// reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
|
|
901
|
+
// relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
|
|
902
|
+
// the tab sits on "Waiting for first frame..." with no explanation (#426).
|
|
903
|
+
//
|
|
904
|
+
// `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
|
|
905
|
+
// `browserSocket` at all. An attached MCP client does have one and will receive this; its
|
|
906
|
+
// dispatcher drops messages no waiter matches, so that is harmless.
|
|
907
|
+
for (const s of agentSessions) {
|
|
908
|
+
if (s.browserSocket) {
|
|
909
|
+
this.sendTo(s.browserSocket, {
|
|
910
|
+
type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected',
|
|
911
|
+
});
|
|
698
912
|
}
|
|
699
913
|
}
|
|
700
914
|
for (const s of agentSessions)
|
|
701
915
|
this.sessions.remove(s.id);
|
|
702
916
|
this.sessions.removeResources(ws);
|
|
917
|
+
logger.info(cause === 'replaced'
|
|
918
|
+
? `agent re-registered — ${agentSessions.length} previous session(s) replaced`
|
|
919
|
+
: `agent disconnected — ${agentSessions.length} session(s) ended`);
|
|
703
920
|
return true;
|
|
704
921
|
}
|
|
705
922
|
handleAgentRegister(ws, msg) {
|
|
@@ -709,34 +926,109 @@ export class RelayServer {
|
|
|
709
926
|
// its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
|
|
710
927
|
// else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
|
|
711
928
|
const identity = msg.agentId ?? msg.agentName;
|
|
929
|
+
// Deduplicate first. Everything below is keyed by device id, so a payload naming one device
|
|
930
|
+
// twice would collapse to a single entry in `registeredSessions` while `create()` had already
|
|
931
|
+
// made two sessions — leaving one the agent is never told about. That is the same orphan the
|
|
932
|
+
// rebind exists to prevent, arriving by a different door.
|
|
933
|
+
const devices = [...new Map((msg.devices ?? []).map((d) => [d.id, d])).values()];
|
|
934
|
+
const agent = {
|
|
935
|
+
agentId: msg.agentId, agentName: msg.agentName,
|
|
936
|
+
agentPlatform: msg.platform, agentCapabilities: msg.capabilities,
|
|
937
|
+
};
|
|
938
|
+
// deviceId → the session id kept across the restart. Also the guard that a device is rebound at
|
|
939
|
+
// most once (#426): before rebinding existed, `create()` ran after every old session had been
|
|
940
|
+
// evicted, so one device could not be behind two sessions. It can now, and `list()` does not
|
|
941
|
+
// deduplicate — the second card would name a session the agent has never heard of, which is the
|
|
942
|
+
// symptom this whole change is fixing.
|
|
943
|
+
const rebound = new Map();
|
|
712
944
|
if (identity) {
|
|
713
945
|
for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
|
|
714
946
|
if (old === ws)
|
|
715
947
|
continue;
|
|
948
|
+
// Before any decision about what to rebind: the eviction below settles this socket either
|
|
949
|
+
// way, so the timer has nothing left to do. Releasing here rather than after a successful
|
|
950
|
+
// rebind also covers the case where nothing is rebound at all — a restart that reports a
|
|
951
|
+
// completely different device list still leaves a timer keyed to a dead socket.
|
|
952
|
+
this.releaseHold(old);
|
|
953
|
+
for (const s of this.sessions.getAllByAgentSocket(old)) {
|
|
954
|
+
if (rebound.has(s.deviceId))
|
|
955
|
+
continue;
|
|
956
|
+
const device = devices.find((d) => d.id === s.deviceId);
|
|
957
|
+
// The device is gone from this agent's list — unplugged, deleted, renamed away. Leave it
|
|
958
|
+
// for the eviction below, which tells the browser the session ended.
|
|
959
|
+
if (!device)
|
|
960
|
+
continue;
|
|
961
|
+
this.sessions.rebind(s.id, ws, device, agent);
|
|
962
|
+
rebound.set(s.deviceId, s.id);
|
|
963
|
+
}
|
|
716
964
|
// Evict before terminate: the old socket's close fires async, by which point its sessions are
|
|
717
965
|
// gone and its in-flight screenshots would be undiscoverable — reject them here instead.
|
|
718
|
-
this.
|
|
966
|
+
// The rebound sessions have already moved off `old`, so this no longer covers them.
|
|
967
|
+
this.evictAgentSocket(old, 'replaced');
|
|
719
968
|
old.terminate();
|
|
720
969
|
}
|
|
721
970
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
971
|
+
// Their in-flight requests are addressed to a process that is gone, and the eviction above can
|
|
972
|
+
// no longer see them. Nothing else would ever settle these.
|
|
973
|
+
if (rebound.size > 0)
|
|
974
|
+
this.rejectPending(new Set(rebound.values()), 'Agent restarted');
|
|
975
|
+
// A device can be back under an agent this session cannot be rebound to — identity is
|
|
976
|
+
// `agentId ?? agentName`, and the upgrade that prompted the restart is often the one that
|
|
977
|
+
// starts sending an agentId. The device is demonstrably present, so holding its old session
|
|
978
|
+
// any longer strands that viewer while someone else picks the very same simulator. End it now
|
|
979
|
+
// and say so, which is what would have happened before the hold existed.
|
|
980
|
+
for (const d of devices) {
|
|
981
|
+
if (rebound.has(d.id))
|
|
982
|
+
continue;
|
|
983
|
+
for (const s of this.sessions.getAllByDeviceId(d.id)) {
|
|
984
|
+
if (s.agentSocket.readyState === WebSocket.OPEN)
|
|
985
|
+
continue;
|
|
986
|
+
if (s.browserSocket) {
|
|
987
|
+
this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
|
|
988
|
+
}
|
|
989
|
+
this.sessions.remove(s.id);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// Only devices without a surviving session get a new one. Passing all of them here is what
|
|
993
|
+
// would produce the duplicate card described above.
|
|
994
|
+
const fresh = devices.filter((d) => !rebound.has(d.id));
|
|
995
|
+
const freshIds = this.sessions.create(ws, fresh, msg.agentName, msg.platform, msg.agentId, msg.capabilities);
|
|
996
|
+
const byDeviceId = new Map(rebound);
|
|
997
|
+
fresh.forEach((d, i) => byDeviceId.set(d.id, freshIds[i]));
|
|
998
|
+
// Keyed by deviceId, not by position. The old form paired `msg.devices[i]` with `sessionIds[i]`,
|
|
999
|
+
// which only held while every device got a session — now that some are rebound, the arrays have
|
|
1000
|
+
// different lengths and index alignment would hand the agent someone else's session id.
|
|
1001
|
+
const registeredSessions = devices.map((d) => ({ deviceId: d.id, sessionId: byDeviceId.get(d.id) }));
|
|
1002
|
+
this.sendTo(ws, { type: 'agent:registered', registeredSessions });
|
|
1003
|
+
// After the state is final: the browser answers this with `device:boot` on the same session id.
|
|
1004
|
+
for (const sessionId of rebound.values()) {
|
|
1005
|
+
const s = this.sessions.get(sessionId);
|
|
1006
|
+
if (s?.browserSocket) {
|
|
1007
|
+
this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (rebound.size > 0)
|
|
1011
|
+
logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
|
|
1012
|
+
// The startup banner prints "Waiting for agents..." once and then the relay says nothing either
|
|
1013
|
+
// way, so a terminal gives no signal about whether an agent is attached. One line per
|
|
1014
|
+
// transition, matching the disconnect line in evictAgentSocket.
|
|
1015
|
+
logger.info(`agent connected: ${msg.agentName ?? msg.agentId ?? 'unknown'} (${msg.platform ?? 'unknown'}) — ${registeredSessions.length} device(s)`);
|
|
728
1016
|
}
|
|
729
1017
|
handleSessionStart(ws, msg) {
|
|
730
1018
|
const session = this.sessions.get(msg.sessionId);
|
|
731
1019
|
if (!session) {
|
|
732
|
-
|
|
1020
|
+
this.sendTo(ws, { type: 'error', message: 'Session not found' });
|
|
733
1021
|
return;
|
|
734
1022
|
}
|
|
735
|
-
|
|
1023
|
+
// Read before the resource gate, and answered before it too. The gate would otherwise read the
|
|
1024
|
+
// dead socket's last sample — and an overloaded Mac is a common reason to restart an agent, so
|
|
1025
|
+
// the tester would be told the Mac is exhausted at the exact moment it is recovering.
|
|
1026
|
+
const agentAway = session.agentSocket.readyState !== WebSocket.OPEN;
|
|
1027
|
+
const resources = agentAway ? undefined : this.sessions.getResources(session.agentSocket);
|
|
736
1028
|
if (resources) {
|
|
737
1029
|
const memPercent = (resources.memUsedMB / resources.memTotalMB) * 100;
|
|
738
1030
|
if (resources.cpuPercent > RESOURCE_THRESHOLD || memPercent > RESOURCE_THRESHOLD) {
|
|
739
|
-
|
|
1031
|
+
this.sendTo(ws, { type: 'error', message: 'Agent resources exhausted' });
|
|
740
1032
|
return;
|
|
741
1033
|
}
|
|
742
1034
|
}
|
|
@@ -744,71 +1036,104 @@ export class RelayServer {
|
|
|
744
1036
|
this.sessions.join(msg.sessionId, ws);
|
|
745
1037
|
}
|
|
746
1038
|
catch {
|
|
747
|
-
|
|
1039
|
+
this.sendTo(ws, { type: 'error', message: 'Session busy' });
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
// Include the agent's capabilities so the viewer knows up front what is implemented on
|
|
1043
|
+
// the other end — an agent that predates a feature omits it, and the dashboard degrades
|
|
1044
|
+
// deliberately instead of inferring anything from a timeout.
|
|
1045
|
+
this.sendTo(ws, {
|
|
1046
|
+
type: 'session:joined', sessionId: msg.sessionId, capabilities: session.agentCapabilities ?? [],
|
|
1047
|
+
});
|
|
1048
|
+
if (agentAway) {
|
|
1049
|
+
// Joining into a held session. Refusing instead would be worse than it sounds: the viewer
|
|
1050
|
+
// sends `session:start` exactly once per reconnect and ignores a plain `error`, so a browser
|
|
1051
|
+
// blip inside the window would leave a tab that no later `session:rebound` can reach — it is
|
|
1052
|
+
// addressed to `browserSocket`, which the refusal never set. Saying what is happening keeps
|
|
1053
|
+
// the tab inside the contract instead.
|
|
1054
|
+
//
|
|
1055
|
+
// Everything below describes the agent that went away, so it stops here. The viewer will get
|
|
1056
|
+
// it all again from the boot it sends on `session:rebound`.
|
|
1057
|
+
this.sendTo(ws, { type: 'session:agent-away', sessionId: msg.sessionId });
|
|
748
1058
|
return;
|
|
749
1059
|
}
|
|
750
|
-
ws.send(JSON.stringify({ type: 'session:joined', sessionId: msg.sessionId }));
|
|
751
1060
|
if (session.chromeData) {
|
|
752
|
-
|
|
1061
|
+
this.sendTo(ws, { type: 'session:chrome', payload: session.chromeData });
|
|
753
1062
|
}
|
|
754
1063
|
if (session.deviceInfo) {
|
|
755
|
-
|
|
1064
|
+
this.sendTo(ws, { type: 'session:deviceInfo', payload: session.deviceInfo });
|
|
756
1065
|
}
|
|
757
|
-
// Replay device:ready if
|
|
758
|
-
|
|
759
|
-
|
|
1066
|
+
// Replay device:ready only if this session actually announced one (browser WS blip reconnect).
|
|
1067
|
+
// Not `deviceStatus`: that starts from the agent's `simctl list` snapshot, so a session for a
|
|
1068
|
+
// simulator that was already running would fire this before the agent had done anything —
|
|
1069
|
+
// telling the viewer a stream exists when none does (#440).
|
|
1070
|
+
if (session.readySent) {
|
|
1071
|
+
this.sendTo(ws, { type: 'device:ready', payload: { deviceId: session.deviceId } });
|
|
760
1072
|
// (Re)joining a live stream: ask the agent for an IDR so this viewer gets a decodable
|
|
761
1073
|
// keyframe immediately, instead of waiting for the next periodic one — and so it isn't
|
|
762
1074
|
// left blank when the encoder is static-skipping an unchanged screen. Agents that don't
|
|
763
1075
|
// support on-demand IDR ignore the message.
|
|
764
1076
|
if (session.agentSocket.readyState === WebSocket.OPEN) {
|
|
765
|
-
session.agentSocket
|
|
1077
|
+
this.sendTo(session.agentSocket, { type: 'stream:request-idr', sessionId: session.id });
|
|
766
1078
|
}
|
|
767
1079
|
}
|
|
768
1080
|
}
|
|
1081
|
+
/** Relay looks up file_path from DB and enriches it for the agent.
|
|
1082
|
+
*
|
|
1083
|
+
* Every exit carries the request's `sessionId`, including the failures. A dashboard viewer holds
|
|
1084
|
+
* one session per socket, so an uncorrelated error still lands somewhere sensible — but an MCP
|
|
1085
|
+
* caller waits for the reply matching its own sessionId, so anything else is indistinguishable
|
|
1086
|
+
* from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
|
|
1087
|
+
* same reason: a generic `error` cannot be correlated by construction. */
|
|
769
1088
|
handleBrowserAppInstall(ws, msg) {
|
|
770
|
-
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
return;
|
|
775
|
-
|
|
1089
|
+
const sessionId = msg.sessionId;
|
|
1090
|
+
const fail = (message) => this.sendTo(ws, { type: 'app:install-error', sessionId, message });
|
|
1091
|
+
const session = this.sessions.get(sessionId);
|
|
1092
|
+
if (!session)
|
|
1093
|
+
return fail('Session not found');
|
|
1094
|
+
// `JSON.parse` does not honour `RelayMessage`, so buildId is whatever the sender put there.
|
|
1095
|
+
// better-sqlite3 binds a missing value as NULL but *throws* on an object or array — and that
|
|
1096
|
+
// exception is swallowed by the message-loop catch, which is the silence this PR exists to
|
|
1097
|
+
// remove. Checked here rather than trusted; general inbound validation is #444.
|
|
1098
|
+
if (!Number.isInteger(msg.buildId))
|
|
1099
|
+
return fail('Build not found');
|
|
776
1100
|
const build = getDb()
|
|
777
1101
|
.prepare('SELECT file_path, bundle_id FROM builds WHERE id = ?')
|
|
778
1102
|
.get(msg.buildId);
|
|
779
|
-
if (!build)
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
1103
|
+
if (!build)
|
|
1104
|
+
return fail('Build not found');
|
|
1105
|
+
// Answer now rather than letting the caller time out — the same shape as `open-url` above.
|
|
1106
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1107
|
+
return fail('agent offline');
|
|
1108
|
+
this.sendTo(session.agentSocket, {
|
|
1109
|
+
type: 'app:install',
|
|
1110
|
+
sessionId,
|
|
1111
|
+
payload: { filePath: build.file_path, bundleId: build.bundle_id },
|
|
1112
|
+
});
|
|
790
1113
|
}
|
|
1114
|
+
/** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
|
|
791
1115
|
handleBrowserAppLaunch(ws, msg) {
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
return;
|
|
797
|
-
|
|
1116
|
+
const sessionId = msg.sessionId;
|
|
1117
|
+
const fail = (message) => this.sendTo(ws, { type: 'app:launch-error', sessionId, message });
|
|
1118
|
+
const session = this.sessions.get(sessionId);
|
|
1119
|
+
if (!session)
|
|
1120
|
+
return fail('Session not found');
|
|
1121
|
+
// See handleBrowserAppInstall — an object here throws inside the driver and the exception is
|
|
1122
|
+
// swallowed upstream.
|
|
1123
|
+
if (!Number.isInteger(msg.buildId))
|
|
1124
|
+
return fail('Bundle ID not available for this build');
|
|
798
1125
|
const build = getDb()
|
|
799
1126
|
.prepare('SELECT bundle_id FROM builds WHERE id = ?')
|
|
800
1127
|
.get(msg.buildId);
|
|
801
|
-
if (!build?.bundle_id)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}));
|
|
811
|
-
}
|
|
1128
|
+
if (!build?.bundle_id)
|
|
1129
|
+
return fail('Bundle ID not available for this build');
|
|
1130
|
+
if (session.agentSocket.readyState !== WebSocket.OPEN)
|
|
1131
|
+
return fail('agent offline');
|
|
1132
|
+
this.sendTo(session.agentSocket, {
|
|
1133
|
+
type: 'app:launch',
|
|
1134
|
+
sessionId,
|
|
1135
|
+
payload: { bundleId: build.bundle_id },
|
|
1136
|
+
});
|
|
812
1137
|
}
|
|
813
1138
|
async handleGetScreenshot(req, res, params) {
|
|
814
1139
|
if (!requireViewAuth(req, res))
|
|
@@ -854,7 +1179,7 @@ export class RelayServer {
|
|
|
854
1179
|
},
|
|
855
1180
|
timer,
|
|
856
1181
|
});
|
|
857
|
-
session.agentSocket
|
|
1182
|
+
this.sendTo(session.agentSocket, { type: 'screenshot:request', sessionId, requestId, format });
|
|
858
1183
|
});
|
|
859
1184
|
}
|
|
860
1185
|
async handleGetUITree(req, res, params) {
|
|
@@ -897,7 +1222,7 @@ export class RelayServer {
|
|
|
897
1222
|
},
|
|
898
1223
|
timer,
|
|
899
1224
|
});
|
|
900
|
-
session.agentSocket
|
|
1225
|
+
this.sendTo(session.agentSocket, { type: 'ui:tree:request', sessionId, requestId });
|
|
901
1226
|
});
|
|
902
1227
|
}
|
|
903
1228
|
handleUITreeResponse(msg) {
|