@glittering0717/ai-browser 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +155 -20
  2. package/bin/ai-browser.cjs +1 -1
  3. package/examples/AGENT_INSTRUCTIONS.md +22 -2
  4. package/llms-install.md +14 -4
  5. package/package.json +9 -5
  6. package/scripts/build_dmg.cjs +220 -0
  7. package/scripts/marketing/data_snapshot.sh +69 -0
  8. package/scripts/render_icon.cjs +118 -0
  9. package/src/main/axExtractor.js +103 -36
  10. package/src/main/error_log.js +245 -0
  11. package/src/main/index.js +487 -16
  12. package/src/main/mcp_server.js +39 -1
  13. package/src/main/mcp_tools.js +64 -10
  14. package/src/main/network_monitor.js +86 -23
  15. package/src/main/page_manager.js +1040 -115
  16. package/src/main/ws_server.js +127 -16
  17. package/src/preload/actions.cjs +61 -2
  18. package/src/preload/bridge.cjs +31 -1
  19. package/src/preload/extractor.cjs +520 -25
  20. package/src/renderer/error_banner.html +90 -0
  21. package/src/renderer/error_page.html +125 -0
  22. package/src/renderer/tab_bar.html +116 -56
  23. package/src/shared/act_params.cjs +53 -0
  24. package/src/shared/auth_wall.cjs +162 -0
  25. package/src/shared/config.js +8 -2
  26. package/src/shared/deadline.js +6 -0
  27. package/src/shared/deep_active.cjs +31 -0
  28. package/src/shared/error_banner.cjs +43 -0
  29. package/src/shared/find_ref.cjs +44 -0
  30. package/src/shared/frame_probe.cjs +112 -0
  31. package/src/shared/hit_point.cjs +80 -0
  32. package/src/shared/input_verify.js +47 -0
  33. package/src/shared/key_dispatch.cjs +37 -0
  34. package/src/shared/nav_wait.cjs +47 -0
  35. package/src/shared/protocol.js +15 -0
  36. package/src/shared/ref_guard.cjs +133 -0
  37. package/src/shared/screenshot_opts.cjs +130 -0
  38. package/src/shared/tree_diff.cjs +105 -0
  39. package/src/shared/version.js +1 -1
  40. package/tools/coverage_probe.cjs +239 -0
package/src/main/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // main/index.js — Electron entry v5 (multi-tab, real UI)
2
2
  // One process, one WS server, multiple tabs with real tab bar.
3
- import { app, BrowserWindow, BrowserView, ipcMain, net, session } from 'electron';
3
+ import { app, BrowserWindow, BrowserView, ipcMain, net, session, dialog } from 'electron';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { startWSServer } from './ws_server.js';
7
7
  import { PageManager } from './page_manager.js';
8
8
  import { config } from '../shared/config.js';
9
9
  import { parseWatchdogTargets, isWatchdogEnabled } from '../shared/watchdog.js';
10
+ import { initErrorLog, recordError, markShutdown, getLogPath } from './error_log.js';
11
+ import errorBanner from '../shared/error_banner.cjs';
10
12
 
11
13
  const __filename = fileURLToPath(import.meta.url);
12
14
  const __dirname = path.dirname(__filename);
@@ -15,6 +17,239 @@ const __dirname = path.dirname(__filename);
15
17
  // is initialized during ready, so setting it after loses persistence.
16
18
  app.setPath('userData', config.userDataDir);
17
19
 
