@glittering0717/ai-browser 2.1.17 → 2.1.19
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 +9 -3
- package/package.json +3 -2
- package/src/main/page_manager.js +105 -37
- package/src/shared/version.js +1 -1
package/README.md
CHANGED
|
@@ -724,7 +724,7 @@ not bugs. The full write-up, including why the first measurement of this was wro
|
|
|
724
724
|
## Test & dev
|
|
725
725
|
|
|
726
726
|
```bash
|
|
727
|
-
npm test # vitest —
|
|
727
|
+
npm test # vitest — 970 tests across 69 files
|
|
728
728
|
npm run smoke # Electron contract layer, offline fixture — 31 checks
|
|
729
729
|
npm run capabilities # text fields, canvas, page source, network, DOM editing — 54 checks
|
|
730
730
|
npm run richtext # rich-text editors, nested menus, forms, errors, tags, upload — 117 checks
|
|
@@ -736,8 +736,14 @@ npm run audit # every advertised action must exist; wait/downloads/permis
|
|
|
736
736
|
npm run nav # address bar + load-failure error page (the "white screen" report) — 19 checks
|
|
737
737
|
npm run focus # no focus stealing + full capability while backgrounded — 32 checks
|
|
738
738
|
npm run interact # trusted drag / key press / wheel (pointer gestures) — 31 checks
|
|
739
|
-
npm run view # viewport repaint + snapshot/diff verification —
|
|
740
|
-
npm run
|
|
739
|
+
npm run view # viewport repaint budget + snapshot/diff verification — 29 checks
|
|
740
|
+
npm run dialog # browser-level dialogs (native sheet blocks the renderer) — 15 checks
|
|
741
|
+
npm run wrapper # "in the tree but won't click" = matched a container, not a link — 13 checks
|
|
742
|
+
npm run paint # blank-page self-check: verdicts, bounded ladder, no false alarms — 23 checks
|
|
743
|
+
npm run layout # geometry invariants + "no twitching" (zero extra repaints on nav) — 25 checks
|
|
744
|
+
npm run flicker # frame-level: no frame may render the page shrunk (needs screen recording) — 6 checks
|
|
745
|
+
npm run e2e:all # 一次跑完上面全部 19 个套件(每个独立端口)+ 可信汇总 — 636 checks
|
|
746
|
+
npm run verify:app # boot the packaged /Applications app and assert on it — 22 checks
|
|
741
747
|
npm run realsites # navigate/read/act against a matrix of real sites
|
|
742
748
|
```
|
|
743
749
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.19",
|
|
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": {
|
|
@@ -84,7 +84,8 @@
|
|
|
84
84
|
"verify:app": "node e2e/shipped_ws.cjs",
|
|
85
85
|
"wrapper": "node e2e/wrapper_ws.cjs",
|
|
86
86
|
"paint": "node e2e/paint_ws.cjs",
|
|
87
|
-
"layout": "node e2e/layout_ws.cjs"
|
|
87
|
+
"layout": "node e2e/layout_ws.cjs",
|
|
88
|
+
"flicker": "node e2e/flicker_ws.cjs"
|
|
88
89
|
},
|
|
89
90
|
"dependencies": {
|
|
90
91
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/src/main/page_manager.js
CHANGED
|
@@ -1040,7 +1040,18 @@ class PageManager {
|
|
|
1040
1040
|
captureBeyondViewport: o.fullPage,
|
|
1041
1041
|
};
|
|
1042
1042
|
if (o.quality !== undefined) params.quality = o.quality;
|
|
1043
|
-
|
|
1043
|
+
// clip.scale < 1("给我一张缩略概览")**绝不交给合成器**:带 clip.scale 的
|
|
1044
|
+
// 截图会让 Chromium 按该比例临时重建 surface,窗口恰好在合成时就会贴上那张
|
|
1045
|
+
// 缩小的图 1–3 帧(见 SPEC「取帧两条铁律」,2026-10-25 实锤)。
|
|
1046
|
+
// 现在按 scale=1 抓、用 nativeImage 在我们自己这边缩 —— 输出尺寸一致,
|
|
1047
|
+
// 但不再碰合成器。scale>1(放大取值)仍交给 Chromium:本地放大得不到像素。
|
|
1048
|
+
let localScale = 1;
|
|
1049
|
+
if (o.clip && typeof o.clip.scale === 'number' && o.clip.scale > 0 && o.clip.scale < 1) {
|
|
1050
|
+
localScale = o.clip.scale;
|
|
1051
|
+
params.clip = { ...o.clip, scale: 1 };
|
|
1052
|
+
} else if (o.clip) {
|
|
1053
|
+
params.clip = o.clip;
|
|
1054
|
+
}
|
|
1044
1055
|
const shot = await withDeadline(
|
|
1045
1056
|
dbg.sendCommand('Page.captureScreenshot', params),
|
|
1046
1057
|
20000, 'captureScreenshot'
|
|
@@ -1048,6 +1059,21 @@ class PageManager {
|
|
|
1048
1059
|
if (!shot || typeof shot.data !== 'string') {
|
|
1049
1060
|
return { ok: false, error: 'empty screenshot result', tab: tid };
|
|
1050
1061
|
}
|
|
1062
|
+
// 需要缩小就自己做(见上:不把缩放交给合成器)。
|
|
1063
|
+
let imgData = shot.data;
|
|
1064
|
+
if (localScale !== 1) {
|
|
1065
|
+
try {
|
|
1066
|
+
const img0 = nativeImage.createFromBuffer(Buffer.from(shot.data, 'base64'));
|
|
1067
|
+
if (!img0.isEmpty()) {
|
|
1068
|
+
const sz = img0.getSize();
|
|
1069
|
+
const w = Math.max(1, Math.round(sz.width * localScale));
|
|
1070
|
+
const resized = img0.resize({ width: w });
|
|
1071
|
+
imgData = o.format === 'jpeg'
|
|
1072
|
+
? resized.toJPEG(typeof o.quality === 'number' ? o.quality : 80).toString('base64')
|
|
1073
|
+
: resized.toPNG().toString('base64');
|
|
1074
|
+
}
|
|
1075
|
+
} catch (_) { /* 缩失败就用原图(宁可图大,不要空图) */ }
|
|
1076
|
+
}
|
|
1051
1077
|
|
|
1052
1078
|
const mime = 'image/' + (o.format === 'jpeg' ? 'jpeg' : o.format);
|
|
1053
1079
|
// 一次 evaluate 拿两样东西:可见密码框(截图不脱敏,泄密了得让调用方
|
|
@@ -1070,7 +1096,7 @@ class PageManager {
|
|
|
1070
1096
|
const summary = screenshotOpts.describeShot({
|
|
1071
1097
|
format: o.format,
|
|
1072
1098
|
mime,
|
|
1073
|
-
bytes: Buffer.byteLength(
|
|
1099
|
+
bytes: Buffer.byteLength(imgData, 'base64'),
|
|
1074
1100
|
width: meta.width,
|
|
1075
1101
|
height: meta.height,
|
|
1076
1102
|
url: (this._getView(tid) && this._getView(tid).webContents.getURL()) || undefined,
|
|
@@ -1082,7 +1108,7 @@ class PageManager {
|
|
|
1082
1108
|
if (o.path) {
|
|
1083
1109
|
try {
|
|
1084
1110
|
fs.mkdirSync(path.dirname(o.path), { recursive: true });
|
|
1085
|
-
fs.writeFileSync(o.path, Buffer.from(
|
|
1111
|
+
fs.writeFileSync(o.path, Buffer.from(imgData, 'base64'));
|
|
1086
1112
|
filePath = o.path;
|
|
1087
1113
|
} catch (e) {
|
|
1088
1114
|
return { ok: false, tab: tid, error: 'screenshot captured but writing to ' + o.path + ' failed: ' + ((e && e.message) || e), ...summary };
|
|
@@ -1095,7 +1121,7 @@ class PageManager {
|
|
|
1095
1121
|
ok: true,
|
|
1096
1122
|
tab: tid,
|
|
1097
1123
|
// data 是 base64 裸数据(不带 data: 前缀)—— 调用方按需自己拼。
|
|
1098
|
-
data:
|
|
1124
|
+
data: imgData,
|
|
1099
1125
|
mime,
|
|
1100
1126
|
...(filePath ? { path: filePath } : {}),
|
|
1101
1127
|
...(ignored.length ? { ignored_params: ignored } : {}),
|
|
@@ -3727,41 +3753,46 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3727
3753
|
let stats = null;
|
|
3728
3754
|
let frameError = null;
|
|
3729
3755
|
try {
|
|
3730
|
-
//
|
|
3731
|
-
// webContents.capturePage()。实测(2026-10-25,e2e/paint 与真机):
|
|
3732
|
-
// capturePage 对**被其它窗口盖住**的窗口返回空图(isEmpty()===true),
|
|
3733
|
-
// 而"窗口被盖住"恰恰是本产品最常见的状态(不抢焦点原则)。用 capturePage
|
|
3734
|
-
// 就等于在最需要观测的场景下失明 —— 判出来是 no-frame,什么都没看见。
|
|
3735
|
-
// CDP 的 Page.captureScreenshot 走渲染器自己的 surface,被遮挡也拿得到。
|
|
3756
|
+
// 位图来源,优先级从"最不容易打扰合成器"往下排:
|
|
3736
3757
|
//
|
|
3737
|
-
//
|
|
3738
|
-
//
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
const img = nativeImage.createFromBuffer(buf);
|
|
3758
|
-
if (img && !img.isEmpty()) stats = blankPage.statsFromBitmap(img.toBitmap());
|
|
3759
|
-
}
|
|
3758
|
+
// 1) webContents.capturePage() —— 不碰 CDP 的 Page 域、没有 capture 模式,
|
|
3759
|
+
// 只是把视图自己的 surface 拷一份。窗口被别的窗口盖住时会返回空图
|
|
3760
|
+
// (isEmpty()),这时跳过、交给下一档,绝不据此判"页面是白的"。
|
|
3761
|
+
// 2) CDP Page.captureScreenshot,**不带 clip / scale**。
|
|
3762
|
+
//
|
|
3763
|
+
// ⚠️ 2026-10-25 血泪:这里原本是
|
|
3764
|
+
// clip: {x:0,y:0,width:w,height:h, scale:0.25}
|
|
3765
|
+
// 图省事把统计用的图直接缩到 1/4。带 clip.scale 的截图会让 Chromium
|
|
3766
|
+
// **按该比例临时重建 surface**;自检又恰好在 did-finish-load+450ms 跑,
|
|
3767
|
+
// 那几十毫秒里窗口往往正在重新合成,于是把这张 1/4 的图贴上去 1–3 帧,
|
|
3768
|
+
// 用户看到的就是"每次加载先小一下再恢复"。实测抓到的那一帧内容宽度
|
|
3769
|
+
// 正好是 25%,与 clip.scale 一致。
|
|
3770
|
+
// 结论:**缩放只许在我们自己这边做**(nativeImage.resize),不许交给合成器。
|
|
3771
|
+
try {
|
|
3772
|
+
const img0 = await withDeadline(wc.capturePage(), 3000, 'capturePage');
|
|
3773
|
+
if (img0 && typeof img0.isEmpty === 'function' && !img0.isEmpty()) {
|
|
3774
|
+
const small0 = img0.resize({ width: 240 });
|
|
3775
|
+
stats = blankPage.statsFromBitmap(small0.toBitmap());
|
|
3776
|
+
}
|
|
3777
|
+
} catch (_) { /* 视图被遮挡/正在销毁:交给 CDP 档 */ }
|
|
3760
3778
|
if (!stats) {
|
|
3761
|
-
const
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3779
|
+
const shot = await withDeadline(
|
|
3780
|
+
(() => {
|
|
3781
|
+
this._ensureCdp(tid, view, 'Page');
|
|
3782
|
+
return view.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
|
3783
|
+
format: 'jpeg',
|
|
3784
|
+
quality: 70,
|
|
3785
|
+
fromSurface: true,
|
|
3786
|
+
});
|
|
3787
|
+
})(),
|
|
3788
|
+
5000, 'paint_shot'
|
|
3789
|
+
).catch(() => null);
|
|
3790
|
+
if (shot && shot.data) {
|
|
3791
|
+
const img = nativeImage.createFromBuffer(Buffer.from(shot.data, 'base64'));
|
|
3792
|
+
if (img && !img.isEmpty()) {
|
|
3793
|
+
const small = img.resize({ width: 240 });
|
|
3794
|
+
stats = blankPage.statsFromBitmap(small.toBitmap());
|
|
3795
|
+
}
|
|
3765
3796
|
}
|
|
3766
3797
|
}
|
|
3767
3798
|
} catch (e) {
|
|
@@ -3869,6 +3900,43 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3869
3900
|
|| actual.x !== expected.x
|
|
3870
3901
|
|| actual.y !== expected.y;
|
|
3871
3902
|
if (!bad) return { ok: true, expected, actual };
|
|
3903
|
+
// 不一致时先复核一次再动手:窗口刚变化的那一两百毫秒里,getContentBounds 与
|
|
3904
|
+
// 视图 bounds 会短暂对不上,此刻"纠正"等于把视图改成中间尺寸 —— 那正是
|
|
3905
|
+
// "页面缩成左上角一小块"的成因之一。宁可晚 120ms 修,也不要修错。
|
|
3906
|
+
return this._confirmAndFixLayout(tabId, expected, actual);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
_confirmAndFixLayout(tabId, expected, actual) {
|
|
3910
|
+
const view = this._getView(tabId);
|
|
3911
|
+
if (!view) return { ok: false, expected, actual, fixed: false };
|
|
3912
|
+
const t = setTimeout(() => {
|
|
3913
|
+
try {
|
|
3914
|
+
const v = this._getView(tabId);
|
|
3915
|
+
if (!v || !v.webContents || v.webContents.isDestroyed()) return;
|
|
3916
|
+
let cb;
|
|
3917
|
+
try { cb = this.window.getContentBounds(); } catch (_) { return; }
|
|
3918
|
+
const exp = {
|
|
3919
|
+
x: 0,
|
|
3920
|
+
y: config.tabBarHeight,
|
|
3921
|
+
width: cb.width,
|
|
3922
|
+
height: Math.max(0, cb.height - config.tabBarHeight - (this._bottomInset || 0)),
|
|
3923
|
+
};
|
|
3924
|
+
let act;
|
|
3925
|
+
try { act = v.getBounds(); } catch (_) { return; }
|
|
3926
|
+
const stillBad = act.width < exp.width - 2
|
|
3927
|
+
|| Math.abs(act.height - exp.height) > 2
|
|
3928
|
+
|| act.x !== exp.x || act.y !== exp.y;
|
|
3929
|
+
if (!stillBad) return; // 只是中间态,已经自己好了
|
|
3930
|
+
try {
|
|
3931
|
+
this._layoutView(v);
|
|
3932
|
+
v.webContents.invalidate();
|
|
3933
|
+
} catch (_) { /* 视图正在销毁 */ }
|
|
3934
|
+
recordError('layout_mismatch', { tab: tabId, expected: exp, actual: act });
|
|
3935
|
+
this._broadcast('layout_mismatch', { tab: tabId, expected: exp, actual: act });
|
|
3936
|
+
} catch (_) { /* 忽略 */ }
|
|
3937
|
+
}, 120);
|
|
3938
|
+
if (t.unref) t.unref();
|
|
3939
|
+
return { ok: false, expected, actual, fixed: 'deferred' };
|
|
3872
3940
|
try {
|
|
3873
3941
|
this._layoutView(view);
|
|
3874
3942
|
view.webContents.invalidate();
|
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.19';
|