@glittering0717/ai-browser 1.2.1 → 1.2.2

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 CHANGED
@@ -11,7 +11,7 @@
11
11
  <img alt="MIT" src="https://img.shields.io/badge/license-MIT-blue.svg">
12
12
  <img alt="Electron" src="https://img.shields.io/badge/built%20with-Electron%2033-9cf">
13
13
  <img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%E2%80%A2%20Linux-blueviolet">
14
- <img alt="Tests" src="https://img.shields.io/badge/tests-411%20unit%20%2B%20409%20e2e-2ea043">
14
+ <img alt="Tests" src="https://img.shields.io/badge/tests-427%20unit%20%2B%20428%20e2e-2ea043">
15
15
  <img alt="MCP" src="https://img.shields.io/badge/spec-MCP%20(stdio)-f5b23b">
16
16
  </p>
17
17
 
@@ -565,9 +565,11 @@ npm run value # value truncation contract + read-only get_value — 15 ch
565
565
  npm run canvas # canvas draw-call capture — 13 checks
566
566
  npm run modeldiff # canvas app data-model diff (the "unchanged" false green) — 12 checks
567
567
  npm run audit # every advertised action must exist; wait/downloads/permissions/quit — 35 checks
568
+ npm run nav # address bar + load-failure error page (the "white screen" report) — 19 checks
568
569
  npm run focus # no focus stealing + full capability while backgrounded — 32 checks
569
570
  npm run interact # trusted drag / key press / wheel (pointer gestures) — 31 checks
570
571
  npm run view # viewport repaint + snapshot/diff verification — 28 checks
572
+ npm run e2e:all # 一次跑完上面全部套件(每个独立端口)+ 可信汇总 — 428 checks
571
573
  npm run realsites # navigate/read/act against a matrix of real sites
572
574
  ```
573
575
 
@@ -575,6 +577,16 @@ All e2e suites boot a genuine Electron against offline fixtures and exit non-zer
575
577
  Together they cover what jsdom structurally cannot: preload integrity, `ui.evaluate` under a strict
576
578
  CSP, CDP click hit-testing, multi-tab lifecycle, event fan-out, and the `evaluate` guard rails.
577
579
 
580
+ **Each suite proves it is talking to the browser it just started.** Before any assertion runs it
581
+ calls `ui.server_info` and compares the returned `pid` against the child process it spawned; a
582
+ mismatch aborts with exit 4 instead of quietly producing results about somebody else's instance.
583
+ That gap was measured, not assumed: with an impostor WebSocket server holding the port, a suite
584
+ happily sent `ui.new_tab` / `ui.evaluate` / `ui.get_tree` to it and never noticed. Repro:
585
+ `REPRO_PORT=9799 node e2e/_repro_identity.cjs`.
586
+
587
+ `npm run e2e:all` also gives every suite its own port, because they share `/tmp/ai-browser-e2e-<port>`
588
+ as the profile and therefore Electron's single-instance lock too.
589
+
578
590
  They all need the WS port free — stop a running `npm start` first, or point them elsewhere:
579
591
 
580
592
  ```bash
@@ -635,7 +647,13 @@ Two things it changed in the product:
635
647
  the page through `ui.evaluate`.
636
648
 
637
649
  **`npm run network`** — that a POST can be captured with its full request body, that headers are
638
- redacted by default, and that bodies paginate instead of being silently truncated.
650
+ redacted by default, and that bodies paginate instead of being silently truncated. It also pins the
651
+ eviction rule: the retained byte budget is **global across tabs**, so a background tab quietly
652
+ filling it used to evict the body the agent was about to read on the *active* tab — the request
653
+ really happened and the body really was captured, it just got pushed out by someone else's noise.
654
+ Eviction now sacrifices non-active tabs first (including the background body being stored), and
655
+ `ui.network_configure` reports `active_tab` / `body_bytes` so a future `evicted` can be attributed
656
+ instead of guessed at.
639
657
 
640
658
  **`npm run modeldiff`** — the suite for the one place `ui.diff` used to lie. A canvas-rendered app's
641
659
  DOM is a still photograph: dragging a node or editing a widget repaints `<canvas>` and moves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
5
5
  "main": "src/main/index.js",
6
6
  "bin": {
@@ -65,7 +65,9 @@
65
65
  "realsites:meta": "vitest run tests/test_realsites_sites.js",
66
66
  "mcp": "node src/main/mcp_server.js",
67
67
  "modeldiff": "node e2e/modeldiff_ws.cjs",
68
- "audit": "node e2e/audit_ws.cjs"
68
+ "nav": "node e2e/nav_ws.cjs",
69
+ "audit": "node e2e/audit_ws.cjs",
70
+ "e2e:all": "node e2e/run_all.cjs"
69
71
  },
