@glittering0717/ai-browser 2.1.14 → 2.1.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.14",
3
+ "version": "2.1.16",
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
- mainWindow.on('resize', () => repaintActiveSoon(150));
528
- mainWindow.on('enter-full-screen', () => { layoutWithBanner(); repaintActiveSoon(120); });
529
- mainWindow.on('leave-full-screen', () => { layoutWithBanner(); repaintActiveSoon(120); });
530
- const onDisplayChange = () => { try { layoutWithBanner(); } catch (e) {} repaintActiveSoon(60); };
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(); repaintActiveSoon(400); });
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).
@@ -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 也正常、但用户看到的是空"的
@@ -703,7 +707,10 @@ class PageManager {
703
707
  } catch (e) {
704
708
  return this._navigateFailure(e, view, tid, waitUntil);
705
709
  }
706
- this.repaint(tid);
710
+ // 这里**不做**重绘(2026-10-25 修)。以前每次 navigate 结束都调 repaint(),
711
+ // 而 repaint() 走 webContents.invalidate() —— 在新页面刚出现的那一刻强制整页
712
+ // 重栅格化,合成器会先把画面按初始尺寸画一遍再缩放,用户看到的就是
713
+ // "先缩成一小块、闪一下、然后正常"。导航本身会正常出帧,不需要我们推。
707
714
  if (waitUntil === 'load') return { ok: true, waited_for: 'load' };
708
715
  // load 还没来是**正常**的(SPA 本来就不 fire load)。如实说明,并给出
709
716
  // 下一步:调用方需要等某个具体内容时该用 ui.wait,而不是等 load。
@@ -3430,19 +3437,20 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3430
3437
  // so did-finish-load has already fired and repainted an empty shell.
3431
3438
  // did-navigate — any cross-document navigation, including programmatic
3432
3439
  // location.href assignment the app does on its own.
3433
- if (url) {
3434
- const repaint = () => { self._scheduleRepaint(tabId); };
3435
- view.webContents.on('dom-ready', repaint);
3436
- view.webContents.on('did-finish-load', repaint);
3437
- view.webContents.on('did-navigate', repaint);
3438
- view.webContents.on('did-navigate-in-page', repaint);
3439
- view.webContents.on('did-frame-finish-load', repaint);
3440
- } else {
3441
- // No URL at construction time (new_tab with no url, then navigate):
3442
- // register the same hooks now, or the later navigate() would never
3443
- // repaint — which was one of the paths that showed a stale view.
3444
- this._attachRepaintHooks(tabId, view);
3445
- }
3440
+ // ⚠️ 导航事件**不挂任何重绘**(2026-10-25 修)。
3441
+ //
3442
+ // 历史:这条钩子是为了对付"页面已导航但窗口还显示旧画面",手段是
3443
+ // removeBrowserView + addBrowserView(后来换成 invalidate)。但两者都有代价:
3444
+ // remove/add 会闪一帧白;invalidate() 更麻烦 —— 它强制整页重新栅格化,
3445
+ // 而在导航刚开始的那一刻,图层的尺寸还是初始值,于是合成器会先把新页面画进
3446
+ // 一个小尺寸 surface 再缩放显示:用户看到的就是"先缩成左上角一小块,闪一下,
3447
+ // 然后正常"(2026-10-25 用户报"刷新 baidu 总是一闪一闪")。
3448
+ //
3449
+ // 现代 Chromium 在正常情况下不会留旧画面;真留住了也有兜底:
3450
+ // - 白屏/缩放自检(_auditPaint)会判 scale-mismatch 并**定向**重绘
3451
+ // - ui.repaint 显式重绘
3452
+ // - 几何变化由 afterGeometryChange 负责收敛
3453
+ // 所以这里什么都不挂。回归:npm run layout 的 G5 断言"一次导航不引发任何重绘"。
3446
3454
 
3447
3455
  this._attachLoadFailureHooks(tabId, view);
3448
3456
  // "画出来了没有"只能主动看(没有对应事件),"渲染进程崩了"要自己救
@@ -3532,27 +3540,12 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3532
3540
  * Idempotent per tab: re-registering would schedule redundant remove/add
