@glittering0717/ai-browser 2.1.0 → 2.1.1
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/build/icon.icns +0 -0
- package/build/icon.png +0 -0
- package/package.json +5 -2
- package/skills/ai-browser/references/cli.md +2 -2
- package/src/main/index.js +32 -0
- package/src/main/page_manager.js +57 -9
- package/src/main/window_open_shim_source.js +4 -1
- package/src/main/ws_server.js +13 -1
- package/src/shared/version.js +1 -1
package/build/icon.icns
ADDED
|
Binary file
|
package/build/icon.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
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": {
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"examples/AGENT_INSTRUCTIONS.md",
|
|
16
16
|
"examples/mcp-config.json",
|
|
17
17
|
"llms-install.md",
|
|
18
|
-
"LICENSE"
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"build/icon.png",
|
|
20
|
+
"build/icon.icns"
|
|
19
21
|
],
|
|
20
22
|
"type": "module",
|
|
21
23
|
"license": "MIT",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"audit": "node e2e/audit_ws.cjs",
|
|
73
75
|
"e2e:all": "node e2e/run_all.cjs",
|
|
74
76
|
"readcov": "node e2e/readcov_ws.cjs",
|
|
77
|
+
"winopen": "node e2e/window_open_ws.cjs",
|
|
75
78
|
"coverage": "node tools/coverage_probe.cjs"
|
|
76
79
|
},
|
|
77
80
|
"dependencies": {
|
|
@@ -46,8 +46,8 @@ ai-browser call ui.get_tree '{"tab":1,"subset":"interactive"}' # 交互树
|
|
|
46
46
|
ai-browser call ui.get_tree '{"tab":1,"ax":true}' # 阅读树(长正文用它)
|
|
47
47
|
ai-browser call ui.act '{"action":"click","target":"e:a-3"}'
|
|
48
48
|
ai-browser call ui.act '{"action":"type","target":"e:input-1","text":"关键词"}'
|
|
49
|
-
ai-browser call ui.evaluate '{"
|
|
50
|
-
ai-browser call ui.evaluate '{"frame_ref":"frame-1","
|
|
49
|
+
ai-browser call ui.evaluate '{"js":"document.title"}'
|
|
50
|
+
ai-browser call ui.evaluate '{"frame_ref":"frame-1","js":"document.body.innerText"}'
|
|
51
51
|
ai-browser call ui.wait '{"condition":"text_contains","text":"加载完成"}'
|
|
52
52
|
ai-browser call ui.network_list '{"tab":1}'
|
|
53
53
|
ai-browser call ui.screenshot '{"tab":1}'
|
package/src/main/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// One process, one WS server, multiple tabs with real tab bar.
|
|
3
3
|
import { app, BrowserWindow, BrowserView, ipcMain, net, session, dialog } from 'electron';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { startWSServer } from './ws_server.js';
|
|
7
8
|
import { PageManager } from './page_manager.js';
|
|
@@ -326,10 +327,40 @@ if (gotLock) {
|
|
|
326
327
|
app.quit();
|
|
327
328
|
}
|
|
328
329
|
|
|
330
|
+
// === 应用图标(2026-10-20)===
|
|
331
|
+
//
|
|
332
|
+
// macOS 上从源码启动时 Dock 图标是 Electron 的默认火箭 —— 因为进程本体就是
|
|
333
|
+
// node_modules/electron/dist/Electron.app,**Dock 跟的是可执行文件,不是我们的
|
|
334
|
+
// 代码**。打包版没有这个问题(Info.plist 里已经是自定义 icns),但 `npm start`
|
|
335
|
+
// / `npx` 启动占日常使用的大头;图标错了,用户的判断就是"这不是 AI Browser"。
|
|
336
|
+
// (2026-10-20 原话:"我看现在系统里运行的 ai-browser 都是默认图标了"。)
|
|
337
|
+
//
|
|
338
|
+
// app.dock.setIcon 是 Electron 提供的运行时入口,不影响签名与打包。
|
|
339
|
+
// Windows/Linux 走 BrowserWindow 的 icon 选项(见 createWindow)。
|
|
340
|
+
function appIconPath() {
|
|
341
|
+
const p = path.join(__dirname, '..', '..', 'build', 'icon.png');
|
|
342
|
+
try { return fs.existsSync(p) ? p : null; } catch (_) { return null; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function applyAppIcon() {
|
|
346
|
+
if (process.platform !== 'darwin') return false; // 其他平台由窗口 icon 选项负责
|
|
347
|
+
const icon = appIconPath();
|
|
348
|
+
if (!icon) return false; // 装的是精简包(没带 build/)→ 静默跳过,不值得启动失败
|
|
349
|
+
try {
|
|
350
|
+
if (app.dock && typeof app.dock.setIcon === 'function') { app.dock.setIcon(icon); return true; }
|
|
351
|
+
} catch (e) {
|
|
352
|
+
console.error('[icon] dock.setIcon failed:', e && e.message);
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
329
357
|
function createWindow() {
|
|
330
358
|
mainWindow = new BrowserWindow({
|
|
331
359
|
width: 1280,
|
|
332
360
|
height: 800,
|
|
361
|
+
// Windows/Linux 的任务栏图标来自这里(macOS 走 app.dock.setIcon,
|
|
362
|
+
// 见 applyAppIcon:Dock 跟的是进程可执行文件,不是窗口选项)。
|
|
363
|
+
icon: appIconPath() || undefined,
|
|
333
364
|
// 先不显示:如何显示由 showInitialWindow() 按焦点策略决定 —— 窗口必须真实可见
|
|
334
365
|
// (产品承诺人可随时介入),但被 agent 拉起时不该把用户正在做的事打断。
|
|
335
366
|
show: false,
|
|
@@ -764,6 +795,7 @@ function normalizeUrlInput(input) {
|
|
|
764
795
|
}
|
|
765
796
|
|
|
766
797
|
app.whenReady().then(() => {
|
|
798
|
+
applyAppIcon();
|
|
767
799
|
createWindow();
|
|
768
800
|
|
|
769
801
|
// === 自检钩子:AI_BROWSER_SELFTEST=crash ===
|
package/src/main/page_manager.js
CHANGED
|
@@ -81,6 +81,11 @@ const CANVAS_BRIDGE_KEY = '__aiCanvasBridge';
|
|
|
81
81
|
// 请求头都拿不到)。关闭开关:AI_BROWSER_NETWORK_CAPTURE=0。
|
|
82
82
|
const NETWORK_CAPTURE_DEFAULT = !(process.env.AI_BROWSER_NETWORK_CAPTURE === '0' || process.env.AI_BROWSER_NETWORK_CAPTURE === 'false');
|
|
83
83
|
|
|
84
|
+
// window.open("") 派生 tab 的"等垫片再导航"上限。超过就照常导航(退化成
|
|
85
|
+
// 该 tab 没有垫片),页面绝不能因为垫片安装慢而永久空白。
|
|
86
|
+
// 实测正常路径约 120–300ms(about:blank 的 did-finish-load + 两次 CDP 命令)。
|
|
87
|
+
const WINDOW_OPEN_NAV_WAIT_MS = 2000;
|
|
88
|
+
|
|
84
89
|
// 具名按键 → CDP Input.dispatchKeyEvent 参数。agent 用自然名字('Delete'、
|
|
85
90
|
// 'Escape'、'ArrowRight'、'a')而不是让人去查 Windows 虚拟键码。
|
|
86
91
|
// vk 用 Windows 虚拟键码(Chromium 在 macOS 上也按它解释)。
|
|
@@ -174,6 +179,12 @@ class PageManager {
|
|
|
174
179
|
this._openedTabs = new Map(); // sourceTabId -> { id, url } (new tab opened by window.open)
|
|
175
180
|
this._windowOpenTokens = new Map(); // token -> { tabId, sourceTabId } (window.open shim, preload bridge.cjs)
|
|
176
181
|
this._windowOpenShimTabs = new Set(); // 已注入 window.open 垫片的 tabId
|
|
182
|
+
// tabId -> Promise<void>:该 tab 的垫片**注册完成**的信号(不是"注入成功",
|
|
183
|
+
// 失败也 resolve —— 调用方要的是"可以往下走了",不是"一切顺利")。
|
|
184
|
+
// 存在的唯一理由:window.open("") 派生的 tab 里,ai:window_open_nav 必须等
|
|
185
|
+
// 垫片登记完再导航,否则真实文档先到、垫片只对"下一次文档"生效,那个 tab
|
|
186
|
+
// 就成了没有垫片的窗中窗(见 newTab 末尾长注释)。
|
|
187
|
+
this._windowOpenShimReady = new Map();
|
|
177
188
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
178
189
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
179
190
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
@@ -328,16 +339,41 @@ class PageManager {
|
|
|
328
339
|
const entry = this._windowOpenTokens.get(p.token);
|
|
329
340
|
const url = typeof p.url === 'string' ? p.url : '';
|
|
330
341
|
if (!entry || !url) return;
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
342
|
+
const nav = () => {
|
|
343
|
+
const view = this._getView(entry.tabId);
|
|
344
|
+
if (!view) {
|
|
345
|
+
// tab 已被用户/Agent 关掉:赋值静默丢弃(真实浏览器里关掉的
|
|
346
|
+
// 窗口被赋 location 也是无人接收)。
|
|
347
|
+
console.warn('[window_open] nav target tab gone:', entry.tabId, url);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// loadURL 的 rejection 由页面自身处理(站点可能会立刻再导航一次),
|
|
351
|
+
// 这里只防 unhandled。
|
|
335
352
|
view.webContents.loadURL(url).catch(() => {});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
353
|
+
};
|
|
354
|
+
// ⚠️ **必须等垫片登记完再导航**(2026-10-20 修,超星"进入学习"第二层)。
|
|
355
|
+
//
|
|
356
|
+
// 原来这里直接 loadURL。看上去时序够用(站点 ajax 往返几十毫秒,
|
|
357
|
+
// 而 about:blank 的 did-finish-load 通常更早),但那是**赌**:
|
|
358
|
+
// 只要 nav 抢在 did-finish-load 之前到达,真实文档就先把 renderer 占住,
|
|
359
|
+
// 随后 addScriptToEvaluateOnNewDocument 只对"下一次文档"生效 ——
|
|
360
|
+
// 新 tab 里 window.open 是原生实现,站点再走一次
|
|
361
|
+
// `window.open("") + w.location = url` 就得到 null,静默不跳转。
|
|
362
|
+
// 实测(2026-10-20):列表页的"进入学习"能开,新 tab 的 shim 是 false。
|
|
363
|
+
const ready = this._windowOpenShimReady.get(entry.tabId);
|
|
364
|
+
if (!ready) { nav(); return; } // 老路径/测试桩:没有信号就直接走
|
|
365
|
+
let timer = null;
|
|
366
|
+
const cap = new Promise((resolve) => {
|
|
367
|
+
timer = setTimeout(resolve, WINDOW_OPEN_NAV_WAIT_MS);
|
|
368
|
+
if (timer.unref) timer.unref();
|
|
369
|
+
});
|
|
370
|
+
Promise.race([ready, cap]).then(() => {
|
|
371
|
+
if (timer) clearTimeout(timer);
|
|
372
|
+
nav();
|
|
373
|
+
}, () => {
|
|
374
|
+
if (timer) clearTimeout(timer);
|
|
375
|
+
nav();
|
|
376
|
+
});
|
|
341
377
|
});
|
|
342
378
|
|
|
343
379
|
ipcMain.on('ai:window_open_close', (_event, payload) => {
|
|
@@ -3093,11 +3129,18 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3093
3129
|
const wc0 = view.webContents;
|
|
3094
3130
|
const targetUrl = url || null;
|
|
3095
3131
|
if (wc0 && !wc0.isDestroyed()) {
|
|
3132
|
+
// 垫片就绪信号:先挂上去,再去加载 about:blank。顺序不能反 ——
|
|
3133
|
+
// ai:window_open_nav 可能在这一行之后立刻到达(IPC 保序,但它不等我们)。
|
|
3134
|
+
let signalReady = () => {};
|
|
3135
|
+
const ready = new Promise((resolve) => { signalReady = resolve; });
|
|
3136
|
+
this._windowOpenShimReady.set(tabId, ready);
|
|
3096
3137
|
let proceeded = false;
|
|
3097
3138
|
const proceed = () => {
|
|
3098
3139
|
if (proceeded) return;
|
|
3099
3140
|
proceeded = true;
|
|
3100
3141
|
Promise.resolve(this._ensureWindowOpenShim(tabId, view)).finally(() => {
|
|
3142
|
+
// 失败也放行:宁可这个 tab 没有垫片,也不能把它卡在 about:blank。
|
|
3143
|
+
signalReady();
|
|
3101
3144
|
if (targetUrl && wc0 && !wc0.isDestroyed()) {
|
|
3102
3145
|
wc0.loadURL(targetUrl).catch(() => {});
|
|
3103
3146
|
}
|
|
@@ -3338,6 +3381,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3338
3381
|
if (entry.tabId === tabId) this._windowOpenTokens.delete(token);
|
|
3339
3382
|
}
|
|
3340
3383
|
this._windowOpenShimTabs.delete(tabId);
|
|
3384
|
+
this._windowOpenShimReady.delete(tabId);
|
|
3341
3385
|
// Release per-tab network resources so a closed tab can never leak its
|
|
3342
3386
|
// debugger attachment or cached request entries / stale monitors.
|
|
3343
3387
|
this._cleanupTabResources(tabId);
|
|
@@ -3815,6 +3859,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3815
3859
|
);
|
|
3816
3860
|
return true;
|
|
3817
3861
|
} catch (e) {
|
|
3862
|
+
// 失败时撤掉记账:留着就等于给这个 tab 判了"永久无垫片"。
|
|
3863
|
+
// 撤掉后下一次 window.open 还有机会补上(CDP 偶发挂住是环境问题,
|
|
3864
|
+
// 不是这个 tab 的固有属性)。
|
|
3865
|
+
this._windowOpenShimTabs.delete(tabId);
|
|
3818
3866
|
console.error('[window_open] shim inject failed:', tabId, e && e.message);
|
|
3819
3867
|
return false;
|
|
3820
3868
|
}
|
|
@@ -49,7 +49,10 @@ export const WINDOW_OPEN_SHIM_SOURCE = `(function () {
|
|
|
49
49
|
window.open = function (url, name, _features) {
|
|
50
50
|
var target = (url === undefined || url === null) ? '' : String(url);
|
|
51
51
|
var token = 'wo' + (++seq) + '_' + Date.now();
|
|
52
|
-
|
|
52
|
+
// ⚠️ 相对地址必须在这里就解析成绝对。主进程拿到的 '/foo.html' 没有 base,
|
|
53
|
+
// loadURL 会直接报 ERR_INVALID_URL(表现为一个 file:// 错误页)。
|
|
54
|
+
// 实测 2026-10-20:window.open('/l2.html','_blank') 开出的是错误页。
|
|
55
|
+
var ok = send('open', token, target ? abs(target) : '', name || '');
|
|
53
56
|
if (!ok) {
|
|
54
57
|
// 没有桥:退回原生语义(deny → null),至少不像成功那样骗站点。
|
|
55
58
|
return null;
|
package/src/main/ws_server.js
CHANGED
|
@@ -206,7 +206,19 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
|
|
|
206
206
|
break;
|
|
207
207
|
}
|
|
208
208
|
case 'ui.evaluate': {
|
|
209
|
-
|
|
209
|
+
// 参数名是 js;expr 是 MCP 层的叫法,作为别名保留 —— 文档里写错一次
|
|
210
|
+
// (2026-10-20 技能里写成 expr)就会得到 `{value: undefined}`,
|
|
211
|
+
// 看上去像"页面没东西",实际是表达式根本没执行。别名 + 空表达式报错,
|
|
212
|
+
// 把"静默返回 undefined"这条路彻底堵掉。
|
|
213
|
+
const rawJs = params.js !== undefined ? params.js : params.expr;
|
|
214
|
+
const js = String(rawJs === undefined || rawJs === null ? '' : rawJs);
|
|
215
|
+
if (!js.trim()) {
|
|
216
|
+
send({
|
|
217
|
+
jsonrpc: '2.0', id,
|
|
218
|
+
error: { code: -32602, message: 'empty expression: pass {"js":"<expression>"} (alias: "expr")' },
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
210
222
|
// Guard rails (shared single source) — enforce length + reject
|
|
211
223
|
// obvious Node-exfil patterns. The MCP layer applies the same guard;
|
|
212
224
|
// a direct ws client bypasses MCP, so enforce the same contract here.
|
package/src/shared/version.js
CHANGED
|
@@ -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 = '2.1.
|
|
5
|
+
export const VERSION = '2.1.1';
|