70
72
  "dependencies": {
71
73
  "@modelcontextprotocol/sdk": "^1.29.0",
package/src/main/index.js CHANGED
@@ -60,6 +60,8 @@ let wsServer = null;
60
60
  let tabBarView = null;
61
61
  let pageManager = null;
62
62
  let tabRefreshInterval = null;
63
+ let navStateInterval = null;
64
+ let navStateWc = null;
63
65
  let networkWatchdog = null;
64
66
  let isQuitting = false;
65
67
 
@@ -110,15 +112,25 @@ function createWindow() {
110
112
  pageManager = PageManager.getInstance(mainWindow);
111
113
  pageManager._preloadPath = path.join(__dirname, '../preload/bridge.cjs');
112
114
 
113
- // Create first tab
114
- pageManager.newTab('https://www.baidu.com');
115
+ // Create first tab.
116
+ //
117
+ // 默认页用 baidu.com:国内可达、无登录墙、重定向链短。但注意它**不是**必须的 ——
118
+ // 用户报"打开 ai-browser 白屏"时,我们实测过两种独立成因(见 showInitialWindow
119
+ // 的注释),跟默认页选谁无关;换成 about:blank 只会把"加载失败的白屏"变成
120
+ // "空白页的白屏",问题更大(连错误提示都没有)。
121
+ //
122
+ // 可由 AI_BROWSER_START_URL 覆盖;设为空字符串则开空白页。
123
+ const START_URL = process.env.AI_BROWSER_START_URL !== undefined
124
+ ? (process.env.AI_BROWSER_START_URL || null)
125
+ : 'https://www.baidu.com';
126
+ pageManager.newTab(START_URL);
115
127
 
116
128
  // Start WS server — pass cleanupAndQuit so ui.quit can shut down the app
117
129
  wsServer = startWSServer(pageManager, PORT, cleanupAndQuit);
118
130
 
119
131
  // === Tab bar IPC ===
120
132
  ipcMain.on('tab:new', () => {
121
- pageManager.newTab('https://www.baidu.com');
133
+ pageManager.newTab(START_URL);
122
134
  refreshTabBar();
123
135
  });
124
136
  ipcMain.on('tab:close', (_e, tabId) => {
@@ -131,6 +143,41 @@ function createWindow() {
131
143
  });
132
144
  ipcMain.handle('tab:list', () => pageManager.listTabs());
133
145
 
146
+ // === 地址栏 / 导航按钮 ===
147
+ ipcMain.handle('nav:state', () => {
148
+ const wc = activeWebContents();
149
+ if (!wc) return { tab: null, url: '', canBack: false, canForward: false, loading: false, status: '' };
150
+ return {
151
+ tab: pageManager.activeTab,
152
+ url: wc.getURL(),
153
+ canBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
154
+ canForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
155
+ loading: wc.isLoading(),
156
+ status: wc.isLoading() ? '加载中…' : '',
157
+ };
158
+ });
159
+ ipcMain.on('nav:go', (_e, input) => {
160
+ const wc = activeWebContents();
161
+ const url = normalizeUrlInput(input);
162
+ if (!wc || !url) return;
163
+ try { wc.loadURL(url); } catch (e) { console.error('[index] nav:go failed:', e.message); }
164
+ });
165
+ ipcMain.on('nav:back', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goBack(); });
166
+ ipcMain.on('nav:forward', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goForward(); });
167
+ ipcMain.on('nav:reload', () => { const wc = activeWebContents(); if (wc) wc.reload(); });
168
+ ipcMain.on('nav:stop', () => { const wc = activeWebContents(); if (wc) wc.stop(); });
169
+ // 错误页里的重试 / 返回(错误页是我们自己的 renderer,同样走 ipc)
170
+ ipcMain.on('error-page:navigate', (_e, input) => {
171
+ const wc = activeWebContents();
172
+ const url = normalizeUrlInput(input);
173
+ if (!wc || !url) return;
174
+ try { wc.loadURL(url); } catch (e) { console.error('[index] error-page navigate failed:', e.message); }
175
+ });
176
+ ipcMain.on('error-page:back', () => {
177
+ const wc = activeWebContents();
178
+ if (wc && wc.navigationHistory) wc.navigationHistory.goBack();
179
+ });
180
+
134
181
  // Intercept new-window / window.open → new tab instead of new window
135
182
  ipcMain.on('tab:new-url', (_e, url) => {
136
183
  pageManager.newTab(url);
@@ -188,6 +235,32 @@ function createWindow() {
188
235
  // Refresh tab bar periodically
189
236
  tabRefreshInterval = setInterval(refreshTabBar, 2000);
190
237
 
238
+ // === 地址栏状态跟随 ===
239
+ // 导航可能来自三方:人在地址栏输入、agent 调 ui.navigate、页面自己跳转。
240
+ // 地址栏必须反映**真实**状态,所以这里订阅激活 tab 的 webContents 事件主动推,
241
+ // 而不是靠定时轮询(轮询有最多 2s 的滞后,人会以为按钮坏了)。
242
+ const wireNavState = (tabId) => {
243
+ const wc = activeWebContents();
244
+ if (!wc) return;
245
+ if (navStateWc === wc) return; // 同一个 tab 重复 setActive,不重复挂
246
+ navStateWc = wc;
247
+ const push = () => pushNavState();
248
+ wc.on('did-start-loading', push);
249
+ wc.on('did-stop-loading', push);
250
+ wc.on('did-navigate', push);
251
+ wc.on('did-navigate-in-page', push);
252
+ wc.on('page-title-updated', push);
253
+ };
254
+ const origSetActive = pageManager.setActive.bind(pageManager);
255
+ pageManager.setActive = function (tabId) {
256
+ origSetActive(tabId);
257
+ wireNavState(tabId);
258
+ pushNavState();
259
+ };
260
+ wireNavState(pageManager.activeTab);
261
+ // 兜底轮询:只覆盖上面事件没抓到的情况(低频,成本可忽略)
262
+ navStateInterval = setInterval(pushNavState, 1500);
263
+
191
264
  // === #5: NetworkService self-heal watchdog ===
192
265
  // The Electron NetworkService can wedge as a whole (port stays listening but
193
266
  // every ui.navigate fails ERR_FAILED while curl works fine). A clean relaunch
@@ -209,6 +282,12 @@ function createWindow() {
209
282
  * 焦点,人可以继续在原来的窗口里做事;人手动 `npm start` 时窗口照常显示在前台。
210
283
  *
211
284
  * AI_BROWSER_FOCUS=always / never 可整体覆盖(见 page_manager._focusWindow)。
285
+ *
286
+ * 另外:**等首屏 DOM 就绪再 show**。2026-10-14 实测偶发(12 次启动里 1 次)
287
+ * 窗口已显示但 document.body 还是 null、页面整个是白的 —— 用户报的"打开
288
+ * ai-browser 白屏"其中一类就是这个:不是连接错(网络是通的,DNS 也解析了),
289
+ * 是窗口在渲染器还没画出第一帧时就显示了。等到 dom-ready(或最多 3s 兜底)
290
+ * 再显示,窗口一出现就是有内容的。
212
291
  */
213
292
  function showInitialWindow() {
214
293
  if (!mainWindow) return;
@@ -219,12 +298,85 @@ function showInitialWindow() {
219
298
  // 策略只决定**显示时抢不抢焦点**:只有人手动启动、或显式 always,才显示并激活;
220
299
  // agent 拉起(或 never)走 showInactive —— 窗口出现,但键盘焦点留在用户那边。
221
300
  const shouldActivate = policy === 'always' || (policy !== 'never' && !byAgent);
222
- try {
223
- if (shouldActivate) mainWindow.show();
224
- else mainWindow.showInactive();
225
- } catch (e) {
226
- try { mainWindow.show(); } catch (e2) { /* 窗口已销毁 */ }
227
- }
301
+
302
+ const doShow = () => {
303
+ if (!mainWindow || mainWindow.isDestroyed()) return;
304
+ try {
305
+ if (shouldActivate) mainWindow.show();
306
+ else mainWindow.showInactive();
307
+ } catch (e) {
308
+ try { mainWindow.show(); } catch (e2) { /* 窗口已销毁 */ }
309
+ }
310
+ };
311
+
312
+ // 等首屏内容就绪。上限 3s:网络再慢也不该让窗口迟迟不出现(人看不到窗口
313
+ // 比看到白屏更糟),超时就先显示,剩下的交给 did-fail-load 错误页兜底。
314
+ const firstView = pageManager && pageManager.tabs.get(pageManager.activeTab);
315
+ const wc = firstView && firstView.webContents;
316
+ if (!wc || wc.isDestroyed()) { doShow(); return; }
317
+ let shown = false;
318
+ // reason: 为什么在这个时刻显示。必须如实记录 —— 之前 waited_for_dom 恒为
319
+ // true(3s 兜底显示时也报 true),于是"到底等到了 dom-ready 没有"看不出来,
320
+ // 白屏缺陷和"我们主动选择先显示"被混成同一种结果。
321
+ const showOnce = (reason) => {
322
+ if (shown) return;
323
+ shown = true;
324
+ // 同步先落一条记录:哪怕 renderer 还没就绪(executeJavaScript 会 reject),
325
+ // "窗口在这一刻显示了、而页面是空的"这个事实本身也必须留下 —— 那正是要抓的
326
+ // 白屏形态。若等 Promise 成功才记,renderer 起不来时记录永远是 null,
327
+ // 观测口在最需要它的时候失灵。
328
+ const rec = {
329
+ tab: pageManager.activeTab,
330
+ body_len: null,
331
+ ready_state: null,
332
+ // 'dom-ready' | 'fail-load' | 'renderer-ready' | 'timeout'
333
+ show_reason: reason || 'unknown',
334
+ // 真话:只有 3s 兜底(renderer 迟迟没到)才是 false。
335
+ waited_for_dom: reason !== 'timeout',
336
+ renderer_ready: false,
337
+ };
338
+ pageManager.windowShownAt = rec;
339
+ // 再把"窗口在这一刻显示、且此刻页面是什么状态"广播出去。
340
+ // 为什么值得多这一个事件:白屏类缺陷的根因是"窗口显示了但渲染器还没画出
341
+ // 第一帧",而这只能从窗口侧观测 —— WS 端口就绪不等于窗口已显示(端口在
342
+ // createWindow 里就开了,比 showInitialWindow 早),所以从 agent 侧 polling
343
+ // 永远测不到这个时序。有了这个事件,e2e 才能断言"显示的瞬间页面已有内容"。
344
+ // 读数走 pageManager.evaluate(CDP Runtime.evaluate),不用
345
+ // webContents.executeJavaScript:高层 API 在 renderer 尚未完全就绪时会 reject
346
+ // (实测禁硬件加速 + 首次创建 GL context 失败时,dom-ready 已到但
347
+ // executeJavaScript 仍失败,而页面随后照常加载、CDP 读数正常)。
348
+ // 观测口必须能在最需要它的时候读数,否则等于没有。
349
+ const readReady = () => pageManager.evaluate(
350
+ '(function(){var b=document.body;return JSON.stringify({body:b?b.innerHTML.length:-1,ready:document.readyState});})()',
351
+ pageManager.activeTab
352
+ );
353
+ Promise.resolve()
354
+ .then(() => readReady())
355
+ .then((v) => {
356
+ rec.renderer_ready = true;
357
+ try {
358
+ const p = JSON.parse(v);
359
+ rec.body_len = p.body;
360
+ rec.ready_state = p.ready;
361
+ } catch (e) { /* 非预期返回,留 null */ }
362
+ try { pageManager._broadcast('window_shown', rec); } catch (e) { /* 无订阅者 */ }
363
+ })
364
+ .catch(() => {
365
+ // 真的读不到(renderer 始终没起来)—— 这本身就是要抓的白屏形态,
366
+ // 记录里 renderer_ready 保持 false,让 e2e 看得见。
367
+ });
368
+ doShow();
369
+ };
370
+ wc.once('dom-ready', () => showOnce('dom-ready'));
371
+ wc.once('did-fail-load', () => showOnce('fail-load')); // 失败也要显示(错误页会接手)
372
+ setTimeout(() => showOnce('timeout'), 3000);
373
+ // dom-ready 可能在上面注册**之前**就已经触发过(缓存命中时 baidu 首屏
374
+ // 一到就 ready,而 showInitialWindow 是 createWindow 的最后一步)——
375
+ // once() 收不到已经过去的事件,那就只能干等 3s 兜底。先主动查一次
376
+ // readyState(走 CDP,见 showOnce 里的说明),已经就绪就立刻显示。
377
+ pageManager.evaluate('(function(){return document.readyState;})()', pageManager.activeTab)
378
+ .then((rs) => { if (rs && rs !== 'loading') showOnce('renderer-ready'); })
379
+ .catch(() => { /* renderer 未就绪,等事件 */ });
228
380
  }
229
381
 
230
382
  // Probe one target through the NetworkService from the main process. Reuses
@@ -312,6 +464,56 @@ function refreshTabBar() {
312
464
  }
313
465
  }
314
466
 
467
+ // === 地址栏 / 前进 / 后退 / 刷新 / 停止 ===
468
+ //
469
+ // 为什么必须有:窗口是真实可见的普通窗口,产品承诺人可以随时手动介入
470
+ // (登录、扫码、过滑块、纠正 agent 的操作)。但原先窗口里只有 tab 条,
471
+ // 没有地址栏也没有任何导航按钮 —— 人要介入时连"打开某个页面"都做不到,
472
+ // 只能干等 agent。用户原话:"现在有时候需要我操作一下,比如登录验证码等,
473
+ // 页面都打不开,非常难受。"
474
+ //
475
+ // 这里的每一行都只操作 activeTab 的 webContents,不碰页面内容,也不引入
476
+ // 任何人机协作状态机 —— 就是普通浏览器的地址栏。
477
+
478
+ /** 当前激活 tab 的 webContents,没有则 null。 */
479
+ function activeWebContents() {
480
+ if (!pageManager) return null;
481
+ const view = pageManager.tabs.get(pageManager.activeTab);
482
+ if (!view || view.webContents.isDestroyed()) return null;
483
+ return view.webContents;
484
+ }
485
+
486
+ /** 把地址栏需要的一切状态推给 tab bar renderer。 */
487
+ function pushNavState() {
488
+ if (!tabBarView || tabBarView.webContents.isDestroyed()) return;
489
+ const wc = activeWebContents();
490
+ const state = wc ? {
491
+ tab: pageManager.activeTab,
492
+ url: wc.getURL(),
493
+ canBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
494
+ canForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
495
+ loading: wc.isLoading(),
496
+ status: wc.isLoading() ? '加载中…' : '',
497
+ } : { tab: null, url: '', canBack: false, canForward: false, loading: false, status: '' };
498
+ tabBarView.webContents.executeJavaScript(
499
+ 'window.__onNavState && window.__onNavState(' + JSON.stringify(state) + ')'
500
+ ).catch(() => {});
501
+ }
502
+
503
+ /**
504
+ * 把用户输入解析成可加载的 URL。
505
+ * 只做最小处理:带协议的直接用;否则按 https 猜;纯查询词不进这里
506
+ * (地址栏输入什么就打开什么,不做搜索跳转 —— 那会替用户做决定)。
507
+ */
508
+ function normalizeUrlInput(input) {
509
+ const s = String(input || '').trim();
510
+ if (!s) return null;
511
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return s; // http:// https:// file:// …
512
+ if (/^(about|file|data):/i.test(s)) return s;
513
+ if (/^localhost(:\d+)?(\/|$)/i.test(s)) return 'http://' + s;
514
+ return 'https://' + s;
515
+ }
516
+
315
517
  app.whenReady().then(() => {
316
518
  createWindow();
317
519
  // 下载与权限的 session 级钩子。必须装在 will-download 上,否则 Electron 默认
@@ -165,10 +165,22 @@ export class NetworkMonitor {
165
165
  this.captureEnabled = opts.captureEnabled !== undefined ? opts.captureEnabled : envFlag('AI_BROWSER_NETWORK_CAPTURE', true);
166
166
  this.sensitiveAllowed = opts.sensitiveAllowed !== undefined ? opts.sensitiveAllowed : envFlag('AI_BROWSER_NETWORK_SENSITIVE', false);
167
167
  this.globalCaptureBodies = 'api';
168
+ // 当前活跃 tab(由 page_manager 在 setActive / closeTab 时同步进来)。
169
+ // 只被 body 淘汰顺序用:null 表示不知道/没有活跃 tab,此时退回纯按新旧。
170
+ // 注意它可以是 null,所以下面 _enforceBodyLimit 里判等不能假设非空。
171
+ this.activeTabId = null;
168
172
  }
169
173
 
170
174
  // ==== 配置 ============================================================
171
175
 
176
+ /**
177
+ * 告知活跃 tab。page_manager 是唯一调用方(setActive / closeTab 换活跃页时)。
178
+ * 传 null = 没有活跃 tab(最后一个 tab 关掉了)。
179
+ */
180
+ setActiveTab(tabId) {
181
+ this.activeTabId = tabId == null ? null : tabId;
182
+ }
183
+
172
184
  setBodyFetcher(tabId, fn) { this._fetchers.set(tabId, fn); }
173
185
  clearBodyFetcher(tabId) { this._fetchers.delete(tabId); }
174
186
 
@@ -204,16 +216,36 @@ export class NetworkMonitor {
204
216
 
205
217
  _enforceRecordLimit(protectId) {
206
218
  while (this._records.size > this.limits.maxRecords) {
207
- let victim = null;
208
- for (const id of this._order) {
209
- if (id === protectId) continue;
210
- victim = id; break;
211
- }
219
+ const victim = this._evictionVictim(protectId);
212
220
  if (!victim) break;
213
221
  this._dropRecord(victim);
214
222
  }
215
223
  }
216
224
 
225
+ // 淘汰候选:**非活跃 tab 的最旧一条**优先;一条都没有(全是活跃 tab 的,或
226
+ // 根本不知道活跃 tab)才退到全局最旧。
227
+ //
228
+ // 为什么两处淘汰都要挑 tab:全局上限(记录数 / body 字节)是跨 tab 共享的,
229
+ // 而后台 tab 会持续累积。实测默认页 baidu.com 挂久了之后,agent 正在操作的
230
+ // 那个 tab 的 POST 请求体被挤成 evicted(e2e/network_ws.cjs N-04/N-05 红)——
231
+ // 请求确实发生了、body 也确实拿到过,只是被后台噪声顶掉,agent 读到的是
232
+ // evicted 而不是内容。让活跃 tab 有优先保留权,后台 tab 超限时先牺牲自己。
233
+ //
234
+ // activeTabId 为 null(无活跃 tab / 单测未接线)时,`tab_id !== null` 恒真,
235
+ // 直接返回全局最旧 —— 与改动前完全一致。
236
+ _evictionVictim(protectId) {
237
+ const active = this.activeTabId;
238
+ let fallback = null;
239
+ for (const id of this._order) {
240
+ if (id === protectId) continue;
241
+ const rec = this._records.get(id);
242
+ if (!rec) continue;
243
+ if (fallback === null) fallback = id;
244
+ if (rec.tab_id !== active) return id;
245
+ }
246
+ return fallback;
247
+ }
248
+
217
249
  _dropRecord(networkId) {
218
250
  const rec = this._records.get(networkId);
219
251
  if (!rec) return;
@@ -225,25 +257,35 @@ export class NetworkMonitor {
225
257
  this._droppedRecords += 1;
226
258
  }
227
259
 
228
- // body 超限:优先淘汰最旧的 response body,其次 request body;metadata 保留。
260
+ // body 超限淘汰:先 response body 后 request body(同一次超限里),同一种 body
261
+ // 内部按 _order 由旧到新。**并且先淘汰非活跃 tab 的**(见 _evictionVictim 的
262
+ // 注释:全局 32MB 配额跨 tab 共享,后台 tab 会把活跃 tab 的 body 顶成 evicted)。
263
+ // metadata 不淘汰,只丢 body 内容,记录仍然查得到。
264
+ //
265
+ // 单 tab 场景下行为不变(活跃 tab 就是唯一的 tab,排序等价于纯按新旧)。
229
266
  _enforceBodyLimit(protectId) {
230
267
  if (this._bodyBytes <= this.limits.maxBodyBytes) return;
231
- for (const pass of ['response', 'request']) {
232
- for (const id of this._order) {
233
- if (this._bodyBytes <= this.limits.maxBodyBytes) return;
234
- if (id === protectId) continue;
235
- const rec = this._records.get(id);
236
- if (!rec) continue;
237
- if (pass === 'response' && rec._respBody) {
238
- this._bodyBytes -= rec._respBodyBytes || 0;
239
- rec._respBody = null; rec._respBodyBytes = 0;
240
- rec.response_body_state = 'evicted';
241
- rec._respEvicted = true;
242
- } else if (pass === 'request' && rec._reqBody) {
243
- this._bodyBytes -= rec._reqBodyBytes || 0;
244
- rec._reqBody = null; rec._reqBodyBytes = 0;
245
- rec.request_body_state = 'evicted';
246
- rec._reqEvicted = true;
268
+ const active = this.activeTabId;
269
+ // 两轮:先把非活跃 tab 的 body 淘汰掉,仍然超限才动活跃 tab 自己的。
270
+ for (const phase of ['background', 'active']) {
271
+ for (const pass of ['response', 'request']) {
272
+ for (const id of this._order) {
273
+ if (this._bodyBytes <= this.limits.maxBodyBytes) return;
274
+ if (id === protectId) continue;
275
+ const rec = this._records.get(id);
276
+ if (!rec) continue;
277
+ if (phase === 'background' ? rec.tab_id === active : rec.tab_id !== active) continue;
278
+ if (pass === 'response' && rec._respBody) {
279
+ this._bodyBytes -= rec._respBodyBytes || 0;
280
+ rec._respBody = null; rec._respBodyBytes = 0;
281
+ rec.response_body_state = 'evicted';
282
+ rec._respEvicted = true;
283
+ } else if (pass === 'request' && rec._reqBody) {
284
+ this._bodyBytes -= rec._reqBodyBytes || 0;
285
+ rec._reqBody = null; rec._reqBodyBytes = 0;
286
+ rec.request_body_state = 'evicted';
287
+ rec._reqEvicted = true;
288
+ }
247
289
  }
248
290
  }
249
291
  }
@@ -283,7 +325,19 @@ export class NetworkMonitor {
283
325
  rec.response_body_state = overCap ? 'partial' : 'captured';
284
326
  }
285
327
  this._bodyBytes += bytes;
286
- this._enforceBodyLimit(rec.network_id);
328
+ // "刚存进来的这条不在本轮淘汰里"是给活跃 tab 的特权,不是普适规则。
329
+ //
330
+ // 后台 tab 的 body 没有这个权利:否则一条 40KB 的后台响应体会先把活跃
331
+ // tab 里的小 body 全部挤掉(实测 baidu 默认页的一条响应把活跃 tab 上
332
+ // "GET-PAYLOAD-OK" 这 28 字节的响应顶成 evicted —— e2e/network_ws.cjs
333
+ // N-12 间歇红)。让它自己先被牺牲,活跃 tab 的 body 就不受影响:后台的
334
+ // body 反正没人看,而"活跃 tab 里明明还在配额内的小 body 消失了"是硬伤。
335
+ //
336
+ // activeTabId == null(没接线 / 单 tab)时一律按活跃处理 —— 保持改动前的
337
+ // 行为,不让新增逻辑在没有 tab 语义的场景里改变语义。
338
+ const active = this.activeTabId;
339
+ const isActive = active == null || rec.tab_id === active;
340
+ this._enforceBodyLimit(isActive ? rec.network_id : null);
287
341
  return body;
288
342
  }
289
343
 
@@ -522,6 +576,10 @@ export class NetworkMonitor {
522
576
  max_records: this.limits.maxRecords,
523
577
  body_bytes: this._bodyBytes,
524
578
  max_body_bytes: this.limits.maxBodyBytes,
579
+ // 淘汰优先级看这个:非活跃 tab 的 body / 记录先被牺牲。null = 不知道活跃
580
+ // tab(此时按纯新旧淘汰)。暴露出来是因为"我的 body 怎么被 evicted 了"
581
+ // 这类问题只能靠它定位 —— 曾经它就是个黑盒。
582
+ active_tab: this.activeTabId,
525
583
  };
526
584
  }
527
585
 
@@ -531,6 +589,11 @@ export class NetworkMonitor {
531
589
  enabled: !!cfg.enabled,
532
590
  capture_bodies: cfg.capture_bodies,
533
591
  since: this._startedAt,
592
+ // 当前全局占用与淘汰优先级。body 被 evicted 时,先看这里就能分清是
593
+ // "自己这个 tab 太多了"还是"后台 tab 把自己顶了"。
594
+ body_bytes: this._bodyBytes,
595
+ max_body_bytes: this.limits.maxBodyBytes,
596
+ active_tab: this.activeTabId,
534
597
  limits: {
535
598
  max_records: this.limits.maxRecords,
536
599
  max_body_bytes: this.limits.maxBodyBytes,
@@ -2,6 +2,8 @@
2
2
  // One Electron process, one WS server, multiple tabs.
3
3
  // Agent routes via tab ID. Tab 0 is default.
4
4
  import { ipcMain, BrowserView } from 'electron';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { config } from '../shared/config.js';
6
8
  import axExtractor from './axExtractor.js';
7
9
  import { NetworkMonitor, MAX_POST_DATA_SIZE } from './network_monitor.js';
@@ -15,6 +17,9 @@ import valueContract from '../shared/value_contract.cjs';
15
17
 
16
18
  const { expandValueFetchTree } = valueContract;
17
19
 
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = path.dirname(__filename);
22
+
18
23
  // canvas 绘制调用记录:默认开。关闭开关 AI_BROWSER_CANVAS_HOOK=0。
19
24
  const CANVAS_BRIDGE_KEY = '__aiCanvasBridge';
20
25
 
@@ -103,6 +108,11 @@ class PageManager {
103
108
  this.tabs = new Map(); // tabId -> BrowserView
104
109
  this.activeTab = null;
105
110
  this.tabCounter = 0;
111
+ // 窗口首次显示时的那一刻状态(tab / body_len / ready_state)。
112
+ // 白屏类缺陷的根因是"窗口显示了但渲染器还没画出第一帧",而这只能从窗口侧
113
+ // 观测 —— WS 端口在 createWindow 中途就开了,比 showInitialWindow 早。
114
+ // 由 index.js 的 showInitialWindow 填写,ui.window_shown 读取。
115
+ this.windowShownAt = null;
106
116
  this.subscriptions = new Map();
107
117
  this._pendingRequests = new Map();
108
118
  this._requestId = 0;
@@ -259,7 +269,12 @@ class PageManager {
259
269
 
260
270
  _layoutAllViews(bounds) {
261
271
  for (const [id, view] of this.tabs) {
262
- view.setBounds({ x: 0, y: 36, width: bounds.width, height: bounds.height - 36 });
272
+ view.setBounds({
273
+ x: 0,
274
+ y: config.tabBarHeight,
275
+ width: bounds.width,
276
+ height: bounds.height - config.tabBarHeight,
277
+ });
263
278
  }
264
279
  }
265
280
 
@@ -444,13 +459,34 @@ class PageManager {
444
459
  // loadURL resolves at did-finish-load, which is exactly when the old
445
460
  // pixels may still be on screen — repaint here as well as from the event
446
461
  // hooks, so a programmatic navigate never depends on hook timing.
462
+ //
463
+ // 失败时的返回值要说清楚**发生了什么**,而不是笼统的 ok:false。因为
464
+ // did-fail-load 现在会把页面换成我们自己的错误页(见
465
+ // _attachLoadFailureHooks),那次 loadURL(error_page) 会以 ERR_ABORTED
466
+ // 打断当前这次 loadURL 的 Promise —— 如果不区分,agent 收到的就是
467
+ // {ok:false, error:"ERR_ABORTED (-3) loading 'file://.../error_page.html'"},
468
+ // 它会以为自己的导航请求坏了,而实际上错误页已经接管、信息全在那儿。
447
469
  try {
448
470
  await withDeadline(view.webContents.loadURL(url), 30000, 'navigate');
449
471
  } catch (e) {
472
+ const msg = String((e && e.message) || e);
473
+ // 错误页已接管:这不是调用方的错,是目标站点不可达。
474
+ // 两个判据(都不可省):_failureGuard 里还有这个 tab(错误页刚加载、
475
+ // did-finish-load 还没来清标记),或者当前 URL 已经是我们的错误页。
476
+ const onErrorPage = String(view.webContents.getURL() || '').indexOf('error_page.html') >= 0;
477
+ if ((this._failureGuard && this._failureGuard.has(tid)) || onErrorPage) {
478
+ return {
479
+ ok: false,
480
+ error: msg,
481
+ error_page: true,
482
+ hint: '目标站点加载失败,已显示错误页(可重试 / 改网址)。'
483
+ + ' 失败详情在页面里,用 ui.get_tree 或 ui.evaluate 读取。',
484
+ };
485
+ }
450
486
  if (e && e.__deadline) {
451
487
  return { ok: false, error: `${e.message} — the page never finished loading; it may still be loading in the background` };
452
488
  }
453
- return { ok: false, error: String((e && e.message) || e).slice(0, 300) };
489
+ return { ok: false, error: msg.slice(0, 300) };
454
490
  }
455
491
  this.repaint(tid);
456
492
  return { ok: true };
@@ -1952,9 +1988,77 @@ class PageManager {
1952
1988
  // repaint — which was one of the paths that showed a stale view.
1953
1989
  this._attachRepaintHooks(tabId, view);
1954
1990
  }
1991
+
1992
+ this._attachLoadFailureHooks(tabId, view);
1955
1993
  return tabId;
1956
1994
  }
1957
1995
 
1996
+ /**
1997
+ * 导航失败 → 显示我们自己的错误页,而不是 Chromium 的 chrome-error:// 空白页。
1998
+ *
1999
+ * 为什么必须有:实测(2026-10-14)断网后 ui.navigate 到一个不存在的站点,
2000
+ * 页面停在 chrome-error://chromewebdata/,document.body 长度 0、
2001
+ * 截图 15KB(正常渲染 220KB),**白屏 8 秒不恢复、页面上零个字的提示**。
2002
+ * 这正是用户报的"打开 ai-browser 白屏":不是启动 bug,是连接失败后
2003
+ * 浏览器给了一个空白文档,而我们对这个事件零监听。
2004
+ *
2005
+ * 不替换成错误页的话,人(产品承诺人可以随时手动介入)看到一块白,
2006
+ * agent(ui.get_tree)也读不到任何东西 —— 两头都瞎。
2007
+ *
2008
+ * 只处理 isMainFrame 的失败:子资源(图片/字体/JS)加载失败太常见,
2009
+ * 不该把整个页面换成错误页。aborted 是页面自己取消的请求(通常是正常的
2010
+ * 跳转竞态),也不算失败。
2011
+ */
2012
+ _attachLoadFailureHooks(tabId, view) {
2013
+ if (!view || !view.webContents || view.webContents.isDestroyed()) return;
2014
+ if (!this._failureHooked) this._failureHooked = new WeakSet();
2015
+ if (this._failureHooked.has(view.webContents)) return;
2016
+ this._failureHooked.add(view.webContents);
2017
+
2018
+ view.webContents.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => {
2019
+ if (!isMainFrame) return;
2020
+ // -3 = ERR_ABORTED:页面自己取消的请求,通常是导航竞态,不是真的失败。
2021
+ if (errorCode === -3) return;
2022
+ // 防重入:错误页自己加载失败时不要无限套娃。
2023
+ // 用普通 Set 而不是 WeakSet —— tabId 是整数,WeakSet.add(整数) 会抛
2024
+ // "Invalid value used in weak set",而那行异常会从 Electron 的事件回调里
2025
+ // 冒出去,把 loadURL 的 Promise 一起挂死(实测:navigate 到不通的端口,
2026
+ // WS 请求 10s 不返回、页面停在 chrome-error:// 白屏)。
2027
+ if (!this._failureGuard) this._failureGuard = new Set();
2028
+ if (this._failureGuard.has(tabId)) return;
2029
+ this._failureGuard.add(tabId);
2030
+
2031
+ let pagePath = null;
2032
+ try {
2033
+ pagePath = path.join(__dirname, '../renderer/error_page.html');
2034
+ } catch (_) { return; }
2035
+
2036
+ const q = new URLSearchParams({
2037
+ url: String(validatedURL || ''),
2038
+ code: String(errorCode),
2039
+ text: String(errorDescription || ''),
2040
+ });
2041
+ try {
2042
+ view.webContents.loadURL('file://' + pagePath + '?' + q.toString());
2043
+ } catch (e) {
2044
+ console.error('[page_manager] error page load failed:', e.message);
2045
+ this._failureGuard.delete(tabId);
2046
+ return;
2047
+ }
2048
+ // 让 agent 也能读到失败(订阅了事件的会话),而不是只有人看得到。
2049
+ this._broadcast('load_failed', {
2050
+ tab: tabId,
2051
+ url: String(validatedURL || ''),
2052
+ error_code: errorCode,
2053
+ error: String(errorDescription || ''),
2054
+ });
2055
+ // 错误页本身加载成功后清掉防重入标记,否则同 tab 后续失败都不显示。
2056
+ view.webContents.once('did-finish-load', () => {
2057
+ if (this._failureGuard) this._failureGuard.delete(tabId);
2058
+ });
2059
+ });
2060
+ }
2061
+
1958
2062
  /**
1959
2063
  * Attach compositor-repaint hooks to a tab's webContents.
1960
2064
  * Idempotent per tab: re-registering would schedule redundant remove/add
@@ -2072,6 +2176,9 @@ class PageManager {
2072
2176
  // switch to first remaining tab — must re-addBrowserView
2073
2177
  const first = this.tabs.keys().next();
2074
2178
  this.activeTab = first.done ? null : first.value;
2179
+ // 上面是直接改 activeTab(没走 setActive),monitor 不会自己知道;
2180
+ // 不同步的话它会一直拿已关闭的 tab 当"活跃 tab",淘汰顺序就反了。
2181
+ this.networkMonitor.setActiveTab(this.activeTab);
2075
2182
  if (!first.done) {
2076
2183
  const nextView = this._getView(this.activeTab);
2077
2184
  this.window.addBrowserView(nextView);
@@ -2129,6 +2236,8 @@ class PageManager {
2129
2236
  try { this.window.removeBrowserView(view); } catch(e) {}
2130
2237
  }
2131
2238
  this.activeTab = tabId;
2239
+ // body / 记录淘汰要"先牺牲后台 tab",所以必须让它知道谁是活跃的。
2240
+ this.networkMonitor.setActiveTab(tabId);
2132
2241
  const view = this._getView(tabId);
2133
2242
  if (view) {
2134
2243
  this.window.addBrowserView(view);
@@ -2183,8 +2292,12 @@ class PageManager {
2183
2292
 
2184
2293
  _layoutView(view) {
2185
2294
  const bounds = this.window.getContentBounds();
2186
- const TAB_BAR_HEIGHT = 36;
2187
- view.setBounds({ x: 0, y: TAB_BAR_HEIGHT, width: bounds.width, height: bounds.height - TAB_BAR_HEIGHT });
2295
+ view.setBounds({
2296
+ x: 0,
2297
+ y: config.tabBarHeight,
2298
+ width: bounds.width,
2299
+ height: bounds.height - config.tabBarHeight,
2300
+ });
2188
2301
  }
2189
2302
 
2190
2303
  addSubscription(sessionId, events) {
@@ -2690,6 +2803,10 @@ class PageManager {
2690
2803
  sensitive_headers_optin: this.networkMonitor.sensitiveAllowed,
2691
2804
  },
2692
2805
  limits: this.networkMonitor.captureInfo(tabId).limits,
2806
+ // 诊断用:淘汰优先级与当前占用。回答"我的 body 为什么是 evicted"。
2807
+ active_tab: this.networkMonitor.captureInfo(tabId).active_tab,
2808
+ body_bytes: this.networkMonitor.captureInfo(tabId).body_bytes,
2809
+ max_body_bytes: this.networkMonitor.captureInfo(tabId).max_body_bytes,
2693
2810
  note: 'retention limits are controlled by AI_BROWSER_NETWORK_* env only, never by this call',
2694
2811
  };
2695
2812
  }
@@ -33,8 +33,27 @@ function findWaitTarget(tree, target) {
33
33
  return findInTree(tree, 'label', String(target));
34
34
  }
35
35
 
36
+ // 进程启动时刻(秒)。供 ui.server_info 报出去 —— pid 会被复用,只比 pid
37
+ // 不够稳;pid + started_at 两个一起才足以判定"是不是同一个进程"。
38
+ const serverStartedAt = Math.floor(Date.now() / 1000);
39
+
36
40
  export function startWSServer(pageManager, port = config.wsPort, onQuit = null) {
37
- const wss = new WebSocketServer({ port });
41
+ // Bind loopback only — the protocol carries no auth. ws' default (host
42
+ // omitted) is 0.0.0.0, which would expose the browser — including all its
43
+ // persisted logins — to the entire local network. Override via
44
+ // AI_BROWSER_WS_HOST only with eyes open.
45
+ const wss = new WebSocketServer({ port, host: config.wsBindHost });
46
+
47
+ // Passing a host pushes Node onto the async DNS-lookup listen path, so
48
+ // wss.address() is null until 'listening'. Cache it and let callers await.
49
+ let bound = null;
50
+ const ready = new Promise((resolve, reject) => {
51
+ wss.once('listening', () => { bound = wss.address(); resolve(bound); });
52
+ wss.once('error', reject);
53
+ });
54
+ ready.then(() => {
55
+ console.log(`AI Browser WS server listening on ws://${bound.address}:${bound.port}`);
56
+ }).catch(() => {});
38
57
 
39
58
  wss.on('connection', (ws, _req) => {
40
59
  const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -196,11 +215,39 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
196
215
  send({ jsonrpc: '2.0', id, result: { tabs } });
197
216
  break;
198
217
  }
218
+ // 窗口首次显示的时刻与当时页面状态。
219
+ // 存在的理由:白屏类缺陷的根因是"窗口显示了但渲染器还没画出第一帧",
220
+ // 而这只能从窗口侧观测 —— WS 端口在 createWindow 中途就开了,比
221
+ // showInitialWindow 早,从 agent 侧轮询永远测不到那个时序。
222
+ // 一次性记录:窗口只首次显示一次,之后一直是它。
223
+ case 'ui.window_shown': {
224
+ send({ jsonrpc: '2.0', id, result: { shown: pageManager.windowShownAt || null } });
225
+ break;
226
+ }
199
227
  case 'ui.set_active_tab': {
200
228
  const ok = pageManager.setActive(params.tab);
201
229
  send({ jsonrpc: '2.0', id, result: { ok } });
202
230
  break;
203
231
  }
232
+ // 服务端的进程身份。存在的理由(2026-10-15 实测):调用方**无法确认
233
+ // 自己连上的是不是自己拉起的那个实例**。决定性实验——在"端口忙"预检
234
+ // 之后让一个冒牌 WS server 抢占端口,e2e 套件照常发出 ui.new_tab /
235
+ // ui.evaluate / ui.get_tree 却毫不知情:测的是别人的实例,结果毫无
236
+ // 意义,而且不报错,只给出一堆莫名其妙的失败。
237
+ // 有了 pid,调用方就能核对"是不是我 spawn 的那个进程"。
238
+ case 'ui.server_info': {
239
+ send({
240
+ jsonrpc: '2.0', id,
241
+ result: {
242
+ pid: process.pid,
243
+ // 进程启动时刻(秒)。pid 可被复用,两个数字一起才够稳。
244
+ started_at: serverStartedAt,
245
+ ws_port: port,
246
+ platform: process.platform,
247
+ },
248
+ });
249
+ break;
250
+ }
204
251
  // === Subscribe/unsubscribe (with response) ===
205
252
  case 'ui.subscribe': {
206
253
  const events = params.events || [];
@@ -426,10 +473,13 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
426
473
  });
427
474
  });
428
475
 
429
- console.log('AI Browser WS server listening on ws://localhost:' + port);
430
476
  return {
431
- // Actual bound port (for tests that pass port 0 to grab an ephemeral one).
432
- port: wss.address().port,
477
+ // Resolves once the socket is actually bound (port 0 → ephemeral port known).
478
+ ready,
479
+ // Actual bound port/address; null until ready.
480
+ get port() { return bound ? bound.port : null; },
481
+ // Security invariant: must be a loopback literal.
482
+ get host() { return bound ? bound.address : null; },
433
483
  close: () => {
434
484
  for (const client of wss.clients) {
435
485
  try { client.terminate(); } catch (e) {}
@@ -0,0 +1,125 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>页面无法打开</title>
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ html, body { height: 100%; }
9
+ body {
10
+ background: #1e1e1e;
11
+ color: #d4d4d4;
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
13
+ font-size: 13px;
14
+ display: flex;
15
+ align-items: flex-start;
16
+ justify-content: center;
17
+ padding: 48px 24px;
18
+ }
19
+ .box { max-width: 560px; width: 100%; }
20
+ h1 { font-size: 17px; font-weight: 600; color: #f48771; margin-bottom: 4px; }
21
+ .sub { color: #888; font-size: 12px; margin-bottom: 20px; }
22
+ .url {
23
+ background: #252526; border: 1px solid #3c3c3c; border-radius: 4px;
24
+ padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
25
+ font-size: 12px; color: #9cdcfd; word-break: break-all; margin-bottom: 16px;
26
+ }
27
+ .reason { margin-bottom: 20px; line-height: 1.6; }
28
+ .reason b { color: #ce9178; font-weight: 600; }
29
+ .hint { color: #858585; font-size: 12px; line-height: 1.7; margin-bottom: 24px; }
30
+ .hint li { margin-left: 18px; margin-bottom: 4px; }
31
+ .row { display: flex; gap: 8px; }
32
+ button {
33
+ background: #0e639c; color: #fff; border: none; border-radius: 4px;
34
+ padding: 7px 16px; font-size: 12px; cursor: pointer; font-family: inherit;
35
+ }
36
+ button:hover { background: #1177bb; }
37
+ button.ghost { background: #3a3d41; }
38
+ button.ghost:hover { background: #4a4d51; }
39
+ input {
40
+ flex: 1; background: #3c3c3c; border: 1px solid #3c3c3c; border-radius: 4px;
41
+ color: #d4d4d4; padding: 7px 10px; font-size: 12px; font-family: inherit;
42
+ min-width: 0;
43
+ }
44
+ input:focus { outline: none; border-color: #0e639c; }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <div class="box">
49
+ <h1>这个页面打不开</h1>
50
+ <div class="sub">网络请求失败,浏览器没有拿到任何内容</div>
51
+
52
+ <div class="url" id="url"></div>
53
+
54
+ <div class="reason" id="reason"></div>
55
+
56
+ <div class="hint">
57
+ 常见原因:
58
+ <li>网络不通,或 DNS 解析失败</li>
59
+ <li>站点本身不可达 / 已下线</li>
60
+ <li>公司网络或代理拦截了该域名</li>
61
+ </div>
62
+
63
+ <div class="row">
64
+ <button id="retry">重试</button>
65
+ <button id="back" class="ghost">返回上一页</button>
66
+ </div>
67
+ <div class="row" style="margin-top:8px">
68
+ <input id="go" type="text" placeholder="输入网址后按回车打开">
69
+ </div>
70
+ </div>
71
+
72
+ <script>
73
+ // 主进程通过 query 把失败信息带进来。这个页面本身也在 renderer 里跑,
74
+ // 但它是**我们自己**的页面,不是站点内容 —— 所以人能在这里手动重试 /
75
+ // 改网址,这正是产品承诺的"随时可以手动介入"。
76
+ var q = new URLSearchParams(location.search);
77
+ var failedUrl = q.get('url') || '';
78
+ var errCode = q.get('code') || '';
79
+ var errText = q.get('text') || '';
80
+ document.getElementById('url').textContent = failedUrl || '(未知网址)';
81
+
82
+ var REASONS = {
83
+ '-3': '网络中断(ERR_INTERNET_DISCONNECTED)',
84
+ '-102': '连接被拒绝(ERR_CONNECTION_REFUSED)',
85
+ '-105': 'DNS 解析失败,找不到这个域名(ERR_NAME_NOT_RESOLVED)',
86
+ '-106': '网络不可达(ERR_INTERNET_DISCONNECTED)',
87
+ '-109': '连接超时,站点没有响应(ERR_CONNECTION_TIMED_OUT)',
88
+ '-118': '连接超时(ERR_CONNECTION_TIMED_OUT)',
89
+ '-130': '代理连接失败(ERR_PROXY_CONNECTION_FAILED)',
90
+ '-200': '证书错误,站点证书无效(ERR_CERT_AUTHORITY_INVALID)',
91
+ '-201': '证书已过期(ERR_CERT_DATE_INVALID)',
92
+ '-202': '证书包含错误的主机名(ERR_CERT_COMMON_NAME_INVALID)',
93
+ '-501': '服务器不支持安全连接(ERR_SSL_PROTOCOL_ERROR)',
94
+ '-324': '服务器响应为空(ERR_EMPTY_RESPONSE)',
95
+ '-329': '内容被阻止(ERR_BLOCKED_BY_CLIENT)',
96
+ };
97
+ var r = document.getElementById('reason');
98
+ var known = REASONS[errCode];
99
+ if (known) {
100
+ r.innerHTML = '错误类型:<b>' + known + '</b><br>错误码:' + errCode;
101
+ } else if (errText) {
102
+ r.innerHTML = '错误类型:<b>' + errText.replace(/</g, '&lt;') + '</b><br>错误码:' + errCode;
103
+ } else {
104
+ r.textContent = '错误码:' + (errCode || '(未知)');
105
+ }
106
+
107
+ // 这些按钮要能真的动:走 setWindowOpenHandler 拦不住的本地协议,
108
+ // 所以用 webContents 回主进程的 ipc。
109
+ var { ipcRenderer } = require('electron');
110
+ document.getElementById('retry').addEventListener('click', function () {
111
+ if (failedUrl) ipcRenderer.send('error-page:navigate', failedUrl);
112
+ });
113
+ document.getElementById('back').addEventListener('click', function () {
114
+ ipcRenderer.send('error-page:back');
115
+ });
116
+ var go = document.getElementById('go');
117
+ go.value = failedUrl;
118
+ go.addEventListener('keydown', function (e) {
119
+ if (e.key === 'Enter' && go.value.trim()) {
120
+ ipcRenderer.send('error-page:navigate', go.value.trim());
121
+ }
122
+ });
123
+ </script>
124
+ </body>
125
+ </html>
@@ -2,114 +2,174 @@
2
2
  <html>
3
3
  <head>
4
4
  <meta charset="utf-8">
5
+ <title>AI Browser</title>
5
6
  <style>
6
7
  * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ html, body { height: 100%; overflow: hidden; }
7
9
  body {
8
- height: 36px;
9
10
  background: #1a1a1a;
10
11
  display: flex;
11
- align-items: center;
12
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
12
+ flex-direction: column;
13
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
13
14
  font-size: 12px;
14
15
  color: #ccc;
15
16
  user-select: none;
16
17
  -webkit-app-region: drag;
17
18
  }
18
- #tabs {
19
- display: flex;
20
- flex: 1;
21
- overflow: hidden;
22
- -webkit-app-region: no-drag;
23
- }
19
+ /* ---- 第一行:tab 条 ---- */
20
+ #tabs-row { display: flex; align-items: stretch; height: 36px; flex: none; }
21
+ #tabs { display: flex; flex: 1; overflow: hidden; }
24
22
  .tab {
25
- display: flex;
26
- align-items: center;
27
- max-width: 200px;
28
- min-width: 80px;
29
- padding: 0 12px;
30
- height: 36px;
31
- cursor: pointer;
32
- border-right: 1px solid #2a2a2a;
23
+ display: flex; align-items: center;
24
+ max-width: 200px; min-width: 80px; padding: 0 12px; height: 36px;
25
+ cursor: pointer; border-right: 1px solid #2a2a2a;
33
26
  transition: background 0.15s;
27
+ -webkit-app-region: no-drag;
34
28
  }
35
29
  .tab:hover { background: #2a2a2a; }
36
30
  .tab.active { background: #2d2d30; color: #fff; border-bottom: 2px solid #007acc; }
37
31
  .tab-title {
38
- flex: 1;
39
- overflow: hidden;
40
- text-overflow: ellipsis;
41
- white-space: nowrap;
32
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
42
33
  margin-right: 8px;
43
34
  }
44
35
  .tab-close {
45
- width: 16px;
46
- height: 16px;
47
- display: flex;
48
- align-items: center;
49
- justify-content: center;
50
- border-radius: 3px;
51
- font-size: 14px;
52
- color: #666;
53
- cursor: pointer;
36
+ width: 16px; height: 16px; display: flex; align-items: center;
37
+ justify-content: center; border-radius: 3px; font-size: 14px; color: #666;
38
+ cursor: pointer; flex: none;
54
39
  }
55
40
  .tab-close:hover { background: #3a3a3a; color: #fff; }
56
41
  #new-tab {
57
- width: 36px;
58
- height: 36px;
59
- display: flex;
60
- align-items: center;
61
- justify-content: center;
62
- font-size: 20px;
63
- cursor: pointer;
64
- color: #888;
65
- -webkit-app-region: no-drag;
42
+ width: 36px; height: 36px; display: flex; align-items: center;
43
+ justify-content: center; font-size: 20px; cursor: pointer; color: #888;
44
+ -webkit-app-region: no-drag; flex: none;
66
45
  }
67
46
  #new-tab:hover { background: #2a2a2a; color: #fff; }
68
- .tab-id { color: #555; font-size: 10px; margin-right: 4px; }
47
+ .tab-id { color: #555; font-size: 10px; margin-right: 4px; flex: none; }
48
+
49
+ /* ---- 第二行:工具栏(地址栏 + 导航按钮)---- */
50
+ #toolbar {
51
+ display: flex; align-items: center; gap: 6px;
52
+ height: 38px; padding: 0 8px; flex: none;
53
+ background: #202021; border-bottom: 1px solid #333;
54
+ }
55
+ .nav-btn {
56
+ width: 30px; height: 26px; display: flex; align-items: center;
57
+ justify-content: center; border: none; border-radius: 4px;
58
+ background: transparent; color: #bbb; font-size: 14px; cursor: pointer;
59
+ -webkit-app-region: no-drag; flex: none; padding: 0;
60
+ font-family: inherit;
61
+ }
62
+ .nav-btn:hover:not(:disabled) { background: #3a3a3a; color: #fff; }
63
+ .nav-btn:disabled { color: #4a4a4a; cursor: default; }
64
+ #addr {
65
+ flex: 1; min-width: 0; height: 26px;
66
+ background: #3c3c3c; border: 1px solid #3c3c3c; border-radius: 4px;
67
+ color: #e8e8e8; padding: 0 10px; font-size: 12px; font-family: inherit;
68
+ -webkit-app-region: no-drag;
69
+ }
70
+ #addr:focus { outline: none; border-color: #007acc; background: #2d2d2d; }
71
+ #status {
72
+ font-size: 11px; color: #777; flex: none; max-width: 260px;
73
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
74
+ }
75
+ #status.busy { color: #e0a33e; }
69
76
  </style>
70
77
  </head>
71
78
  <body>
72
- <div id="tabs"></div>
73
- <div id="new-tab">+</div>
79
+ <div id="tabs-row">
80
+ <div id="tabs"></div>
81
+ <div id="new-tab" title="新建标签页">+</div>
82
+ </div>
83
+
84
+ <div id="toolbar">
85
+ <button class="nav-btn" id="back" title="后退" disabled>&#8592;</button>
86
+ <button class="nav-btn" id="forward" title="前进" disabled>&#8594;</button>
87
+ <button class="nav-btn" id="reload" title="刷新">&#8635;</button>
88
+ <button class="nav-btn" id="stop" title="停止加载" disabled>&times;</button>
89
+ <input id="addr" type="text" placeholder="输入网址,回车打开" spellcheck="false" autocomplete="off">
90
+ <span id="status"></span>
91
+ </div>
92
+
74
93
  <script>
75
94
  const { ipcRenderer } = require('electron');
76
95
 
96
+ const $ = (id) => document.getElementById(id);
97
+ const tabsEl = $('tabs'), addrEl = $('addr'), statusEl = $('status');
98
+ const backBtn = $('back'), fwdBtn = $('forward'), reloadBtn = $('reload'), stopBtn = $('stop');
99
+
100
+ // ---- tab 条(原有逻辑,保持可用)----
101
+ // 主进程通过 window.__onNavState 推送地址栏状态(push 模式)。
102
+ // 也暴露 window.__applyNavState 供本地自取(nav:state invoke 的回落)。
103
+ window.__onNavState = function (s) { applyNavState(s); };
104
+ function applyNavState(s) {
105
+ activeTab = s.tab;
106
+ // 地址栏只在不是用户正在输入时才更新,否则会打断打字
107
+ if (document.activeElement !== addrEl) addrEl.value = s.url || '';
108
+ backBtn.disabled = !s.canBack;
109
+ fwdBtn.disabled = !s.canForward;
110
+ reloadBtn.style.display = s.loading ? 'none' : '';
111
+ stopBtn.style.display = s.loading ? '' : 'none';
112
+ stopBtn.disabled = !s.loading;
113
+ statusEl.textContent = s.status || '';
114
+ statusEl.className = s.loading ? 'busy' : '';
115
+ addrEl.style.borderColor = s.loading ? '#e0a33e' : '';
116
+ }
117
+
77
118
  async function renderTabs() {
78
119
  try {
79
120
  const tabs = await ipcRenderer.invoke('tab:list');
80
- const container = document.getElementById('tabs');
81
- container.innerHTML = '';
121
+ tabsEl.innerHTML = '';
82
122
  for (const tab of tabs) {
83
123
  const el = document.createElement('div');
84
124
  el.className = 'tab' + (tab.active ? ' active' : '');
85
125
  const title = (tab.title || tab.url || 'New Tab').slice(0, 30);
86
126
  el.innerHTML = '<span class="tab-id">[' + tab.id + ']</span>' +
87
- '<span class="tab-title">' + title + '</span>' +
127
+ '<span class="tab-title">' + escapeHtml(title) + '</span>' +
88
128
  '<span class="tab-close" data-id="' + tab.id + '">&times;</span>';
89
- el.addEventListener('click', function(e) {
129
+ el.addEventListener('click', function (e) {
90
130
  if (e.target.classList.contains('tab-close')) {
91
131
  ipcRenderer.send('tab:close', parseInt(e.target.dataset.id));
92
132
  } else {
93
133
  ipcRenderer.send('tab:activate', tab.id);
94
134
  }
95
135
  });
96
- container.appendChild(el);
136
+ tabsEl.appendChild(el);
97
137
  }
98
- } catch(e) {
99
- // ipcRenderer not ready yet
100
- }
138
+ } catch (e) { /* ipcRenderer 尚未就绪 */ }
139
+ }
140
+ function escapeHtml(s) {
141
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
101
142
  }
102
143
 
103
- // expose for main process to call
104
- window.renderTabs = renderTabs;
144
+ // ---- 地址栏 + 导航按钮 ----
145
+ // 所有交互都走 ipc 到主进程,由主进程操作对应 tab 的 webContents。
146
+ // 这里不直接碰页面:地址栏必须能反映**真实**状态(包括 agent 造成的导航),
147
+ // 所以显示数据全部由主进程推送,本地不缓存。
148
+ let activeTab = null;
105
149
 
106
- document.getElementById('new-tab').addEventListener('click', function() {
107
- ipcRenderer.send('tab:new');
150
+ ipcRenderer.on('nav:state', (_e, s) => applyNavState(s));
151
+
152
+ backBtn.addEventListener('click', () => ipcRenderer.send('nav:back'));
153
+ fwdBtn.addEventListener('click', () => ipcRenderer.send('nav:forward'));
154
+ reloadBtn.addEventListener('click', () => ipcRenderer.send('nav:reload'));
155
+ stopBtn.addEventListener('click', () => ipcRenderer.send('nav:stop'));
156
+
157
+ addrEl.addEventListener('keydown', (e) => {
158
+ if (e.key !== 'Enter') return;
159
+ const v = addrEl.value.trim();
160
+ if (!v) return;
161
+ addrEl.blur();
162
+ ipcRenderer.send('nav:go', v);
108
163
  });
164
+ // 聚焦时全选,方便直接输入覆盖(浏览器惯例)
165
+ addrEl.addEventListener('focus', () => addrEl.select());
166
+
167
+ $('new-tab').addEventListener('click', () => ipcRenderer.send('tab:new'));
109
168
 
110
- // Initial render + periodic refresh
169
+ // 初始拉一次状态,然后 tab 条照旧定时刷新
170
+ ipcRenderer.invoke('nav:state').then((s) => { if (s) applyNavState(s); });
111
171
  renderTabs();
112
172
  setInterval(renderTabs, 1500);
113
173
  </script>
114
174
  </body>
115
- </html>
175
+ </html>
@@ -12,12 +12,18 @@ const DEFAULT_UA =
12
12
  export const config = {
13
13
  // WebSocket JSON-RPC server + MCP auto-launch probe port
14
14
  wsPort: Number(process.env.AI_BROWSER_PORT) || 9223,
15
+ // Loopback-only by default: the JSON-RPC port has NO authentication, so
16
+ // binding 0.0.0.0 would let every host on the local network drive the
17
+ // browser and its logged-in sessions. Set AI_BROWSER_WS_HOST=0.0.0.0 only
18
+ // behind a firewall you trust, and know that the protocol itself is open.
19
+ wsBindHost: process.env.AI_BROWSER_WS_HOST || '127.0.0.1',
15
20
  // Electron userData dir — session/cookie persistence survives restart
16
21
  userDataDir: process.env.AI_BROWSER_USER_DATA || path.join(os.homedir(), '.ai-browser'),
17
22
  // Spoofed UA to mask the automation fingerprint
18
23
  userAgent: process.env.AI_BROWSER_UA || DEFAULT_UA,
19
- // Height of the in-app tab strip below the window title bar
20
- tabBarHeight: 36,
24
+ // Height of the in-app tab strip below the window title bar.
25
+ // 36px = tab row, 38px = toolbar row (address bar + back/forward/reload/stop).
26
+ tabBarHeight: 36 + 38,
21
27
  // The session every tab's BrowserView uses. **Must stay a single source of
22
28
  // truth**: session-level hooks (will-download, permission requests) are
23
29
  // installed on `session.fromPartition(viewPartition)`. Hooking
@@ -76,6 +76,13 @@ export const VALID_METHODS = [
76
76
  'ui.close_tab',
77
77
  'ui.list_tabs',
78
78
  'ui.set_active_tab',
79
+ // 窗口首次显示的时刻与当时页面状态。白屏类缺陷的根因是"窗口显示了但渲染器
80
+ // 还没画出第一帧",而这只能从窗口侧观测(WS 端口比 showInitialWindow 早开),
81
+ // 所以留一个查询口;见 e2e/nav_ws.cjs NV-A。
82
+ 'ui.window_shown',
83
+ // 服务端进程身份(pid + 启动时刻)。调用方据此核对"我连上的是不是我拉起的
84
+ // 那个实例" —— 连错实例时结果毫无意义但不会报错,是最难查的一类。
85
+ 'ui.server_info',
79
86
  'ui.canvas_configure',
80
87
  'ui.canvas_list',
81
88
  'ui.canvas_read',
@@ -2,4 +2,4 @@
2
2
  // Kept in JS (not imported from package.json) to avoid Node import-attribute
3
3
  // (JSON module) compatibility issues inside Electron. package.json "version"
4
4
  // is manually synced to this value; tests assert they match.
5
- export const VERSION = '1.2.1';
5
+ export const VERSION = '1.2.2';