@glittering0717/ai-browser 2.1.12 → 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 +4 -2
- package/src/main/index.js +36 -4
- package/src/main/page_manager.js +504 -1
- package/src/main/ws_server.js +25 -0
- package/src/shared/blank_page.cjs +151 -0
- package/src/shared/protocol.js +6 -0
- 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.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": {
|
|
@@ -82,7 +82,9 @@
|
|
|
82
82
|
"install:app": "node scripts/install_app.cjs",
|
|
83
83
|
"dialog": "node e2e/dialog_ws.cjs",
|
|
84
84
|
"verify:app": "node e2e/shipped_ws.cjs",
|
|
85
|
-
"wrapper": "node e2e/wrapper_ws.cjs"
|
|
85
|
+
"wrapper": "node e2e/wrapper_ws.cjs",
|
|
86
|
+
"paint": "node e2e/paint_ws.cjs",
|
|
87
|
+
"layout": "node e2e/layout_ws.cjs"
|
|
86
88
|
},
|
|
87
89
|
"dependencies": {
|
|
88
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';
|
|
@@ -454,10 +454,12 @@ function createWindow() {
|
|
|
454
454
|
};
|
|
455
455
|
});
|
|
456
456
|
ipcMain.on('nav:go', (_e, input) => {
|
|
457
|
-
const wc = activeWebContents();
|
|
458
457
|
const url = normalizeUrlInput(input);
|
|
459
|
-
if (!
|
|
460
|
-
|
|
458
|
+
if (!url) return;
|
|
459
|
+
// 走 pageManager.navigate 而不是裸 loadURL:裸的这条路上没有导航状态机
|
|
460
|
+
// (list_tabs 会一直报 ready)、没有失败错误页、也没有导航完成后的重绘。
|
|
461
|
+
// 地址栏是最常用的一条路,缺这三样的代价就是"有时白屏、说不清在干嘛"。
|
|
462
|
+
pageManager.navigate(url, pageManager.activeTab).catch(() => {});
|
|
461
463
|
});
|
|
462
464
|
ipcMain.on('nav:back', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goBack(); });
|
|
463
465
|
ipcMain.on('nav:forward', () => { const wc = activeWebContents(); if (wc && wc.navigationHistory) wc.navigationHistory.goForward(); });
|
|
@@ -486,6 +488,8 @@ function createWindow() {
|
|
|
486
488
|
mainWindow.on('resize', () => {
|
|
487
489
|
layoutWithBanner();
|
|
488
490
|
});
|
|
491
|
+
// 注:resize 的重绘(repaintActiveSoon)在下面"几何变化"一段里统一绑定 ——
|
|
492
|
+
// 那里能看到 repaintActive 的定义。
|
|
489
493
|
|
|
490
494
|
// === Window becoming visible again → force a compositor repaint ===
|
|
491
495
|
//
|
|
@@ -501,9 +505,37 @@ function createWindow() {
|
|
|
501
505
|
// looking at a stale frame. Repainting on show/restore/focus means the view
|
|
502
506
|
// catches up the moment it becomes visible — no clicking tabs required.
|
|
503
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
|
+
};
|
|
504
514
|
mainWindow.on('show', repaintActive);
|
|
505
515
|
mainWindow.on('restore', repaintActive);
|
|
506
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 */ }
|
|
507
539
|
mainWindow.on('app-command-added', (_e, cmd) => {
|
|
508
540
|
// macOS window-management commands (zoom / minimize / fullscreen).
|
|
509
541
|
if (cmd === 'togglefullscreen' || cmd === 'zoom' || cmd === 'minimize') repaintActive();
|
package/src/main/page_manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// main/page_manager.js — Multi-tab page lifecycle manager v2
|
|
2
2
|
// One Electron process, one WS server, multiple tabs.
|
|
3
3
|
// Agent routes via tab ID. Tab 0 is default.
|
|
4
|
-
import { ipcMain, BrowserView } from 'electron';
|
|
4
|
+
import { ipcMain, BrowserView, nativeImage } from 'electron';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -29,6 +29,9 @@ import { wrapperHintExpr, wrapperHintText } from '../shared/wrapper_hint.js';
|
|
|
29
29
|
// normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
|
|
30
30
|
import editorsProbe from '../shared/editors_probe.js';
|
|
31
31
|
import { newTracker, noteTree, diagnoseMissingRef } from '../shared/ref_epoch.js';
|
|
32
|
+
// "这一页到底画出来没有"的判定口径(纯函数)。此前没有任何检测覆盖"加载成功但
|
|
33
|
+
// 屏幕纯白"这一类 —— 见 shared/blank_page.cjs 顶部(2026-10-25 用户报百度白屏)。
|
|
34
|
+
import blankPage from '../shared/blank_page.cjs';
|
|
32
35
|
import { diffValues, previewOf, safeByteLength, DEFAULT_SNAPSHOT_JS, DIFF_MAX_ENTRIES, evictOldest } from './snapshot_diff.js';
|
|
33
36
|
import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
|
|
34
37
|
import { DownloadMonitor } from './download_monitor.js';
|
|
@@ -213,6 +216,26 @@ class PageManager {
|
|
|
213
216
|
this._openDialogs = new Map();
|
|
214
217
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
215
218
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
219
|
+
// === 白屏自检(paint audit)===
|
|
220
|
+
// tabId -> {verdict, blank, white_ratio, unique, action, at, url}:最近一次自检的结论。
|
|
221
|
+
// 为什么必须有它:白屏是唯一一种"所有事件都成功、DOM 也正常、但用户看到的是空"的
|
|
222
|
+
// 失败形态,没有观测就只能靠人截图喊"白的"。有了它,list_tabs / paint_check
|
|
223
|
+
// 能把"这一页画出来了没有"当成一个可查询的事实。
|
|
224
|
+
this._paintState = new Map();
|
|
225
|
+
// tabId -> 本轮已经执行过几步补救(0=还没救过)。一有新的导航就清零,
|
|
226
|
+
// 所以"救不动"不会变成跨页面的永久状态。
|
|
227
|
+
this._paintSteps = new Map();
|
|
228
|
+
this._paintTimers = new Map(); // tabId -> 排队中的自检(合并 dom-ready/finish-load 的连发)
|
|
229
|
+
// tabId -> 正在跑的那一轮自检的 Promise。并发调用共享同一个 Promise,
|
|
230
|
+
// 而不是"发现有人在跑就返回 null"(那会让调用方拿到一个空结论)。
|
|
231
|
+
this._paintAuditInflight = new Map();
|
|
232
|
+
// 白屏自检的总开关。默认开;关掉它的唯一场景是"我要精确测量导航时延"。
|
|
233
|
+
this._paintAuditEnabled = process.env.AI_BROWSER_PAINT_AUDIT !== '0';
|
|
234
|
+
// 自己发起的 reload(自救用)。用它区分"这次 did-finish-load 是我们的补救"
|
|
235
|
+
// 还是"用户/页面自己导航了"—— 前者必须保留补救步数,否则会无限重载。
|
|
236
|
+
this._paintSelfReload = new Set();
|
|
237
|
+
// tabId -> 上次因渲染进程崩溃而重载的时刻(见 _attachCrashRecovery 的 15s 上限)
|
|
238
|
+
this._crashReloadAt = new Map();
|
|
216
239
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
217
240
|
this._snapshotSeq = 0;
|
|
218
241
|
this.networkMonitor = new NetworkMonitor({}, { captureEnabled: NETWORK_CAPTURE_DEFAULT });
|
|
@@ -650,6 +673,16 @@ class PageManager {
|
|
|
650
673
|
// {ok:false, error:"ERR_ABORTED (-3) loading 'file://.../error_page.html'"},
|
|
651
674
|
// 它会以为自己的导航请求坏了,而实际上错误页已经接管、信息全在那儿。
|
|
652
675
|
const pending = wc.loadURL(url);
|
|
676
|
+
// 导航状态机也在这里维护:此前只有 new_tab 的占位流程会写 _tabNavState,
|
|
677
|
+
// 于是 ui.navigate 期间 list_tabs 一直报 'ready' —— agent 看到 ready 就去
|
|
678
|
+
// 读树/截图,拿到的是旧页面或半张页面,然后开始"重试"。
|
|
679
|
+
this._tabNavState.set(tid, 'loading');
|
|
680
|
+
const markReady = () => {
|
|
681
|
+
if (this._tabNavState.get(tid) === 'loading') this._tabNavState.set(tid, 'ready');
|
|
682
|
+
};
|
|
683
|
+
wc.once('did-finish-load', markReady);
|
|
684
|
+
wc.once('did-fail-load', markReady);
|
|
685
|
+
setTimeout(markReady, 20000).unref?.();
|
|
653
686
|
// commit / domcontentloaded 会**提前**返回;之后这个 Promise 仍可能 reject
|
|
654
687
|
// (错误页接管、用户中止)。不接住就是 unhandledRejection,会把整个进程
|
|
655
688
|
// 的日志淹掉。错误本身在返回结果里另行汇报。
|
|
@@ -3412,6 +3445,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3412
3445
|
}
|
|
3413
3446
|
|
|
3414
3447
|
this._attachLoadFailureHooks(tabId, view);
|
|
3448
|
+
// "画出来了没有"只能主动看(没有对应事件),"渲染进程崩了"要自己救
|
|
3449
|
+
// (以前只打一行日志,用户看到的是永久白屏)。
|
|
3450
|
+
this._attachPaintHook(tabId, view);
|
|
3451
|
+
this._attachCrashRecovery(tabId, view);
|
|
3415
3452
|
return tabId;
|
|
3416
3453
|
}
|
|
3417
3454
|
|
|
@@ -3541,6 +3578,17 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3541
3578
|
this.window.removeBrowserView(view);
|
|
3542
3579
|
this.window.addBrowserView(view);
|
|
3543
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 (_) {}
|
|
3544
3592
|
} catch(e) { return false; }
|
|
3545
3593
|
// Cumulative counter, reported by ui.repaint. Not just a debug aid: it is
|
|
3546
3594
|
// the only externally observable proof that a repaint actually happened.
|
|
@@ -3568,6 +3616,390 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3568
3616
|
return null;
|
|
3569
3617
|
}
|
|
3570
3618
|
|
|
3619
|
+
// === 白屏自检 ==============================================================
|
|
3620
|
+
//
|
|
3621
|
+
// 为什么这一层以前不存在,而它必须存在:
|
|
3622
|
+
// "网络失败"有 did-fail-load(→ 错误页),"渲染进程死了"有 render-process-gone,
|
|
3623
|
+
// 但**"页面加载成功、DOM 也有内容、屏幕上却是纯白"**这一类没有任何事件。
|
|
3624
|
+
// 它是唯一一种"所有回调都成功"的失败形态,所以只能靠主动看一眼画面。
|
|
3625
|
+
// 2026-10-25 用户报"打开了百度有时候是纯白屏"就是这一类 —— 当时工具侧
|
|
3626
|
+
// (evaluate / get_tree / screenshot)全都读得到内容,等于集体失明。
|
|
3627
|
+
//
|
|
3628
|
+
// 观测口径写在 shared/blank_page.cjs:白度 + 唯一色数 + DOM 事实,三者一起判。
|
|
3629
|
+
|
|
3630
|
+
/** 页面自述的事实:有多少字、多少个节点、到哪一步了。 */
|
|
3631
|
+
static PAINT_DOM_JS =
|
|
3632
|
+
'(function(){var b=document.body,d=document.documentElement;' +
|
|
3633
|
+
'var t=b?(b.innerText||b.textContent||""):"";' +
|
|
3634
|
+
'return JSON.stringify({' +
|
|
3635
|
+
'text_len:t.replace(/\\s+/g," ").trim().length,' +
|
|
3636
|
+
'nodes:document.querySelectorAll("*").length,' +
|
|
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,' +
|
|
3642
|
+
'url:location.href});})()';
|
|
3643
|
+
|
|
3644
|
+
/**
|
|
3645
|
+
* 看一眼这个 tab 现在到底画出来了没有(只读,不改任何东西)。
|
|
3646
|
+
*
|
|
3647
|
+
* 两个数据源,缺一不可:
|
|
3648
|
+
* 1. 位图 —— webContents.capturePage(),走 view 自己的 surface,和用户截图同源。
|
|
3649
|
+
* 缩到 240px 宽再统计:判"是不是白"不需要细节,缩图把统计开销从 ~3.5M 像素
|
|
3650
|
+
* 降到 ~5 万,单次自检从几十毫秒降到个位数。
|
|
3651
|
+
* 2. DOM 事实 —— 有文字/有节点却纯白,才是缺陷;本来就空的页纯白是正确的。
|
|
3652
|
+
*
|
|
3653
|
+
* @returns {Promise<{ok:boolean, tab:number, blank:boolean, verdict:string,
|
|
3654
|
+
* reason:string, white_ratio?:number, unique?:number, dom?:object,
|
|
3655
|
+
* bounds?:object, layout_ok?:boolean, ms?:number, error?:string}>}
|
|
3656
|
+
*/
|
|
3657
|
+
async paintCheck(tabId) {
|
|
3658
|
+
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
3659
|
+
const view = this._getView(tid);
|
|
3660
|
+
if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
|
|
3661
|
+
const wc = view.webContents;
|
|
3662
|
+
if (!wc || wc.isDestroyed()) return { ok: false, error: 'tab-destroyed', tab: tid };
|
|
3663
|
+
const startedAt = Date.now();
|
|
3664
|
+
|
|
3665
|
+
let bounds = null;
|
|
3666
|
+
try { bounds = view.getBounds(); } catch (_) { /* 视图正在销毁 */ }
|
|
3667
|
+
|
|
3668
|
+
// 布局是否占了该占的地方。压在 0 高/0 宽上时页面"白"不是内容问题,
|
|
3669
|
+
// 是没人给它地方画 —— 这两种在截图上长得一样,必须靠几何量分开。
|
|
3670
|
+
let expected = null;
|
|
3671
|
+
let layoutOk = null;
|
|
3672
|
+
try {
|
|
3673
|
+
const cb = this.window.getContentBounds();
|
|
3674
|
+
expected = {
|
|
3675
|
+
width: cb.width,
|
|
3676
|
+
height: Math.max(0, cb.height - config.tabBarHeight - (this._bottomInset || 0)),
|
|
3677
|
+
};
|
|
3678
|
+
if (bounds) {
|
|
3679
|
+
layoutOk = bounds.width >= expected.width - 2 && Math.abs(bounds.height - expected.height) <= 2;
|
|
3680
|
+
}
|
|
3681
|
+
} catch (_) { /* 窗口已销毁 */ }
|
|
3682
|
+
|
|
3683
|
+
let dom = { text_len: 0, nodes: 0, ready_state: '' };
|
|
3684
|
+
try {
|
|
3685
|
+
const raw = await withDeadline(this.evaluate(PageManager.PAINT_DOM_JS, tid), 2000, 'paint_dom');
|
|
3686
|
+
const s = (raw && typeof raw === 'object' && 'value' in raw) ? raw.value : raw;
|
|
3687
|
+
const parsed = typeof s === 'string' ? JSON.parse(s) : null;
|
|
3688
|
+
if (parsed) dom = parsed;
|
|
3689
|
+
} catch (_) {
|
|
3690
|
+
// 读不到 DOM 事实(渲染器卡住/正在崩溃)。继续往下走:宁可只用位图判,
|
|
3691
|
+
// 也不要因为"诊断都失败了"就跳过诊断本身。
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
let stats = null;
|
|
3695
|
+
let frameError = null;
|
|
3696
|
+
try {
|
|
3697
|
+
// 位图来源优先走 CDP(和 ui.screenshot 同一条路),而不是
|
|
3698
|
+
// webContents.capturePage()。实测(2026-10-25,e2e/paint 与真机):
|
|
3699
|
+
// capturePage 对**被其它窗口盖住**的窗口返回空图(isEmpty()===true),
|
|
3700
|
+
// 而"窗口被盖住"恰恰是本产品最常见的状态(不抢焦点原则)。用 capturePage
|
|
3701
|
+
// 就等于在最需要观测的场景下失明 —— 判出来是 no-frame,什么都没看见。
|
|
3702
|
+
// CDP 的 Page.captureScreenshot 走渲染器自己的 surface,被遮挡也拿得到。
|
|
3703
|
+
//
|
|
3704
|
+
// clip.scale=0.25:判"是不是白的"不需要细节,缩到 1/4 后像素数从 350 万
|
|
3705
|
+
// 降到 22 万,统计从几十毫秒降到几毫秒,而且和用户看到的是同一帧内容。
|
|
3706
|
+
const w = bounds && bounds.width ? Math.max(1, Math.round(bounds.width)) : 0;
|
|
3707
|
+
const h = bounds && bounds.height ? Math.max(1, Math.round(bounds.height)) : 0;
|
|
3708
|
+
let shot = null;
|
|
3709
|
+
if (w && h) {
|
|
3710
|
+
try {
|
|
3711
|
+
this._ensureCdp(tid, view, 'Page');
|
|
3712
|
+
shot = await withDeadline(
|
|
3713
|
+
view.webContents.debugger.sendCommand('Page.captureScreenshot', {
|
|
3714
|
+
format: 'png',
|
|
3715
|
+
fromSurface: true,
|
|
3716
|
+
clip: { x: 0, y: 0, width: w, height: h, scale: 0.25 },
|
|
3717
|
+
}),
|
|
3718
|
+
4000, 'paint_shot'
|
|
3719
|
+
);
|
|
3720
|
+
} catch (_) { /* 落到 capturePage 兜底 */ }
|
|
3721
|
+
}
|
|
3722
|
+
if (shot && shot.data) {
|
|
3723
|
+
const buf = Buffer.from(shot.data, 'base64');
|
|
3724
|
+
const img = nativeImage.createFromBuffer(buf);
|
|
3725
|
+
if (img && !img.isEmpty()) stats = blankPage.statsFromBitmap(img.toBitmap());
|
|
3726
|
+
}
|
|
3727
|
+
if (!stats) {
|
|
3728
|
+
const img = await withDeadline(wc.capturePage(), 4000, 'capturePage');
|
|
3729
|
+
if (img && typeof img.isEmpty === 'function' && !img.isEmpty()) {
|
|
3730
|
+
const small = img.resize({ width: 240 });
|
|
3731
|
+
stats = blankPage.statsFromBitmap(small.toBitmap());
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
} catch (e) {
|
|
3735
|
+
frameError = e && e.message ? e.message : String(e);
|
|
3736
|
+
}
|
|
3737
|
+
|
|
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
|
+
}
|
|
3754
|
+
// 还在"正在打开…"占位页(file:///…/loading.html?url=…)上:真实导航排在建
|
|
3755
|
+
// 垫片之后,还没开始。这时既不是白屏也不是就绪 —— 报 not-ready 才会让调用方
|
|
3756
|
+
// "等一会儿"而不是"这页坏了"。这个状态在 new_tab 回执后约 100–200ms 内可见。
|
|
3757
|
+
const onPlaceholder = dom && dom.url && /\/loading\.html(\?|$)/.test(String(dom.url));
|
|
3758
|
+
const verdict = onPlaceholder ? 'not-ready' : cls.verdict;
|
|
3759
|
+
const reason = onPlaceholder
|
|
3760
|
+
? 'still on the loading placeholder — the real navigation has not started yet'
|
|
3761
|
+
: cls.reason;
|
|
3762
|
+
const blank = cls.blank;
|
|
3763
|
+
// 观测到的就是最新结论,落进 _paintState —— 否则 list_tabs 报的是"上一轮自动
|
|
3764
|
+
// 自检"的旧结论,和调用方刚刚 paint_check 到的结果自相矛盾。
|
|
3765
|
+
// no-frame 不落(那不是对页面的判断,而是我们没看见)。
|
|
3766
|
+
if (verdict !== 'no-frame') {
|
|
3767
|
+
this._paintState.set(tid, { tab: tid, blank, verdict, reason, dom, at: Date.now(), action: 'none' });
|
|
3768
|
+
}
|
|
3769
|
+
return {
|
|
3770
|
+
ok: true,
|
|
3771
|
+
tab: tid,
|
|
3772
|
+
blank,
|
|
3773
|
+
verdict,
|
|
3774
|
+
reason,
|
|
3775
|
+
...(stats ? {
|
|
3776
|
+
white_ratio: Math.round(stats.white_ratio * 1000) / 1000,
|
|
3777
|
+
unique: stats.unique,
|
|
3778
|
+
...(stats.unique_pinned ? { unique_pinned: true } : {}),
|
|
3779
|
+
} : {}),
|
|
3780
|
+
dom,
|
|
3781
|
+
...(bounds ? { bounds: { width: bounds.width, height: bounds.height } } : {}),
|
|
3782
|
+
...(expected ? { expected } : {}),
|
|
3783
|
+
...(layoutOk !== null ? { layout_ok: layoutOk } : {}),
|
|
3784
|
+
...(frameError ? { frame_error: frameError } : {}),
|
|
3785
|
+
ms: Date.now() - startedAt,
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
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
|
+
|
|
3830
|
+
/**
|
|
3831
|
+
* 排一次自检。合并连发的导航事件;只对**活动** tab 做 —— 后台 tab 不在屏幕上,
|
|
3832
|
+
* 它"白不白"没有用户可见的后果,为它反复 capturePage 只是白烧 CPU。
|
|
3833
|
+
*/
|
|
3834
|
+
_schedulePaintAudit(tabId, delay = 450) {
|
|
3835
|
+
if (!this._paintAuditEnabled) return;
|
|
3836
|
+
if (tabId !== this.activeTab) return;
|
|
3837
|
+
const prev = this._paintTimers.get(tabId);
|
|
3838
|
+
if (prev) clearTimeout(prev);
|
|
3839
|
+
const t = setTimeout(() => {
|
|
3840
|
+
this._paintTimers.delete(tabId);
|
|
3841
|
+
this._auditPaint(tabId).catch(() => {});
|
|
3842
|
+
}, delay);
|
|
3843
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
3844
|
+
this._paintTimers.set(tabId, t);
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
/**
|
|
3848
|
+
* 自检入口。合并并发调用:**同一 tab 同时只跑一轮**,后来的调用者拿到正在跑的那一轮。
|
|
3849
|
+
*
|
|
3850
|
+
* 为什么不是"发现已在跑就返回 null":自动自检(did-finish-load 后 450ms)和
|
|
3851
|
+
* 调用方的 ui.paint_check{heal:true} 很容易撞在一起,返回 null 会让调用方
|
|
3852
|
+
* 拿到一个"什么都没有"的结论,然后自己猜。共享同一个 Promise 才是对的。
|
|
3853
|
+
*/
|
|
3854
|
+
async _auditPaint(tabId) {
|
|
3855
|
+
if (!this._paintAuditEnabled) return null;
|
|
3856
|
+
const inflight = this._paintAuditInflight.get(tabId);
|
|
3857
|
+
if (inflight) return inflight;
|
|
3858
|
+
let p;
|
|
3859
|
+
p = this._auditPaintNow(tabId).finally(() => {
|
|
3860
|
+
if (this._paintAuditInflight.get(tabId) === p) this._paintAuditInflight.delete(tabId);
|
|
3861
|
+
});
|
|
3862
|
+
this._paintAuditInflight.set(tabId, p);
|
|
3863
|
+
return p;
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
/**
|
|
3867
|
+
* 自检 + 按阶梯自救(真正的执行体,递归时直接调它,避免绕回合并逻辑死等自己)。
|
|
3868
|
+
*
|
|
3869
|
+
* 阶梯(见 blank_page.decidePaintAction):repaint → reload → give-up。
|
|
3870
|
+
* 有上限是硬要求:CSS 自己把文字刷成白色的页面,reload 一万次也还是白,
|
|
3871
|
+
* 而无限重试会变成一个看不见的死循环(CPU 烧着、页面反复重载、用户看到的仍是白屏,
|
|
3872
|
+
* 而且没有任何地方说过"我试过了、没用")。
|
|
3873
|
+
*/
|
|
3874
|
+
async _auditPaintNow(tabId) {
|
|
3875
|
+
{
|
|
3876
|
+
// 先量几何再量画面:视图没跟上窗口时,像素判定的意义有限(看到的是一张
|
|
3877
|
+
// 被缩放的旧 surface),而且这个错误必须先修,否则后面的重画都白做。
|
|
3878
|
+
this._auditLayout(tabId);
|
|
3879
|
+
const res = await this.paintCheck(tabId);
|
|
3880
|
+
if (!res.ok) return res;
|
|
3881
|
+
const steps = this._paintSteps.get(tabId) || 0;
|
|
3882
|
+
const action = blankPage.decidePaintAction({ verdict: res.verdict, steps });
|
|
3883
|
+
const rec = { ...res, action, at: Date.now() };
|
|
3884
|
+
this._paintState.set(tabId, rec);
|
|
3885
|
+
|
|
3886
|
+
if (action === 'none') return rec;
|
|
3887
|
+
|
|
3888
|
+
const view = this._getView(tabId);
|
|
3889
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return rec;
|
|
3890
|
+
if (action === 'repaint') {
|
|
3891
|
+
this._paintSteps.set(tabId, steps + 1);
|
|
3892
|
+
this._forceRepaint(view);
|
|
3893
|
+
await new Promise((r) => { const t = setTimeout(r, 320); if (t.unref) t.unref(); });
|
|
3894
|
+
return this._auditPaintNow(tabId);
|
|
3895
|
+
}
|
|
3896
|
+
if (action === 'reload') {
|
|
3897
|
+
this._paintSteps.set(tabId, steps + 1);
|
|
3898
|
+
this._paintSelfReload.add(tabId);
|
|
3899
|
+
try {
|
|
3900
|
+
view.webContents.reload();
|
|
3901
|
+
} catch (e) {
|
|
3902
|
+
this._paintSelfReload.delete(tabId);
|
|
3903
|
+
return rec;
|
|
3904
|
+
}
|
|
3905
|
+
await this._waitFinishLoad(tabId, 10000);
|
|
3906
|
+
await new Promise((r) => { const t = setTimeout(r, 450); if (t.unref) t.unref(); });
|
|
3907
|
+
return this._auditPaintNow(tabId);
|
|
3908
|
+
}
|
|
3909
|
+
// give-up:救不动就如实上报。这是唯一能让人知道"白屏是真的、重试没用"的地方。
|
|
3910
|
+
const info = {
|
|
3911
|
+
tab: tabId,
|
|
3912
|
+
url: res.dom && res.dom.url,
|
|
3913
|
+
verdict: res.verdict,
|
|
3914
|
+
reason: res.reason,
|
|
3915
|
+
white_ratio: res.white_ratio,
|
|
3916
|
+
unique: res.unique,
|
|
3917
|
+
attempts: steps,
|
|
3918
|
+
};
|
|
3919
|
+
recordError('paint_blank', info);
|
|
3920
|
+
this._broadcast('paint_blank', info);
|
|
3921
|
+
return rec;
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
/** 等到 did-finish-load(或超时)。只用于自救后的确认,不影响导航本身。 */
|
|
3926
|
+
_waitFinishLoad(tabId, timeoutMs) {
|
|
3927
|
+
const view = this._getView(tabId);
|
|
3928
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return Promise.resolve(false);
|
|
3929
|
+
const wc = view.webContents;
|
|
3930
|
+
return new Promise((resolve) => {
|
|
3931
|
+
let done = false;
|
|
3932
|
+
const finish = (ok) => {
|
|
3933
|
+
if (done) return;
|
|
3934
|
+
done = true;
|
|
3935
|
+
try { wc.removeListener('did-finish-load', onLoad); } catch (_) {}
|
|
3936
|
+
clearTimeout(timer);
|
|
3937
|
+
resolve(ok);
|
|
3938
|
+
};
|
|
3939
|
+
const onLoad = () => finish(true);
|
|
3940
|
+
wc.once('did-finish-load', onLoad);
|
|
3941
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
3942
|
+
if (timer.unref) timer.unref();
|
|
3943
|
+
});
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
/**
|
|
3947
|
+
* 给一个 tab 挂上自检钩子(幂等)。
|
|
3948
|
+
*
|
|
3949
|
+
* 为什么是 did-finish-load + dom-ready 两条:did-finish-load 在长轮询/SPA 上可能
|
|
3950
|
+
* 迟迟不来(那正是首屏白最久的时候),dom-ready 是更早的第二落点。两次都被
|
|
3951
|
+
* _schedulePaintAudit 按 tab 合并,不会真的跑两遍。
|
|
3952
|
+
*/
|
|
3953
|
+
_attachPaintHook(tabId, view) {
|
|
3954
|
+
if (!this._paintAuditEnabled) return;
|
|
3955
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return;
|
|
3956
|
+
if (!this._paintAuditHooked) this._paintAuditHooked = new WeakSet();
|
|
3957
|
+
if (this._paintAuditHooked.has(view)) return;
|
|
3958
|
+
this._paintAuditHooked.add(view);
|
|
3959
|
+
const onLoad = () => {
|
|
3960
|
+
// 自己触发的 reload 不清零补救步数 —— 否则 "reload 后还是白" 会重新从
|
|
3961
|
+
// repaint 开始,变成"重载 → 再重载"的无限循环。
|
|
3962
|
+
if (this._paintSelfReload.has(tabId)) {
|
|
3963
|
+
this._paintSelfReload.delete(tabId);
|
|
3964
|
+
} else {
|
|
3965
|
+
this._paintSteps.set(tabId, 0);
|
|
3966
|
+
this._paintState.delete(tabId);
|
|
3967
|
+
}
|
|
3968
|
+
this._schedulePaintAudit(tabId, 450);
|
|
3969
|
+
};
|
|
3970
|
+
view.webContents.on('did-finish-load', onLoad);
|
|
3971
|
+
view.webContents.on('dom-ready', () => this._schedulePaintAudit(tabId, 900));
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
/**
|
|
3975
|
+
* 渲染进程崩了 → 自己重载一次。
|
|
3976
|
+
*
|
|
3977
|
+
* 以前这里只有 console.error:进程崩溃后 Chromium 给窗口留一块**白**的
|
|
3978
|
+
* surface,事件不再来,DOM 读不到,用户看到的是永久白屏,而日志里只有一行。
|
|
3979
|
+
* reload 之后 Chromium 会重新拉一个渲染进程,页面照常回来。
|
|
3980
|
+
* 15 秒内只救一次:崩了又崩说明是死循环,重载只会让人反复看着它闪。
|
|
3981
|
+
*/
|
|
3982
|
+
_attachCrashRecovery(tabId, view) {
|
|
3983
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return;
|
|
3984
|
+
if (!this._crashHooked) this._crashHooked = new WeakSet();
|
|
3985
|
+
if (this._crashHooked.has(view)) return;
|
|
3986
|
+
this._crashHooked.add(view);
|
|
3987
|
+
view.webContents.on('render-process-gone', (_e, details) => {
|
|
3988
|
+
const reason = (details && details.reason) || 'unknown';
|
|
3989
|
+
let url = null;
|
|
3990
|
+
try { url = view.webContents.getURL(); } catch (_) {}
|
|
3991
|
+
recordError('render_process_gone', { tab: tabId, reason, exit_code: details && details.exitCode, url });
|
|
3992
|
+
if (reason === 'clean-exit') return;
|
|
3993
|
+
const last = this._crashReloadAt.get(tabId) || 0;
|
|
3994
|
+
if (Date.now() - last < 15000) return;
|
|
3995
|
+
this._crashReloadAt.set(tabId, Date.now());
|
|
3996
|
+
try {
|
|
3997
|
+
view.webContents.reload();
|
|
3998
|
+
this._broadcast('tab_recovered', { tab: tabId, reason, action: 'reload', url });
|
|
3999
|
+
} catch (_) { /* 视图已销毁 */ }
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
|
|
3571
4003
|
/**
|
|
3572
4004
|
* Repaint on demand (ui.repaint). The "page shows stale content" case has
|
|
3573
4005
|
* no observable state to detect it from — the DOM and evaluate already report
|
|
@@ -3657,6 +4089,30 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3657
4089
|
// 同理,下载记录只对"这个 tab 还在"有意义。注意**只清记录,不删文件** ——
|
|
3658
4090
|
// 文件是用户要的东西,删文件是另一个决定,不能藏在关 tab 里。
|
|
3659
4091
|
this.downloadMonitor.clearTab(tabId);
|
|
4092
|
+
// 白屏自检 / 崩溃自救的 per-tab 状态。不清的话 tabId 会被新 tab 复用,
|
|
4093
|
+
// 新 tab 一开就继承上一任的"已经救过两步",白屏时直接被判定成 give-up。
|
|
4094
|
+
this._paintState.delete(tabId);
|
|
4095
|
+
this._paintSteps.delete(tabId);
|
|
4096
|
+
this._paintSelfReload.delete(tabId);
|
|
4097
|
+
this._crashReloadAt.delete(tabId);
|
|
4098
|
+
const pt = this._paintTimers.get(tabId);
|
|
4099
|
+
if (pt) { clearTimeout(pt); this._paintTimers.delete(tabId); }
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
/** 白屏相关的"值得报出去的结论";正常页面返回 null(listTabs 不带这个字段)。 */
|
|
4103
|
+
_paintProblem(tabId) {
|
|
4104
|
+
const p = this._paintState.get(tabId);
|
|
4105
|
+
if (!p) return null;
|
|
4106
|
+
// 这四种都不是"用户可见的白屏",不该出现在 list_tabs 里:
|
|
4107
|
+
// ok / empty-legit 是正常,not-ready 是还没加载完,no-frame 是**我们自己
|
|
4108
|
+
// 没看到画面**(视图被销毁等)—— 把诊断失败说成页面故障,是最坏的一种谎。
|
|
4109
|
+
if (p.verdict === 'ok' || p.verdict === 'empty-legit' || p.verdict === 'not-ready' || p.verdict === 'no-frame') return null;
|
|
4110
|
+
return {
|
|
4111
|
+
blank: !!p.blank,
|
|
4112
|
+
verdict: p.verdict,
|
|
4113
|
+
reason: p.reason,
|
|
4114
|
+
action: p.action,
|
|
4115
|
+
};
|
|
3660
4116
|
}
|
|
3661
4117
|
|
|
3662
4118
|
listTabs() {
|
|
@@ -3676,6 +4132,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3676
4132
|
// 只有 manual 策略下才会长期存在。auto 模式下对话框在打开的瞬间就被
|
|
3677
4133
|
// 应答了,所以正常情况这个字段不出现 —— 出现即意味着"有人需要点它"。
|
|
3678
4134
|
...(this._openDialogs.has(id) ? { dialog: this._openDialogs.get(id) } : {}),
|
|
4135
|
+
// 白屏只在与众不同时出现:正常页面不带这个字段(省 token,也不给
|
|
4136
|
+
// 调用方制造"每次都要判"的噪音)。出现即"这一页没画出来"。
|
|
4137
|
+
...(this._paintProblem(id) ? { paint: this._paintProblem(id) } : {}),
|
|
3679
4138
|
});
|
|
3680
4139
|
}
|
|
3681
4140
|
return list;
|
|
@@ -3742,6 +4201,50 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3742
4201
|
} catch (e) { /* debugger 未 attach / 页面已销毁 —— 忽略 */ }
|
|
3743
4202
|
}
|
|
3744
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
|
+
|
|
3745
4248
|
_layoutView(view) {
|
|
3746
4249
|
const bounds = this.window.getContentBounds();
|
|
3747
4250
|
const inset = this._bottomInset || 0;
|
package/src/main/ws_server.js
CHANGED
|
@@ -185,6 +185,31 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
|
|
|
185
185
|
send({ jsonrpc: '2.0', id, result: r });
|
|
186
186
|
break;
|
|
187
187
|
}
|
|
188
|
+
case 'ui.paint_check': {
|
|
189
|
+
// "这一页到底画出来了没有"—— 只读,不改任何东西。
|
|
190
|
+
//
|
|
191
|
+
// 为什么必须有这个入口:白屏是唯一一种"did-finish-load 到了、
|
|
192
|
+
// readyState=complete、evaluate 读得到文字、CDP 截图也有内容,
|
|
193
|
+
// 但屏幕上就是纯白"的失败形态。所有既有观测口在这里集体失明,
|
|
194
|
+
// 于是用户说"白的"、工具说"好的",谁也拿不出证据。
|
|
195
|
+
// 判定口径见 shared/blank_page.cjs。
|
|
196
|
+
//
|
|
197
|
+
// heal:true 时才走自救阶梯(repaint → reload → 给结论并上报),
|
|
198
|
+
// 默认只观测 —— 调用方问"是不是白的"时,不该顺手把页面重载掉。
|
|
199
|
+
const tid = params.tab !== undefined ? params.tab : pageManager.activeTab;
|
|
200
|
+
const r = params.heal ? await pageManager._auditPaint(tid) : await pageManager.paintCheck(tid);
|
|
201
|
+
send({ jsonrpc: '2.0', id, result: r });
|
|
202
|
+
break;
|
|
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
|
+
}
|
|
188
213
|
case 'ui.snapshot': {
|
|
189
214
|
const r = await pageManager.snapshot({ js: params.js, label: params.label, tab: tabId });
|
|
190
215
|
send({ jsonrpc: '2.0', id, result: r });
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// shared/blank_page.cjs — "这一页到底画出来没有"的判定口径(纯函数)。
|
|
2
|
+
//
|
|
3
|
+
// 事故(2026-10-25,用户报"打开了百度有时候是纯白屏"):
|
|
4
|
+
// 页面加载事件全都到了(did-finish-load / readyState=complete)、DOM 里有内容、
|
|
5
|
+
// agent 通过 ui.evaluate 也能读到文字,但**用户看到的那块区域是纯白的**。
|
|
6
|
+
// 这类白屏此前没有任何检测手段:
|
|
7
|
+
// - did-fail-load 只覆盖"网络失败",不覆盖"画布是空的";
|
|
8
|
+
// - chrome-error:// 的错误页覆盖"连接失败",也不覆盖这个;
|
|
9
|
+
// - capturePage / CDP 截图**不测**它 —— 它们走 view 自己的 surface,
|
|
10
|
+
// 视图内部画得好好的,白的是窗口那一层。
|
|
11
|
+
// 结果就是"用户说白屏,工具说一切正常",双方都拿不出证据。
|
|
12
|
+
//
|
|
13
|
+
// 这份模块只做一件事:给一张**位图**和一份**DOM 事实**,回答
|
|
14
|
+
// "它是不是白的?白是因为这页本来就空,还是因为它该有内容却没画出来?"
|
|
15
|
+
// 判定与"怎么修"分开:修有副作用(reload 会丢掉页面里的临时状态),
|
|
16
|
+
// 所以由 decidePaintAction 单独决定,且**有上限**(见下)。
|
|
17
|
+
//
|
|
18
|
+
// 为什么白度 + 唯一色两个指标都要:
|
|
19
|
+
// - 只用白度:百度首页本身 90%+ 是白的,会天天误报;
|
|
20
|
+
// - 只用唯一色:深色加载页只有 400 多种颜色,也会误报成"空白"。
|
|
21
|
+
// 两个一起看才分得清"内容页(白底但有正文/图标 → 唯一色几千)"和
|
|
22
|
+
// "真的什么都没画(纯白 → 唯一色个位数)"。
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// 位图格式:Electron 的 nativeImage.toBitmap() 给的是 BGRA(4 字节/像素,
|
|
27
|
+
// 从上到下、无 padding)。这里不假设调用方用什么解码,只要求同一约定。
|
|
28
|
+
const BPP = 4;
|
|
29
|
+
|
|
30
|
+
// 阈值定了两个数,都有实测依据(2026-10-25,1280x800 窗口、2x 屏):
|
|
31
|
+
// - 真实页面:百度首页 90–93% 白 / 3.9k–6.1k 唯一色;
|
|
32
|
+
// 纯白故障帧:99–100% 白 / 3–25 唯一色(抗锯齿边缘也会贡献几个色阶);
|
|
33
|
+
// - 深色页面(我们的 loading.html):0% 白 / 400+ 唯一色。
|
|
34
|
+
// 取 white ≥ 99% 且 unique ≤ 32:两侧都留了余量,既不会把"白底文章页"算进来
|
|
35
|
+
// (它的唯一色必然上千),也不会因为一个圆角边框的四个渐变色就漏掉真白屏。
|
|
36
|
+
const WHITE_MIN_RATIO = 0.99;
|
|
37
|
+
const WHITE_PIXEL = 250; // 单通道 ≥ 250 算"白"(250 而不是 255:留点压缩/缩放余量)
|
|
38
|
+
const UNIQUE_MAX = 32;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 统计位图的白度与唯一色数。
|
|
42
|
+
* @param {Buffer|Uint8Array} bitmap BGRA 位图
|
|
43
|
+
* @returns {{total:number, white:number, white_ratio:number, unique:number}}
|
|
44
|
+
*/
|
|
45
|
+
function statsFromBitmap(bitmap) {
|
|
46
|
+
if (!bitmap || !bitmap.length) {
|
|
47
|
+
return { total: 0, white: 0, white_ratio: 0, unique: 0 };
|
|
48
|
+
}
|
|
49
|
+
const total = Math.floor(bitmap.length / BPP);
|
|
50
|
+
let white = 0;
|
|
51
|
+
// 唯一色用 Set 存打包后的 24 位整数。纯白图只有 1–3 个元素,正常页面几千个。
|
|
52
|
+
// 数过 UNIQUE_MAX 就不再往 Set 里塞(内存有界),但白色像素要继续数到最后一个 ——
|
|
53
|
+
// 提前 break 会让 white_ratio 变成"扫过的部分"的比例,回执里的数字就成了假话。
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let overflow = false;
|
|
56
|
+
for (let i = 0; i + 3 < bitmap.length; i += BPP) {
|
|
57
|
+
const b = bitmap[i], g = bitmap[i + 1], r = bitmap[i + 2];
|
|
58
|
+
if (r >= WHITE_PIXEL && g >= WHITE_PIXEL && b >= WHITE_PIXEL) white++;
|
|
59
|
+
if (!overflow) {
|
|
60
|
+
seen.add((r << 16) | (g << 8) | b);
|
|
61
|
+
if (seen.size > UNIQUE_MAX) overflow = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
total,
|
|
66
|
+
white,
|
|
67
|
+
white_ratio: total ? white / total : 0,
|
|
68
|
+
// pinned = "颜色数已超过上限,这里只是下限"。回执里要如实标注,
|
|
69
|
+
// 否则一个信息丰富的页面会被显示成"33 种颜色"。
|
|
70
|
+
unique: seen.size,
|
|
71
|
+
unique_pinned: overflow,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 判定一帧画面。
|
|
77
|
+
*
|
|
78
|
+
* @param {object} p
|
|
79
|
+
* @param {{total:number,white:number,white_ratio:number,unique:number}} p.stats
|
|
80
|
+
* @param {{text_len:number, nodes:number, ready_state:string}} p.dom 页面自述的事实
|
|
81
|
+
* @param {{width:number,height:number}} [p.bounds] 视图当前尺寸
|
|
82
|
+
* @returns {{verdict:string, blank:boolean, reason:string}}
|
|
83
|
+
*
|
|
84
|
+
* verdict:
|
|
85
|
+
* ok 有内容(正常)
|
|
86
|
+
* empty-legit 这页本来就是空的(about:blank / 空文档),白是正确结果
|
|
87
|
+
* not-ready 页面还没到 interactive/complete,白是暂时的,等它
|
|
88
|
+
* collapsed 视图本身被压成 0 高/0 宽 —— 不是没画,是没地方画
|
|
89
|
+
* blank-with-text DOM 里有文字,但屏幕上纯白 —— **真白屏**
|
|
90
|
+
* blank-with-dom DOM 有几十个节点却纯白 —— **真白屏**(内容层失败)
|
|
91
|
+
* scale-mismatch 页面按 A 宽排版、视图只有 B 宽(合成器画了一张陈旧尺寸的
|
|
92
|
+
* surface)—— 表现为"内容缩在左上角,其余全白"
|
|
93
|
+
* no-frame 拿不到位图(视图不可见/被销毁),无法判定
|
|
94
|
+
*/
|
|
95
|
+
function classifyPaint({ stats, dom, bounds } = {}) {
|
|
96
|
+
const d = dom || {};
|
|
97
|
+
const textLen = Number(d.text_len) || 0;
|
|
98
|
+
const nodes = Number(d.nodes) || 0;
|
|
99
|
+
const ready = String(d.ready_state || '');
|
|
100
|
+
|
|
101
|
+
if (bounds && (Number(bounds.width) <= 0 || Number(bounds.height) <= 0)) {
|
|
102
|
+
return { verdict: 'collapsed', blank: true, reason: `view is ${bounds.width}x${bounds.height} — nothing to paint into` };
|
|
103
|
+
}
|
|
104
|
+
if (!stats || !stats.total) {
|
|
105
|
+
return { verdict: 'no-frame', blank: false, reason: 'no bitmap (view not visible or destroyed)' };
|
|
106
|
+
}
|
|
107
|
+
const isWhite = stats.white_ratio >= WHITE_MIN_RATIO && stats.unique <= UNIQUE_MAX;
|
|
108
|
+
const colourDesc = `${stats.unique}${stats.unique_pinned ? '+' : ''} colours`;
|
|
109
|
+
if (ready && ready !== 'interactive' && ready !== 'complete') {
|
|
110
|
+
// 还没就绪时"白"是暂时的,不是缺陷 —— blank 字段仍要如实反映画面。
|
|
111
|
+
return { verdict: 'not-ready', blank: isWhite, reason: `document.readyState = ${ready}` };
|
|
112
|
+
}
|
|
113
|
+
if (!isWhite) {
|
|
114
|
+
return { verdict: 'ok', blank: false, reason: `${Math.round(stats.white_ratio * 100)}% white, ${colourDesc}` };
|
|
115
|
+
}
|
|
116
|
+
// 到这里:屏幕上确实是纯白。白得对不对,只能问 DOM。
|
|
117
|
+
if (textLen === 0 && nodes <= 2) {
|
|
118
|
+
return { verdict: 'empty-legit', blank: false, reason: 'empty document — white is the correct result' };
|
|
119
|
+
}
|
|
120
|
+
if (textLen > 0) {
|
|
121
|
+
return { verdict: 'blank-with-text', blank: true, reason: `${textLen} chars of text in the DOM but the screen is white` };
|
|
122
|
+
}
|
|
123
|
+
return { verdict: 'blank-with-dom', blank: true, reason: `${nodes} DOM nodes but the screen is white` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 白屏了该怎么救 —— 阶梯,且有上限。
|
|
128
|
+
*
|
|
129
|
+
* 为什么要有上限:救不动的时候(比如页面 CSS 自己把文字刷成白色),reload 一百次
|
|
130
|
+
* 也还是白。无上限重试会变成一个看不见的循环:CPU 烧着、页面反复重载、
|
|
131
|
+
* 用户看到的仍是白屏,而且没有任何地方说过"我试过了、没用"。
|
|
132
|
+
*
|
|
133
|
+
* @param {{verdict:string, steps:number}} p steps = 已执行过的补救次数
|
|
134
|
+
* @returns {'none'|'repaint'|'reload'|'give-up'}
|
|
135
|
+
*/
|
|
136
|
+
function decidePaintAction({ verdict, steps = 0 } = {}) {
|
|
137
|
+
// ok / empty-legit / not-ready / no-frame 都不该动。not-ready 尤其重要:
|
|
138
|
+
// 首屏那一刻本来就可能是白的,这时候 reload 等于把正在下载的页面打断。
|
|
139
|
+
if (verdict === 'ok' || verdict === 'empty-legit' || verdict === 'not-ready' || verdict === 'no-frame') {
|
|
140
|
+
return 'none';
|
|
141
|
+
}
|
|
142
|
+
// scale-mismatch 和 blank-* 走同一个阶梯:先重画,再重载,最后如实上报。
|
|
143
|
+
if (steps <= 0) return 'repaint'; // 第一步:remove+add 逼合成器重画(代价最小、不丢状态)
|
|
144
|
+
if (steps === 1) return 'reload'; // 第二步:重载(会丢页面临时状态,所以排在重画之后)
|
|
145
|
+
return 'give-up'; // 第三步:如实上报,不再折腾
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 位图统计的阈值(供测试与回执引用,避免两处写死)。 */
|
|
149
|
+
const THRESHOLDS = { WHITE_MIN_RATIO, WHITE_PIXEL, UNIQUE_MAX };
|
|
150
|
+
|
|
151
|
+
module.exports = { statsFromBitmap, classifyPaint, decidePaintAction, THRESHOLDS };
|
package/src/shared/protocol.js
CHANGED
|
@@ -65,6 +65,12 @@ export const VALID_METHODS = [
|
|
|
65
65
|
'ui.screenshot',
|
|
66
66
|
'ui.navigate',
|
|
67
67
|
'ui.repaint',
|
|
68
|
+
// "这一页到底画出来了没有"—— 只读观测,heal:true 才走自救阶梯。
|
|
69
|
+
// 与 ui.repaint 的区别:repaint 是"外面的人说窗口不对,重画一次";
|
|
70
|
+
// paint_check 是"工具自己看一眼,白的话按阶梯救,救不动就如实上报"。
|
|
71
|
+
'ui.paint_check',
|
|
72
|
+
// 窗口/视图几何(读 + 改)。布局类缺陷需要它才能复现与回归。
|
|
73
|
+
'ui.window_bounds',
|
|
68
74
|
'ui.snapshot',
|
|
69
75
|
'ui.diff',
|
|
70
76
|
'ui.capabilities',
|
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.14';
|