@glittering0717/ai-browser 2.1.13 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.13",
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": {
@@ -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,45 @@ 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
+ // 几何变化后不能只重画:还要确认"渲染视口真的跟上了新尺寸"(见 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); };
539
+ try {
540
+ screen.on('display-metrics-changed', onDisplayChange);
541
+ screen.on('display-added', onDisplayChange);
542
+ screen.on('display-removed', onDisplayChange);
543
+ } catch (e) { /* 无 screen 模块(极老版本) */ }
544
+ try {
545
+ powerMonitor.on('resume', () => { onDisplayChange(); afterGeometrySoon(400); });
546
+ } catch (e) { /* 无 powerMonitor */ }
509
547
  mainWindow.on('app-command-added', (_e, cmd) => {
510
548
  // macOS window-management commands (zoom / minimize / fullscreen).
511
549
  if (cmd === 'togglefullscreen' || cmd === 'zoom' || cmd === 'minimize') repaintActive();
@@ -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,32 +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
- _forceRepaint(view) {
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
- const wasActive = tabId === this.activeTab;
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
- this.window.removeBrowserView(view);
3579
- this.window.addBrowserView(view);
3580
- this._layoutView(view);
3581
- } catch(e) { return false; }
3582
- // Cumulative counter, reported by ui.repaint. Not just a debug aid: it is
3583
- // the only externally observable proof that a repaint actually happened.
3584
- // "The window looks stale" has no other symptom to check, and a caller
3585
- // cannot poll for it — polling calls repaint itself, which *consumes* the
3586
- // queued debounce timer and destroys the very evidence being looked for.
3587
- this._repaintCount = (this._repaintCount || 0) + 1;
3588
- // If we just re-added an inactive view it is now on top of the stack —
3589
- // put the real active view back so the user sees the tab they selected.
3590
- if (!wasActive) {
3591
- const active = this._getView(this.activeTab);
3592
- if (active) {
3593
- try {
3594
- this.window.removeBrowserView(view);
3595
- this.window.addBrowserView(active);
3596
- this._layoutView(active);
3597
- } catch(e) {}
3598
- }
3639
+ wc.invalidate();
3640
+ } catch (e) {
3641
+ return false;
3599
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) { /* 视图正在销毁 */ }
3600
3655
  return true;
3601
3656
  }
3602
3657
 
@@ -3624,6 +3679,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3624
3679
  'text_len:t.replace(/\\s+/g," ").trim().length,' +
3625
3680
  'nodes:document.querySelectorAll("*").length,' +
3626
3681
  'ready_state:document.readyState,' +
3682
+ // 渲染口径的三个数:布局视口宽高 + 设备像素比。它们是"窗口几何 vs 实际渲染"
3683
+ // 对不上的唯一证据 —— 视图 bounds 1300 而 innerWidth 325,说明页面被按 1/4
3684
+ // 缩放渲染(2026-10-25 用户截图那种"内容缩在左上角"就属于这一类)。
3685
+ 'iw:window.innerWidth,ih:window.innerHeight,dpr:window.devicePixelRatio,' +
3627
3686
  'url:location.href});})()';
3628
3687
 
3629
3688
  /**
@@ -3720,7 +3779,41 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3720
3779
  frameError = e && e.message ? e.message : String(e);
3721
3780
  }
3722
3781
 
3723
- const cls = blankPage.classifyPaint({ stats, dom, bounds });
3782
+ let cls = blankPage.classifyPaint({ stats, dom, bounds });
3783
+ // 渲染口径与视图几何必须相等。不等只有两种原因:用户主动缩放(合法)或
3784
+ // **合成器把旧尺寸的 surface 缩进了新 bounds**(就是"内容缩成左上角一小块")。
3785
+ // 用 zoomFactor 把两者分开:页面缩放有明确的 zoom 值,surface 脱节时 zoom 仍是 1。
3786
+ let zoom = null;
3787
+ try { zoom = typeof wc.getZoomFactor === 'function' ? wc.getZoomFactor() : null; } catch (_) {}
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) {
3810
+ cls = {
3811
+ verdict: 'scale-mismatch',
3812
+ blank: cls.blank,
3813
+ reason: `page lays out at ${iw}px but the view is ${bounds.width}px wide (zoom=${zoom})`
3814
+ + ' — the compositor is drawing a stale surface',
3815
+ };
3816
+ }
3724
3817
  // 还在"正在打开…"占位页(file:///…/loading.html?url=…)上:真实导航排在建
3725
3818
  // 垫片之后,还没开始。这时既不是白屏也不是就绪 —— 报 not-ready 才会让调用方
3726
3819
  // "等一会儿"而不是"这页坏了"。这个状态在 new_tab 回执后约 100–200ms 内可见。
@@ -3756,6 +3849,47 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3756
3849
  };
3757
3850
  }
3758
3851
 
3852
+ /**
3853
+ * 布局看门狗:活动视图的几何必须等于"内容区"。
3854
+ *
3855
+ * 为什么需要它:窗口尺寸变了而视图没跟上,是**没有任何事件会报**的一类失败 ——
3856
+ * 事件只告诉你"窗口 resize 了",不告诉你"视图还是旧尺寸"。视图比窗口大时,
3857
+ * 合成器会把那张大 surface 缩进新 bounds,表现就是"页面缩成左上角一小块、
3858
+ * 其余全白"(2026-10-25 用户截图)。此时 DOM/截图全都正常,只有窗口像素不对。
3859
+ *
3860
+ * 所以:每次自检顺手量一下几何,错了就当场纠正并落一条 layout_mismatch ——
3861
+ * 这样下次再出现,error.jsonl 里会有窗口尺寸和视图尺寸的实际数字。
3862
+ */
3863
+ _auditLayout(tabId) {
3864
+ const view = this._getView(tabId);
3865
+ if (!view || !view.webContents || view.webContents.isDestroyed()) return null;
3866
+ let expected;
3867
+ try {
3868
+ const cb = this.window.getContentBounds();
3869
+ expected = {
3870
+ x: 0,
3871
+ y: config.tabBarHeight,
3872
+ width: cb.width,
3873
+ height: Math.max(0, cb.height - config.tabBarHeight - (this._bottomInset || 0)),
3874
+ };
3875
+ } catch (_) { return null; }
3876
+ let actual;
3877
+ try { actual = view.getBounds(); } catch (_) { return null; }
3878
+ const bad = actual.width < expected.width - 2
3879
+ || Math.abs(actual.height - expected.height) > 2
3880
+ || actual.x !== expected.x
3881
+ || actual.y !== expected.y;
3882
+ if (!bad) return { ok: true, expected, actual };
3883
+ try {
3884
+ this._layoutView(view);
3885
+ view.webContents.invalidate();
3886
+ } catch (_) { /* 视图正在销毁 */ }
3887
+ const info = { tab: tabId, expected, actual };
3888
+ recordError('layout_mismatch', info);
3889
+ this._broadcast('layout_mismatch', info);
3890
+ return { ok: false, expected, actual, fixed: true };
3891
+ }
3892
+
3759
3893
  /**
3760
3894
  * 排一次自检。合并连发的导航事件;只对**活动** tab 做 —— 后台 tab 不在屏幕上,
3761
3895
  * 它"白不白"没有用户可见的后果,为它反复 capturePage 只是白烧 CPU。
@@ -3802,6 +3936,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3802
3936
  */
3803
3937
  async _auditPaintNow(tabId) {
3804
3938
  {
3939
+ // 先量几何再量画面:视图没跟上窗口时,像素判定的意义有限(看到的是一张
3940
+ // 被缩放的旧 surface),而且这个错误必须先修,否则后面的重画都白做。
3941
+ this._auditLayout(tabId);
3805
3942
  const res = await this.paintCheck(tabId);
3806
3943
  if (!res.ok) return res;
3807
3944
  const steps = this._paintSteps.get(tabId) || 0;
@@ -3815,7 +3952,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3815
3952
  if (!view || !view.webContents || view.webContents.isDestroyed()) return rec;
3816
3953
  if (action === 'repaint') {
3817
3954
  this._paintSteps.set(tabId, steps + 1);
3818
- this._forceRepaint(view);
3955
+ // 这里必须 hard:被判成白屏/scale-mismatch 时,surface 很可能就是旧尺寸,
3956
+ // 只 invalidate 不会重新栅格化。用户已经看到坏画面了,1px 重排的代价无所谓。
3957
+ this._forceRepaint(view, { hard: true });
3819
3958
  await new Promise((r) => { const t = setTimeout(r, 320); if (t.unref) t.unref(); });
3820
3959
  return this._auditPaintNow(tabId);
3821
3960
  }
@@ -3933,7 +4072,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3933
4072
  * explicit "I know this window looks wrong, redraw it" call rather than a
3934
4073
  * heuristic.
3935
4074
  */
3936
- repaint(tabId) {
4075
+ repaint(tabId, opts = {}) {
3937
4076
  const tid = tabId !== undefined && tabId !== null ? tabId : this.activeTab;
3938
4077
  const view = this._getView(tid);
3939
4078
  if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
@@ -3945,7 +4084,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3945
4084
  // to ask, the browser already scheduled one" — i.e. the window was stale
3946
4085
  // for ~120ms and has since caught up on its own.
3947
4086
  if (pending) { clearTimeout(pending); this._repaintTimers.delete(tid); }
3948
- const ok = this._forceRepaint(view);
4087
+ const ok = this._forceRepaint(view, { hard: opts && opts.hard === true });
3949
4088
  return { ok, tab: tid, was_active: tid === this.activeTab, was_pending: !!pending, repaint_count: this._repaintCount || 0 };
3950
4089
  }
3951
4090
 
@@ -4127,6 +4266,51 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
4127
4266
  } catch (e) { /* debugger 未 attach / 页面已销毁 —— 忽略 */ }
4128
4267
  }
4129
4268
 
4269
+ /**
4270
+ * 窗口尺寸诊断 / 复现入口(ui.window_bounds)。
4271
+ *
4272
+ * 存在的理由:**布局类缺陷此前无法复现也无法回归** —— 没有入口能把窗口改成
4273
+ * 另一个尺寸,于是"窗口放大/缩小/全屏之后视图错位、内容缩成一小块"这类问题
4274
+ * 只能靠人手拖窗口碰运气。读的时候返回窗口 bounds / content bounds / 各视图
4275
+ * 实际 bounds,一眼就能看出"视图没跟上窗口"。
4276
+ *
4277
+ * 不传尺寸时纯读;传了就 setBounds + 立刻重排,再回读实际结果(不做乐观报告)。
4278
+ */
4279
+ windowBounds(next) {
4280
+ const snapshot = () => {
4281
+ const out = { ok: true };
4282
+ try { out.bounds = this.window.getBounds(); } catch (_) { /* 窗口已销毁 */ }
4283
+ try { out.content = this.window.getContentBounds(); } catch (_) { /* 窗口已销毁 */ }
4284
+ out.repaints = { soft: this._repaintCount || 0, hard: this._hardRepaintCount || 0 };
4285
+ const views = {};
4286
+ for (const [id, view] of this.tabs) {
4287
+ try { views[id] = view.getBounds(); } catch (_) { /* 视图已销毁 */ }
4288
+ }
4289
+ out.views = views;
4290
+ return out;
4291
+ };
4292
+ if (!next || typeof next !== 'object') return snapshot();
4293
+ let cur;
4294
+ try { cur = this.window.getBounds(); } catch (e) { return { ok: false, error: 'no-window' }; }
4295
+ const nb = { x: cur.x, y: cur.y, width: cur.width, height: cur.height };
4296
+ for (const k of ['x', 'y', 'width', 'height']) {
4297
+ if (typeof next[k] === 'number' && isFinite(next[k]) && next[k] > 0) nb[k] = Math.round(next[k]);
4298
+ }
4299
+ try {
4300
+ this.window.setBounds(nb);
4301
+ } catch (e) {
4302
+ return { ok: false, error: 'setBounds failed: ' + (e && e.message), ...snapshot() };
4303
+ }
4304
+ // 尺寸变了就必须让每个视图重新栅格化:setBounds 只改几何,surface 可能还是
4305
+ // 旧尺寸的 —— 那正是"内容缩成左上角一小块、其余全白"的成因(见 SPEC 白屏一节)。
4306
+ const view = this._getView(this.activeTab);
4307
+ if (view) {
4308
+ this._layoutView(view);
4309
+ try { view.webContents.invalidate(); } catch (_) { /* 已销毁 */ }
4310
+ }
4311
+ return snapshot();
4312
+ }
4313
+
4130
4314
  _layoutView(view) {
4131
4315
  const bounds = this.window.getContentBounds();
4132
4316
  const inset = this._bottomInset || 0;
@@ -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
  }
@@ -201,6 +203,15 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
201
203
  send({ jsonrpc: '2.0', id, result: r });
202
204
  break;
203
205
  }
206
+ case 'ui.window_bounds': {
207
+ // 读:窗口 bounds / content bounds / 每个视图实际 bounds(一眼看出视图有没有
208
+ // 跟上窗口)。写:setBounds + 立刻重排 + invalidate,然后回读实际结果。
209
+ // 存在的理由:布局类缺陷(窗口缩放/全屏后内容缩成左上角一小块)此前无法
210
+ // 复现也无法回归 —— 没有任何入口能改窗口尺寸。
211
+ const r = pageManager.windowBounds(params && params.set ? params.set : null);
212
+ send({ jsonrpc: '2.0', id, result: r });
213
+ break;
214
+ }
204
215
  case 'ui.snapshot': {
205
216
  const r = await pageManager.snapshot({ js: params.js, label: params.label, tab: tabId });
206
217
  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.15';