20
+ // === 错误日志:必须在 ready 之前就绪 ===
21
+ // 早期异常(模块初始化、窗口创建)恰恰是最常见的一类,等 ready 再初始化就等于
22
+ // 漏掉最关键的那一段。目录放在 userData 下(可写、可预期):~/.ai-browser/logs/
23
+ const LOG_DIR = path.join(config.userDataDir, 'logs');
24
+ initErrorLog({ dir: LOG_DIR, meta: { port: config.wsPort, userData: config.userDataDir } });
25
+
26
+ // === 主进程兜底 ===
27
+ // 注册 uncaughtException 就等于接管 Electron 的默认行为 —— 而那个默认行为正是
28
+ // "弹一个只有栈的原生框,栈不进任何文件"。用户看到的是「弹了个框说 js 崩溃」,
29
+ // 点掉之后**没有任何地方能查到它崩在哪**(2026-10-17 实测)。
30
+ //
31
+ // 修法两步:① 先同步落盘(appendFileSync,崩之前一定写完);
32
+ // ② 提示改成**窗口底部的非阻塞红色横幅**,不再用原生模态框。
33
+ //
34
+ // 为什么不能用原生框(实测,不是推测):dialog.showMessageBox 虽然返回 Promise,
35
+ // macOS 底层仍是 NSAlert 模态循环,**会阻塞主进程**。对照实验:不弹框时 WS 4ms
36
+ // 返回,弹框期间 8 秒无响应。一次"能自愈的异常"就这样被放大成"所有 RPC 超时",
37
+ // agent 那边看到的是满屏 timed_out,与真实原因(一个无关的 JS 异常)完全对不上。
38
+ // 横幅是窗口里的一个 BrowserView:主进程照常跑,RPC 不受影响。
39
+ //
40
+ // 原生框仍作为调试开关保留(AI_BROWSER_ERROR_DIALOG=1),但只在需要"停下来盯着看"
41
+ // 时用;默认路径绝不允许阻塞。
42
+ const ERROR_BANNER_HEIGHT = 34;
43
+ // 连续异常时横幅不重开、只刷新内容;2 秒内的抖动直接合并(异常循环里每帧都发一次
44
+ // 会白烧 IPC,而人眼也看不出差别)。日志那条照旧一条不漏。
45
+ const BANNER_REFRESH_MS = 2000;
46
+
47
+ let errorBannerView = null;
48
+ let lastBannerAt = 0;
49
+ let bannerCount = 0;
50
+ // 启动阶段崩溃时窗口还不存在:先排队,等窗口建好再挂上去。
51
+ let pendingBanner = null;
52
+ let pendingBannerTimer = null;
53
+ // 连窗口都等不到(启动即死)时,多久后退回一次原生框 —— 这是唯一"什么都不显示"
54
+ // 的场景,否则用户会看到应用静默不动、也没有任何提示。
55
+ const BANNER_GIVE_UP_MS = 8000;
56
+
57
+ function bannerEnabled() {
58
+ // e2e / 无人值守可以关掉(日志照记):横幅虽不阻塞,但会改变窗口布局,
59
+ // 截图对比类断言会因此漂移。
60
+ return String(process.env.AI_BROWSER_ERROR_BANNER || '') !== '0';
61
+ }
62
+ function nativeDialogForced() {
63
+ return String(process.env.AI_BROWSER_ERROR_DIALOG || '') === '1';
64
+ }
65
+
66
+ // 原生模态框:只走 AI_BROWSER_ERROR_DIALOG=1。保留它是因为排查启动阶段崩溃时
67
+ // "让窗口停在那儿"仍然有用,但它不能是默认路径。
68
+ function showNativeFatalDialog(detail) {
69
+ const body = 'A JavaScript error occurred in the main process.\n\n'
70
+ + detail
71
+ + '\n\n已写入日志:\n' + (getLogPath() || '(日志未就绪)');
72
+ if (!app.isReady()) {
73
+ try { dialog.showErrorBox('AI Browser 主进程异常(启动阶段)', body); } catch {}
74
+ return;
75
+ }
76
+ setTimeout(() => {
77
+ recordError('error_dialog', {
78
+ note: 'modal dialog opened — main-process event loop is blocked until dismissed',
79
+ detail: String(detail || '').slice(0, 500),
80
+ });
81
+ try {
82
+ const p = dialog.showMessageBox({
83
+ type: 'error',
84
+ title: 'AI Browser 主进程异常',
85
+ message: 'AI Browser 主进程异常',
86
+ detail: body,
87
+ buttons: ['好'],
88
+ noLink: true,
89
+ });
90
+ if (p && typeof p.then === 'function') p.then(() => {}).catch(() => {});
91
+ } catch {
92
+ /* 无 GUI / 弹框失败:日志已经在盘上,这不影响记录 */
93
+ }
94
+ }, 800);
95
+ }
96
+
97
+ // 布局集中在一处算:横幅要占掉底部 34px,页面视图必须相应让出来,
98
+ // 否则横幅会被页面盖住(或反过来压住页面最后一行)。
99
+ function layoutWithBanner() {
100
+ if (!mainWindow || mainWindow.isDestroyed()) return;
101
+ let b;
102
+ try { b = mainWindow.getContentBounds(); } catch { return; }
103
+ if (tabBarView && !tabBarView.webContents.isDestroyed()) {
104
+ tabBarView.setBounds({ x: 0, y: 0, width: b.width, height: TAB_BAR_HEIGHT });
105
+ }
106
+ const inset = errorBannerView ? ERROR_BANNER_HEIGHT : 0;
107
+ if (pageManager) pageManager._bottomInset = inset;
108
+ if (errorBannerView && !errorBannerView.webContents.isDestroyed()) {
109
+ errorBannerView.setBounds({
110
+ x: 0,
111
+ y: Math.max(0, b.height - ERROR_BANNER_HEIGHT),
112
+ width: b.width,
113
+ height: ERROR_BANNER_HEIGHT,
114
+ });
115
+ }
116
+ if (pageManager) pageManager._layoutAllViews(b);
117
+ }
118
+
119
+ function ensureErrorBanner() {
120
+ if (errorBannerView && !errorBannerView.webContents.isDestroyed()) return errorBannerView;
121
+ if (!mainWindow || mainWindow.isDestroyed()) return null;
122
+ // 与标签栏同样的配置(nodeIntegration + 非隔离):横幅是我们自己的本地页面,
123
+ // 需要 ipcRenderer 才能把「×」传回主进程。页面里没有任何远程内容。
124
+ errorBannerView = new BrowserView({
125
+ webPreferences: { contextIsolation: false, nodeIntegration: true },
126
+ });
127
+ try { errorBannerView.setBackgroundColor('#7f1d1d'); } catch {}
128
+ mainWindow.addBrowserView(errorBannerView);
129
+ // 新开 tab 会往窗口里再加一个视图;显式置顶一次,避免"横幅被后来的页面盖住"
130
+ // 这种只在特定时序下出现的 bug。
131
+ try { mainWindow.setTopBrowserView(errorBannerView); } catch {}
132
+ errorBannerView.webContents.loadFile(path.join(__dirname, '../renderer/error_banner.html'));
133
+ return errorBannerView;
134
+ }
135
+
136
+ function showErrorBanner(payload) {
137
+ const view = ensureErrorBanner();
138
+ if (!view) return false;
139
+ const deliver = () => {
140
+ try { view.webContents.send('error-banner:show', payload); } catch { /* 帧已销毁就放弃,日志已有 */ }
141
+ };
142
+ // 首次创建时 listener 还没注册,直接 send 会静默丢失 —— 等 load 完再发。
143
+ if (view.webContents.isLoading()) view.webContents.once('did-finish-load', deliver);
144
+ else deliver();
145
+ layoutWithBanner();
146
+ return true;
147
+ }
148
+
149
+ function hideErrorBanner() {
150
+ if (!errorBannerView) return;
151
+ const view = errorBannerView;
152
+ errorBannerView = null;
153
+ try {
154
+ if (mainWindow && !mainWindow.isDestroyed()) mainWindow.removeBrowserView(view);
155
+ } catch {}
156
+ try { view.webContents.destroy(); } catch {}
157
+ layoutWithBanner();
158
+ }
159
+
160
+ function flushPendingBanner() {
161
+ if (!pendingBanner) return;
162
+ const payload = pendingBanner;
163
+ pendingBanner = null;
164
+ if (pendingBannerTimer) { clearTimeout(pendingBannerTimer); pendingBannerTimer = null; }
165
+ showErrorBanner(payload);
166
+ }
167
+
168
+ function reportFatal(detail) {
169
+ const text = String(detail === undefined || detail === null ? '' : detail);
170
+ if (nativeDialogForced()) { showNativeFatalDialog(text); return; }
171
+ if (!bannerEnabled()) return;
172
+ const logPath = getLogPath() || '';
173
+ const now = Date.now();
174
+ bannerCount = now - lastBannerAt < BANNER_REFRESH_MS ? bannerCount + 1 : 1;
175
+ lastBannerAt = now;
176
+ const payload = errorBanner.buildBannerPayload({ detail: text, logPath, count: bannerCount });
177
+ recordError('error_banner', {
178
+ detail: text.slice(0, 500),
179
+ log_path: logPath,
180
+ note: 'shown in-window (non-blocking); no modal dialog opened',
181
+ });
182
+ if (app.isReady() && mainWindow && !mainWindow.isDestroyed()) {
183
+ showErrorBanner(payload);
184
+ return;
185
+ }
186
+ // 窗口还没建好:排队。等 createWindow 完成后 flushPendingBanner() 挂上去。
187
+ pendingBanner = payload;
188
+ if (pendingBannerTimer) clearTimeout(pendingBannerTimer);
189
+ pendingBannerTimer = setTimeout(() => {
190
+ pendingBannerTimer = null;
191
+ if (!pendingBanner) return;
192
+ const p = pendingBanner;
193
+ pendingBanner = null;
194
+ // 这里退回原生框是**有意的例外**:没有窗口 = 没有别的地方能显示任何东西,
195
+ // 静默死掉比阻塞更糟。日志里记一笔,避免以后把它当成"又弹框了"的回归。
196
+ recordError('error_dialog_fallback', {
197
+ note: 'no window to host the banner — native box used once so the failure is visible',
198
+ detail: p.detail,
199
+ });
200
+ try {
201
+ dialog.showErrorBox(
202
+ 'AI Browser 主进程异常(启动阶段)',
203
+ p.detail + '\n\n已写入日志:\n' + (p.log_path || '(日志未就绪)')
204
+ );
205
+ } catch {}
206
+ }, BANNER_GIVE_UP_MS);
207
+ }
208
+
209
+ process.on('uncaughtException', (err, origin) => {
210
+ const e = err instanceof Error ? err : new Error(String(err));
211
+ recordError('uncaught_exception', {
212
+ origin: origin || null,
213
+ message: e.message,
214
+ stack: String(e.stack || '').slice(0, 8000),
215
+ });
216
+ console.error('[fatal] uncaughtException:', (e && e.stack) || e);
217
+ reportFatal((e && e.message) || String(e));
218
+ });
219
+
220
+ process.on('unhandledRejection', (reason) => {
221
+ const e = reason instanceof Error ? reason : new Error(String(reason));
222
+ recordError('unhandled_rejection', {
223
+ message: e.message,
224
+ stack: String(e.stack || '').slice(0, 8000),
225
+ });
226
+ // 这里**不弹框**:没接住 Promise 属于实现疏漏,噪音大但多数不影响用户当下的
227
+ // 操作。弹框只会把真正需要人介入的致命异常淹掉 —— 记下来就够。
228
+ console.error('[warn] unhandledRejection:', (e && e.stack) || e);
229
+ });
230
+
231
+ // 网页渲染进程崩溃("Aw, Snap!")与 Chromium 子进程崩溃(网络服务/GPU)。
232
+ // 2026-10-17 实测见过 `Network service crashed, restarting service`:网络服务
233
+ // 挂了之后所有页面都打不开,但主进程还活着 —— 没有这层记录就完全无从判断。
234
+ app.on('render-process-gone', (_event, webContents, details) => {
235
+ let url = null;
236
+ try { url = webContents && webContents.getURL(); } catch {}
237
+ recordError('render_process_gone', {
238
+ reason: details && details.reason,
239
+ exit_code: details && details.exitCode,
240
+ url,
241
+ });
242
+ });
243
+
244
+ app.on('child-process-gone', (_event, details) => {
245
+ recordError('child_process_gone', {
246
+ type: details && details.type,
247
+ reason: details && details.reason,
248
+ exit_code: details && details.exitCode,
249
+ service: details && details.serviceName,
250
+ });
251
+ });
252
+
18
253
  // Disable GPU compositing to avoid a whole class of GPU driver crashes
