@glittering0717/ai-browser 2.1.13 → 2.1.14

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.13",
3
+ "version": "2.1.14",
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": {
@@ -83,7 +83,8 @@
83
83
  "dialog": "node e2e/dialog_ws.cjs",
84
84
  "verify:app": "node e2e/shipped_ws.cjs",
85
85
  "wrapper": "node e2e/wrapper_ws.cjs",
86
- "paint": "node e2e/paint_ws.cjs"
86
+ "paint": "node e2e/paint_ws.cjs",
87
+ "layout": "node e2e/layout_ws.cjs"
87
88
  },
88
89
  "dependencies": {
89
90
  "@modelcontextprotocol/sdk": "^1.29.0",
package/src/main/index.js CHANGED
@@ -1,6 +1,6 @@
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, dialog } from 'electron';
3
+ import { app, BrowserWindow, BrowserView, ipcMain, net, session, dialog, screen, powerMonitor } from 'electron';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
@@ -488,6 +488,8 @@ function createWindow() {
488
488
  mainWindow.on('resize', () => {
489
489
  layoutWithBanner();
490
490
  });
491
+ // 注:resize 的重绘(repaintActiveSoon)在下面"几何变化"一段里统一绑定 ——
492
+ // 那里能看到 repaintActive 的定义。
491
493
 
492
494
  // === Window becoming visible again → force a compositor repaint ===
493
495
  //
@@ -503,9 +505,37 @@ function createWindow() {
503
505
  // looking at a stale frame. Repainting on show/restore/focus means the view
504
506
  // catches up the moment it becomes visible — no clicking tabs required.
505
507
  const repaintActive = () => { try { pageManager.repaint(pageManager.activeTab); } catch (e) {} };
508
+ // 拖动窗口边缘时会连发几十个 resize;逐个重绘会抖成幻灯片。150ms 去抖:松手就补一帧。
509
+ let repaintTimer = null;
510
+ const repaintActiveSoon = (ms = 150) => {
511
+ if (repaintTimer) clearTimeout(repaintTimer);
512
+ repaintTimer = setTimeout(repaintActive, ms);
513
+ };
506
514
  mainWindow.on('show', repaintActive);
507
515
  mainWindow.on('restore', repaintActive);
508
516
  mainWindow.on('focus', repaintActive);
517
+
518
+ // === 几何变化 → 必须让 surface 重新栅格化 ===
519
+ //
520
+ // 只重排(setBounds)不够:如果 surface 还是旧尺寸,合成器会把那张大图缩进新的
521
+ // bounds —— 用户看到"页面缩成左上角一小块、其余全白",而 DOM、evaluate、截图
522
+ // 全都正常(2026-10-25 用户截图就是这个形态)。所以要连 invalidate 一起做。
523
+ //
524
+ // 触发点选这几个:窗口缩放、进出全屏(macOS 全屏会换 display)、显示器参数变化
525
+ // (分辨率/缩放/插拔外接屏)、系统从睡眠唤醒 —— 这四类都会让"窗口几何"和
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); };
531
+ try {
532
+ screen.on('display-metrics-changed', onDisplayChange);
533
+ screen.on('display-added', onDisplayChange);
534
+ screen.on('display-removed', onDisplayChange);
535
+ } catch (e) { /* 无 screen 模块(极老版本) */ }
536
+ try {
537
+ powerMonitor.on('resume', () => { onDisplayChange(); repaintActiveSoon(400); });
538
+ } catch (e) { /* 无 powerMonitor */ }
509
539
  mainWindow.on('app-command-added', (_e, cmd) => {
510
540
  // macOS window-management commands (zoom / minimize / fullscreen).
511
541
  if (cmd === 'togglefullscreen' || cmd === 'zoom' || cmd === 'minimize') repaintActive();
@@ -3578,6 +3578,17 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3578
3578
  this.window.removeBrowserView(view);
3579
3579
  this.window.addBrowserView(view);
3580
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 (_) {}
3581
3592
  } catch(e) { return false; }
3582
3593
  // Cumulative counter, reported by ui.repaint. Not just a debug aid: it is
3583
3594
  // the only externally observable proof that a repaint actually happened.
@@ -3624,6 +3635,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3624
3635
  'text_len:t.replace(/\\s+/g," ").trim().length,' +
3625
3636
  'nodes:document.querySelectorAll("*").length,' +
3626
3637
  'ready_state:document.readyState,' +
3638
+ // 渲染口径的三个数:布局视口宽高 + 设备像素比。它们是"窗口几何 vs 实际渲染"
3639
+ // 对不上的唯一证据 —— 视图 bounds 1300 而 innerWidth 325,说明页面被按 1/4
3640
+ // 缩放渲染(2026-10-25 用户截图那种"内容缩在左上角"就属于这一类)。
3641
+ 'iw:window.innerWidth,ih:window.innerHeight,dpr:window.devicePixelRatio,' +
3627
3642
  'url:location.href});})()';
3628
3643
 
3629
3644
  /**
@@ -3720,7 +3735,22 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3720
3735
  frameError = e && e.message ? e.message : String(e);
3721
3736
  }
3722
3737
 
3723
- const cls = blankPage.classifyPaint({ stats, dom, bounds });
3738
+ let cls = blankPage.classifyPaint({ stats, dom, bounds });
3739
+ // 渲染口径与视图几何必须相等。不等只有两种原因:用户主动缩放(合法)或
3740
+ // **合成器把旧尺寸的 surface 缩进了新 bounds**(就是"内容缩成左上角一小块")。
3741
+ // 用 zoomFactor 把两者分开:页面缩放有明确的 zoom 值,surface 脱节时 zoom 仍是 1。
3742
+ let zoom = null;
3743
+ 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)) {
3747
+ cls = {
3748
+ verdict: 'scale-mismatch',
3749
+ blank: cls.blank,
3750
+ reason: `page lays out at ${iw}px but the view is ${bounds.width}px wide (zoom=${zoom})`
3751
+ + ' — the compositor is drawing a stale surface',
3752
+ };
3753
+ }
3724
3754
  // 还在"正在打开…"占位页(file:///…/loading.html?url=…)上:真实导航排在建
3725
3755
  // 垫片之后,还没开始。这时既不是白屏也不是就绪 —— 报 not-ready 才会让调用方
3726
3756
  // "等一会儿"而不是"这页坏了"。这个状态在 new_tab 回执后约 100–200ms 内可见。
@@ -3756,6 +3786,47 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3756
3786
  };
3757
3787
  }
3758
3788
 
3789
+ /**
3790
+ * 布局看门狗:活动视图的几何必须等于"内容区"。
3791
+ *
3792
+ * 为什么需要它:窗口尺寸变了而视图没跟上,是**没有任何事件会报**的一类失败 ——
3793
+ * 事件只告诉你"窗口 resize 了",不告诉你"视图还是旧尺寸"。视图比窗口大时,
3794
+ * 合成器会把那张大 surface 缩进新 bounds,表现就是"页面缩成左上角一小块、
3795
+ * 其余全白"(2026-10-25 用户截图)。此时 DOM/截图全都正常,只有窗口像素不对。
3796
+ *
3797
+ * 所以:每次自检顺手量一下几何,错了就当场纠正并落一条 layout_mismatch ——
3798
+ * 这样下次再出现,error.jsonl 里会有窗口尺寸和视图尺寸的实际数字。
3799
+ */
3800
+ _auditLayout(tabId) {
3801
+ const view = this._getView(tabId);
3802
+ if (!view || !view.webContents || view.webContents.isDestroyed()) return null;
3803
+ let expected;
3804
+ try {
3805
+ const cb = this.window.getContentBounds();
3806
+ expected = {
3807
+ x: 0,
3808
+ y: config.tabBarHeight,
3809
+ width: cb.width,
3810
+ height: Math.max(0, cb.height - config.tabBarHeight - (this._bottomInset || 0)),
3811
+ };
3812
+ } catch (_) { return null; }
3813
+ let actual;
3814
+ try { actual = view.getBounds(); } catch (_) { return null; }
3815
+ const bad = actual.width < expected.width - 2
3816
+ || Math.abs(actual.height - expected.height) > 2
3817
+ || actual.x !== expected.x
3818
+ || actual.y !== expected.y;
3819
+ if (!bad) return { ok: true, expected, actual };
3820
+ try {
3821
+ this._layoutView(view);
3822
+ view.webContents.invalidate();
3823
+ } catch (_) { /* 视图正在销毁 */ }
3824
+ const info = { tab: tabId, expected, actual };
3825
+ recordError('layout_mismatch', info);
3826
+ this._broadcast('layout_mismatch', info);
3827
+ return { ok: false, expected, actual, fixed: true };
3828
+ }
3829
+
3759
3830
  /**
3760
3831
  * 排一次自检。合并连发的导航事件;只对**活动** tab 做 —— 后台 tab 不在屏幕上,
3761
3832
  * 它"白不白"没有用户可见的后果,为它反复 capturePage 只是白烧 CPU。
@@ -3802,6 +3873,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3802
3873
  */
3803
3874
  async _auditPaintNow(tabId) {
3804
3875
  {
3876
+ // 先量几何再量画面:视图没跟上窗口时,像素判定的意义有限(看到的是一张
3877
+ // 被缩放的旧 surface),而且这个错误必须先修,否则后面的重画都白做。
3878
+ this._auditLayout(tabId);
3805
3879
  const res = await this.paintCheck(tabId);
3806
3880
  if (!res.ok) return res;
3807
3881
  const steps = this._paintSteps.get(tabId) || 0;
@@ -4127,6 +4201,50 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
4127
4201
  } catch (e) { /* debugger 未 attach / 页面已销毁 —— 忽略 */ }
4128
4202
  }
4129
4203
 
4204
+ /**
4205
+ * 窗口尺寸诊断 / 复现入口(ui.window_bounds)。
4206
+ *
4207
+ * 存在的理由:**布局类缺陷此前无法复现也无法回归** —— 没有入口能把窗口改成
4208
+ * 另一个尺寸,于是"窗口放大/缩小/全屏之后视图错位、内容缩成一小块"这类问题
4209
+ * 只能靠人手拖窗口碰运气。读的时候返回窗口 bounds / content bounds / 各视图
4210
+ * 实际 bounds,一眼就能看出"视图没跟上窗口"。
4211
+ *
4212
+ * 不传尺寸时纯读;传了就 setBounds + 立刻重排,再回读实际结果(不做乐观报告)。
4213
+ */
4214
+ windowBounds(next) {
4215
+ const snapshot = () => {
4216
+ const out = { ok: true };
4217
+ try { out.bounds = this.window.getBounds(); } catch (_) { /* 窗口已销毁 */ }
4218
+ try { out.content = this.window.getContentBounds(); } catch (_) { /* 窗口已销毁 */ }
4219
+ const views = {};
4220
+ for (const [id, view] of this.tabs) {
4221
+ try { views[id] = view.getBounds(); } catch (_) { /* 视图已销毁 */ }
4222
+ }
4223
+ out.views = views;
4224
+ return out;
4225
+ };
4226
+ if (!next || typeof next !== 'object') return snapshot();
4227
+ let cur;
4228
+ try { cur = this.window.getBounds(); } catch (e) { return { ok: false, error: 'no-window' }; }
4229
+ const nb = { x: cur.x, y: cur.y, width: cur.width, height: cur.height };
4230
+ for (const k of ['x', 'y', 'width', 'height']) {
4231
+ if (typeof next[k] === 'number' && isFinite(next[k]) && next[k] > 0) nb[k] = Math.round(next[k]);
4232
+ }
4233
+ try {
4234
+ this.window.setBounds(nb);
4235
+ } catch (e) {
4236
+ return { ok: false, error: 'setBounds failed: ' + (e && e.message), ...snapshot() };
4237
+ }
4238
+ // 尺寸变了就必须让每个视图重新栅格化:setBounds 只改几何,surface 可能还是
4239
+ // 旧尺寸的 —— 那正是"内容缩成左上角一小块、其余全白"的成因(见 SPEC 白屏一节)。
4240
+ const view = this._getView(this.activeTab);
4241
+ if (view) {
4242
+ this._layoutView(view);
4243
+ try { view.webContents.invalidate(); } catch (_) { /* 已销毁 */ }
4244
+ }
4245
+ return snapshot();
4246
+ }
4247
+
4130
4248
  _layoutView(view) {
4131
4249
  const bounds = this.window.getContentBounds();
4132
4250
  const inset = this._bottomInset || 0;
@@ -201,6 +201,15 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
201
201
  send({ jsonrpc: '2.0', id, result: r });
202
202
  break;
203
203
  }
204
+ case 'ui.window_bounds': {
205
+ // 读:窗口 bounds / content bounds / 每个视图实际 bounds(一眼看出视图有没有
206
+ // 跟上窗口)。写:setBounds + 立刻重排 + invalidate,然后回读实际结果。
207
+ // 存在的理由:布局类缺陷(窗口缩放/全屏后内容缩成左上角一小块)此前无法
208
+ // 复现也无法回归 —— 没有任何入口能改窗口尺寸。
209
+ const r = pageManager.windowBounds(params && params.set ? params.set : null);
210
+ send({ jsonrpc: '2.0', id, result: r });
211
+ break;
212
+ }
204
213
  case 'ui.snapshot': {
205
214
  const r = await pageManager.snapshot({ js: params.js, label: params.label, tab: tabId });
206
215
  send({ jsonrpc: '2.0', id, result: r });
@@ -88,6 +88,8 @@ function statsFromBitmap(bitmap) {
88
88
  * collapsed 视图本身被压成 0 高/0 宽 —— 不是没画,是没地方画
89
89
  * blank-with-text DOM 里有文字,但屏幕上纯白 —— **真白屏**
90
90
  * blank-with-dom DOM 有几十个节点却纯白 —— **真白屏**(内容层失败)
91
+ * scale-mismatch 页面按 A 宽排版、视图只有 B 宽(合成器画了一张陈旧尺寸的
92
+ * surface)—— 表现为"内容缩在左上角,其余全白"
91
93
  * no-frame 拿不到位图(视图不可见/被销毁),无法判定
92
94
  */
93
95
  function classifyPaint({ stats, dom, bounds } = {}) {
@@ -137,6 +139,7 @@ function decidePaintAction({ verdict, steps = 0 } = {}) {
137
139
  if (verdict === 'ok' || verdict === 'empty-legit' || verdict === 'not-ready' || verdict === 'no-frame') {
138
140
  return 'none';
139
141
  }
142
+ // scale-mismatch 和 blank-* 走同一个阶梯:先重画,再重载,最后如实上报。
140
143
  if (steps <= 0) return 'repaint'; // 第一步:remove+add 逼合成器重画(代价最小、不丢状态)
141
144
  if (steps === 1) return 'reload'; // 第二步:重载(会丢页面临时状态,所以排在重画之后)
142
145
  return 'give-up'; // 第三步:如实上报,不再折腾
@@ -69,6 +69,8 @@ export const VALID_METHODS = [
69
69
  // 与 ui.repaint 的区别:repaint 是"外面的人说窗口不对,重画一次";
70
70
  // paint_check 是"工具自己看一眼,白的话按阶梯救,救不动就如实上报"。
71
71
  'ui.paint_check',
72
+ // 窗口/视图几何(读 + 改)。布局类缺陷需要它才能复现与回归。
73
+ 'ui.window_bounds',
72
74
  'ui.snapshot',
73
75
  'ui.diff',
74
76
  'ui.capabilities',
@@ -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.13';
5
+ export const VERSION = '2.1.14';