@glittering0717/ai-browser 1.2.2 → 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.
- package/README.md +136 -19
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +7 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +276 -7
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/page_manager.js +936 -128
- package/src/main/ws_server.js +73 -12
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +8 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- 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
|
|
@@ -125,6 +360,9 @@ function createWindow() {
|
|
|
125
360
|
: 'https://www.baidu.com';
|
|
126
361
|
pageManager.newTab(START_URL);
|
|
127
362
|
|
|
363
|
+
// 启动阶段崩溃排的队到这里兑现:窗口、标签栏、页面视图都已就绪,横幅才挂得上。
|
|
364
|
+
flushPendingBanner();
|
|
365
|
+
|
|
128
366
|
// Start WS server — pass cleanupAndQuit so ui.quit can shut down the app
|
|
129
367
|
wsServer = startWSServer(pageManager, PORT, cleanupAndQuit);
|
|
130
368
|
|
|
@@ -143,6 +381,12 @@ function createWindow() {
|
|
|
143
381
|
});
|
|
144
382
|
ipcMain.handle('tab:list', () => pageManager.listTabs());
|
|
145
383
|
|
|
384
|
+
// 错误横幅上的「×」:连关闭动作也走 IPC —— 横幅是独立的 BrowserView,
|
|
385
|
+
// 它没有别的通道能告诉主进程"把我收起来"。
|
|
386
|
+
// setImmediate:不要在渲染进程自己派发的事件里销毁它所属的 frame(先让这条
|
|
387
|
+
// IPC 走完,再拆视图)。
|
|
388
|
+
ipcMain.on('error-banner:close', () => { setImmediate(hideErrorBanner); });
|
|
389
|
+
|
|
146
390
|
// === 地址栏 / 导航按钮 ===
|
|
147
391
|
ipcMain.handle('nav:state', () => {
|
|
148
392
|
const wc = activeWebContents();
|
|
@@ -184,13 +428,10 @@ function createWindow() {
|
|
|
184
428
|
refreshTabBar();
|
|
185
429
|
});
|
|
186
430
|
|
|
187
|
-
// Resize handler
|
|
431
|
+
// Resize handler —— 走 layoutWithBanner:横幅在的时候页面视图要让出底部 34px,
|
|
432
|
+
// 各算各的迟早会出现"横幅被页面盖住"或"页面最后一行被横幅压住"。
|
|
188
433
|
mainWindow.on('resize', () => {
|
|
189
|
-
|
|
190
|
-
if (tabBarView && !tabBarView.webContents.isDestroyed()) {
|
|
191
|
-
tabBarView.setBounds({ x: 0, y: 0, width: bounds.width, height: TAB_BAR_HEIGHT });
|
|
192
|
-
}
|
|
193
|
-
pageManager._layoutAllViews(bounds);
|
|
434
|
+
layoutWithBanner();
|
|
194
435
|
});
|
|
195
436
|
|
|
196
437
|
// === Window becoming visible again → force a compositor repaint ===
|
|
@@ -516,6 +757,31 @@ function normalizeUrlInput(input) {
|
|
|
516
757
|
|
|
517
758
|
app.whenReady().then(() => {
|
|
518
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
|
+
}
|
|
519
785
|
// 下载与权限的 session 级钩子。必须装在 will-download 上,否则 Electron 默认
|
|
520
786
|
// **取消**下载:agent 点"导出"只看到 success,然后什么都没有,连错误都没有
|
|
521
787
|
// (2026-10-12 审计发现 src/ 里原先没有任何下载处理)。
|
|
@@ -536,6 +802,9 @@ app.whenReady().then(() => {
|
|
|
536
802
|
function cleanupAndQuit() {
|
|
537
803
|
if (isQuitting) return;
|
|
538
804
|
isQuitting = true;
|
|
805
|
+
// 收尾行:日志里**没有** shutdown 行,就说明上一次是硬退(崩溃 / 被 kill)——
|
|
806
|
+
// 这是事后判断"到底有没有崩过"最直接的依据。
|
|
807
|
+
try { markShutdown('cleanup'); } catch {}
|
|
539
808
|
if (tabRefreshInterval) {
|
|
540
809
|
clearInterval(tabRefreshInterval);
|
|
541
810
|
tabRefreshInterval = null;
|
package/src/main/mcp_server.js
CHANGED
|
@@ -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', {
|
|
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) {}
|
package/src/main/mcp_tools.js
CHANGED
|
@@ -11,13 +11,14 @@ export const MCP_TOOLS = [
|
|
|
11
11
|
{
|
|
12
12
|
name: 'browse_navigate',
|
|
13
13
|
title: 'Navigate tab',
|
|
14
|
-
description: 'Navigate the active/focused tab to a URL.',
|
|
14
|
+
description: 'Navigate the active/focused tab to a URL. Returns as soon as the requested milestone is reached (default: DOM ready) — it does not wait for the load event, which SPAs often never fire.',
|
|
15
15
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
16
16
|
inputSchema: {
|
|
17
17
|
type: 'object',
|
|
18
18
|
properties: {
|
|
19
19
|
url: { type: 'string', description: 'URL to navigate to.' },
|
|
20
|
-
tab: { type: 'integer', description: 'Tab id (default: active tab).' }
|
|
20
|
+
tab: { type: 'integer', description: 'Tab id (default: active tab).' },
|
|
21
|
+
wait_until: { type: 'string', enum: ['commit', 'domcontentloaded', 'load'], description: 'How far to wait. commit = navigation committed (fastest, content may not be painted); domcontentloaded = DOM ready (default, safe for SPAs); load = also subresources (can hang forever on pages that never fire load).' }
|
|
21
22
|
},
|
|
22
23
|
required: ['url']
|
|
23
24
|
}
|
|
@@ -41,20 +42,21 @@ export const MCP_TOOLS = [
|
|
|
41
42
|
{
|
|
42
43
|
name: 'browse_act',
|
|
43
44
|
title: 'Act on element',
|
|
44
|
-
description: 'Act on an element by data-ai-id: click, type (append at caret), setContent (replace all), clear, focus, hover, scroll_to, upload, get_value (read-only), drag, press, wheel.',
|
|
45
|
+
description: 'Act on an element by data-ai-id: click, type (append at caret), setContent (replace all), clear, focus, hover, scroll_to, upload, get_value (read-only), drag, press, wheel, paste.',
|
|
45
46
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
46
47
|
inputSchema: {
|
|
47
48
|
type: 'object',
|
|
48
49
|
properties: {
|
|
49
|
-
action: { type: 'string', enum: ['click', 'type', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel'], description: 'Action.' },
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
action: { type: 'string', enum: ['click', 'type', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Action.' },
|
|
51
|
+
html: { type: 'string', description: 'paste: HTML to inject (preferred). Editors convert it through their own paste pipeline (Draft.js/ProseMirror/Quill), so headings/code blocks/bold/lists survive. Plain text loses all formatting.' },
|
|
52
|
+
target: { type: 'string', description: 'Element data-ai-id from the semantic tree (e.g. e:button-12, axf-3-1) — NOT the visible label. Omit for press (keys go to the current focus); for drag you may also omit it and pass from_x/from_y to start from a bare point. upload also accepts a CSS selector (descendant selectors with spaces included), which is the only way to reach upload widgets that have no semantic node.' },
|
|
53
|
+
text: { type: 'string', description: 'Text to type (type/setContent). For paste: plain-text fallback used when html is absent.' },
|
|
52
54
|
value: { type: 'string', description: 'Value for select.' },
|
|
53
55
|
offset: { type: 'integer', description: 'get_value: start offset in Unicode code points (default 0).' },
|
|
54
56
|
limit: { type: 'integer', description: 'get_value: max code points to return (default 20000, hard cap 1000000).' },
|
|
55
57
|
url: { type: 'string', description: 'For click: click the link whose href matches this URL instead of guessing the target label.' },
|
|
56
58
|
keep_tab: { type: 'boolean', description: 'For click: keep the current tab active instead of auto-following a newly opened tab.' },
|
|
57
|
-
file: { type: 'string', description: 'Absolute path to upload (upload only
|
|
59
|
+
file: { type: 'string', description: 'Absolute path to upload (upload only). target may be the file input itself, the control that opens the picker, or — when the control has no semantic node at all — a CSS selector such as ".upload-button". Works inside cross-process iframes.' },
|
|
58
60
|
timeout_ms: { type: 'integer', description: 'Override the action deadline in ms (default 15000; type/setContent/drag/press scale with text length / duration / repeat; 1s floor, 300s cap). A timeout returns success=false, timed_out=true and warns the action may have partially applied.' },
|
|
59
61
|
to_target: { type: 'string', description: 'drag: data-ai-id to drop onto (e.g. drag from one node\'s output to another node to connect them).' },
|
|
60
62
|
dx: { type: 'number', description: 'drag: horizontal distance to move (use with dy instead of to_target).' },
|
|
@@ -79,13 +81,14 @@ export const MCP_TOOLS = [
|
|
|
79
81
|
{
|
|
80
82
|
name: 'browse_evaluate',
|
|
81
83
|
title: 'Run page JavaScript',
|
|
82
|
-
description: 'Run a JS expression in the page context and return its value.',
|
|
84
|
+
description: 'Run a JS expression in the page context and return its value. Promises are awaited (async IIFEs return their resolved value, not {}).',
|
|
83
85
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
84
86
|
inputSchema: {
|
|
85
87
|
type: 'object',
|
|
86
88
|
properties: {
|
|
87
|
-
js: { type: 'string', description: 'Page-context JS expression (not statements). Max 5000 chars.' },
|
|
88
|
-
tab: { type: 'integer', description: 'Tab id (default: active tab).' }
|
|
89
|
+
js: { type: 'string', description: 'Page-context JS expression (not statements). Max 5000 chars. Promises are awaited.' },
|
|
90
|
+
tab: { type: 'integer', description: 'Tab id (default: active tab).' },
|
|
91
|
+
frame_ref: { type: 'string', description: 'Run inside this cross-process frame (an axf-... id from the tree). Pass it explicitly when touching iframe content: building the selector by string concatenation defeats the automatic route and the top-frame run returns null.' }
|
|
89
92
|
},
|
|
90
93
|
required: ['js']
|
|
91
94
|
}
|
|
@@ -122,6 +125,44 @@ export const MCP_TOOLS = [
|
|
|
122
125
|
required: ['condition']
|
|
123
126
|
}
|
|
124
127
|
},
|
|
128
|
+
{
|
|
129
|
+
name: 'browse_auth_state',
|
|
130
|
+
title: 'Check login / auth-wall state',
|
|
131
|
+
description: 'Is this tab still logged in, or bounced to a login wall? auth_wall.detected=true means stop debugging and re-authenticate first. logged_in is heuristic; auth_wall is the signal to trust.',
|
|
132
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
tab: { type: 'integer', description: 'Tab id (default: active tab).' }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'browse_screenshot',
|
|
142
|
+
title: 'Screenshot the viewport',
|
|
143
|
+
description: 'Screenshot the viewport (base64 in data). Use FIRST when an action misbehaves: the element tree cannot show overlays, canvas, occlusion or glitches. Warns if a visible password input is on screen.',
|
|
144
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
tab: { type: 'integer', description: 'Tab id (default: active tab).' },
|
|
149
|
+
format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Image format. Default png. jpeg at quality 85 is ~5x smaller — prefer it for long pages.' },
|
|
150
|
+
quality: { type: 'number', description: '0-100, jpeg/webp only (ignored for png). Default 85 for jpeg.' },
|
|
151
|
+
fullPage: { type: 'boolean', description: 'Capture the full scrollable page, not just the viewport. Cannot be combined with clip.' },
|
|
152
|
+
clip: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
description: 'Capture a rectangle in CSS pixels: {x, y, width, height, scale?}. Use to zoom into one element when the viewport shot is too small to read.',
|
|
155
|
+
properties: {
|
|
156
|
+
x: { type: 'number' },
|
|
157
|
+
y: { type: 'number' },
|
|
158
|
+
width: { type: 'number' },
|
|
159
|
+
height: { type: 'number' },
|
|
160
|
+
scale: { type: 'number', description: 'Device scale factor, 0-4. Default 1.' }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
125
166
|
{
|
|
126
167
|
name: 'browse_list_tabs',
|
|
127
168
|
title: 'List tabs',
|
|
@@ -279,6 +320,19 @@ export const MCP_TOOLS = [
|
|
|
279
320
|
},
|
|
280
321
|
},
|
|
281
322
|
},
|
|
323
|
+
{
|
|
324
|
+
name: 'browse_errors',
|
|
325
|
+
title: 'Read error log',
|
|
326
|
+
description: 'Read the main-process error log: uncaught exceptions, crashes, failed navigations, RPC errors. Each entry includes recent calls as context. Returns the on-disk JSONL path.',
|
|
327
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
328
|
+
inputSchema: {
|
|
329
|
+
type: 'object',
|
|
330
|
+
properties: {
|
|
331
|
+
limit: { type: 'integer', description: 'Max entries to return (default 50).' },
|
|
332
|
+
kind: { type: 'string', description: 'Filter by kind: uncaught_exception / unhandled_rejection / render_process_gone / child_process_gone / nav_fail / ws_error.' },
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
},
|
|
282
336
|
{
|
|
283
337
|
name: 'browse_quit',
|
|
284
338
|
title: 'Quit browser',
|