@glittering0717/ai-browser 2.1.14 → 2.1.15
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/package.json +1 -1
- package/src/main/index.js +13 -5
- package/src/main/page_manager.js +106 -40
- package/src/main/ws_server.js +3 -1
- package/src/shared/version.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.15",
|
|
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": {
|
package/src/main/index.js
CHANGED
|
@@ -524,17 +524,25 @@ function createWindow() {
|
|
|
524
524
|
// 触发点选这几个:窗口缩放、进出全屏(macOS 全屏会换 display)、显示器参数变化
|
|
525
525
|
// (分辨率/缩放/插拔外接屏)、系统从睡眠唤醒 —— 这四类都会让"窗口几何"和
|
|
526
526
|
// "合成器手里的 surface 尺寸"脱节,是最常见的来源。
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
527
|
+
// 几何变化后不能只重画:还要确认"渲染视口真的跟上了新尺寸"(见 afterGeometryChange
|
|
528
|
+
// 的注释 —— 视图 800 而页面还按 1280 排版,就是用户看到的"大一下小一下")。
|
|
529
|
+
const afterGeometry = () => { try { pageManager.afterGeometryChange(); } catch (e) {} };
|
|
530
|
+
let geomTimer = null;
|
|
531
|
+
const afterGeometrySoon = (ms = 150) => {
|
|
532
|
+
if (geomTimer) clearTimeout(geomTimer);
|
|
533
|
+
geomTimer = setTimeout(afterGeometry, ms);
|
|
534
|
+
};
|
|
535
|
+
mainWindow.on('resize', () => afterGeometrySoon(150));
|
|
536
|
+
mainWindow.on('enter-full-screen', () => { layoutWithBanner(); afterGeometrySoon(120); });
|
|
537
|
+
mainWindow.on('leave-full-screen', () => { layoutWithBanner(); afterGeometrySoon(120); });
|
|
538
|
+
const onDisplayChange = () => { try { layoutWithBanner(); } catch (e) {} afterGeometrySoon(80); };
|
|
531
539
|
try {
|
|
532
540
|
screen.on('display-metrics-changed', onDisplayChange);
|
|
533
541
|
screen.on('display-added', onDisplayChange);
|
|
534
542
|
screen.on('display-removed', onDisplayChange);
|
|
535
543
|
} catch (e) { /* 无 screen 模块(极老版本) */ }
|
|
536
544
|
try {
|
|
537
|
-
powerMonitor.on('resume', () => { onDisplayChange();
|
|
545
|
+
powerMonitor.on('resume', () => { onDisplayChange(); afterGeometrySoon(400); });
|
|
538
546
|
} catch (e) { /* 无 powerMonitor */ }
|
|
539
547
|
mainWindow.on('app-command-added', (_e, cmd) => {
|
|
540
548
|
// macOS window-management commands (zoom / minimize / fullscreen).
|
package/src/main/page_manager.js
CHANGED
|
@@ -216,6 +216,10 @@ class PageManager {
|
|
|
216
216
|
this._openDialogs = new Map();
|
|
217
217
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
218
218
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
219
|
+
// 重绘计数:soft = 只 invalidate;hard = 额外 1px 微调(会重排两次,肉眼可见)。
|
|
220
|
+
// 高频路径(导航完成/窗口焦点)只许走 soft —— 这条要被回归钉住。
|
|
221
|
+
this._repaintCount = 0;
|
|
222
|
+
this._hardRepaintCount = 0;
|
|
219
223
|
// === 白屏自检(paint audit)===
|
|
220
224
|
// tabId -> {verdict, blank, white_ratio, unique, action, at, url}:最近一次自检的结论。
|
|
221
225
|
// 为什么必须有它:白屏是唯一一种"所有事件都成功、DOM 也正常、但用户看到的是空"的
|
|
@@ -3571,43 +3575,83 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3571
3575
|
* (only the active view is normally attached, so re-adding an inactive one
|
|
3572
3576
|
* would show it — guard by re-asserting the active view afterwards).
|
|
3573
3577
|
*/
|
|
3574
|
-
|
|
3578
|
+
/**
|
|
3579
|
+
* 窗口几何变过之后调一次:重排 → 软重绘 → 160ms 后确认渲染视口真的跟上了。
|
|
3580
|
+
*
|
|
3581
|
+
* 为什么不能只 setBounds:实测(e2e/layout_ws 的 G2,2026-10-25)窗口从
|
|
3582
|
+
* 1280 缩到 800 之后,视图 bounds 已经是 800,但页面 `innerWidth` 还是 1280 ——
|
|
3583
|
+
* 也就是渲染器没接到/没应用这次尺寸变化。此时合成器会把 1280 宽的内容画进
|
|
3584
|
+
* 800 的框里,内容整体缩小或错位;等它某一刻突然追上,就是用户说的
|
|
3585
|
+
* "一闪一闪、大一下小一下"。
|
|
3586
|
+
* 确认的方式和判定口径一致:innerWidth 必须等于视图宽度。不等就做一次 hard
|
|
3587
|
+
* 重绘(1px 微调会强制 Chromium 重算 surface 与布局)—— 这时代价是值得的,
|
|
3588
|
+
* 因为画面本来就是错的。
|
|
3589
|
+
*/
|
|
3590
|
+
afterGeometryChange() {
|
|
3591
|
+
const tid = this.activeTab;
|
|
3592
|
+
const view = this._getView(tid);
|
|
3593
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return;
|
|
3594
|
+
try { this._layoutView(view); } catch (_) { return; }
|
|
3595
|
+
this._forceRepaint(view); // soft:不可见
|
|
3596
|
+
const timer = setTimeout(async () => {
|
|
3597
|
+
try {
|
|
3598
|
+
const v = this._getView(tid);
|
|
3599
|
+
if (!v || !v.webContents || v.webContents.isDestroyed()) return;
|
|
3600
|
+
const b = v.getBounds();
|
|
3601
|
+
const raw = await withDeadline(this.evaluate('(function(){return String(window.innerWidth);})()', tid), 1500, 'geom_iw');
|
|
3602
|
+
const iw = Number((raw && typeof raw === 'object' && 'value' in raw) ? raw.value : raw) || 0;
|
|
3603
|
+
if (iw > 0 && b.width > 0 && Math.abs(iw - b.width) > 2) {
|
|
3604
|
+
recordError('layout_mismatch', {
|
|
3605
|
+
tab: tid, phase: 'after-resize',
|
|
3606
|
+
expected: { width: b.width }, actual: { innerWidth: iw },
|
|
3607
|
+
});
|
|
3608
|
+
this._forceRepaint(v, { hard: true });
|
|
3609
|
+
}
|
|
3610
|
+
} catch (_) { /* renderer 正在忙/已销毁:下一次 resize 会再来一次 */ }
|
|
3611
|
+
}, 160);
|
|
3612
|
+
if (timer.unref) timer.unref();
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
/**
|
|
3616
|
+
* 让合成器重新画一次,分两档。
|
|
3617
|
+
*
|
|
3618
|
+
* soft(默认):只 webContents.invalidate() —— 不改几何、不重排、不可见。
|
|
3619
|
+
* hard:额外做 1px 微调(width+1 再 width),因为**只有尺寸变化才会让 surface
|
|
3620
|
+
* 重新栅格化**(尺寸不变时 setBounds 被 Chromium 直接忽略)。
|
|
3621
|
+
*
|
|
3622
|
+
* 为什么要分档(2026-10-25 用户报"一闪一闪、大一下小一下"):
|
|
3623
|
+
* 1px 微调必然触发两次重排,文字会肉眼可见地跳一下;再叠 removeBrowserView /
|
|
3624
|
+
* addBrowserView 还会闪一帧白。把这套放在"每次导航完成 / 每次窗口焦点变化"
|
|
3625
|
+
* 这种高频路径上,就变成了用户看到的抽搐。
|
|
3626
|
+
* 所以高频路径一律 soft;只有"确认 surface 尺寸脱节"(scale-mismatch、白屏阶梯)
|
|
3627
|
+
* 才付 hard 的代价。
|
|
3628
|
+
*
|
|
3629
|
+
* 也不再 remove/add:那是过去没有 invalidate 时的唯一手段,代价是闪一帧白,
|
|
3630
|
+
* 而现在 invalidate 覆盖了它的作用。
|
|
3631
|
+
*/
|
|
3632
|
+
_forceRepaint(view, opts = {}) {
|
|
3633
|
+
const hard = opts && opts.hard === true;
|
|
3575
3634
|
const tabId = this._tabIdOfView(view);
|
|
3576
|
-
|
|
3635
|
+
if (tabId === null) return false;
|
|
3636
|
+
const wc = view.webContents;
|
|
3637
|
+
if (!wc || (typeof wc.isDestroyed === 'function' && wc.isDestroyed())) return false;
|
|
3577
3638
|
try {
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
// remove+add 只改变合成树里的位置,**不保证 surface 重新栅格化**:
|
|
3582
|
-
// 如果 surface 还是旧尺寸(窗口从大到小之后很常见),合成器会把那张大图
|
|
3583
|
-
// 缩小/裁到新 bounds 里 —— 用户看到的就是"内容缩成左上角一小块、其余全白"。
|
|
3584
|
-
// 真正的重栅格化要靠 invalidate();再叠一次 1px 微调,是因为
|
|
3585
|
-
// setBounds 尺寸没变时 Chromium 会直接忽略(不会重算 surface)。
|
|
3586
|
-
try { view.webContents.invalidate(); } catch (_) {}
|
|
3587
|
-
const b = view.getBounds();
|
|
3588
|
-
try {
|
|
3589
|
-
view.setBounds({ ...b, width: b.width + 1 });
|
|
3590
|
-
view.setBounds(b);
|
|
3591
|
-
} catch (_) {}
|
|
3592
|
-
} catch(e) { return false; }
|
|
3593
|
-
// Cumulative counter, reported by ui.repaint. Not just a debug aid: it is
|
|
3594
|
-
// the only externally observable proof that a repaint actually happened.
|
|
3595
|
-
// "The window looks stale" has no other symptom to check, and a caller
|
|
3596
|
-
// cannot poll for it — polling calls repaint itself, which *consumes* the
|
|
3597
|
-
// queued debounce timer and destroys the very evidence being looked for.
|
|
3598
|
-
this._repaintCount = (this._repaintCount || 0) + 1;
|
|
3599
|
-
// If we just re-added an inactive view it is now on top of the stack —
|
|
3600
|
-
// put the real active view back so the user sees the tab they selected.
|
|
3601
|
-
if (!wasActive) {
|
|
3602
|
-
const active = this._getView(this.activeTab);
|
|
3603
|
-
if (active) {
|
|
3604
|
-
try {
|
|
3605
|
-
this.window.removeBrowserView(view);
|
|
3606
|
-
this.window.addBrowserView(active);
|
|
3607
|
-
this._layoutView(active);
|
|
3608
|
-
} catch(e) {}
|
|
3609
|
-
}
|
|
3639
|
+
wc.invalidate();
|
|
3640
|
+
} catch (e) {
|
|
3641
|
+
return false;
|
|
3610
3642
|
}
|
|
3643
|
+
// Cumulative counter, reported by ui.repaint / ui.window_bounds. Not just a
|
|
3644
|
+
// debug aid: it is the only externally observable proof that a repaint
|
|
3645
|
+
// actually happened, and (for hard) that no geometry nudge leaked into a
|
|
3646
|
+
// hot path.
|
|
3647
|
+
this._repaintCount = (this._repaintCount || 0) + 1;
|
|
3648
|
+
if (!hard) return true;
|
|
3649
|
+
try {
|
|
3650
|
+
const b = view.getBounds();
|
|
3651
|
+
view.setBounds({ ...b, width: b.width + 1 });
|
|
3652
|
+
view.setBounds(b);
|
|
3653
|
+
this._hardRepaintCount = (this._hardRepaintCount || 0) + 1;
|
|
3654
|
+
} catch (e2) { /* 视图正在销毁 */ }
|
|
3611
3655
|
return true;
|
|
3612
3656
|
}
|
|
3613
3657
|
|
|
@@ -3741,9 +3785,28 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3741
3785
|
// 用 zoomFactor 把两者分开:页面缩放有明确的 zoom 值,surface 脱节时 zoom 仍是 1。
|
|
3742
3786
|
let zoom = null;
|
|
3743
3787
|
try { zoom = typeof wc.getZoomFactor === 'function' ? wc.getZoomFactor() : null; } catch (_) {}
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3788
|
+
let iw = Number(dom && dom.iw) || 0;
|
|
3789
|
+
let mismatched = bounds && bounds.width > 0 && iw > 0 && Math.abs(iw - bounds.width) > 2
|
|
3790
|
+
&& (zoom === null || Math.abs(zoom - 1) < 0.01);
|
|
3791
|
+
if (mismatched) {
|
|
3792
|
+
// 刚 resize 完的那一两帧里 innerWidth 会短暂滞后(布局还没跟上新尺寸),
|
|
3793
|
+
// 直接判 scale-mismatch 就会误触发 hard 重绘 —— 而 hard 会重排两次,
|
|
3794
|
+
// 用户看到的就是"大一下小一下"(2026-10-25 真机就这么抖的)。
|
|
3795
|
+
// 所以等 120ms 再读一次:还是不一致才算 surface 真的脱节。
|
|
3796
|
+
await new Promise((r) => { const t = setTimeout(r, 120); if (t.unref) t.unref(); });
|
|
3797
|
+
let iw2 = 0;
|
|
3798
|
+
try {
|
|
3799
|
+
const raw2 = await withDeadline(this.evaluate(PageManager.PAINT_DOM_JS, tid), 2000, 'paint_dom2');
|
|
3800
|
+
const s2 = (raw2 && typeof raw2 === 'object' && 'value' in raw2) ? raw2.value : raw2;
|
|
3801
|
+
const parsed2 = typeof s2 === 'string' ? JSON.parse(s2) : null;
|
|
3802
|
+
iw2 = Number(parsed2 && parsed2.iw) || 0;
|
|
3803
|
+
} catch (_) { /* 读不到就按原判断 */ }
|
|
3804
|
+
if (iw2 > 0) {
|
|
3805
|
+
if (Math.abs(iw2 - bounds.width) <= 2) mismatched = false;
|
|
3806
|
+
else iw = iw2;
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
if (mismatched) {
|
|
3747
3810
|
cls = {
|
|
3748
3811
|
verdict: 'scale-mismatch',
|
|
3749
3812
|
blank: cls.blank,
|
|
@@ -3889,7 +3952,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3889
3952
|
if (!view || !view.webContents || view.webContents.isDestroyed()) return rec;
|
|
3890
3953
|
if (action === 'repaint') {
|
|
3891
3954
|
this._paintSteps.set(tabId, steps + 1);
|
|
3892
|
-
|
|
3955
|
+
// 这里必须 hard:被判成白屏/scale-mismatch 时,surface 很可能就是旧尺寸,
|
|
3956
|
+
// 只 invalidate 不会重新栅格化。用户已经看到坏画面了,1px 重排的代价无所谓。
|
|
3957
|
+
this._forceRepaint(view, { hard: true });
|
|
3893
3958
|
await new Promise((r) => { const t = setTimeout(r, 320); if (t.unref) t.unref(); });
|
|
3894
3959
|
return this._auditPaintNow(tabId);
|
|
3895
3960
|
}
|
|
@@ -4007,7 +4072,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
4007
4072
|
* explicit "I know this window looks wrong, redraw it" call rather than a
|
|
4008
4073
|
* heuristic.
|
|
4009
4074
|
*/
|
|
4010
|
-
repaint(tabId) {
|
|
4075
|
+
repaint(tabId, opts = {}) {
|
|
4011
4076
|
const tid = tabId !== undefined && tabId !== null ? tabId : this.activeTab;
|
|
4012
4077
|
const view = this._getView(tid);
|
|
4013
4078
|
if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
|
|
@@ -4019,7 +4084,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
4019
4084
|
// to ask, the browser already scheduled one" — i.e. the window was stale
|
|
4020
4085
|
// for ~120ms and has since caught up on its own.
|
|
4021
4086
|
if (pending) { clearTimeout(pending); this._repaintTimers.delete(tid); }
|
|
4022
|
-
const ok = this._forceRepaint(view);
|
|
4087
|
+
const ok = this._forceRepaint(view, { hard: opts && opts.hard === true });
|
|
4023
4088
|
return { ok, tab: tid, was_active: tid === this.activeTab, was_pending: !!pending, repaint_count: this._repaintCount || 0 };
|
|
4024
4089
|
}
|
|
4025
4090
|
|
|
@@ -4216,6 +4281,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
4216
4281
|
const out = { ok: true };
|
|
4217
4282
|
try { out.bounds = this.window.getBounds(); } catch (_) { /* 窗口已销毁 */ }
|
|
4218
4283
|
try { out.content = this.window.getContentBounds(); } catch (_) { /* 窗口已销毁 */ }
|
|
4284
|
+
out.repaints = { soft: this._repaintCount || 0, hard: this._hardRepaintCount || 0 };
|
|
4219
4285
|
const views = {};
|
|
4220
4286
|
for (const [id, view] of this.tabs) {
|
|
4221
4287
|
try { views[id] = view.getBounds(); } catch (_) { /* 视图已销毁 */ }
|
package/src/main/ws_server.js
CHANGED
|
@@ -181,7 +181,9 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
|
|
|
181
181
|
// the DOM tree / evaluate already report the NEW page while the
|
|
182
182
|
// screen shows the OLD one. So the agent's only handle on it is an
|
|
183
183
|
// explicit "this window looks wrong, redraw it".
|
|
184
|
-
|
|
184
|
+
// hard:true 才做 1px 微调(会让页面重排两次,肉眼可见)。
|
|
185
|
+
// 默认 soft:只 invalidate —— 高频调用不会把窗口刷成幻灯片。
|
|
186
|
+
const r = pageManager.repaint(tabId, { hard: params && params.hard === true });
|
|
185
187
|
send({ jsonrpc: '2.0', id, result: r });
|
|
186
188
|
break;
|
|
187
189
|
}
|
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.15';
|