deckide 3.5.33 → 3.5.34
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 +42 -1
- package/bin/deckide.js +1 -1
- package/dist/middleware/cors.js +1 -1
- package/dist/routes/browser.js +40 -0
- package/dist/routes/decks.js +0 -2
- package/dist/routes/mcp.js +1241 -0
- package/dist/routes/terminals.js +180 -17
- package/dist/server.js +24 -4
- package/dist/utils/agent-browser.js +815 -0
- package/dist/utils/browser-audio.js +381 -0
- package/dist/utils/database.js +0 -40
- package/dist/utils/shell.js +34 -0
- package/dist/websocket.js +50 -3
- package/package.json +11 -7
- package/web/dist/assets/index-BHNlWxgh.js +178 -0
- package/web/dist/assets/index-mimw-Xdi.css +32 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DINx38Yu.css +0 -32
- package/web/dist/assets/index-MGg98kDU.js +0 -65
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { BrowserAudioRelay } from './browser-audio.js';
|
|
9
|
+
const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'profile');
|
|
10
|
+
const DEFAULT_OUTPUT_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'output');
|
|
11
|
+
const START_TIMEOUT_MS = 30_000;
|
|
12
|
+
const MAX_CLIENT_BUFFERED_AMOUNT = 4 * 1024 * 1024;
|
|
13
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
14
|
+
const MIN_VIEWPORT = { width: 320, height: 240 };
|
|
15
|
+
const MAX_VIEWPORT = { width: 3840, height: 2160 };
|
|
16
|
+
function sleep(ms) {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
|
+
}
|
|
19
|
+
function isOpen(socket) {
|
|
20
|
+
return socket.readyState === WebSocket.OPEN;
|
|
21
|
+
}
|
|
22
|
+
function coercePositiveInt(value, fallback, min, max) {
|
|
23
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
27
|
+
}
|
|
28
|
+
function normalizeBrowserUrl(input) {
|
|
29
|
+
const trimmed = input.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
throw new Error('URL is required');
|
|
32
|
+
}
|
|
33
|
+
const hasExplicitProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
|
34
|
+
|| /^(about|data|file):/i.test(trimmed);
|
|
35
|
+
const isLocalHost = /^(localhost|127(?:\.\d{1,3}){0,3}|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(trimmed);
|
|
36
|
+
const withScheme = hasExplicitProtocol
|
|
37
|
+
? trimmed
|
|
38
|
+
: `${isLocalHost ? 'http' : 'https'}://${trimmed}`;
|
|
39
|
+
const parsed = new URL(withScheme);
|
|
40
|
+
const allowedProtocols = new Set(['http:', 'https:', 'file:', 'about:', 'data:']);
|
|
41
|
+
if (!allowedProtocols.has(parsed.protocol)) {
|
|
42
|
+
throw new Error(`Unsupported URL protocol: ${parsed.protocol}`);
|
|
43
|
+
}
|
|
44
|
+
return parsed.href;
|
|
45
|
+
}
|
|
46
|
+
async function pathExists(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(filePath);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function findInPath(names) {
|
|
56
|
+
const pathValue = process.env.PATH || '';
|
|
57
|
+
for (const dir of pathValue.split(path.delimiter)) {
|
|
58
|
+
if (!dir)
|
|
59
|
+
continue;
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const candidate = path.join(dir, name);
|
|
62
|
+
if (await pathExists(candidate)) {
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
async function findPlaywrightChromium() {
|
|
70
|
+
const cacheRoot = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await fs.readdir(cacheRoot, { withFileTypes: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const chromiumDirs = entries
|
|
79
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('chromium-'))
|
|
80
|
+
.map((entry) => entry.name)
|
|
81
|
+
.sort()
|
|
82
|
+
.reverse();
|
|
83
|
+
for (const dir of chromiumDirs) {
|
|
84
|
+
const candidates = [
|
|
85
|
+
path.join(cacheRoot, dir, 'chrome-linux64', 'chrome'),
|
|
86
|
+
path.join(cacheRoot, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
|
87
|
+
path.join(cacheRoot, dir, 'chrome-win', 'chrome.exe'),
|
|
88
|
+
];
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
if (await pathExists(candidate)) {
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
async function findChromeExecutable() {
|
|
98
|
+
if (process.env.AGENT_BROWSER_CHROME) {
|
|
99
|
+
if (await pathExists(process.env.AGENT_BROWSER_CHROME)) {
|
|
100
|
+
return process.env.AGENT_BROWSER_CHROME;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`AGENT_BROWSER_CHROME does not exist: ${process.env.AGENT_BROWSER_CHROME}`);
|
|
103
|
+
}
|
|
104
|
+
const playwrightChrome = await findPlaywrightChromium();
|
|
105
|
+
if (playwrightChrome) {
|
|
106
|
+
return playwrightChrome;
|
|
107
|
+
}
|
|
108
|
+
return findInPath([
|
|
109
|
+
'google-chrome',
|
|
110
|
+
'google-chrome-stable',
|
|
111
|
+
'chromium',
|
|
112
|
+
'chromium-browser',
|
|
113
|
+
'chrome',
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
function getFreePort() {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const server = net.createServer();
|
|
119
|
+
server.unref();
|
|
120
|
+
server.once('error', reject);
|
|
121
|
+
server.listen(0, '127.0.0.1', () => {
|
|
122
|
+
const address = server.address();
|
|
123
|
+
server.close(() => {
|
|
124
|
+
if (address && typeof address === 'object') {
|
|
125
|
+
resolve(address.port);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
reject(new Error('Unable to allocate a local port'));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function fetchJson(url, options = {}, timeoutMs = 5000) {
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
137
|
+
timer.unref?.();
|
|
138
|
+
let response;
|
|
139
|
+
try {
|
|
140
|
+
response = await fetch(url, { ...options, signal: options.signal ?? controller.signal });
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const body = await response.text().catch(() => '');
|
|
147
|
+
throw new Error(`Chrome CDP request failed (${response.status}): ${body || response.statusText}`);
|
|
148
|
+
}
|
|
149
|
+
return response.json();
|
|
150
|
+
}
|
|
151
|
+
class CdpConnection {
|
|
152
|
+
socket;
|
|
153
|
+
nextId = 1;
|
|
154
|
+
pending = new Map();
|
|
155
|
+
onEvent;
|
|
156
|
+
constructor(socket) {
|
|
157
|
+
this.socket = socket;
|
|
158
|
+
socket.on('message', (data) => this.handleMessage(data));
|
|
159
|
+
socket.on('close', () => this.rejectAll(new Error('Chrome DevTools connection closed')));
|
|
160
|
+
socket.on('error', (error) => this.rejectAll(error));
|
|
161
|
+
}
|
|
162
|
+
send(method, params, timeoutMs = 10_000) {
|
|
163
|
+
if (!isOpen(this.socket)) {
|
|
164
|
+
return Promise.reject(new Error('Chrome DevTools connection is not open'));
|
|
165
|
+
}
|
|
166
|
+
const id = this.nextId++;
|
|
167
|
+
const payload = params ? { id, method, params } : { id, method };
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
this.pending.delete(id);
|
|
171
|
+
reject(new Error(`Chrome DevTools command timed out: ${method}`));
|
|
172
|
+
}, timeoutMs);
|
|
173
|
+
this.pending.set(id, {
|
|
174
|
+
resolve: (value) => resolve(value),
|
|
175
|
+
reject,
|
|
176
|
+
timer,
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
this.socket.send(JSON.stringify(payload));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
this.pending.delete(id);
|
|
184
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
close() {
|
|
189
|
+
try {
|
|
190
|
+
this.socket.close();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// ignore
|
|
194
|
+
}
|
|
195
|
+
this.rejectAll(new Error('Chrome DevTools connection closed'));
|
|
196
|
+
}
|
|
197
|
+
handleMessage(data) {
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
const text = typeof data === 'string'
|
|
201
|
+
? data
|
|
202
|
+
: Buffer.isBuffer(data)
|
|
203
|
+
? data.toString('utf8')
|
|
204
|
+
: Array.isArray(data)
|
|
205
|
+
? Buffer.concat(data).toString('utf8')
|
|
206
|
+
: Buffer.from(data).toString('utf8');
|
|
207
|
+
parsed = JSON.parse(text);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (typeof parsed.id === 'number') {
|
|
213
|
+
const pending = this.pending.get(parsed.id);
|
|
214
|
+
if (!pending)
|
|
215
|
+
return;
|
|
216
|
+
this.pending.delete(parsed.id);
|
|
217
|
+
clearTimeout(pending.timer);
|
|
218
|
+
if (parsed.error) {
|
|
219
|
+
pending.reject(new Error(parsed.error.message || 'Chrome DevTools command failed'));
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
pending.resolve(parsed.result);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.onEvent?.(parsed);
|
|
227
|
+
}
|
|
228
|
+
rejectAll(error) {
|
|
229
|
+
for (const [id, pending] of this.pending) {
|
|
230
|
+
this.pending.delete(id);
|
|
231
|
+
clearTimeout(pending.timer);
|
|
232
|
+
pending.reject(error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export class AgentBrowserService {
|
|
237
|
+
chromeProcess = null;
|
|
238
|
+
logStream = null;
|
|
239
|
+
launching = null;
|
|
240
|
+
port = null;
|
|
241
|
+
executablePath = null;
|
|
242
|
+
cdp = null;
|
|
243
|
+
cdpSocket = null;
|
|
244
|
+
cdpConnecting = null;
|
|
245
|
+
screencastStarted = false;
|
|
246
|
+
clients = new Set();
|
|
247
|
+
viewport = { ...DEFAULT_VIEWPORT };
|
|
248
|
+
pageUrl = null;
|
|
249
|
+
pageTitle = null;
|
|
250
|
+
lastError;
|
|
251
|
+
audioRelay = new BrowserAudioRelay();
|
|
252
|
+
profileDir = process.env.AGENT_BROWSER_PROFILE_DIR || DEFAULT_PROFILE_DIR;
|
|
253
|
+
outputDir = process.env.AGENT_BROWSER_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
|
|
254
|
+
async getStatus() {
|
|
255
|
+
return {
|
|
256
|
+
running: this.isRunning(),
|
|
257
|
+
launching: Boolean(this.launching),
|
|
258
|
+
profileDir: this.profileDir,
|
|
259
|
+
outputDir: this.outputDir,
|
|
260
|
+
executablePath: this.executablePath ?? await findChromeExecutable().catch(() => null),
|
|
261
|
+
cdpUrl: this.port ? `http://127.0.0.1:${this.port}` : null,
|
|
262
|
+
pageUrl: this.pageUrl,
|
|
263
|
+
pageTitle: this.pageTitle,
|
|
264
|
+
audio: await this.audioRelay.getStatus(),
|
|
265
|
+
error: this.lastError,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async start() {
|
|
269
|
+
if (this.isRunning()) {
|
|
270
|
+
await this.ensureCdp();
|
|
271
|
+
if (this.clients.size > 0) {
|
|
272
|
+
await this.startScreencast();
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (this.launching) {
|
|
277
|
+
await this.launching;
|
|
278
|
+
if (this.clients.size > 0) {
|
|
279
|
+
await this.startScreencast();
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.launching = this.launch();
|
|
284
|
+
try {
|
|
285
|
+
await this.launching;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
289
|
+
await this.stop().catch(() => undefined);
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
this.launching = null;
|
|
294
|
+
}
|
|
295
|
+
if (this.clients.size > 0) {
|
|
296
|
+
await this.startScreencast();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async stop() {
|
|
300
|
+
this.closeCdp();
|
|
301
|
+
const proc = this.chromeProcess;
|
|
302
|
+
this.chromeProcess = null;
|
|
303
|
+
this.port = null;
|
|
304
|
+
this.pageUrl = null;
|
|
305
|
+
this.pageTitle = null;
|
|
306
|
+
this.screencastStarted = false;
|
|
307
|
+
if (proc && proc.exitCode == null && proc.signalCode == null) {
|
|
308
|
+
await new Promise((resolve) => {
|
|
309
|
+
const killTimer = setTimeout(() => {
|
|
310
|
+
try {
|
|
311
|
+
proc.kill('SIGKILL');
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// ignore
|
|
315
|
+
}
|
|
316
|
+
}, 3000);
|
|
317
|
+
killTimer.unref?.();
|
|
318
|
+
proc.once('exit', () => {
|
|
319
|
+
clearTimeout(killTimer);
|
|
320
|
+
resolve();
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
proc.kill('SIGTERM');
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
clearTimeout(killTimer);
|
|
327
|
+
resolve();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
this.closeLogStream();
|
|
332
|
+
await this.audioRelay.stop();
|
|
333
|
+
await this.broadcastStatus();
|
|
334
|
+
}
|
|
335
|
+
attachWebSocket(socket) {
|
|
336
|
+
this.clients.add(socket);
|
|
337
|
+
socket.on('message', (data, isBinary) => {
|
|
338
|
+
if (isBinary)
|
|
339
|
+
return;
|
|
340
|
+
void this.handleClientMessage(socket, data);
|
|
341
|
+
});
|
|
342
|
+
socket.on('close', () => {
|
|
343
|
+
this.clients.delete(socket);
|
|
344
|
+
if (this.clients.size === 0) {
|
|
345
|
+
void this.stopScreencast();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
void this.openForClient(socket);
|
|
349
|
+
}
|
|
350
|
+
attachAudioWebSocket(socket) {
|
|
351
|
+
this.audioRelay.attachWebSocket(socket);
|
|
352
|
+
}
|
|
353
|
+
async navigate(input) {
|
|
354
|
+
const url = normalizeBrowserUrl(input);
|
|
355
|
+
await this.start();
|
|
356
|
+
await this.ensureCdp();
|
|
357
|
+
await this.cdp?.send('Page.navigate', { url });
|
|
358
|
+
this.pageUrl = url;
|
|
359
|
+
await this.refreshPageInfo();
|
|
360
|
+
await this.broadcastStatus();
|
|
361
|
+
}
|
|
362
|
+
async launch() {
|
|
363
|
+
this.lastError = undefined;
|
|
364
|
+
await fs.mkdir(this.profileDir, { recursive: true });
|
|
365
|
+
await fs.mkdir(this.outputDir, { recursive: true });
|
|
366
|
+
const executable = await findChromeExecutable();
|
|
367
|
+
if (!executable) {
|
|
368
|
+
throw new Error('Chromium or Chrome was not found. Set AGENT_BROWSER_CHROME to a browser executable.');
|
|
369
|
+
}
|
|
370
|
+
this.executablePath = executable;
|
|
371
|
+
this.port = await getFreePort();
|
|
372
|
+
this.openLogStream();
|
|
373
|
+
const args = [
|
|
374
|
+
'--headless=new',
|
|
375
|
+
`--remote-debugging-address=127.0.0.1`,
|
|
376
|
+
`--remote-debugging-port=${this.port}`,
|
|
377
|
+
`--user-data-dir=${this.profileDir}`,
|
|
378
|
+
'--no-first-run',
|
|
379
|
+
'--no-default-browser-check',
|
|
380
|
+
'--disable-dev-shm-usage',
|
|
381
|
+
'--autoplay-policy=no-user-gesture-required',
|
|
382
|
+
'--window-size=1280,720',
|
|
383
|
+
'about:blank',
|
|
384
|
+
];
|
|
385
|
+
if (process.getuid?.() === 0) {
|
|
386
|
+
args.splice(args.length - 1, 0, '--no-sandbox');
|
|
387
|
+
}
|
|
388
|
+
const child = spawn(executable, args, {
|
|
389
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
390
|
+
env: process.env,
|
|
391
|
+
});
|
|
392
|
+
this.chromeProcess = child;
|
|
393
|
+
child.stdout.pipe(this.logStream ?? process.stdout, { end: false });
|
|
394
|
+
child.stderr.pipe(this.logStream ?? process.stderr, { end: false });
|
|
395
|
+
child.once('exit', (code, signal) => {
|
|
396
|
+
if (this.chromeProcess === child) {
|
|
397
|
+
this.chromeProcess = null;
|
|
398
|
+
this.port = null;
|
|
399
|
+
this.closeCdp();
|
|
400
|
+
this.closeLogStream();
|
|
401
|
+
this.lastError = code === 0 ? undefined : `Browser exited (${signal ?? code ?? 'unknown'})`;
|
|
402
|
+
void this.broadcastStatus();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
child.once('error', (error) => {
|
|
406
|
+
this.lastError = error.message;
|
|
407
|
+
void this.broadcastStatus();
|
|
408
|
+
});
|
|
409
|
+
await this.waitForChrome();
|
|
410
|
+
await this.ensureCdp();
|
|
411
|
+
await this.refreshPageInfo();
|
|
412
|
+
await this.broadcastStatus();
|
|
413
|
+
}
|
|
414
|
+
async waitForChrome() {
|
|
415
|
+
if (!this.port) {
|
|
416
|
+
throw new Error('Chrome debugging port is not assigned');
|
|
417
|
+
}
|
|
418
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
419
|
+
let lastError = null;
|
|
420
|
+
while (Date.now() < deadline) {
|
|
421
|
+
if (!this.isRunning()) {
|
|
422
|
+
throw new Error(this.lastError || 'Browser exited before DevTools became available');
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
lastError = error;
|
|
430
|
+
await sleep(200);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const detail = lastError instanceof Error ? `: ${lastError.message}` : '';
|
|
434
|
+
throw new Error(`Timed out waiting for Chrome DevTools${detail}`);
|
|
435
|
+
}
|
|
436
|
+
async openForClient(socket) {
|
|
437
|
+
try {
|
|
438
|
+
await this.start();
|
|
439
|
+
await this.ensureCdp();
|
|
440
|
+
await this.sendStatus(socket);
|
|
441
|
+
if (this.clients.size > 0) {
|
|
442
|
+
await this.startScreencast();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
447
|
+
this.lastError = message;
|
|
448
|
+
this.send(socket, { type: 'error', error: message });
|
|
449
|
+
await this.broadcastStatus();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
isRunning() {
|
|
453
|
+
const proc = this.chromeProcess;
|
|
454
|
+
return Boolean(proc && proc.exitCode == null && proc.signalCode == null);
|
|
455
|
+
}
|
|
456
|
+
async ensureCdp() {
|
|
457
|
+
if (this.cdp && this.cdpSocket && isOpen(this.cdpSocket)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (this.cdpConnecting) {
|
|
461
|
+
return this.cdpConnecting;
|
|
462
|
+
}
|
|
463
|
+
this.cdpConnecting = this.connectCdp();
|
|
464
|
+
try {
|
|
465
|
+
await this.cdpConnecting;
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
this.cdpConnecting = null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async connectCdp() {
|
|
472
|
+
if (!this.port) {
|
|
473
|
+
throw new Error('Browser is not running');
|
|
474
|
+
}
|
|
475
|
+
const target = await this.getOrCreatePageTarget();
|
|
476
|
+
if (!target.webSocketDebuggerUrl) {
|
|
477
|
+
throw new Error('Chrome page target has no DevTools WebSocket URL');
|
|
478
|
+
}
|
|
479
|
+
this.closeCdp();
|
|
480
|
+
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
481
|
+
await new Promise((resolve, reject) => {
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
reject(new Error('Timed out connecting to Chrome DevTools WebSocket'));
|
|
484
|
+
}, 10_000);
|
|
485
|
+
socket.once('open', () => {
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
resolve();
|
|
488
|
+
});
|
|
489
|
+
socket.once('error', (error) => {
|
|
490
|
+
clearTimeout(timer);
|
|
491
|
+
reject(error);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
const cdp = new CdpConnection(socket);
|
|
495
|
+
cdp.onEvent = (message) => this.handleCdpEvent(message);
|
|
496
|
+
this.cdp = cdp;
|
|
497
|
+
this.cdpSocket = socket;
|
|
498
|
+
this.screencastStarted = false;
|
|
499
|
+
this.pageUrl = target.url || this.pageUrl;
|
|
500
|
+
this.pageTitle = target.title || this.pageTitle;
|
|
501
|
+
socket.on('close', () => {
|
|
502
|
+
if (this.cdp === cdp) {
|
|
503
|
+
this.cdp = null;
|
|
504
|
+
this.cdpSocket = null;
|
|
505
|
+
this.screencastStarted = false;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
await cdp.send('Page.enable');
|
|
509
|
+
await cdp.send('Runtime.enable');
|
|
510
|
+
await this.applyViewport();
|
|
511
|
+
}
|
|
512
|
+
async getOrCreatePageTarget() {
|
|
513
|
+
if (!this.port) {
|
|
514
|
+
throw new Error('Browser is not running');
|
|
515
|
+
}
|
|
516
|
+
const baseUrl = `http://127.0.0.1:${this.port}`;
|
|
517
|
+
const targets = await fetchJson(`${baseUrl}/json/list`);
|
|
518
|
+
const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl);
|
|
519
|
+
if (page) {
|
|
520
|
+
return page;
|
|
521
|
+
}
|
|
522
|
+
return fetchJson(`${baseUrl}/json/new?about:blank`, { method: 'PUT' });
|
|
523
|
+
}
|
|
524
|
+
async startScreencast() {
|
|
525
|
+
if (this.screencastStarted || !this.cdp) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
await this.applyViewport();
|
|
529
|
+
await this.cdp.send('Page.startScreencast', {
|
|
530
|
+
format: 'jpeg',
|
|
531
|
+
quality: 72,
|
|
532
|
+
maxWidth: this.viewport.width,
|
|
533
|
+
maxHeight: this.viewport.height,
|
|
534
|
+
everyNthFrame: 1,
|
|
535
|
+
});
|
|
536
|
+
this.screencastStarted = true;
|
|
537
|
+
}
|
|
538
|
+
async stopScreencast() {
|
|
539
|
+
if (!this.screencastStarted || !this.cdp) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
await this.cdp.send('Page.stopScreencast');
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// ignore
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
this.screencastStarted = false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async applyViewport() {
|
|
553
|
+
if (!this.cdp) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await this.cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
557
|
+
width: this.viewport.width,
|
|
558
|
+
height: this.viewport.height,
|
|
559
|
+
deviceScaleFactor: 1,
|
|
560
|
+
mobile: false,
|
|
561
|
+
screenWidth: this.viewport.width,
|
|
562
|
+
screenHeight: this.viewport.height,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async resize(width, height) {
|
|
566
|
+
const next = {
|
|
567
|
+
width: coercePositiveInt(width, this.viewport.width, MIN_VIEWPORT.width, MAX_VIEWPORT.width),
|
|
568
|
+
height: coercePositiveInt(height, this.viewport.height, MIN_VIEWPORT.height, MAX_VIEWPORT.height),
|
|
569
|
+
};
|
|
570
|
+
if (next.width === this.viewport.width && next.height === this.viewport.height) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
this.viewport = next;
|
|
574
|
+
if (!this.cdp) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
await this.applyViewport();
|
|
578
|
+
if (this.screencastStarted) {
|
|
579
|
+
await this.cdp.send('Page.stopScreencast').catch(() => undefined);
|
|
580
|
+
this.screencastStarted = false;
|
|
581
|
+
await this.startScreencast();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async handleClientMessage(socket, data) {
|
|
585
|
+
let message;
|
|
586
|
+
try {
|
|
587
|
+
const text = typeof data === 'string'
|
|
588
|
+
? data
|
|
589
|
+
: Buffer.isBuffer(data)
|
|
590
|
+
? data.toString('utf8')
|
|
591
|
+
: Array.isArray(data)
|
|
592
|
+
? Buffer.concat(data).toString('utf8')
|
|
593
|
+
: Buffer.from(data).toString('utf8');
|
|
594
|
+
message = JSON.parse(text);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
if (message.type === 'resize') {
|
|
601
|
+
await this.resize(message.width, message.height);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await this.start();
|
|
605
|
+
await this.ensureCdp();
|
|
606
|
+
if (message.type === 'navigate') {
|
|
607
|
+
await this.navigate(message.url);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (message.type === 'mouse') {
|
|
611
|
+
await this.dispatchMouse(message);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (message.type === 'key') {
|
|
615
|
+
await this.dispatchKey(message);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
620
|
+
this.lastError = detail;
|
|
621
|
+
this.send(socket, { type: 'error', error: detail });
|
|
622
|
+
await this.broadcastStatus();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async dispatchMouse(message) {
|
|
626
|
+
if (!this.cdp)
|
|
627
|
+
return;
|
|
628
|
+
await this.cdp.send('Input.dispatchMouseEvent', {
|
|
629
|
+
type: message.eventType,
|
|
630
|
+
x: coercePositiveInt(message.x, 0, 0, MAX_VIEWPORT.width),
|
|
631
|
+
y: coercePositiveInt(message.y, 0, 0, MAX_VIEWPORT.height),
|
|
632
|
+
button: message.button || 'none',
|
|
633
|
+
clickCount: message.clickCount ?? 0,
|
|
634
|
+
deltaX: message.deltaX ?? 0,
|
|
635
|
+
deltaY: message.deltaY ?? 0,
|
|
636
|
+
modifiers: message.modifiers ?? 0,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
async dispatchKey(message) {
|
|
640
|
+
if (!this.cdp)
|
|
641
|
+
return;
|
|
642
|
+
const windowsVirtualKeyCode = getWindowsVirtualKeyCode(message.key);
|
|
643
|
+
await this.cdp.send('Input.dispatchKeyEvent', {
|
|
644
|
+
type: message.eventType,
|
|
645
|
+
key: message.key,
|
|
646
|
+
code: message.code,
|
|
647
|
+
text: message.eventType === 'keyDown' ? message.text || undefined : undefined,
|
|
648
|
+
unmodifiedText: message.eventType === 'keyDown' ? message.text || undefined : undefined,
|
|
649
|
+
windowsVirtualKeyCode,
|
|
650
|
+
nativeVirtualKeyCode: windowsVirtualKeyCode,
|
|
651
|
+
modifiers: message.modifiers ?? 0,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
handleCdpEvent(message) {
|
|
655
|
+
if (message.method === 'Page.screencastFrame') {
|
|
656
|
+
const params = message.params;
|
|
657
|
+
const data = typeof params.data === 'string' ? params.data : null;
|
|
658
|
+
const sessionId = typeof params.sessionId === 'number' ? params.sessionId : null;
|
|
659
|
+
if (sessionId != null) {
|
|
660
|
+
void this.cdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
|
|
661
|
+
}
|
|
662
|
+
if (data) {
|
|
663
|
+
this.broadcast({
|
|
664
|
+
type: 'frame',
|
|
665
|
+
data,
|
|
666
|
+
metadata: params.metadata ?? null,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (message.method === 'Page.frameNavigated') {
|
|
672
|
+
const frame = (message.params?.frame ?? null);
|
|
673
|
+
if (frame && !frame.parentId && frame.url) {
|
|
674
|
+
this.pageUrl = frame.url;
|
|
675
|
+
void this.refreshPageInfo().finally(() => {
|
|
676
|
+
void this.broadcastStatus();
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (message.method === 'Page.loadEventFired') {
|
|
682
|
+
void this.refreshPageInfo().finally(() => {
|
|
683
|
+
void this.broadcastStatus();
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async refreshPageInfo() {
|
|
688
|
+
if (!this.cdp) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const result = await this.cdp.send('Runtime.evaluate', {
|
|
693
|
+
expression: '({ title: document.title, url: location.href })',
|
|
694
|
+
returnByValue: true,
|
|
695
|
+
});
|
|
696
|
+
const value = result.result?.value;
|
|
697
|
+
if (value) {
|
|
698
|
+
this.pageTitle = typeof value.title === 'string' ? value.title : this.pageTitle;
|
|
699
|
+
this.pageUrl = typeof value.url === 'string' ? value.url : this.pageUrl;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// Some internal pages do not allow Runtime.evaluate.
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
async sendStatus(socket) {
|
|
707
|
+
this.send(socket, { type: 'status', status: await this.getStatus() });
|
|
708
|
+
}
|
|
709
|
+
async broadcastStatus() {
|
|
710
|
+
const status = await this.getStatus();
|
|
711
|
+
this.broadcast({ type: 'status', status });
|
|
712
|
+
}
|
|
713
|
+
broadcast(payload) {
|
|
714
|
+
const text = JSON.stringify(payload);
|
|
715
|
+
for (const socket of this.clients) {
|
|
716
|
+
this.sendRaw(socket, text);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
send(socket, payload) {
|
|
720
|
+
this.sendRaw(socket, JSON.stringify(payload));
|
|
721
|
+
}
|
|
722
|
+
sendRaw(socket, payload) {
|
|
723
|
+
if (!isOpen(socket)) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (socket.bufferedAmount > MAX_CLIENT_BUFFERED_AMOUNT) {
|
|
727
|
+
try {
|
|
728
|
+
socket.close(1009, 'Browser stream overflow');
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// ignore
|
|
732
|
+
}
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
socket.send(payload);
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
try {
|
|
740
|
+
socket.close(1011, 'Browser stream send failed');
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// ignore
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
closeCdp() {
|
|
748
|
+
const cdp = this.cdp;
|
|
749
|
+
const socket = this.cdpSocket;
|
|
750
|
+
this.cdp = null;
|
|
751
|
+
this.cdpSocket = null;
|
|
752
|
+
this.screencastStarted = false;
|
|
753
|
+
if (cdp) {
|
|
754
|
+
cdp.close();
|
|
755
|
+
}
|
|
756
|
+
else if (socket) {
|
|
757
|
+
try {
|
|
758
|
+
socket.close();
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// ignore
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
openLogStream() {
|
|
766
|
+
this.closeLogStream();
|
|
767
|
+
const logPath = path.join(this.outputDir, 'deckide-browser.log');
|
|
768
|
+
this.logStream = fsSync.createWriteStream(logPath, { flags: 'a' });
|
|
769
|
+
}
|
|
770
|
+
closeLogStream() {
|
|
771
|
+
const stream = this.logStream;
|
|
772
|
+
this.logStream = null;
|
|
773
|
+
if (stream) {
|
|
774
|
+
stream.end();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
function getWindowsVirtualKeyCode(key) {
|
|
779
|
+
const table = {
|
|
780
|
+
Backspace: 8,
|
|
781
|
+
Tab: 9,
|
|
782
|
+
Enter: 13,
|
|
783
|
+
Shift: 16,
|
|
784
|
+
Control: 17,
|
|
785
|
+
Alt: 18,
|
|
786
|
+
Pause: 19,
|
|
787
|
+
CapsLock: 20,
|
|
788
|
+
Escape: 27,
|
|
789
|
+
Space: 32,
|
|
790
|
+
' ': 32,
|
|
791
|
+
PageUp: 33,
|
|
792
|
+
PageDown: 34,
|
|
793
|
+
End: 35,
|
|
794
|
+
Home: 36,
|
|
795
|
+
ArrowLeft: 37,
|
|
796
|
+
ArrowUp: 38,
|
|
797
|
+
ArrowRight: 39,
|
|
798
|
+
ArrowDown: 40,
|
|
799
|
+
Insert: 45,
|
|
800
|
+
Delete: 46,
|
|
801
|
+
Meta: 91,
|
|
802
|
+
ContextMenu: 93,
|
|
803
|
+
};
|
|
804
|
+
if (key in table) {
|
|
805
|
+
return table[key];
|
|
806
|
+
}
|
|
807
|
+
const functionKey = /^F([1-9]|1[0-2])$/.exec(key);
|
|
808
|
+
if (functionKey) {
|
|
809
|
+
return 111 + Number(functionKey[1]);
|
|
810
|
+
}
|
|
811
|
+
if (key.length === 1) {
|
|
812
|
+
return key.toUpperCase().charCodeAt(0);
|
|
813
|
+
}
|
|
814
|
+
return 0;
|
|
815
|
+
}
|