19
254
  // (exit_code=6) that take down the whole app on some systems. This only turns
20
255
  // off hardware acceleration — the window itself stays a real, visible window
@@ -60,6 +295,8 @@ let wsServer = null;
60
295
  let tabBarView = null;
61
296
  let pageManager = null;
62
297
  let tabRefreshInterval = null;
298
+ let navStateInterval = null;
299
+ let navStateWc = null;
63
300
  let networkWatchdog = null;
64
301
  let isQuitting = false;
65
302
 
@@ -110,15 +347,28 @@ function createWindow() {
110
347
  pageManager = PageManager.getInstance(mainWindow);
111
348
  pageManager._preloadPath = path.join(__dirname, '../preload/bridge.cjs');
112
349
 
113
- // Create first tab
114
- pageManager.newTab('https://www.baidu.com');
350
+ // Create first tab.
351
+ //
352
+ // 默认页用 baidu.com:国内可达、无登录墙、重定向链短。但注意它**不是**必须的 ——
353
+ // 用户报"打开 ai-browser 白屏"时,我们实测过两种独立成因(见 showInitialWindow
354
+ // 的注释),跟默认页选谁无关;换成 about:blank 只会把"加载失败的白屏"变成
355
+ // "空白页的白屏",问题更大(连错误提示都没有)。
356
+ //
357
+ // 可由 AI_BROWSER_START_URL 覆盖;设为空字符串则开空白页。
358
+ const START_URL = process.env.AI_BROWSER_START_URL !== undefined
359
+ ? (process.env.AI_BROWSER_START_URL || null)
360
+ : 'https://www.baidu.com';
361
+ pageManager.newTab(START_URL);
362
+
363
+ // 启动阶段崩溃排的队到这里兑现:窗口、标签栏、页面视图都已就绪,横幅才挂得上。
364
+ flushPendingBanner();
115
365
 
116
366
  // Start WS server — pass cleanupAndQuit so ui.quit can shut down the app
117
367
  wsServer = startWSServer(pageManager, PORT, cleanupAndQuit);
118
368
 
119
369
  // === Tab bar IPC ===
120
370
  ipcMain.on('tab:new', () => {
121
- pageManager.newTab('https://www.baidu.com');
371
+ pageManager.newTab(START_URL);
122
372
  refreshTabBar();
123
373
  });
124
374
  ipcMain.on('tab:close', (_e, tabId) => {
@@ -131,19 +381,57 @@ function createWindow() {
131
381
  });
132
382
  ipcMain.handle('tab:list', () => pageManager.listTabs());
133
383
 
384
+ // 错误横幅上的「×」:连关闭动作也走 IPC —— 横幅是独立的 BrowserView,
385
+ // 它没有别的通道能告诉主进程"把我收起来"。
386
+ // setImmediate:不要在渲染进程自己派发的事件里销毁它所属的 frame(先让这条
387
+ // IPC 走完,再拆视图)。
388
+ ipcMain.on('error-banner:close', () => { setImmediate(hideErrorBanner); });
389
+
390
+ // === 地址栏 / 导航按钮 ===
391
+ ipcMain.handle('nav:state', () => {
392
+ const wc = activeWebContents();
393
+ if (!wc) return { tab: null, url: '', canBack: false, canForward: false, loading: false, status: '' };
394
+ return {
395
+ tab: pageManager.activeTab,
396
+ url: wc.getURL(),
397
+ canBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
398
+ canForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
399
+ loading: wc.isLoading(),
400
+ status: wc.isLoading() ? '加载中…' : '',
401
+ };
402
+ });
403
+ ipcMain.on('nav:go', (_e, input) => {
404
+ const wc = activeWebContents();
405
+ const url = normalizeUrlInput(input);
406
+ if (!wc || !url) return;
407
+ try { wc.loadURL(url); } catch (e) { console.error('[index] nav:go failed:', e.message); }
408
+ });
409
+ ipcMain.on('nav:back', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goBack(); });
410
+ ipcMain.on('nav:forward', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goForward(); });
411
+ ipcMain.on('nav:reload', () => { const wc = activeWebContents(); if (wc) wc.reload(); });
412
+ ipcMain.on('nav:stop', () => { const wc = activeWebContents(); if (wc) wc.stop(); });
413
+ // 错误页里的重试 / 返回(错误页是我们自己的 renderer,同样走 ipc)
414
+ ipcMain.on('error-page:navigate', (_e, input) => {
415
+ const wc = activeWebContents();
416
+ const url = normalizeUrlInput(input);
417
+ if (!wc || !url) return;
418
+ try { wc.loadURL(url); } catch (e) { console.error('[index] error-page navigate failed:', e.message); }
419
+ });
420
+ ipcMain.on('error-page:back', () => {
421
+ const wc = activeWebContents();
422
+ if (wc && wc.navigationHistory) wc.navigationHistory.goBack();
423
+ });
424
+
134
425
  // Intercept new-window / window.open → new tab instead of new window
