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
- const playwrightChrome = await findPlaywrightChromium();
105
- if (playwrightChrome) {
106
- return playwrightChrome;
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
- 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
- });
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
- cdp = null;
243
- cdpSocket = null;
244
- cdpConnecting = null;
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: this.pageUrl,
268
- pageTitle: this.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.ensureCdp();
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.closeCdp();
367
+ this.disconnectActiveCdp();
368
+ this.closeBrowserCdp();
306
369
  const proc = this.chromeProcess;
307
370
  this.chromeProcess = null;
308
371
  this.port = null;
309
- this.pageUrl = null;
310
- this.pageTitle = null;
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.ensureCdp();
363
- await this.cdp?.send('Page.navigate', { url });
364
- this.pageUrl = url;
365
- await this.refreshPageInfo();
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 = await getFreePort();
481
+ this.port = resolveCdpPort();
378
482
  this.openLogStream();
379
- const args = [
380
- '--headless=new',
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
- args.splice(args.length - 1, 0, '--no-sandbox');
505
+ chromeArgs.push('--no-sandbox');
393
506
  }
394
- const child = spawn(executable, args, {
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.closeCdp();
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.ensureCdp();
417
- await this.refreshPageInfo();
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.ensureCdp();
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.sendBinaryRaw(socket, this.lastFrameBuffer);
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
- async ensureCdp() {
470
- if (this.cdp && this.cdpSocket && isOpen(this.cdpSocket)) {
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.cdpConnecting) {
474
- return this.cdpConnecting;
597
+ if (this.browserCdpConnecting) {
598
+ return this.browserCdpConnecting;
475
599
  }
476
- this.cdpConnecting = this.connectCdp();
600
+ this.browserCdpConnecting = this.doConnectBrowserCdp();
477
601
  try {
478
- await this.cdpConnecting;
602
+ await this.browserCdpConnecting;
479
603
  }
480
604
  finally {
481
- this.cdpConnecting = null;
605
+ this.browserCdpConnecting = null;
482
606
  }
483
607
  }
484
- async connectCdp() {
608
+ async doConnectBrowserCdp() {
485
609
  if (!this.port) {
486
610
  throw new Error('Browser is not running');
487
611
  }
488
- const target = await this.getOrCreatePageTarget();
489
- if (!target.webSocketDebuggerUrl) {
490
- throw new Error('Chrome page target has no DevTools WebSocket URL');
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.closeCdp();
493
- const socket = new WebSocket(target.webSocketDebuggerUrl);
494
- await new Promise((resolve, reject) => {
495
- const timer = setTimeout(() => {
496
- reject(new Error('Timed out connecting to Chrome DevTools WebSocket'));
497
- }, 10_000);
498
- socket.once('open', () => {
499
- clearTimeout(timer);
500
- resolve();
501
- });
502
- socket.once('error', (error) => {
503
- clearTimeout(timer);
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.handleCdpEvent(message);
509
- this.cdp = cdp;
510
- this.cdpSocket = socket;
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.cdp === cdp) {
516
- this.cdp = null;
517
- this.cdpSocket = null;
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
- async getOrCreatePageTarget() {
526
- if (!this.port) {
527
- throw new Error('Browser is not running');
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
- const baseUrl = `http://127.0.0.1:${this.port}`;
530
- const targets = await fetchJson(`${baseUrl}/json/list`);
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.cdp) {
835
+ if (this.screencastStarted || !this.activeCdp) {
539
836
  return;
540
837
  }
541
838
  await this.applyViewport();
542
- await this.cdp.send('Page.startScreencast', {
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.cdp) {
849
+ if (!this.screencastStarted || !this.activeCdp) {
553
850
  return;
554
851
  }
555
852
  try {
556
- await this.cdp.send('Page.stopScreencast');
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.cdp) {
863
+ if (!this.activeCdp) {
567
864
  return;
568
865
  }
569
- await this.cdp.send('Emulation.setDeviceMetricsOverride', {
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.cdp) {
884
+ if (!this.activeCdp) {
588
885
  return;
589
886
  }
590
887
  await this.applyViewport();
591
888
  if (this.screencastStarted) {
592
- await this.cdp.send('Page.stopScreencast').catch(() => undefined);
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
- await this.ensureCdp();
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.cdp)
948
+ if (!this.activeCdp)
640
949
  return;
641
- await this.cdp.send('Input.dispatchMouseEvent', {
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.cdp)
962
+ if (!this.activeCdp)
654
963
  return;
655
964
  const windowsVirtualKeyCode = getWindowsVirtualKeyCode(message.key);
656
- await this.cdp.send('Input.dispatchKeyEvent', {
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
- handleCdpEvent(message) {
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.cdp?.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined);
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.pageUrl = frame.url;
686
- void this.refreshPageInfo().finally(() => {
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.refreshPageInfo().finally(() => {
1006
+ void this.refreshActivePageInfo().finally(() => {
694
1007
  void this.broadcastStatus();
695
1008
  });
696
1009
  }
697
1010
  }
698
- async refreshPageInfo() {
699
- if (!this.cdp) {
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.cdp.send('Runtime.evaluate', {
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
- if (value) {
709
- this.pageTitle = typeof value.title === 'string' ? value.title : this.pageTitle;
710
- this.pageUrl = typeof value.url === 'string' ? value.url : this.pageUrl;
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.sendBinaryRaw(socket, buffer);
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
- sendBinaryRaw(socket, buffer) {
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
- // Drop the slow client rather than letting frames pile up in memory.
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
- closeCdp() {
790
- const cdp = this.cdp;
791
- const socket = this.cdpSocket;
792
- this.cdp = null;
793
- this.cdpSocket = null;
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');