3533
3541
  * cycles, so a WeakSet of hooked views guards it.
3534
3542
  */
3535
- _attachRepaintHooks(tabId, view) {
3536
- if (!view || !view.webContents || view.webContents.isDestroyed()) return;
3537
- if (!this._repaintHooked) this._repaintHooked = new WeakSet();
3538
- if (this._repaintHooked.has(view)) return;
3539
- this._repaintHooked.add(view);
3540
- const repaint = () => { this._scheduleRepaint(tabId); };
3541
- view.webContents.on('dom-ready', repaint);
3542
- view.webContents.on('did-finish-load', repaint);
3543
- view.webContents.on('did-navigate', repaint);
3544
- view.webContents.on('did-navigate-in-page', repaint);
3545
- view.webContents.on('did-frame-finish-load', repaint);
3546
- }
3547
-
3548
3543
  /**
3549
- * Coalesce repaint requests. A single page load fires dom-ready +
3550
- * did-navigate + did-navigate-in-page + did-frame-finish-load within a few
3551
- * hundred ms; a load like ComfyUI's fires dozens of did-frame-finish-load.
3552
- * Without coalescing that is dozens of remove/add cycles — visible flicker
3553
- * and wasted compositing work. Debounce ~120ms: late enough to collapse the
3554
- * burst, early enough that the user sees the new view essentially at once.
3544
+ * 已废弃:以前用来给"后来才导航的 tab"补挂重绘钩子。现在导航事件一律不重绘
3545
+ * (原因见 newTab 里的长注释),这里只留一个空实现,避免调用点报错。
3555
3546
  */