135
426
  ipcMain.on('tab:new-url', (_e, url) => {
136
427
  pageManager.newTab(url);
137
428
  refreshTabBar();
138
429
  });
139
430
 
140
- // Resize handler
431
+ // Resize handler —— 走 layoutWithBanner:横幅在的时候页面视图要让出底部 34px,
432
+ // 各算各的迟早会出现"横幅被页面盖住"或"页面最后一行被横幅压住"。
141
433
  mainWindow.on('resize', () => {
142
- const bounds = mainWindow.getContentBounds();
143
- if (tabBarView && !tabBarView.webContents.isDestroyed()) {
144
- tabBarView.setBounds({ x: 0, y: 0, width: bounds.width, height: TAB_BAR_HEIGHT });
145
- }
146
- pageManager._layoutAllViews(bounds);
434
+ layoutWithBanner();
147
435
  });
148
436
 
149
437
  // === Window becoming visible again → force a compositor repaint ===
@@ -188,6 +476,32 @@ function createWindow() {
188
476
  // Refresh tab bar periodically
189
477
  tabRefreshInterval = setInterval(refreshTabBar, 2000);
190
478
 
479
+ // === 地址栏状态跟随 ===
480
+ // 导航可能来自三方:人在地址栏输入、agent 调 ui.navigate、页面自己跳转。
481
+ // 地址栏必须反映**真实**状态,所以这里订阅激活 tab 的 webContents 事件主动推,
482
+ // 而不是靠定时轮询(轮询有最多 2s 的滞后,人会以为按钮坏了)。
483
+ const wireNavState = (tabId) => {
484
+ const wc = activeWebContents();
485
+ if (!wc) return;
486
+ if (navStateWc === wc) return; // 同一个 tab 重复 setActive,不重复挂
487
+ navStateWc = wc;
488
+ const push = () => pushNavState();
489
+ wc.on('did-start-loading', push);
490
+ wc.on('did-stop-loading', push);
491
+ wc.on('did-navigate', push);
492
+ wc.on('did-navigate-in-page', push);
493
+ wc.on('page-title-updated', push);
494
+ };
495
+ const origSetActive = pageManager.setActive.bind(pageManager);
496
+ pageManager.setActive = function (tabId) {
497
+ origSetActive(tabId);
498
+ wireNavState(tabId);
499
+ pushNavState();
500
+ };
501
+ wireNavState(pageManager.activeTab);
502
+ // 兜底轮询:只覆盖上面事件没抓到的情况(低频,成本可忽略)
503
+ navStateInterval = setInterval(pushNavState, 1500);
504
+
191
505
  // === #5: NetworkService self-heal watchdog ===
192
506
  // The Electron NetworkService can wedge as a whole (port stays listening but
193
507
  // every ui.navigate fails ERR_FAILED while curl works fine). A clean relaunch
@@ -209,6 +523,12 @@ function createWindow() {
209
523
  * 焦点,人可以继续在原来的窗口里做事;人手动 `npm start` 时窗口照常显示在前台。
210
524
  *
211
525
  * AI_BROWSER_FOCUS=always / never 可整体覆盖(见 page_manager._focusWindow)。
526
+ *
527
+ * 另外:**等首屏 DOM 就绪再 show**。2026-10-14 实测偶发(12 次启动里 1 次)
528
+ * 窗口已显示但 document.body 还是 null、页面整个是白的 —— 用户报的"打开
529
+ * ai-browser 白屏"其中一类就是这个:不是连接错(网络是通的,DNS 也解析了),
530
+ * 是窗口在渲染器还没画出第一帧时就显示了。等到 dom-ready(或最多 3s 兜底)
531
+ * 再显示,窗口一出现就是有内容的。
212
532
  */
213
533
  function showInitialWindow() {
214
534
  if (!mainWindow) return;
@@ -219,12 +539,85 @@ function showInitialWindow() {
219
539
  // 策略只决定**显示时抢不抢焦点**:只有人手动启动、或显式 always,才显示并激活;
220
540
  // agent 拉起(或 never)走 showInactive —— 窗口出现,但键盘焦点留在用户那边。
221
541
  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
- }
542
+
543
+ const doShow = () => {
544
+ if (!mainWindow || mainWindow.isDestroyed()) return;
545
+ try {
546
+ if (shouldActivate) mainWindow.show();
547
+ else mainWindow.showInactive();
548
+ } catch (e) {
549
+ try { mainWindow.show(); } catch (e2) { /* 窗口已销毁 */ }
550
+ }
551
+ };
552
+
553
+ // 等首屏内容就绪。上限 3s:网络再慢也不该让窗口迟迟不出现(人看不到窗口
554
+ // 比看到白屏更糟),超时就先显示,剩下的交给 did-fail-load 错误页兜底。
555
+ const firstView = pageManager && pageManager.tabs.get(pageManager.activeTab);
556
+ const wc = firstView && firstView.webContents;
557
+ if (!wc || wc.isDestroyed()) { doShow(); return; }
558
+ let shown = false;
559
+ // reason: 为什么在这个时刻显示。必须如实记录 —— 之前 waited_for_dom 恒为
560
+ // true(3s 兜底显示时也报 true),于是"到底等到了 dom-ready 没有"看不出来,
561
+ // 白屏缺陷和"我们主动选择先显示"被混成同一种结果。
562
+ const showOnce = (reason) => {
563
+ if (shown) return;
564
+ shown = true;
565
+ // 同步先落一条记录:哪怕 renderer 还没就绪(executeJavaScript 会 reject),
566
+ // "窗口在这一刻显示了、而页面是空的"这个事实本身也必须留下 —— 那正是要抓的
567
+ // 白屏形态。若等 Promise 成功才记,renderer 起不来时记录永远是 null,
568
+ // 观测口在最需要它的时候失灵。
569
+ const rec = {
570
+ tab: pageManager.activeTab,
571
+ body_len: null,
572
+ ready_state: null,
573
+ // 'dom-ready' | 'fail-load' | 'renderer-ready' | 'timeout'
574
+ show_reason: reason || 'unknown',
575
+ // 真话:只有 3s 兜底(renderer 迟迟没到)才是 false。
576
+ waited_for_dom: reason !== 'timeout',
577
+ renderer_ready: false,
578
+ };
579
+ pageManager.windowShownAt = rec;
580
+ // 再把"窗口在这一刻显示、且此刻页面是什么状态"广播出去。
581
+ // 为什么值得多这一个事件:白屏类缺陷的根因是"窗口显示了但渲染器还没画出
582
+ // 第一帧",而这只能从窗口侧观测 —— WS 端口就绪不等于窗口已显示(端口在
583
+ // createWindow 里就开了,比 showInitialWindow 早),所以从 agent 侧 polling
584
+ // 永远测不到这个时序。有了这个事件,e2e 才能断言"显示的瞬间页面已有内容"。
585
+ // 读数走 pageManager.evaluate(CDP Runtime.evaluate),不用
586
+ // webContents.executeJavaScript:高层 API 在 renderer 尚未完全就绪时会 reject
587
+ // (实测禁硬件加速 + 首次创建 GL context 失败时,dom-ready 已到但
588
+ // executeJavaScript 仍失败,而页面随后照常加载、CDP 读数正常)。
589
+ // 观测口必须能在最需要它的时候读数,否则等于没有。
590
+ const readReady = () => pageManager.evaluate(
591
+ '(function(){var b=document.body;return JSON.stringify({body:b?b.innerHTML.length:-1,ready:document.readyState});})()',
592
+ pageManager.activeTab
593
+ );
594
+ Promise.resolve()
595
+ .then(() => readReady())
596
+ .then((v) => {
597
+ rec.renderer_ready = true;
598
+ try {
599
+ const p = JSON.parse(v);
600
+ rec.body_len = p.body;
601
+ rec.ready_state = p.ready;
602
+ } catch (e) { /* 非预期返回,留 null */ }
603
+ try { pageManager._broadcast('window_shown', rec); } catch (e) { /* 无订阅者 */ }
604
+ })
605
+ .catch(() => {
606
+ // 真的读不到(renderer 始终没起来)—— 这本身就是要抓的白屏形态,
607
+ // 记录里 renderer_ready 保持 false,让 e2e 看得见。
608
+ });
609
+ doShow();
610
+ };
611
+ wc.once('dom-ready', () => showOnce('dom-ready'));
612
+ wc.once('did-fail-load', () => showOnce('fail-load')); // 失败也要显示(错误页会接手)
613
+ setTimeout(() => showOnce('timeout'), 3000);
614
+ // dom-ready 可能在上面注册**之前**就已经触发过(缓存命中时 baidu 首屏
615
+ // 一到就 ready,而 showInitialWindow 是 createWindow 的最后一步)——
616
+ // once() 收不到已经过去的事件,那就只能干等 3s 兜底。先主动查一次
617
+ // readyState(走 CDP,见 showOnce 里的说明),已经就绪就立刻显示。
618
+ pageManager.evaluate('(function(){return document.readyState;})()', pageManager.activeTab)
619
+ .then((rs) => { if (rs && rs !== 'loading') showOnce('renderer-ready'); })
620
+ .catch(() => { /* renderer 未就绪,等事件 */ });
228
621
  }
229
622
 
230
623
  // Probe one target through the NetworkService from the main process. Reuses
@@ -312,8 +705,83 @@ function refreshTabBar() {
312
705
  }
313
706
  }
314
707
 
708
+ // === 地址栏 / 前进 / 后退 / 刷新 / 停止 ===
709
+ //
710
+ // 为什么必须有:窗口是真实可见的普通窗口,产品承诺人可以随时手动介入
711
+ // (登录、扫码、过滑块、纠正 agent 的操作)。但原先窗口里只有 tab 条,
712
+ // 没有地址栏也没有任何导航按钮 —— 人要介入时连"打开某个页面"都做不到,
713
+ // 只能干等 agent。用户原话:"现在有时候需要我操作一下,比如登录验证码等,
714
+ // 页面都打不开,非常难受。"
715
+ //
716
+ // 这里的每一行都只操作 activeTab 的 webContents,不碰页面内容,也不引入
717
+ // 任何人机协作状态机 —— 就是普通浏览器的地址栏。
718
+
719
+ /** 当前激活 tab 的 webContents,没有则 null。 */
720
+ function activeWebContents() {
721
+ if (!pageManager) return null;
722
+ const view = pageManager.tabs.get(pageManager.activeTab);
723
+ if (!view || view.webContents.isDestroyed()) return null;
724
+ return view.webContents;
725
+ }
726
+
727
+ /** 把地址栏需要的一切状态推给 tab bar renderer。 */
728
+ function pushNavState() {
729
+ if (!tabBarView || tabBarView.webContents.isDestroyed()) return;
730
+ const wc = activeWebContents();
731
+ const state = wc ? {
732
+ tab: pageManager.activeTab,
733
+ url: wc.getURL(),
734
+ canBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
735
+ canForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
736
+ loading: wc.isLoading(),
737
+ status: wc.isLoading() ? '加载中…' : '',
738
+ } : { tab: null, url: '', canBack: false, canForward: false, loading: false, status: '' };
739
+ tabBarView.webContents.executeJavaScript(
740
+ 'window.__onNavState && window.__onNavState(' + JSON.stringify(state) + ')'
741
+ ).catch(() => {});
742
+ }
743
+
744
+ /**
745
+ * 把用户输入解析成可加载的 URL。
746
+ * 只做最小处理:带协议的直接用;否则按 https 猜;纯查询词不进这里
747
+ * (地址栏输入什么就打开什么,不做搜索跳转 —— 那会替用户做决定)。
748
+ */
749
+ function normalizeUrlInput(input) {
750
+ const s = String(input || '').trim();
751
+ if (!s) return null;
752
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return s; // http:// https:// file:// …
753
+ if (/^(about|file|data):/i.test(s)) return s;
754
+ if (/^localhost(:\d+)?(\/|$)/i.test(s)) return 'http://' + s;
755
+ return 'https://' + s;
756
+ }
757
+
315
758
  app.whenReady().then(() => {
316
759
  createWindow();
760
+
761
+ // === 自检钩子:AI_BROWSER_SELFTEST=crash ===
762
+ // 主动制造一次主进程未捕获异常,用来验证"崩溃时的日志与横幅"这条链路真的通。
763
+ // 没有它,这条链路只能等真事故发生才能验证 —— 而真事故发生的时候,恰恰是
764
+ // 最不适合(也最没心情)做实验的时候。2026-10-17 就是因为这个空缺,用户看到
765
+ // 一个"js 崩溃"框、事后却无从查证。
766
+ // AI_BROWSER_SELFTEST=crash node scripts/start.cjs
767
+ // AI_BROWSER_SELFTEST=crash-close node scripts/start.cjs # 5 秒后自动收起
768
+ // 默认走窗口内红色横幅(非阻塞);要在崩溃处停下来用原生模态框,加
769
+ // AI_BROWSER_ERROR_DIALOG=1(它阻塞主进程,只用于人工盯着看)。
770
+ //
771
+ // `crash-close` 存在的原因:收起横幅那条路(销毁视图 + 把让出去的 34px 还回去)
772
+ // 没法从外面点 —— 它是个独立的 BrowserView,没有 WS 入口,而 macOS 上模拟点击
773
+ // 需要辅助功能权限(实测被拒,-10004)。不测就等于把"横幅关不掉 / 关了以后
774
+ // 底部一直空一条"这类问题留给用户。
775
+ const _selftest = String(process.env.AI_BROWSER_SELFTEST || '');
776
+ if (_selftest === 'crash' || _selftest === 'crash-close') {
777
+ setTimeout(() => { throw new Error('selftest: injected uncaught exception'); }, 2000);
778
+ if (_selftest === 'crash-close') {
779
+ setTimeout(() => {
780
+ hideErrorBanner();
781
+ console.log('[selftest] error banner hidden — bottom inset restored');
782
+ }, 5000);
783
+ }
784
+ }
317
785
  // 下载与权限的 session 级钩子。必须装在 will-download 上,否则 Electron 默认
318
786
  // **取消**下载:agent 点"导出"只看到 success,然后什么都没有,连错误都没有
319
787
  // (2026-10-12 审计发现 src/ 里原先没有任何下载处理)。
@@ -334,6 +802,9 @@ app.whenReady().then(() => {
334
802
  function cleanupAndQuit() {
335
803
  if (isQuitting) return;
336
804
  isQuitting = true;
805
+ // 收尾行:日志里**没有** shutdown 行,就说明上一次是硬退(崩溃 / 被 kill)——
806
+ // 这是事后判断"到底有没有崩过"最直接的依据。
807
+ try { markShutdown('cleanup'); } catch {}
337
808
  if (tabRefreshInterval) {
338
809
  clearInterval(tabRefreshInterval);
339
810
  tabRefreshInterval = null;
@@ -19,6 +19,8 @@ import { fileURLToPath } from 'node:url';
19
19
  import electronResolve from '../../scripts/electron_resolve.cjs';
20
20
  import { MCP_TOOLS } from './mcp_tools.js';
21
21
  import { evaluateGuardError } from '../shared/guards.js';
22
+ // 截图体积上限(与 WS 侧共用同一把尺):超过就不把图塞进上下文。
23
+ import screenshotOpts from '../shared/screenshot_opts.cjs';
22
24
  import { config, wsUrl } from '../shared/config.js';
23
25
  import { VERSION } from '../shared/version.js';
24
26
  import { createWsClient } from './mcp_ws.js';
@@ -148,7 +150,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
150
 
149
151
  switch (name) {
150
152
  case 'browse_navigate': {
151
- const result = await wsClient.call('ui.navigate', { url: args.url, tab: args.tab });
153
+ const result = await wsClient.call('ui.navigate', {
154
+ url: args.url,
155
+ tab: args.tab,
156
+ wait_until: args.wait_until || undefined,
157
+ });
152
158
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
153
159
  }
154
160
 
@@ -246,6 +252,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
246
252
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
247
253
  }
248
254
 
255
+ case 'browse_auth_state': {
256
+ const result = await wsClient.call('ui.auth_state', { tab: args.tab });
257
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
258
+ }
259
+
260
+ case 'browse_screenshot': {
261
+ // 图片走 MCP 原生 image content(客户端能直接渲染),摘要走 text。
262
+ // 超大图(默认 >512KB base64)只回摘要:塞进上下文会挤爆调用方的
263
+ // token 预算,而"这张图太大"本身就该让它换 jpeg/换 clip。
264
+ const result = await wsClient.call('ui.screenshot', args);
265
+ if (result.ok && typeof result.data === 'string' && (result.bytes || 0) <= screenshotOpts.MAX_DATA_URL_CHARS) {
266
+ const meta = { ...result };
267
+ delete meta.data;
268
+ return {
269
+ content: [
270
+ { type: 'image', data: result.data, mimeType: result.mime },
271
+ { type: 'text', text: JSON.stringify(meta) },
272
+ ],
273
+ };
274
+ }
275
+ const slim = result.ok ? { ...result, data: undefined, truncated: true } : result;
276
+ return { content: [{ type: 'text', text: JSON.stringify(slim) }] };
277
+ }
278
+
249
279
  case 'browse_list_tabs': {
250
280
  const result = await wsClient.call('ui.list_tabs', {});
251
281
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
@@ -378,6 +408,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
378
408
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
379
409
  }
380
410
 
411
+ case 'browse_errors': {
412
+ // 只读:读主进程错误日志(本次进程的内存记录 + 落盘文件路径)。
413
+ // 用途是"事后自查"——界面里弹过框、或者某个动作莫名其妙失败时,
414
+ // 这里能看到 stack 与出错那一刻最近的调用上下文。
415
+ const result = await wsClient.call('ui.errors', { limit: args.limit, kind: args.kind });
416
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
417
+ }
418
+
381
419
  case 'browse_quit': {
382
420
  // Fire and forget — the WS server closes before we'd get the response.
383
421
  try { await wsClient.call('ui.quit', {}); } catch (e) {}