deckide 3.5.35 → 3.5.37
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.
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import fsSync from 'node:fs';
|
|
4
|
-
import net from 'node:net';
|
|
5
4
|
import os from 'node:os';
|
|
6
5
|
import path from 'node:path';
|
|
7
6
|
import { WebSocket } from 'ws';
|
|
@@ -10,6 +9,10 @@ const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.local', 'share', 'agent-br
|
|
|
10
9
|
const DEFAULT_OUTPUT_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'output');
|
|
11
10
|
const START_TIMEOUT_MS = 30_000;
|
|
12
11
|
const MAX_CLIENT_BUFFERED_AMOUNT = 4 * 1024 * 1024;
|
|
12
|
+
// Above this many bytes already queued on a client socket we hold the newest
|
|
13
|
+
// frame instead of piling on, then flush it once the socket drains.
|
|
14
|
+
const FRAME_COALESCE_THRESHOLD = 256 * 1024;
|
|
15
|
+
const FRAME_FLUSH_INTERVAL_MS = 16;
|
|
13
16
|
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
14
17
|
const MIN_VIEWPORT = { width: 320, height: 240 };
|
|
15
18
|
const MAX_VIEWPORT = { width: 3840, height: 2160 };
|
|
@@ -19,6 +22,22 @@ function sleep(ms) {
|
|
|
19
22
|
function isOpen(socket) {
|
|
20
23
|
return socket.readyState === WebSocket.OPEN;
|
|
21
24
|
}
|
|
25
|
+
function waitForSocketOpen(socket, timeoutMs = 10_000) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
reject(new Error('Timed out connecting to Chrome DevTools WebSocket'));
|
|
29
|
+
}, timeoutMs);
|
|
30
|
+
timer.unref?.();
|
|
31
|
+
socket.once('open', () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
socket.once('error', (error) => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
22
41
|
function coercePositiveInt(value, fallback, min, max) {
|
|
23
42
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
24
43
|
return fallback;
|
|
@@ -101,35 +120,57 @@ async function findChromeExecutable() {
|
|
|
101
120
|
}
|
|
102
121
|
throw new Error(`AGENT_BROWSER_CHROME does not exist: ${process.env.AGENT_BROWSER_CHROME}`);
|
|
103
122
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
return findInPath([
|
|
123
|
+
// 本物の Google Chrome を最優先(指紋が自然でボット判定を通りやすい)。
|
|
124
|
+
// Playwright の Chromium はフォールバックに回す。
|
|
125
|
+
const systemChrome = await findInPath([
|
|
109
126
|
'google-chrome',
|
|
110
127
|
'google-chrome-stable',
|
|
111
128
|
'chromium',
|
|
112
129
|
'chromium-browser',
|
|
113
130
|
'chrome',
|
|
114
131
|
]);
|
|
132
|
+
if (systemChrome) {
|
|
133
|
+
return systemChrome;
|
|
134
|
+
}
|
|
135
|
+
return findPlaywrightChromium();
|
|
115
136
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
// MCP(chrome-devtools-mcp 等)が --browserUrl で確実に相乗りできるよう、
|
|
138
|
+
// CDP ポートは固定にする。AGENT_BROWSER_CDP_PORT で上書き可、既定 9222。
|
|
139
|
+
const DEFAULT_CDP_PORT = 9222;
|
|
140
|
+
function resolveCdpPort() {
|
|
141
|
+
const raw = process.env.AGENT_BROWSER_CDP_PORT;
|
|
142
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
143
|
+
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) {
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
return DEFAULT_CDP_PORT;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Chrome の起動コマンドを解決する。
|
|
150
|
+
* ボット判定対策として「実 Chrome を headful」で動かしたい。Linux サーバ等で
|
|
151
|
+
* ディスプレイが無い場合は xvfb-run で仮想ディスプレイ上の headful を使う。
|
|
152
|
+
* xvfb-run も無ければやむを得ず headless にフォールバックする。
|
|
153
|
+
*/
|
|
154
|
+
function resolveLaunchCommand(executable, chromeArgs) {
|
|
155
|
+
const hasDisplay = Boolean(process.env.DISPLAY);
|
|
156
|
+
if (process.platform === 'linux' && !hasDisplay) {
|
|
157
|
+
const xvfbRun = ['/usr/bin/xvfb-run', '/usr/local/bin/xvfb-run'].find((p) => fsSync.existsSync(p));
|
|
158
|
+
if (xvfbRun) {
|
|
159
|
+
return {
|
|
160
|
+
command: xvfbRun,
|
|
161
|
+
args: [
|
|
162
|
+
'-a',
|
|
163
|
+
'--server-args=-screen 0 1280x720x24 -nolisten tcp',
|
|
164
|
+
executable,
|
|
165
|
+
...chromeArgs,
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// ディスプレイも xvfb-run も無い → headless で動かす(Cloudflare は通りにくい)。
|
|
170
|
+
return { command: executable, args: ['--headless=new', ...chromeArgs] };
|
|
171
|
+
}
|
|
172
|
+
// ディスプレイがある(mac/win/Linux+X)→ そのまま headful 起動。
|
|
173
|
+
return { command: executable, args: chromeArgs };
|
|
133
174
|
}
|
|
134
175
|
async function fetchJson(url, options = {}, timeoutMs = 5000) {
|
|
135
176
|
const controller = new AbortController();
|
|
@@ -239,24 +280,43 @@ export class AgentBrowserService {
|
|
|
239
280
|
launching = null;
|
|
240
281
|
port = null;
|
|
241
282
|
executablePath = null;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
283
|
+
// Browser-level CDP connection used only to discover tabs (page targets) and
|
|
284
|
+
// drive their lifecycle (create/close/activate). One per running Chrome.
|
|
285
|
+
browserCdp = null;
|
|
286
|
+
browserCdpSocket = null;
|
|
287
|
+
browserCdpConnecting = null;
|
|
288
|
+
// CDP connection to the *active* tab's page target — the only tab that streams
|
|
289
|
+
// frames and receives input. Switching tabs tears this down and reconnects to
|
|
290
|
+
// the new target.
|
|
291
|
+
activeCdp = null;
|
|
292
|
+
activeCdpSocket = null;
|
|
293
|
+
activeConnecting = null;
|
|
294
|
+
// Bumped on every tab switch. A slower in-flight connect compares against it
|
|
295
|
+
// and bails if it lost the race, so it can't clobber the newer active tab.
|
|
296
|
+
activeGeneration = 0;
|
|
245
297
|
screencastStarted = false;
|
|
298
|
+
// Known page targets (tabs) keyed by targetId, in discovery/creation order.
|
|
299
|
+
tabs = new Map();
|
|
300
|
+
activeTargetId = null;
|
|
301
|
+
// True once the first active tab is established. Gates popup auto-follow so
|
|
302
|
+
// targets discovered during launch don't hijack focus before we've picked one.
|
|
303
|
+
ready = false;
|
|
246
304
|
// Raw JPEG bytes of the most recent screencast frame, kept so reconnecting or
|
|
247
305
|
// late-joining clients can be shown the current page immediately. Frames are
|
|
248
306
|
// pushed to clients as binary WebSocket messages (no base64) to cut ~33% size
|
|
249
307
|
// and avoid per-frame string allocation.
|
|
250
308
|
lastFrameBuffer = null;
|
|
309
|
+
// Newest frame held back per slow client, flushed when its socket drains.
|
|
310
|
+
pendingFrames = new Map();
|
|
311
|
+
frameFlushTimer = null;
|
|
251
312
|
clients = new Set();
|
|
252
313
|
viewport = { ...DEFAULT_VIEWPORT };
|
|
253
|
-
pageUrl = null;
|
|
254
|
-
pageTitle = null;
|
|
255
314
|
lastError;
|
|
256
315
|
audioRelay = new BrowserAudioRelay();
|
|
257
316
|
profileDir = process.env.AGENT_BROWSER_PROFILE_DIR || DEFAULT_PROFILE_DIR;
|
|
258
317
|
outputDir = process.env.AGENT_BROWSER_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
|
|
259
318
|
async getStatus() {
|
|
319
|
+
const active = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
|
|
260
320
|
return {
|
|
261
321
|
running: this.isRunning(),
|
|
262
322
|
launching: Boolean(this.launching),
|
|
@@ -264,15 +324,17 @@ export class AgentBrowserService {
|
|
|
264
324
|
outputDir: this.outputDir,
|
|
265
325
|
executablePath: this.executablePath ?? await findChromeExecutable().catch(() => null),
|
|
266
326
|
cdpUrl: this.port ? `http://127.0.0.1:${this.port}` : null,
|
|
267
|
-
pageUrl:
|
|
268
|
-
pageTitle:
|
|
327
|
+
pageUrl: active?.url ?? null,
|
|
328
|
+
pageTitle: active?.title ?? null,
|
|
329
|
+
tabs: [...this.tabs.values()].map((tab) => ({ ...tab })),
|
|
330
|
+
activeTabId: this.activeTargetId,
|
|
269
331
|
audio: await this.audioRelay.getStatus(),
|
|
270
332
|
error: this.lastError,
|
|
271
333
|
};
|
|
272
334
|
}
|
|
273
335
|
async start() {
|
|
274
336
|
if (this.isRunning()) {
|
|
275
|
-
await this.
|
|
337
|
+
await this.ensureSession();
|
|
276
338
|
if (this.clients.size > 0) {
|
|
277
339
|
await this.startScreencast();
|
|
278
340
|
}
|
|
@@ -302,14 +364,21 @@ export class AgentBrowserService {
|
|
|
302
364
|
}
|
|
303
365
|
}
|
|
304
366
|
async stop() {
|
|
305
|
-
this.
|
|
367
|
+
this.disconnectActiveCdp();
|
|
368
|
+
this.closeBrowserCdp();
|
|
306
369
|
const proc = this.chromeProcess;
|
|
307
370
|
this.chromeProcess = null;
|
|
308
371
|
this.port = null;
|
|
309
|
-
this.
|
|
310
|
-
this.
|
|
372
|
+
this.tabs.clear();
|
|
373
|
+
this.activeTargetId = null;
|
|
374
|
+
this.ready = false;
|
|
311
375
|
this.screencastStarted = false;
|
|
312
376
|
this.lastFrameBuffer = null;
|
|
377
|
+
this.pendingFrames.clear();
|
|
378
|
+
if (this.frameFlushTimer) {
|
|
379
|
+
clearInterval(this.frameFlushTimer);
|
|
380
|
+
this.frameFlushTimer = null;
|
|
381
|
+
}
|
|
313
382
|
if (proc && proc.exitCode == null && proc.signalCode == null) {
|
|
314
383
|
await new Promise((resolve) => {
|
|
315
384
|
const killTimer = setTimeout(() => {
|
|
@@ -347,6 +416,7 @@ export class AgentBrowserService {
|
|
|
347
416
|
});
|
|
348
417
|
socket.on('close', () => {
|
|
349
418
|
this.clients.delete(socket);
|
|
419
|
+
this.pendingFrames.delete(socket);
|
|
350
420
|
if (this.clients.size === 0) {
|
|
351
421
|
void this.stopScreencast();
|
|
352
422
|
}
|
|
@@ -359,12 +429,46 @@ export class AgentBrowserService {
|
|
|
359
429
|
async navigate(input) {
|
|
360
430
|
const url = normalizeBrowserUrl(input);
|
|
361
431
|
await this.start();
|
|
362
|
-
await this.
|
|
363
|
-
await this.
|
|
364
|
-
this.
|
|
365
|
-
|
|
432
|
+
await this.ensureActiveCdp();
|
|
433
|
+
await this.activeCdp?.send('Page.navigate', { url });
|
|
434
|
+
const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
|
|
435
|
+
if (tab) {
|
|
436
|
+
tab.url = url;
|
|
437
|
+
}
|
|
438
|
+
await this.refreshActivePageInfo();
|
|
366
439
|
await this.broadcastStatus();
|
|
367
440
|
}
|
|
441
|
+
async newTab(input) {
|
|
442
|
+
await this.start();
|
|
443
|
+
await this.connectBrowserCdp();
|
|
444
|
+
const url = input ? normalizeBrowserUrl(input) : 'about:blank';
|
|
445
|
+
const result = await this.browserCdp?.send('Target.createTarget', { url });
|
|
446
|
+
const targetId = result?.targetId;
|
|
447
|
+
if (!targetId) {
|
|
448
|
+
throw new Error('Failed to open a new tab');
|
|
449
|
+
}
|
|
450
|
+
if (!this.tabs.has(targetId)) {
|
|
451
|
+
this.tabs.set(targetId, { id: targetId, url: url === 'about:blank' ? '' : url, title: '' });
|
|
452
|
+
}
|
|
453
|
+
await this.setActiveTab(targetId);
|
|
454
|
+
}
|
|
455
|
+
async closeTab(targetId) {
|
|
456
|
+
await this.connectBrowserCdp();
|
|
457
|
+
await this.browserCdp?.send('Target.closeTarget', { targetId }).catch(() => undefined);
|
|
458
|
+
this.removeTab(targetId);
|
|
459
|
+
// Never leave the pane with zero tabs — reopen a blank one like a real
|
|
460
|
+
// browser keeping a minimum of one tab.
|
|
461
|
+
if (this.tabs.size === 0 && this.isRunning()) {
|
|
462
|
+
await this.newTab().catch(() => undefined);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async activateTab(targetId) {
|
|
466
|
+
if (!this.tabs.has(targetId)) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
await this.setActiveTab(targetId);
|
|
470
|
+
await this.browserCdp?.send('Target.activateTarget', { targetId }).catch(() => undefined);
|
|
471
|
+
}
|
|
368
472
|
async launch() {
|
|
369
473
|
this.lastError = undefined;
|
|
370
474
|
await fs.mkdir(this.profileDir, { recursive: true });
|
|
@@ -374,10 +478,11 @@ export class AgentBrowserService {
|
|
|
374
478
|
throw new Error('Chromium or Chrome was not found. Set AGENT_BROWSER_CHROME to a browser executable.');
|
|
375
479
|
}
|
|
376
480
|
this.executablePath = executable;
|
|
377
|
-
this.port =
|
|
481
|
+
this.port = resolveCdpPort();
|
|
378
482
|
this.openLogStream();
|
|
379
|
-
|
|
380
|
-
|
|
483
|
+
// headless フラグは付けない(headful を既定にする)。ディスプレイ無し環境では
|
|
484
|
+
// resolveLaunchCommand が xvfb-run で仮想ディスプレイを用意する。
|
|
485
|
+
const chromeArgs = [
|
|
381
486
|
`--remote-debugging-address=127.0.0.1`,
|
|
382
487
|
`--remote-debugging-port=${this.port}`,
|
|
383
488
|
`--user-data-dir=${this.profileDir}`,
|
|
@@ -385,13 +490,23 @@ export class AgentBrowserService {
|
|
|
385
490
|
'--no-default-browser-check',
|
|
386
491
|
'--disable-dev-shm-usage',
|
|
387
492
|
'--autoplay-policy=no-user-gesture-required',
|
|
493
|
+
// GPU の無いサーバ + Xvfb 上で headful 起動すると、別プロセスの GPU が
|
|
494
|
+
// 起動失敗して "GPU process isn't usable. Goodbye." で即クラッシュする。
|
|
495
|
+
// GPU を browser プロセス内で動かし、GPU サンドボックスを外して回避する。
|
|
496
|
+
'--in-process-gpu',
|
|
497
|
+
'--disable-gpu-sandbox',
|
|
498
|
+
// ボット判定(Cloudflare の Managed Challenge 等)で誤検知されにくくする。
|
|
499
|
+
// navigator.webdriver を立てる自動化フラグを無効化する。残りの偽装は CDP 側。
|
|
500
|
+
'--disable-blink-features=AutomationControlled',
|
|
501
|
+
'--lang=ja-JP',
|
|
388
502
|
'--window-size=1280,720',
|
|
389
|
-
'about:blank',
|
|
390
503
|
];
|
|
391
504
|
if (process.getuid?.() === 0) {
|
|
392
|
-
|
|
505
|
+
chromeArgs.push('--no-sandbox');
|
|
393
506
|
}
|
|
394
|
-
|
|
507
|
+
chromeArgs.push('about:blank');
|
|
508
|
+
const { command, args } = resolveLaunchCommand(executable, chromeArgs);
|
|
509
|
+
const child = spawn(command, args, {
|
|
395
510
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
396
511
|
env: process.env,
|
|
397
512
|
});
|
|
@@ -402,7 +517,11 @@ export class AgentBrowserService {
|
|
|
402
517
|
if (this.chromeProcess === child) {
|
|
403
518
|
this.chromeProcess = null;
|
|
404
519
|
this.port = null;
|
|
405
|
-
this.
|
|
520
|
+
this.disconnectActiveCdp();
|
|
521
|
+
this.closeBrowserCdp();
|
|
522
|
+
this.tabs.clear();
|
|
523
|
+
this.activeTargetId = null;
|
|
524
|
+
this.ready = false;
|
|
406
525
|
this.closeLogStream();
|
|
407
526
|
this.lastError = code === 0 ? undefined : `Browser exited (${signal ?? code ?? 'unknown'})`;
|
|
408
527
|
void this.broadcastStatus();
|
|
@@ -413,8 +532,8 @@ export class AgentBrowserService {
|
|
|
413
532
|
void this.broadcastStatus();
|
|
414
533
|
});
|
|
415
534
|
await this.waitForChrome();
|
|
416
|
-
await this.
|
|
417
|
-
await this.
|
|
535
|
+
await this.connectBrowserCdp();
|
|
536
|
+
await this.ensureActiveTab();
|
|
418
537
|
await this.broadcastStatus();
|
|
419
538
|
}
|
|
420
539
|
async waitForChrome() {
|
|
@@ -442,14 +561,14 @@ export class AgentBrowserService {
|
|
|
442
561
|
async openForClient(socket) {
|
|
443
562
|
try {
|
|
444
563
|
await this.start();
|
|
445
|
-
await this.
|
|
564
|
+
await this.ensureActiveCdp();
|
|
446
565
|
await this.sendStatus(socket);
|
|
447
566
|
// Replay the most recent frame so a reconnecting or late-joining client
|
|
448
567
|
// sees the current page immediately. Screencast only emits frames on
|
|
449
568
|
// change, so without this a client attaching to a static page would stay
|
|
450
569
|
// blank until the page next repaints.
|
|
451
570
|
if (this.lastFrameBuffer) {
|
|
452
|
-
this.
|
|
571
|
+
this.writeFrame(socket, this.lastFrameBuffer);
|
|
453
572
|
}
|
|
454
573
|
if (this.clients.size > 0) {
|
|
455
574
|
await this.startScreencast();
|
|
@@ -466,80 +585,258 @@ export class AgentBrowserService {
|
|
|
466
585
|
const proc = this.chromeProcess;
|
|
467
586
|
return Boolean(proc && proc.exitCode == null && proc.signalCode == null);
|
|
468
587
|
}
|
|
469
|
-
|
|
470
|
-
|
|
588
|
+
// Make sure the browser-level connection and an active tab (+ its CDP) exist.
|
|
589
|
+
async ensureSession() {
|
|
590
|
+
await this.connectBrowserCdp();
|
|
591
|
+
await this.ensureActiveTab();
|
|
592
|
+
}
|
|
593
|
+
async connectBrowserCdp() {
|
|
594
|
+
if (this.browserCdp && this.browserCdpSocket && isOpen(this.browserCdpSocket)) {
|
|
471
595
|
return;
|
|
472
596
|
}
|
|
473
|
-
if (this.
|
|
474
|
-
return this.
|
|
597
|
+
if (this.browserCdpConnecting) {
|
|
598
|
+
return this.browserCdpConnecting;
|
|
475
599
|
}
|
|
476
|
-
this.
|
|
600
|
+
this.browserCdpConnecting = this.doConnectBrowserCdp();
|
|
477
601
|
try {
|
|
478
|
-
await this.
|
|
602
|
+
await this.browserCdpConnecting;
|
|
479
603
|
}
|
|
480
604
|
finally {
|
|
481
|
-
this.
|
|
605
|
+
this.browserCdpConnecting = null;
|
|
482
606
|
}
|
|
483
607
|
}
|
|
484
|
-
async
|
|
608
|
+
async doConnectBrowserCdp() {
|
|
485
609
|
if (!this.port) {
|
|
486
610
|
throw new Error('Browser is not running');
|
|
487
611
|
}
|
|
488
|
-
const
|
|
489
|
-
if (!
|
|
490
|
-
throw new Error('Chrome
|
|
612
|
+
const version = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
|
|
613
|
+
if (!version.webSocketDebuggerUrl) {
|
|
614
|
+
throw new Error('Chrome has no browser-level DevTools endpoint');
|
|
491
615
|
}
|
|
492
|
-
this.
|
|
493
|
-
const socket = new WebSocket(
|
|
494
|
-
await
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
reject(error);
|
|
505
|
-
});
|
|
616
|
+
this.closeBrowserCdp();
|
|
617
|
+
const socket = new WebSocket(version.webSocketDebuggerUrl);
|
|
618
|
+
await waitForSocketOpen(socket);
|
|
619
|
+
const cdp = new CdpConnection(socket);
|
|
620
|
+
cdp.onEvent = (message) => this.handleBrowserEvent(message);
|
|
621
|
+
this.browserCdp = cdp;
|
|
622
|
+
this.browserCdpSocket = socket;
|
|
623
|
+
socket.on('close', () => {
|
|
624
|
+
if (this.browserCdpSocket === socket) {
|
|
625
|
+
this.browserCdp = null;
|
|
626
|
+
this.browserCdpSocket = null;
|
|
627
|
+
}
|
|
506
628
|
});
|
|
629
|
+
// Emits Target.targetCreated for every existing target, then keeps us posted
|
|
630
|
+
// on creation/url/title changes and destruction.
|
|
631
|
+
await cdp.send('Target.setDiscoverTargets', { discover: true });
|
|
632
|
+
}
|
|
633
|
+
async ensureActiveTab() {
|
|
634
|
+
await this.connectBrowserCdp();
|
|
635
|
+
// Discovery events normally populate `tabs`; if they haven't landed yet (or
|
|
636
|
+
// none exist), fall back to the HTTP target list, then create one.
|
|
637
|
+
if (this.tabs.size === 0) {
|
|
638
|
+
const targets = await fetchJson(`http://127.0.0.1:${this.port}/json/list`)
|
|
639
|
+
.catch(() => []);
|
|
640
|
+
for (const target of targets) {
|
|
641
|
+
if (target.type === 'page' && !target.url.startsWith('devtools://')) {
|
|
642
|
+
this.tabs.set(target.id, { id: target.id, url: target.url, title: target.title ?? '' });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (this.tabs.size === 0) {
|
|
647
|
+
const created = await this.browserCdp?.send('Target.createTarget', {
|
|
648
|
+
url: 'about:blank',
|
|
649
|
+
});
|
|
650
|
+
const targetId = created?.targetId;
|
|
651
|
+
if (!targetId) {
|
|
652
|
+
throw new Error('Failed to open a browser tab');
|
|
653
|
+
}
|
|
654
|
+
this.tabs.set(targetId, { id: targetId, url: '', title: '' });
|
|
655
|
+
await this.setActiveTab(targetId);
|
|
656
|
+
this.ready = true;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
|
|
660
|
+
const first = this.tabs.keys().next().value;
|
|
661
|
+
await this.setActiveTab(first);
|
|
662
|
+
}
|
|
663
|
+
else if (!this.activeCdp || !this.activeCdpSocket || !isOpen(this.activeCdpSocket)) {
|
|
664
|
+
await this.connectActiveCdp(this.activeTargetId);
|
|
665
|
+
}
|
|
666
|
+
this.ready = true;
|
|
667
|
+
}
|
|
668
|
+
async ensureActiveCdp() {
|
|
669
|
+
if (this.activeCdp && this.activeCdpSocket && isOpen(this.activeCdpSocket)) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (this.activeConnecting) {
|
|
673
|
+
return this.activeConnecting;
|
|
674
|
+
}
|
|
675
|
+
this.activeConnecting = (async () => {
|
|
676
|
+
if (!this.activeTargetId || !this.tabs.has(this.activeTargetId)) {
|
|
677
|
+
await this.ensureActiveTab();
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
await this.connectActiveCdp(this.activeTargetId);
|
|
681
|
+
}
|
|
682
|
+
})();
|
|
683
|
+
try {
|
|
684
|
+
await this.activeConnecting;
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
this.activeConnecting = null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async setActiveTab(targetId) {
|
|
691
|
+
this.activeGeneration += 1;
|
|
692
|
+
const generation = this.activeGeneration;
|
|
693
|
+
this.activeTargetId = targetId;
|
|
694
|
+
this.clearActiveFrame();
|
|
695
|
+
// Drop the stale page from every client while the new tab's first frame is
|
|
696
|
+
// still in flight.
|
|
697
|
+
this.broadcast({ type: 'clear' });
|
|
698
|
+
try {
|
|
699
|
+
await this.connectActiveCdp(targetId, generation);
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
if (generation === this.activeGeneration) {
|
|
703
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await this.broadcastStatus();
|
|
707
|
+
}
|
|
708
|
+
async connectActiveCdp(targetId, generation = this.activeGeneration) {
|
|
709
|
+
if (!this.port) {
|
|
710
|
+
throw new Error('Browser is not running');
|
|
711
|
+
}
|
|
712
|
+
this.disconnectActiveCdp();
|
|
713
|
+
const socket = new WebSocket(`ws://127.0.0.1:${this.port}/devtools/page/${targetId}`);
|
|
714
|
+
try {
|
|
715
|
+
await waitForSocketOpen(socket);
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
try {
|
|
719
|
+
socket.close();
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// ignore
|
|
723
|
+
}
|
|
724
|
+
throw error;
|
|
725
|
+
}
|
|
726
|
+
// A newer switch started while we were connecting — discard this one.
|
|
727
|
+
if (generation !== this.activeGeneration) {
|
|
728
|
+
try {
|
|
729
|
+
socket.close();
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// ignore
|
|
733
|
+
}
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
507
736
|
const cdp = new CdpConnection(socket);
|
|
508
|
-
cdp.onEvent = (message) => this.
|
|
509
|
-
this.
|
|
510
|
-
this.
|
|
737
|
+
cdp.onEvent = (message) => this.handleActiveEvent(message);
|
|
738
|
+
this.activeCdp = cdp;
|
|
739
|
+
this.activeCdpSocket = socket;
|
|
511
740
|
this.screencastStarted = false;
|
|
512
|
-
this.pageUrl = target.url || this.pageUrl;
|
|
513
|
-
this.pageTitle = target.title || this.pageTitle;
|
|
514
741
|
socket.on('close', () => {
|
|
515
|
-
if (this.
|
|
516
|
-
this.
|
|
517
|
-
this.
|
|
742
|
+
if (this.activeCdpSocket === socket) {
|
|
743
|
+
this.activeCdp = null;
|
|
744
|
+
this.activeCdpSocket = null;
|
|
518
745
|
this.screencastStarted = false;
|
|
519
746
|
}
|
|
520
747
|
});
|
|
521
748
|
await cdp.send('Page.enable');
|
|
522
749
|
await cdp.send('Runtime.enable');
|
|
750
|
+
await this.applyAntiDetection(cdp);
|
|
751
|
+
if (generation !== this.activeGeneration) {
|
|
752
|
+
this.disconnectActiveCdp();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
523
755
|
await this.applyViewport();
|
|
756
|
+
if (this.clients.size > 0) {
|
|
757
|
+
await this.startScreencast();
|
|
758
|
+
}
|
|
524
759
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
760
|
+
/**
|
|
761
|
+
* ヘッドレス Chrome がボット判定(Cloudflare の Managed Challenge / Turnstile 等)で
|
|
762
|
+
* 誤検知されるのを減らす。完全な突破は保証できないが、代表的な自動化シグナルを消す:
|
|
763
|
+
* - UA から "HeadlessChrome" を除去(実バージョンに一致させる)
|
|
764
|
+
* - navigator.webdriver / languages / chrome / plugins / WebGL ベンダを通常ブラウザ相当に
|
|
765
|
+
* 偽装スクリプトは addScriptToEvaluateOnNewDocument で「以後の」ページ生成時に毎回適用される。
|
|
766
|
+
*/
|
|
767
|
+
async applyAntiDetection(cdp) {
|
|
768
|
+
try {
|
|
769
|
+
// 実際のブラウザ版に一致した UA を使う(バージョン不一致自体が検知シグナルになるため)。
|
|
770
|
+
// ページセッションでは Browser.* が使えないことがあるので /json/version を優先。
|
|
771
|
+
let userAgent = '';
|
|
772
|
+
try {
|
|
773
|
+
const info = await fetchJson(`http://127.0.0.1:${this.port}/json/version`);
|
|
774
|
+
userAgent = (info['User-Agent'] || '').replace(/HeadlessChrome/gi, 'Chrome');
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// フォールバック: ページの navigator.userAgent を取得して整形。
|
|
778
|
+
const r = await cdp.send('Runtime.evaluate', {
|
|
779
|
+
expression: 'navigator.userAgent',
|
|
780
|
+
returnByValue: true,
|
|
781
|
+
});
|
|
782
|
+
userAgent = String(r.result?.value ?? '').replace(/HeadlessChrome/gi, 'Chrome');
|
|
783
|
+
}
|
|
784
|
+
const major = (userAgent.match(/Chrome\/(\d+)/) || [])[1] || '120';
|
|
785
|
+
if (userAgent) {
|
|
786
|
+
await cdp.send('Emulation.setUserAgentOverride', {
|
|
787
|
+
userAgent,
|
|
788
|
+
acceptLanguage: 'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
789
|
+
platform: 'Linux x86_64',
|
|
790
|
+
userAgentMetadata: {
|
|
791
|
+
brands: [
|
|
792
|
+
{ brand: 'Not_A Brand', version: '24' },
|
|
793
|
+
{ brand: 'Chromium', version: major },
|
|
794
|
+
{ brand: 'Google Chrome', version: major },
|
|
795
|
+
],
|
|
796
|
+
fullVersion: (userAgent.match(/Chrome\/([\d.]+)/) || [])[1] || `${major}.0.0.0`,
|
|
797
|
+
platform: 'Linux',
|
|
798
|
+
platformVersion: '',
|
|
799
|
+
architecture: 'x86',
|
|
800
|
+
model: '',
|
|
801
|
+
mobile: false,
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const stealthSource = `
|
|
806
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
807
|
+
if (!window.chrome) { window.chrome = { runtime: {} }; }
|
|
808
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['ja-JP', 'ja', 'en-US', 'en'] });
|
|
809
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
810
|
+
try {
|
|
811
|
+
const _q = window.navigator.permissions && window.navigator.permissions.query;
|
|
812
|
+
if (_q) {
|
|
813
|
+
window.navigator.permissions.query = (p) =>
|
|
814
|
+
p && p.name === 'notifications'
|
|
815
|
+
? Promise.resolve({ state: Notification.permission })
|
|
816
|
+
: _q(p);
|
|
817
|
+
}
|
|
818
|
+
} catch (e) { /* ignore */ }
|
|
819
|
+
try {
|
|
820
|
+
const getParam = WebGLRenderingContext.prototype.getParameter;
|
|
821
|
+
WebGLRenderingContext.prototype.getParameter = function (p) {
|
|
822
|
+
if (p === 37445) return 'Intel Inc.';
|
|
823
|
+
if (p === 37446) return 'Intel Iris OpenGL Engine';
|
|
824
|
+
return getParam.call(this, p);
|
|
825
|
+
};
|
|
826
|
+
} catch (e) { /* ignore */ }
|
|
827
|
+
`;
|
|
828
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: stealthSource });
|
|
528
829
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl);
|
|
532
|
-
if (page) {
|
|
533
|
-
return page;
|
|
830
|
+
catch {
|
|
831
|
+
// 反検知設定は best-effort。失敗してもブラウジング自体は続行する。
|
|
534
832
|
}
|
|
535
|
-
return fetchJson(`${baseUrl}/json/new?about:blank`, { method: 'PUT' });
|
|
536
833
|
}
|
|
537
834
|
async startScreencast() {
|
|
538
|
-
if (this.screencastStarted || !this.
|
|
835
|
+
if (this.screencastStarted || !this.activeCdp) {
|
|
539
836
|
return;
|
|
540
837
|
}
|
|
541
838
|
await this.applyViewport();
|
|
542
|
-
await this.
|
|
839
|
+
await this.activeCdp.send('Page.startScreencast', {
|
|
543
840
|
format: 'jpeg',
|
|
544
841
|
quality: 72,
|
|
545
842
|
maxWidth: this.viewport.width,
|
|
@@ -549,11 +846,11 @@ export class AgentBrowserService {
|
|
|
549
846
|
this.screencastStarted = true;
|
|
550
847
|
}
|
|
551
848
|
async stopScreencast() {
|
|
552
|
-
if (!this.screencastStarted || !this.
|
|
849
|
+
if (!this.screencastStarted || !this.activeCdp) {
|
|
553
850
|
return;
|
|
554
851
|
}
|
|
555
852
|
try {
|
|
556
|
-
await this.
|
|
853
|
+
await this.activeCdp.send('Page.stopScreencast');
|
|
557
854
|
}
|
|
558
855
|
catch {
|
|
559
856
|
// ignore
|
|
@@ -563,10 +860,10 @@ export class AgentBrowserService {
|
|
|
563
860
|
}
|
|
564
861
|
}
|
|
565
862
|
async applyViewport() {
|
|
566
|
-
if (!this.
|
|
863
|
+
if (!this.activeCdp) {
|
|
567
864
|
return;
|
|
568
865
|
}
|
|
569
|
-
await this.
|
|
866
|
+
await this.activeCdp.send('Emulation.setDeviceMetricsOverride', {
|
|
570
867
|
width: this.viewport.width,
|
|
571
868
|
height: this.viewport.height,
|
|
572
869
|
deviceScaleFactor: 1,
|
|
@@ -584,12 +881,12 @@ export class AgentBrowserService {
|
|
|
584
881
|
return;
|
|
585
882
|
}
|
|
586
883
|
this.viewport = next;
|
|
587
|
-
if (!this.
|
|
884
|
+
if (!this.activeCdp) {
|
|
588
885
|
return;
|
|
589
886
|
}
|
|
590
887
|
await this.applyViewport();
|
|
591
888
|
if (this.screencastStarted) {
|
|
592
|
-
await this.
|
|
889
|
+
await this.activeCdp.send('Page.stopScreencast').catch(() => undefined);
|
|
593
890
|
this.screencastStarted = false;
|
|
594
891
|
await this.startScreencast();
|
|
595
892
|
}
|
|
@@ -615,7 +912,19 @@ export class AgentBrowserService {
|
|
|
615
912
|
return;
|
|
616
913
|
}
|
|
617
914
|
await this.start();
|
|
618
|
-
|
|
915
|
+
if (message.type === 'new-tab') {
|
|
916
|
+
await this.newTab(message.url);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (message.type === 'close-tab') {
|
|
920
|
+
await this.closeTab(message.targetId);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (message.type === 'activate-tab') {
|
|
924
|
+
await this.activateTab(message.targetId);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
await this.ensureActiveCdp();
|
|
619
928
|
if (message.type === 'navigate') {
|
|
620
929
|
await this.navigate(message.url);
|
|
621
930
|
return;
|
|
@@ -636,9 +945,9 @@ export class AgentBrowserService {
|
|
|
636
945
|
}
|
|
637
946
|
}
|
|
638
947
|
async dispatchMouse(message) {
|
|
639
|
-
if (!this.
|
|
948
|
+
if (!this.activeCdp)
|
|
640
949
|
return;
|
|
641
|
-
await this.
|
|
950
|
+
await this.activeCdp.send('Input.dispatchMouseEvent', {
|
|
642
951
|
type: message.eventType,
|
|
643
952
|
x: coercePositiveInt(message.x, 0, 0, MAX_VIEWPORT.width),
|
|
644
953
|
y: coercePositiveInt(message.y, 0, 0, MAX_VIEWPORT.height),
|
|
@@ -650,10 +959,10 @@ export class AgentBrowserService {
|
|
|
650
959
|
});
|
|
651
960
|
}
|
|
652
961
|
async dispatchKey(message) {
|
|
653
|
-
if (!this.
|
|
962
|
+
if (!this.activeCdp)
|
|
654
963
|
return;
|
|
655
964
|
const windowsVirtualKeyCode = getWindowsVirtualKeyCode(message.key);
|
|
656
|
-
await this.
|
|
965
|
+
await this.activeCdp.send('Input.dispatchKeyEvent', {
|
|
657
966
|
type: message.eventType,
|
|
658
967
|
key: message.key,
|
|
659
968
|
code: message.code,
|
|
@@ -664,13 +973,14 @@ export class AgentBrowserService {
|
|
|
664
973
|
modifiers: message.modifiers ?? 0,
|
|
665
974
|
});
|
|
666
975
|
}
|
|
667
|
-
|
|
976
|
+
// Events from the active tab's page CDP connection.
|
|
977
|
+
handleActiveEvent(message) {
|
|
668
978
|
if (message.method === 'Page.screencastFrame') {
|
|
669
979
|
const params = message.params;
|
|
670
980
|
const data = typeof params.data === 'string' ? params.data : null;
|
|
671
981
|
const sessionId = typeof params.sessionId === 'number' ? params.sessionId : null;
|
|
672
982
|
if (sessionId != null) {
|
|
673
|
-
void this.
|
|
983
|
+
void this.activeCdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
|
|
674
984
|
}
|
|
675
985
|
if (data) {
|
|
676
986
|
const buffer = Buffer.from(data, 'base64');
|
|
@@ -682,32 +992,104 @@ export class AgentBrowserService {
|
|
|
682
992
|
if (message.method === 'Page.frameNavigated') {
|
|
683
993
|
const frame = (message.params?.frame ?? null);
|
|
684
994
|
if (frame && !frame.parentId && frame.url) {
|
|
685
|
-
this.
|
|
686
|
-
|
|
995
|
+
const tab = this.activeTargetId ? this.tabs.get(this.activeTargetId) : null;
|
|
996
|
+
if (tab) {
|
|
997
|
+
tab.url = frame.url;
|
|
998
|
+
}
|
|
999
|
+
void this.refreshActivePageInfo().finally(() => {
|
|
687
1000
|
void this.broadcastStatus();
|
|
688
1001
|
});
|
|
689
1002
|
}
|
|
690
1003
|
return;
|
|
691
1004
|
}
|
|
692
1005
|
if (message.method === 'Page.loadEventFired') {
|
|
693
|
-
void this.
|
|
1006
|
+
void this.refreshActivePageInfo().finally(() => {
|
|
694
1007
|
void this.broadcastStatus();
|
|
695
1008
|
});
|
|
696
1009
|
}
|
|
697
1010
|
}
|
|
698
|
-
|
|
699
|
-
|
|
1011
|
+
// Events from the browser-level CDP connection: tab lifecycle.
|
|
1012
|
+
handleBrowserEvent(message) {
|
|
1013
|
+
const method = message.method;
|
|
1014
|
+
if (method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') {
|
|
1015
|
+
const info = message.params?.targetInfo;
|
|
1016
|
+
if (info) {
|
|
1017
|
+
this.upsertTab(info);
|
|
1018
|
+
}
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (method === 'Target.targetDestroyed') {
|
|
1022
|
+
const targetId = typeof message.params?.targetId === 'string' ? message.params.targetId : null;
|
|
1023
|
+
if (targetId) {
|
|
1024
|
+
this.removeTab(targetId);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
upsertTab(info) {
|
|
1029
|
+
if (info.type !== 'page') {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const url = info.url ?? '';
|
|
1033
|
+
if (url.startsWith('devtools://')) {
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const prev = this.tabs.get(info.targetId);
|
|
1037
|
+
const isNew = !prev;
|
|
1038
|
+
const title = info.title ?? '';
|
|
1039
|
+
const changed = !prev || prev.url !== url || prev.title !== title;
|
|
1040
|
+
this.tabs.set(info.targetId, { id: info.targetId, url, title });
|
|
1041
|
+
if (isNew && this.ready && this.activeTargetId !== info.targetId) {
|
|
1042
|
+
// A page spawned a new tab/popup (target=_blank, window.open) — follow it
|
|
1043
|
+
// the way a normal browser does.
|
|
1044
|
+
void this.setActiveTab(info.targetId);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (changed) {
|
|
1048
|
+
void this.broadcastStatus();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
removeTab(targetId) {
|
|
1052
|
+
if (!this.tabs.has(targetId)) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const ids = [...this.tabs.keys()];
|
|
1056
|
+
const idx = ids.indexOf(targetId);
|
|
1057
|
+
this.tabs.delete(targetId);
|
|
1058
|
+
if (this.activeTargetId === targetId) {
|
|
1059
|
+
this.activeTargetId = null;
|
|
1060
|
+
this.disconnectActiveCdp();
|
|
1061
|
+
this.clearActiveFrame();
|
|
1062
|
+
this.broadcast({ type: 'clear' });
|
|
1063
|
+
const nextId = ids[idx + 1] ?? ids[idx - 1] ?? null;
|
|
1064
|
+
if (nextId && this.tabs.has(nextId)) {
|
|
1065
|
+
void this.setActiveTab(nextId);
|
|
1066
|
+
return; // setActiveTab broadcasts status
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
void this.broadcastStatus();
|
|
1070
|
+
}
|
|
1071
|
+
clearActiveFrame() {
|
|
1072
|
+
this.lastFrameBuffer = null;
|
|
1073
|
+
this.pendingFrames.clear();
|
|
1074
|
+
}
|
|
1075
|
+
async refreshActivePageInfo() {
|
|
1076
|
+
if (!this.activeCdp || !this.activeTargetId) {
|
|
700
1077
|
return;
|
|
701
1078
|
}
|
|
702
1079
|
try {
|
|
703
|
-
const result = await this.
|
|
1080
|
+
const result = await this.activeCdp.send('Runtime.evaluate', {
|
|
704
1081
|
expression: '({ title: document.title, url: location.href })',
|
|
705
1082
|
returnByValue: true,
|
|
706
1083
|
});
|
|
707
1084
|
const value = result.result?.value;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1085
|
+
const tab = this.tabs.get(this.activeTargetId);
|
|
1086
|
+
if (value && tab) {
|
|
1087
|
+
if (typeof value.title === 'string') {
|
|
1088
|
+
tab.title = value.title;
|
|
1089
|
+
}
|
|
1090
|
+
if (typeof value.url === 'string') {
|
|
1091
|
+
tab.url = value.url;
|
|
1092
|
+
}
|
|
711
1093
|
}
|
|
712
1094
|
}
|
|
713
1095
|
catch {
|
|
@@ -732,14 +1114,56 @@ export class AgentBrowserService {
|
|
|
732
1114
|
}
|
|
733
1115
|
broadcastBinary(buffer) {
|
|
734
1116
|
for (const socket of this.clients) {
|
|
735
|
-
this.
|
|
1117
|
+
this.queueFrameForClient(socket, buffer);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// Per-client, backpressure-aware frame delivery. A fast client (e.g. on
|
|
1121
|
+
// localhost) drains instantly and receives every frame; a slow client (remote
|
|
1122
|
+
// or mobile) has only the *newest* frame held and gets it once its send buffer
|
|
1123
|
+
// clears. This trades frame rate for latency so motion stays smooth instead of
|
|
1124
|
+
// accumulating a backlog that stutters and then bursts.
|
|
1125
|
+
queueFrameForClient(socket, buffer) {
|
|
1126
|
+
if (!isOpen(socket)) {
|
|
1127
|
+
this.pendingFrames.delete(socket);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (socket.bufferedAmount > FRAME_COALESCE_THRESHOLD) {
|
|
1131
|
+
this.pendingFrames.set(socket, buffer);
|
|
1132
|
+
this.ensureFrameFlushTimer();
|
|
1133
|
+
return;
|
|
736
1134
|
}
|
|
1135
|
+
this.pendingFrames.delete(socket);
|
|
1136
|
+
this.writeFrame(socket, buffer);
|
|
737
1137
|
}
|
|
738
|
-
|
|
1138
|
+
ensureFrameFlushTimer() {
|
|
1139
|
+
if (this.frameFlushTimer) {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
this.frameFlushTimer = setInterval(() => this.flushPendingFrames(), FRAME_FLUSH_INTERVAL_MS);
|
|
1143
|
+
this.frameFlushTimer.unref?.();
|
|
1144
|
+
}
|
|
1145
|
+
flushPendingFrames() {
|
|
1146
|
+
for (const [socket, buffer] of this.pendingFrames) {
|
|
1147
|
+
if (!isOpen(socket)) {
|
|
1148
|
+
this.pendingFrames.delete(socket);
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (socket.bufferedAmount > FRAME_COALESCE_THRESHOLD) {
|
|
1152
|
+
continue; // still draining; keep holding the newest frame
|
|
1153
|
+
}
|
|
1154
|
+
this.pendingFrames.delete(socket);
|
|
1155
|
+
this.writeFrame(socket, buffer);
|
|
1156
|
+
}
|
|
1157
|
+
if (this.pendingFrames.size === 0 && this.frameFlushTimer) {
|
|
1158
|
+
clearInterval(this.frameFlushTimer);
|
|
1159
|
+
this.frameFlushTimer = null;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
writeFrame(socket, buffer) {
|
|
739
1163
|
if (!isOpen(socket)) {
|
|
740
1164
|
return;
|
|
741
1165
|
}
|
|
742
|
-
//
|
|
1166
|
+
// Hard ceiling: a client this far behind is unrecoverable, so drop it.
|
|
743
1167
|
if (socket.bufferedAmount > MAX_CLIENT_BUFFERED_AMOUNT) {
|
|
744
1168
|
try {
|
|
745
1169
|
socket.close(1009, 'Browser stream overflow');
|
|
@@ -786,11 +1210,11 @@ export class AgentBrowserService {
|
|
|
786
1210
|
}
|
|
787
1211
|
}
|
|
788
1212
|
}
|
|
789
|
-
|
|
790
|
-
const cdp = this.
|
|
791
|
-
const socket = this.
|
|
792
|
-
this.
|
|
793
|
-
this.
|
|
1213
|
+
disconnectActiveCdp() {
|
|
1214
|
+
const cdp = this.activeCdp;
|
|
1215
|
+
const socket = this.activeCdpSocket;
|
|
1216
|
+
this.activeCdp = null;
|
|
1217
|
+
this.activeCdpSocket = null;
|
|
794
1218
|
this.screencastStarted = false;
|
|
795
1219
|
if (cdp) {
|
|
796
1220
|
cdp.close();
|
|
@@ -804,6 +1228,23 @@ export class AgentBrowserService {
|
|
|
804
1228
|
}
|
|
805
1229
|
}
|
|
806
1230
|
}
|
|
1231
|
+
closeBrowserCdp() {
|
|
1232
|
+
const cdp = this.browserCdp;
|
|
1233
|
+
const socket = this.browserCdpSocket;
|
|
1234
|
+
this.browserCdp = null;
|
|
1235
|
+
this.browserCdpSocket = null;
|
|
1236
|
+
if (cdp) {
|
|
1237
|
+
cdp.close();
|
|
1238
|
+
}
|
|
1239
|
+
else if (socket) {
|
|
1240
|
+
try {
|
|
1241
|
+
socket.close();
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
// ignore
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
807
1248
|
openLogStream() {
|
|
808
1249
|
this.closeLogStream();
|
|
809
1250
|
const logPath = path.join(this.outputDir, 'deckide-browser.log');
|