3547
+ _attachRepaintHooks() {}
3548
+
3556
3549
  _scheduleRepaint(tabId, delay = 120) {
3557
3550
  const prev = this._repaintTimers.get(tabId);
3558
3551
  if (prev) clearTimeout(prev);
@@ -3571,43 +3564,83 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3571
3564
  * (only the active view is normally attached, so re-adding an inactive one
3572
3565
  * would show it — guard by re-asserting the active view afterwards).
3573
3566
  */
3574
- _forceRepaint(view) {
3567
+ /**
3568
+ * 窗口几何变过之后调一次:重排 → 软重绘 → 160ms 后确认渲染视口真的跟上了。
3569
+ *
3570
+ * 为什么不能只 setBounds:实测(e2e/layout_ws 的 G2,2026-10-25)窗口从
3571
+ * 1280 缩到 800 之后,视图 bounds 已经是 800,但页面 `innerWidth` 还是 1280 ——
3572
+ * 也就是渲染器没接到/没应用这次尺寸变化。此时合成器会把 1280 宽的内容画进
3573
+ * 800 的框里,内容整体缩小或错位;等它某一刻突然追上,就是用户说的
3574
+ * "一闪一闪、大一下小一下"。
3575
+ * 确认的方式和判定口径一致:innerWidth 必须等于视图宽度。不等就做一次 hard
3576
+ * 重绘(1px 微调会强制 Chromium 重算 surface 与布局)—— 这时代价是值得的,
3577
+ * 因为画面本来就是错的。
3578
+ */
3579
+ afterGeometryChange() {
3580
+ const tid = this.activeTab;
3581
+ const view = this._getView(tid);
3582
+ if (!view || !view.webContents || view.webContents.isDestroyed()) return;
3583
+ try { this._layoutView(view); } catch (_) { return; }
3584
+ this._forceRepaint(view); // soft:不可见
3585
+ const timer = setTimeout(async () => {
3586
+ try {
3587
+ const v = this._getView(tid);
3588
+ if (!v || !v.webContents || v.webContents.isDestroyed()) return;
3589
+ const b = v.getBounds();
3590
+ const raw = await withDeadline(this.evaluate('(function(){return String(window.innerWidth);})()', tid), 1500, 'geom_iw');
3591
+ const iw = Number((raw && typeof raw === 'object' && 'value' in raw) ? raw.value : raw) || 0;
3592
+ if (iw > 0 && b.width > 0 && Math.abs(iw - b.width) > 2) {
3593
+ recordError('layout_mismatch', {
3594
+ tab: tid, phase: 'after-resize',
3595
+ expected: { width: b.width }, actual: { innerWidth: iw },
3596
+ });
3597
+ this._forceRepaint(v, { hard: true });
3598
+ }
3599
+ } catch (_) { /* renderer 正在忙/已销毁:下一次 resize 会再来一次 */ }
3600
+ }, 160);
3601
+ if (timer.unref) timer.unref();
3602
+ }
3603
+
3604
+ /**
3605
+ * 让合成器重新画一次,分两档。
3606
+ *
3607
+ * soft(默认):只 webContents.invalidate() —— 不改几何、不重排、不可见。
3608
+ * hard:额外做 1px 微调(width+1 再 width),因为**只有尺寸变化才会让 surface
3609
+ * 重新栅格化**(尺寸不变时 setBounds 被 Chromium 直接忽略)。
3610
+ *
3611
+ * 为什么要分档(2026-10-25 用户报"一闪一闪、大一下小一下"):
3612
+ * 1px 微调必然触发两次重排,文字会肉眼可见地跳一下;再叠 removeBrowserView /
3613
+ * addBrowserView 还会闪一帧白。把这套放在"每次导航完成 / 每次窗口焦点变化"
3614
+ * 这种高频路径上,就变成了用户看到的抽搐。
3615
+ * 所以高频路径一律 soft;只有"确认 surface 尺寸脱节"(scale-mismatch、白屏阶梯)
3616
+ * 才付 hard 的代价。
3617
+ *
3618
+ * 也不再 remove/add:那是过去没有 invalidate 时的唯一手段,代价是闪一帧白,
3619
+ * 而现在 invalidate 覆盖了它的作用。
3620
+ */
3621
+ _forceRepaint(view, opts = {}) {
3622
+ const hard = opts && opts.hard === true;
3575
3623
  const tabId = this._tabIdOfView(view);
3576
- const wasActive = tabId === this.activeTab;
3624
+ if (tabId === null) return false;
3625
+ const wc = view.webContents;
3626
+ if (!wc || (typeof wc.isDestroyed === 'function' && wc.isDestroyed())) return false;
3577
3627
  try {
3578
- this.window.removeBrowserView(view);
3579
- this.window.addBrowserView(view);
3580
- this._layoutView(view);
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
- }
3628
+ wc.invalidate();
3629
+ } catch (e) {
3630
+ return false;
3610
3631
  }
3632
+ // Cumulative counter, reported by ui.repaint / ui.window_bounds. Not just a
3633
+ // debug aid: it is the only externally observable proof that a repaint
3634
+ // actually happened, and (for hard) that no geometry nudge leaked into a
3635
+ // hot path.
3636
+ this._repaintCount = (this._repaintCount || 0) + 1;
3637
+ if (!hard) return true;
3638
+ try {
3639
+ const b = view.getBounds();
3640
+ view.setBounds({ ...b, width: b.width + 1 });
3641
+ view.setBounds(b);
3642
+ this._hardRepaintCount = (this._hardRepaintCount || 0) + 1;
3643
+ } catch (e2) { /* 视图正在销毁 */ }
3611
3644
  return true;
3612
3645
  }
3613
3646
 
@@ -3741,9 +3774,28 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3741
3774
  // 用 zoomFactor 把两者分开:页面缩放有明确的 zoom 值,surface 脱节时 zoom 仍是 1。
3742
3775
  let zoom = null;
3743
3776
  try { zoom = typeof wc.getZoomFactor === 'function' ? wc.getZoomFactor() : null; } catch (_) {}
3744
- const iw = Number(dom && dom.iw) || 0;
3745
- if (bounds && bounds.width > 0 && iw > 0 && Math.abs(iw - bounds.width) > 2
3746
- && (zoom === null || Math.abs(zoom - 1) < 0.01)) {
3777
+ let iw = Number(dom && dom.iw) || 0;
3778
+ let mismatched = bounds && bounds.width > 0 && iw > 0 && Math.abs(iw - bounds.width) > 2
3779
+ && (zoom === null || Math.abs(zoom - 1) < 0.01);
3780
+ if (mismatched) {
3781
+ // 刚 resize 完的那一两帧里 innerWidth 会短暂滞后(布局还没跟上新尺寸),
3782
+ // 直接判 scale-mismatch 就会误触发 hard 重绘 —— 而 hard 会重排两次,
3783
+ // 用户看到的就是"大一下小一下"(2026-10-25 真机就这么抖的)。
3784
+ // 所以等 120ms 再读一次:还是不一致才算 surface 真的脱节。
3785
+ await new Promise((r) => { const t = setTimeout(r, 120); if (t.unref) t.unref(); });
3786
+ let iw2 = 0;
3787
+ try {
3788
+ const raw2 = await withDeadline(this.evaluate(PageManager.PAINT_DOM_JS, tid), 2000, 'paint_dom2');
3789
+ const s2 = (raw2 && typeof raw2 === 'object' && 'value' in raw2) ? raw2.value : raw2;
3790
+ const parsed2 = typeof s2 === 'string' ? JSON.parse(s2) : null;
3791
+ iw2 = Number(parsed2 && parsed2.iw) || 0;
3792
+ } catch (_) { /* 读不到就按原判断 */ }
3793
+ if (iw2 > 0) {
3794
+ if (Math.abs(iw2 - bounds.width) <= 2) mismatched = false;
3795
+ else iw = iw2;
3796
+ }
3797
+ }
3798
+ if (mismatched) {
3747
3799
  cls = {
3748
3800
  verdict: 'scale-mismatch',
3749
3801
  blank: cls.blank,
@@ -3889,7 +3941,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3889
3941
  if (!view || !view.webContents || view.webContents.isDestroyed()) return rec;
3890
3942
  if (action === 'repaint') {
3891
3943
  this._paintSteps.set(tabId, steps + 1);
3892
- this._forceRepaint(view);
3944
+ // 这里必须 hard:被判成白屏/scale-mismatch 时,surface 很可能就是旧尺寸,
3945
+ // 只 invalidate 不会重新栅格化。用户已经看到坏画面了,1px 重排的代价无所谓。
3946
+ this._forceRepaint(view, { hard: true });
3893
3947
  await new Promise((r) => { const t = setTimeout(r, 320); if (t.unref) t.unref(); });
3894
3948
  return this._auditPaintNow(tabId);
3895
3949
  }
@@ -4007,7 +4061,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
4007
4061
  * explicit "I know this window looks wrong, redraw it" call rather than a
4008
4062
  * heuristic.
4009
4063
  */
4010
- repaint(tabId) {
4064
+ repaint(tabId, opts = {}) {
4011
4065
  const tid = tabId !== undefined && tabId !== null ? tabId : this.activeTab;
4012
4066
  const view = this._getView(tid);
4013
4067
  if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
@@ -4019,7 +4073,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
4019
4073
  // to ask, the browser already scheduled one" — i.e. the window was stale
4020
4074
  // for ~120ms and has since caught up on its own.
4021
4075
  if (pending) { clearTimeout(pending); this._repaintTimers.delete(tid); }
4022
- const ok = this._forceRepaint(view);
4076
+ const ok = this._forceRepaint(view, { hard: opts && opts.hard === true });
4023
4077
  return { ok, tab: tid, was_active: tid === this.activeTab, was_pending: !!pending, repaint_count: this._repaintCount || 0 };
4024
4078
  }
4025
4079
 
@@ -4216,6 +4270,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
4216
4270
  const out = { ok: true };
4217
4271
  try { out.bounds = this.window.getBounds(); } catch (_) { /* 窗口已销毁 */ }
4218
4272
  try { out.content = this.window.getContentBounds(); } catch (_) { /* 窗口已销毁 */ }
4273
+ out.repaints = { soft: this._repaintCount || 0, hard: this._hardRepaintCount || 0 };
4219
4274
  const views = {};
4220
4275
  for (const [id, view] of this.tabs) {
4221
4276
  try { views[id] = view.getBounds(); } catch (_) { /* 视图已销毁 */ }
@@ -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
- const r = pageManager.repaint(tabId);
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
  }
@@ -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.14';
5
+ export const VERSION = '2.1.16';