@glittering0717/ai-browser 1.2.1 → 1.3.0
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/README.md +155 -20
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +9 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +487 -16
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/network_monitor.js +86 -23
- package/src/main/page_manager.js +1040 -115
- package/src/main/ws_server.js +127 -16
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/renderer/error_page.html +125 -0
- package/src/renderer/tab_bar.html +116 -56
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/config.js +8 -2
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +15 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +239 -0
package/src/main/page_manager.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// One Electron process, one WS server, multiple tabs.
|
|
3
3
|
// Agent routes via tab ID. Tab 0 is default.
|
|
4
4
|
import { ipcMain, BrowserView } from 'electron';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
5
8
|
import { config } from '../shared/config.js';
|
|
6
9
|
import axExtractor from './axExtractor.js';
|
|
7
10
|
import { NetworkMonitor, MAX_POST_DATA_SIZE } from './network_monitor.js';
|
|
@@ -12,8 +15,52 @@ import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
|
|
|
12
15
|
import { DownloadMonitor } from './download_monitor.js';
|
|
13
16
|
import { probeCapabilities } from './capability_probe.js';
|
|
14
17
|
import valueContract from '../shared/value_contract.cjs';
|
|
18
|
+
// 输入成功与否的判定口径(纯函数,独立模块便于单测)。详见文件内注释:
|
|
19
|
+
// 富文本编辑器会把一个语义换行渲染成一串 \n,逐字比对会把成功的输入误判成失败。
|
|
20
|
+
import { normalizeInputText, skeletalInputText } from '../shared/input_verify.js';
|
|
21
|
+
// 截图参数规范化(纯函数):坏参数在入口就给可读错误,不拖到 CDP 层。
|
|
22
|
+
import screenshotOpts from '../shared/screenshot_opts.cjs';
|
|
23
|
+
// 跨进程子帧(OOPIF)判定与自动路由回执(纯函数):默认读法检测到不可读
|
|
24
|
+
// 子帧时自己补一次 AX 读取,调用方无需知道 ax:true 的存在。
|
|
25
|
+
import frameProbe from '../shared/frame_probe.cjs';
|
|
26
|
+
// ui.act target 形状体检(纯函数):传了可见文字而不是 data-ai-id 时,
|
|
27
|
+
// 入口就给可操作的错误,而不是等到选择器查空才说 "Target not found"。
|
|
28
|
+
import refGuard from '../shared/ref_guard.cjs';
|
|
29
|
+
// 错误日志:导航失败(界面会显示错误页)必须落盘 —— load_failed 只是内存里的
|
|
30
|
+
// 一次广播,进程一退出就再也查不到"哪个 tab、哪个网址、什么错误码"。
|
|
31
|
+
import { recordError } from './error_log.js';
|
|
32
|
+
// navigate 的完成信号(commit | domcontentloaded | load):默认不再是 load ——
|
|
33
|
+
// SPA 永不 fire load,把它当完成信号等于把"页面可用"报成"超时"。
|
|
34
|
+
import navWait from '../shared/nav_wait.cjs';
|
|
35
|
+
// 穿透影子根的 ref 查找(纯字符串构造,见 shared/find_ref.cjs 顶部注释)
|
|
36
|
+
import findRefUtil from '../shared/find_ref.cjs';
|
|
37
|
+
// 真正的焦点元素(穿透 shadow root)。`document.activeElement` 在 Web Component
|
|
38
|
+
// 里只到宿主为止,用它做输入校验会读到空串,把"已打进去"报成"没打进去"。
|
|
39
|
+
import deepActiveUtil from '../shared/deep_active.cjs';
|
|
40
|
+
// 悬停前后语义树的差异(ui.peek)。数据源是 DOM 语义树而不是 AX 树,理由与两条
|
|
41
|
+
// 实测证据都写在 shared/tree_diff.cjs 顶部。
|
|
42
|
+
import treeDiff from '../shared/tree_diff.cjs';
|
|
43
|
+
// 按键派发计划(纯函数):rawKeyDown 不产生 keypress,Enter 的默认动作会落空。
|
|
44
|
+
import keyDispatchUtil from '../shared/key_dispatch.cjs';
|
|
45
|
+
// 命中探测(穿透影子根)。`document.elementFromPoint` 不跨 shadow boundary,
|
|
46
|
+
// 对影子内部的元素恒返回宿主 —— 于是"点击前证明坐标会落在目标上"这道校验
|
|
47
|
+
// 恒为假,点击只能降级成非受信的 el.click()。详见 shared/hit_point.cjs 顶部。
|
|
48
|
+
import hitPointUtil from '../shared/hit_point.cjs';
|
|
15
49
|
|
|
16
50
|
const { expandValueFetchTree } = valueContract;
|
|
51
|
+
const deepActiveExpr = deepActiveUtil.deepActiveExpr;
|
|
52
|
+
const keyDispatchPlan = keyDispatchUtil.keyDispatchPlan;
|
|
53
|
+
const { hitTestExpr, hitInfoExpr, deepHitExpr } = hitPointUtil;
|
|
54
|
+
|
|
55
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
56
|
+
const __dirname = path.dirname(__filename);
|
|
57
|
+
|
|
58
|
+
// 穿透影子根的 ref 查找(返回自包含 JS 表达式,可原地替换 document.querySelector)。
|
|
59
|
+
// 实现与理由见 src/shared/find_ref.cjs —— 那里的注释解释了为什么查询必须穿 shadow
|
|
60
|
+
// boundary,以及为什么返回"表达式字符串"而不是"运行时函数"。
|
|
61
|
+
const findRefExpr = findRefUtil.findRefExpr;
|
|
62
|
+
const findDescExpr = findRefUtil.findDescExpr;
|
|
63
|
+
|
|
17
64
|
|
|
18
65
|
// canvas 绘制调用记录:默认开。关闭开关 AI_BROWSER_CANVAS_HOOK=0。
|
|
19
66
|
const CANVAS_BRIDGE_KEY = '__aiCanvasBridge';
|
|
@@ -103,13 +150,17 @@ class PageManager {
|
|
|
103
150
|
this.tabs = new Map(); // tabId -> BrowserView
|
|
104
151
|
this.activeTab = null;
|
|
105
152
|
this.tabCounter = 0;
|
|
153
|
+
// 窗口首次显示时的那一刻状态(tab / body_len / ready_state)。
|
|
154
|
+
// 白屏类缺陷的根因是"窗口显示了但渲染器还没画出第一帧",而这只能从窗口侧
|
|
155
|
+
// 观测 —— WS 端口在 createWindow 中途就开了,比 showInitialWindow 早。
|
|
156
|
+
// 由 index.js 的 showInitialWindow 填写,ui.window_shown 读取。
|
|
157
|
+
this.windowShownAt = null;
|
|
106
158
|
this.subscriptions = new Map();
|
|
107
159
|
this._pendingRequests = new Map();
|
|
108
160
|
this._requestId = 0;
|
|
109
161
|
this._wsClients = new Map();
|
|
110
162
|
this._openedTabs = new Map(); // sourceTabId -> { id, url } (new tab opened by window.open)
|
|
111
163
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
112
|
-
this._revertTimers = new Map(); // tabId -> 挂起的 ui.peek 自动回撤定时器(新 peek 会取消它)
|
|
113
164
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
114
165
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
115
166
|
this._snapshotSeq = 0;
|
|
@@ -258,8 +309,17 @@ class PageManager {
|
|
|
258
309
|
}
|
|
259
310
|
|
|
260
311
|
_layoutAllViews(bounds) {
|
|
312
|
+
// `_bottomInset`:窗口底部被别的东西占掉的高度(目前只有主进程异常横幅)。
|
|
313
|
+
// 由 index.js 设置、这里统一扣减 —— 让每个"给视图定尺寸"的地方各自记住
|
|
314
|
+
// 这 34px 必然会漏掉一处(新建 tab 走的是 _layoutView,不是这里)。
|
|
315
|
+
const inset = this._bottomInset || 0;
|
|
261
316
|
for (const [id, view] of this.tabs) {
|
|
262
|
-
view.setBounds({
|
|
317
|
+
view.setBounds({
|
|
318
|
+
x: 0,
|
|
319
|
+
y: config.tabBarHeight,
|
|
320
|
+
width: bounds.width,
|
|
321
|
+
height: Math.max(0, bounds.height - config.tabBarHeight - inset),
|
|
322
|
+
});
|
|
263
323
|
}
|
|
264
324
|
}
|
|
265
325
|
|
|
@@ -436,24 +496,121 @@ class PageManager {
|
|
|
436
496
|
* (挂起的连接、不结束的加载)。这与 ui.act 是同一类故障:await 一个可能不返回的
|
|
437
497
|
* 东西。所以同样套 deadline。
|
|
438
498
|
*/
|
|
439
|
-
async navigate(url, tabId) {
|
|
499
|
+
async navigate(url, tabId, opts = {}) {
|
|
440
500
|
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
441
501
|
const view = this._getView(tid);
|
|
442
502
|
if (!view) return { ok: false, error: 'Tab not found' };
|
|
503
|
+
const w = navWait.normalizeWaitUntil(opts.waitUntil);
|
|
504
|
+
if (w.error) return { ok: false, error: w.error, wait_until_valid: navWait.LEVELS };
|
|
505
|
+
const waitUntil = w.value;
|
|
443
506
|
this._axDiffCache.delete(tid); // fresh page → drop stale incremental snapshot
|
|
507
|
+
const wc = view.webContents;
|
|
444
508
|
// loadURL resolves at did-finish-load, which is exactly when the old
|
|
445
509
|
// pixels may still be on screen — repaint here as well as from the event
|
|
446
510
|
// hooks, so a programmatic navigate never depends on hook timing.
|
|
511
|
+
//
|
|
512
|
+
// 失败时的返回值要说清楚**发生了什么**,而不是笼统的 ok:false。因为
|
|
513
|
+
// did-fail-load 现在会把页面换成我们自己的错误页(见
|
|
514
|
+
// _attachLoadFailureHooks),那次 loadURL(error_page) 会以 ERR_ABORTED
|
|
515
|
+
// 打断当前这次 loadURL 的 Promise —— 如果不区分,agent 收到的就是
|
|
516
|
+
// {ok:false, error:"ERR_ABORTED (-3) loading 'file://.../error_page.html'"},
|
|
517
|
+
// 它会以为自己的导航请求坏了,而实际上错误页已经接管、信息全在那儿。
|
|
518
|
+
const pending = wc.loadURL(url);
|
|
519
|
+
// commit / domcontentloaded 会**提前**返回;之后这个 Promise 仍可能 reject
|
|
520
|
+
// (错误页接管、用户中止)。不接住就是 unhandledRejection,会把整个进程
|
|
521
|
+
// 的日志淹掉。错误本身在返回结果里另行汇报。
|
|
522
|
+
pending.catch(() => {});
|
|
523
|
+
|
|
447
524
|
try {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
525
|
+
if (waitUntil === 'load') {
|
|
526
|
+
await withDeadline(pending, 30000, 'navigate');
|
|
527
|
+
} else {
|
|
528
|
+
// 事件驱动,不等 did-finish-load。与 loadURL 自身的结果赛跑:同文档
|
|
529
|
+
// 导航(#hash、history.pushState)不会 fire did-navigate,但那类导航
|
|
530
|
+
// 的 loadURL 会立刻 resolve —— 不赛跑就会白等一整个 deadline。
|
|
531
|
+
await withDeadline(Promise.race([
|
|
532
|
+
this._waitForNavStep(wc, waitUntil, url),
|
|
533
|
+
pending.then(() => ({ at: 'resolved' }), () => ({ at: 'resolved' })),
|
|
534
|
+
]), waitUntil === 'commit' ? 15000 : 20000, 'navigate');
|
|
452
535
|
}
|
|
453
|
-
|
|
536
|
+
} catch (e) {
|
|
537
|
+
return this._navigateFailure(e, view, tid, waitUntil);
|
|
454
538
|
}
|
|
455
539
|
this.repaint(tid);
|
|
456
|
-
return { ok: true };
|
|
540
|
+
if (waitUntil === 'load') return { ok: true, waited_for: 'load' };
|
|
541
|
+
// load 还没来是**正常**的(SPA 本来就不 fire load)。如实说明,并给出
|
|
542
|
+
// 下一步:调用方需要等某个具体内容时该用 ui.wait,而不是等 load。
|
|
543
|
+
const stillLoading = typeof wc.isLoading === 'function' ? !!wc.isLoading() : false;
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
waited_for: waitUntil,
|
|
547
|
+
url: wc.getURL(),
|
|
548
|
+
load_pending: stillLoading,
|
|
549
|
+
hint: stillLoading
|
|
550
|
+
? 'returned at ' + waitUntil + ' — the load event has not fired (normal for SPAs); the page is usable now. Wait for specific content with ui.wait, not for load.'
|
|
551
|
+
: null,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// commit / domcontentloaded 的事件等待。只认主帧:子帧导航不改变"这个 tab 到了
|
|
556
|
+
// 没有"。任一事件先到就 resolve;did-fail-load 立刻 reject(错误页逻辑在外层)。
|
|
557
|
+
//
|
|
558
|
+
// 两个必须忽略的迟到事件(都实测踩到过):
|
|
559
|
+
// -3 ERR_ABORTED:上一次导航被这次导航打断,这是**正常**的取代,不是失败。
|
|
560
|
+
// 不忽略的话,连续两次 navigate 的第二跳稳定被报成 "did-fail-load -3"。
|
|
561
|
+
// failedUrl ≠ 本次目标:上一轮导航的迟到事件。它描述的不是我们等的东西。
|
|
562
|
+
_waitForNavStep(wc, step, expectedUrl) {
|
|
563
|
+
const norm = (u) => String(u || '').split('#')[0];
|
|
564
|
+
return new Promise((resolve, reject) => {
|
|
565
|
+
const cleanup = () => {
|
|
566
|
+
wc.removeListener('did-navigate', onCommit);
|
|
567
|
+
wc.removeListener('dom-ready', onDom);
|
|
568
|
+
wc.removeListener('did-fail-load', onFail);
|
|
569
|
+
};
|
|
570
|
+
const onCommit = () => {
|
|
571
|
+
if (step !== 'commit') return; // domcontentloaded 还要再等一步
|
|
572
|
+
cleanup();
|
|
573
|
+
resolve({ at: 'commit' });
|
|
574
|
+
};
|
|
575
|
+
const onDom = () => { cleanup(); resolve({ at: 'domcontentloaded' }); };
|
|
576
|
+
const onFail = (_e, code, desc, failedUrl, isMainFrame) => {
|
|
577
|
+
if (isMainFrame === false) return;
|
|
578
|
+
if (code === -3) return; // 被新的导航取代(见上)
|
|
579
|
+
if (expectedUrl && failedUrl && norm(failedUrl) !== norm(expectedUrl)) return;
|
|
580
|
+
cleanup();
|
|
581
|
+
reject(Object.assign(new Error('did-fail-load ' + code + ' ' + desc), { __navFail: true }));
|
|
582
|
+
};
|
|
583
|
+
wc.once('did-navigate', onCommit);
|
|
584
|
+
wc.once('dom-ready', onDom);
|
|
585
|
+
wc.once('did-fail-load', onFail);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
_navigateFailure(e, view, tid, waitUntil) {
|
|
590
|
+
const msg = String((e && e.message) || e);
|
|
591
|
+
// 错误页已接管:这不是调用方的错,是目标站点不可达。
|
|
592
|
+
// 两个判据(都不可省):_failureGuard 里还有这个 tab(错误页刚加载、
|
|
593
|
+
// did-finish-load 还没来清标记),或者当前 URL 已经是我们的错误页。
|
|
594
|
+
const onErrorPage = String(view.webContents.getURL() || '').indexOf('error_page.html') >= 0;
|
|
595
|
+
if ((this._failureGuard && this._failureGuard.has(tid)) || onErrorPage) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
error: msg,
|
|
599
|
+
error_page: true,
|
|
600
|
+
hint: '目标站点加载失败,已显示错误页(可重试 / 改网址)。'
|
|
601
|
+
+ ' 失败详情在页面里,用 ui.get_tree 或 ui.evaluate 读取。',
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
if (e && e.__deadline) {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
error: `${e.message} — navigation did not reach "${waitUntil}" in time; the page may still be loading in the background`,
|
|
608
|
+
waited_for: waitUntil,
|
|
609
|
+
hint: 'If the page is a SPA that never fires load, use wait_until:"domcontentloaded" or "commit". '
|
|
610
|
+
+ 'If the tab is stuck altogether, rebuild it: ui.close_tab + ui.new_tab.',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return { ok: false, error: msg.slice(0, 300), waited_for: waitUntil };
|
|
457
614
|
}
|
|
458
615
|
|
|
459
616
|
async getTree(focusedOnly = false, tabId) {
|
|
@@ -475,9 +632,213 @@ class PageManager {
|
|
|
475
632
|
// 截断的 node.value 带内部字段 value_fetch_ref —— 展开成含实际 tab 的
|
|
476
633
|
// value_fetch(mcp/ws 两种可直接执行的调用提示),再删掉内部字段。
|
|
477
634
|
if (res && res.tree) expandValueFetchTree(res.tree, tid);
|
|
635
|
+
// P1-1:页面有可见的跨进程子帧时,自动把子帧内容合并进默认树。
|
|
636
|
+
// 此前这条通路只在 ax:true 下存在,而**没有任何地方告诉调用方 ax:true
|
|
637
|
+
// 的存在** —— B站图文编辑器(正文在 OOPIF 里)因此隐形了两小时,agent
|
|
638
|
+
// 得出"编辑器没加载"的错误结论。现在默认读法自己补上这一步。
|
|
639
|
+
if (!focusedOnly) await this._autoMergeOpaqueFrames(res, tid);
|
|
478
640
|
return res;
|
|
479
641
|
}
|
|
480
642
|
|
|
643
|
+
// 把跨进程子帧的内容读出来、合并进默认(DOM)树。轻量:只读子帧,
|
|
644
|
+
// 不重建主树,所以默认树的形状/id 全部保持原样。
|
|
645
|
+
// 三种结局都写进 context.frame_hints(见 shared/frame_probe.describeAutoAx):
|
|
646
|
+
// merged / no-new-content / failed+reason。调用方必须能区分"已尽力"和
|
|
647
|
+
// "还有一条没走过的路"。
|
|
648
|
+
async _autoMergeOpaqueFrames(res, tid) {
|
|
649
|
+
try {
|
|
650
|
+
if (!res || !res.tree || !res.context) return;
|
|
651
|
+
if (res.tree.__oopif_merged) return; // 幂等:合并过的树不重复做
|
|
652
|
+
const summary = res.context.cross_process_frames;
|
|
653
|
+
if (!frameProbe.shouldAutoAx(summary)) return;
|
|
654
|
+
const view = this._getView(tid);
|
|
655
|
+
if (!view) return;
|
|
656
|
+
const frames = await withDeadline(this._readOopifFrames(view, tid), 15000, 'get_tree(auto-oopif)');
|
|
657
|
+
if (frames && frames.length) {
|
|
658
|
+
res.tree = axExtractor.mergeFrameTrees(res.tree, frames);
|
|
659
|
+
res.tree.__oopif_merged = true;
|
|
660
|
+
// 子帧里的必填提示 / 校验错误也一并合进 context(否则最该有这些字段的
|
|
661
|
+
// 页面恰好是空的,见 frame_probe.mergeFrameContext 顶部注释)。
|
|
662
|
+
const mergedCtx = frameProbe.mergeFrameContext(res.context, frames);
|
|
663
|
+
if (mergedCtx) Object.assign(res.context, mergedCtx);
|
|
664
|
+
}
|
|
665
|
+
res.context.frame_hints = frameProbe.describeAutoAx({
|
|
666
|
+
merged: !!(frames && frames.length),
|
|
667
|
+
framesRead: (frames || []).length,
|
|
668
|
+
});
|
|
669
|
+
} catch (e) {
|
|
670
|
+
// 自动合并失败绝不拖垮主读法:默认树照常返回,只在 hints 里说明原因。
|
|
671
|
+
// 调用方看到 failed 可以手动切 ax:true 重试。
|
|
672
|
+
if (res && res.context) {
|
|
673
|
+
res.context.frame_hints = frameProbe.describeAutoAx({
|
|
674
|
+
error: (e && e.__deadline ? e.message + ' — the page main thread is likely blocked' : e && e.message) || String(e),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 登录态 / 登录墙自查。长任务里每个关键步骤前都可以问一次,成本只是走一次
|
|
681
|
+
// extractPageContext(不建树)。
|
|
682
|
+
//
|
|
683
|
+
// 为什么单独开一个原语而不是让调用方自己去 getTree().context 里翻:CSDN 那次
|
|
684
|
+
// 事故里,登录态信息其实一直在 context 里,但它是个不显眼的小字段,且当时的
|
|
685
|
+
// 判定还是错的(csrfToken 子串命中 "token" → 谎报已登录)。显式暴露成
|
|
686
|
+
// "问一句就答一句"的原语,才能让"先验登录态"变成低成本的第一步。
|
|
687
|
+
// 一次性 DOM 侧上下文(不建树)。authState 与 AX 读法共用:AX 自身的
|
|
688
|
+
// context 只有 modals/session/stats,登录墙、校验错误、必填提示全都来自
|
|
689
|
+
// 这条 DOM 扫描 —— 两条读法给出不一致的 context 只会让调用方怀疑读数。
|
|
690
|
+
async _readContext(view) {
|
|
691
|
+
const res = await new Promise((resolve, reject) => {
|
|
692
|
+
const id = ++this._requestId;
|
|
693
|
+
this._pendingRequests.set(id, { resolve, reject });
|
|
694
|
+
view.webContents.send('ai:context', { id });
|
|
695
|
+
setTimeout(() => {
|
|
696
|
+
if (this._pendingRequests.has(id)) {
|
|
697
|
+
this._pendingRequests.delete(id);
|
|
698
|
+
reject(new Error('context read timeout'));
|
|
699
|
+
}
|
|
700
|
+
}, 5000);
|
|
701
|
+
});
|
|
702
|
+
return (res && res.context) || {};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async authState(tabId) {
|
|
706
|
+
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
707
|
+
const view = this._getView(tid);
|
|
708
|
+
if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
|
|
709
|
+
try {
|
|
710
|
+
const ctx = (await this._readContext(view)) || {};
|
|
711
|
+
const wall = ctx.auth_wall || null;
|
|
712
|
+
return {
|
|
713
|
+
ok: true,
|
|
714
|
+
tab: tid,
|
|
715
|
+
url: ctx.url || null,
|
|
716
|
+
auth_wall: wall,
|
|
717
|
+
// 兼容旧形状:session.logged_in 仍从 context 取,但语义已收紧(见
|
|
718
|
+
// shared/auth_wall.cjs)。auth_wall.detected 是硬事实,优先级高于它。
|
|
719
|
+
logged_in: ctx.session ? !!ctx.session.logged_in : null,
|
|
720
|
+
evidence: (ctx.session && ctx.session.evidence) || [],
|
|
721
|
+
// 一句话结论,方便直接读:调用方不必自己组合字段。
|
|
722
|
+
verdict: wall && wall.detected
|
|
723
|
+
? 'AUTH_WALL — this tab is on a login page, not the site you meant to operate'
|
|
724
|
+
: (ctx.session && ctx.session.logged_in
|
|
725
|
+
? 'looks-logged-in (heuristic)'
|
|
726
|
+
: 'not-logged-in (heuristic) — verify before continuing'),
|
|
727
|
+
};
|
|
728
|
+
} catch (e) {
|
|
729
|
+
return { ok: false, error: e.message, tab: tid };
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 视口截图(P0-1)。此前 ai-browser 只有元素树这一只"眼睛":树里没有的
|
|
734
|
+
// 东西(浮层、canvas、图片、CSS 遮挡、渲染错位)就完全不可见,而这类情况
|
|
735
|
+
// 恰恰是操作失败的主因。调用方失败时第一反应是"看截图",工具面必须支持。
|
|
736
|
+
//
|
|
737
|
+
// 走 webContents.debugger 的 Page.captureScreenshot —— 与 AX/Network 域
|
|
738
|
+
// 共用同一个 per-tab attach(_ensureCdp 幂等),不额外占用调试器。
|
|
739
|
+
// 截图是同步渲染快照,主线程卡住时它不返回,所以带 deadline。
|
|
740
|
+
async screenshot(tabId, opts = {}) {
|
|
741
|
+
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
742
|
+
const view = this._getView(tid);
|
|
743
|
+
if (!view) return { ok: false, error: 'no-such-tab', tab: tid };
|
|
744
|
+
|
|
745
|
+
const norm = screenshotOpts.normalizeScreenshotOpts(opts);
|
|
746
|
+
if (!norm.ok) return { ok: false, error: norm.error, tab: tid };
|
|
747
|
+
const o = norm.opts;
|
|
748
|
+
|
|
749
|
+
let meta = {};
|
|
750
|
+
try {
|
|
751
|
+
this._ensureCdp(tid, view, 'Page');
|
|
752
|
+
} catch (e) {
|
|
753
|
+
return { ok: false, error: 'cdp-unavailable: ' + e.message, tab: tid };
|
|
754
|
+
}
|
|
755
|
+
const dbg = view.webContents.debugger;
|
|
756
|
+
if (!dbg) return { ok: false, error: 'cdp-unavailable', tab: tid };
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
// 视口尺寸:clip 缺省时用于回执,也让调用方知道拍的到底是哪块。
|
|
760
|
+
// 失败不致命(老页面没布局完也会返回 0),降级为 null。
|
|
761
|
+
try {
|
|
762
|
+
const lm = await withDeadline(dbg.sendCommand('Page.getLayoutMetrics'), 10000, 'getLayoutMetrics');
|
|
763
|
+
const vp = lm && lm.cssLayoutViewport ? lm.cssLayoutViewport : (lm && lm.layoutViewport) || null;
|
|
764
|
+
if (vp) meta = { width: Math.round(vp.clientWidth || 0), height: Math.round(vp.clientHeight || 0) };
|
|
765
|
+
} catch (_) { /* 布局指标拿不到就算了,截图本身才是主目标 */ }
|
|
766
|
+
|
|
767
|
+
const params = {
|
|
768
|
+
format: o.format,
|
|
769
|
+
fromSurface: true,
|
|
770
|
+
captureBeyondViewport: o.fullPage,
|
|
771
|
+
};
|
|
772
|
+
if (o.quality !== undefined) params.quality = o.quality;
|
|
773
|
+
if (o.clip) params.clip = o.clip;
|
|
774
|
+
const shot = await withDeadline(
|
|
775
|
+
dbg.sendCommand('Page.captureScreenshot', params),
|
|
776
|
+
20000, 'captureScreenshot'
|
|
777
|
+
);
|
|
778
|
+
if (!shot || typeof shot.data !== 'string') {
|
|
779
|
+
return { ok: false, error: 'empty screenshot result', tab: tid };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const mime = 'image/' + (o.format === 'jpeg' ? 'jpeg' : o.format);
|
|
783
|
+
// 一次 evaluate 拿两样东西:可见密码框(截图不脱敏,泄密了得让调用方
|
|
784
|
+
// 知道)和 devicePixelRatio —— 后者因为 CDP 的 clip 是 CSS 像素而输出
|
|
785
|
+
// 是设备像素:调用方按 getBoundingClientRect 传 500 宽,Retina 上会
|
|
786
|
+
// 拿到 1000 像素的图。没有这个比值,"zoom 拍元素"的调用方无法把图上的
|
|
787
|
+
// 坐标换算回页面坐标。
|
|
788
|
+
let hasPwd = false;
|
|
789
|
+
let dpr = null;
|
|
790
|
+
try {
|
|
791
|
+
const p = await this.evaluate(
|
|
792
|
+
`(function(){var els=document.querySelectorAll('input[type=password]');` +
|
|
793
|
+
`var pwd=false;for(var i=0;i<els.length;i++){var r=els[i].getBoundingClientRect();` +
|
|
794
|
+
`if(r.width>0&&r.height>0)pwd=true;}return JSON.stringify({pwd:pwd,dpr:window.devicePixelRatio||1});})()`, tid);
|
|
795
|
+
let parsed = null;
|
|
796
|
+
try { parsed = typeof p === 'string' ? JSON.parse(p) : p; } catch (_) { /* evaluate 可能返回 undefined */ }
|
|
797
|
+
if (parsed) { hasPwd = parsed.pwd === true; dpr = Number(parsed.dpr) || null; }
|
|
798
|
+
} catch (_) { /* 探测失败不影响截图交付 */ }
|
|
799
|
+
|
|
800
|
+
const summary = screenshotOpts.describeShot({
|
|
801
|
+
format: o.format,
|
|
802
|
+
mime,
|
|
803
|
+
bytes: Buffer.byteLength(shot.data, 'base64'),
|
|
804
|
+
width: meta.width,
|
|
805
|
+
height: meta.height,
|
|
806
|
+
url: (this._getView(tid) && this._getView(tid).webContents.getURL()) || undefined,
|
|
807
|
+
hasVisiblePasswordInput: hasPwd,
|
|
808
|
+
devicePixelRatio: dpr,
|
|
809
|
+
});
|
|
810
|
+
// path:直接把图写到磁盘。写失败要如实报错(不是"截图成功但没文件")。
|
|
811
|
+
let filePath = null;
|
|
812
|
+
if (o.path) {
|
|
813
|
+
try {
|
|
814
|
+
fs.mkdirSync(path.dirname(o.path), { recursive: true });
|
|
815
|
+
fs.writeFileSync(o.path, Buffer.from(shot.data, 'base64'));
|
|
816
|
+
filePath = o.path;
|
|
817
|
+
} catch (e) {
|
|
818
|
+
return { ok: false, tab: tid, error: 'screenshot captured but writing to ' + o.path + ' failed: ' + ((e && e.message) || e), ...summary };
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// 未知参数不再静默丢弃:调用方传了什么没被用上,回执里点名。
|
|
822
|
+
const known = { format: 1, quality: 1, fullPage: 1, clip: 1, path: 1 };
|
|
823
|
+
const ignored = Object.keys(opts || {}).filter((k) => !known[k]);
|
|
824
|
+
return {
|
|
825
|
+
ok: true,
|
|
826
|
+
tab: tid,
|
|
827
|
+
// data 是 base64 裸数据(不带 data: 前缀)—— 调用方按需自己拼。
|
|
828
|
+
data: shot.data,
|
|
829
|
+
mime,
|
|
830
|
+
...(filePath ? { path: filePath } : {}),
|
|
831
|
+
...(ignored.length ? { ignored_params: ignored } : {}),
|
|
832
|
+
...summary,
|
|
833
|
+
};
|
|
834
|
+
} catch (e) {
|
|
835
|
+
if (e && e.__deadline) {
|
|
836
|
+
return { ok: false, error: e.message + ' — the page main thread is likely blocked', tab: tid };
|
|
837
|
+
}
|
|
838
|
+
return { ok: false, error: e.message, tab: tid };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
481
842
|
// AX probe read layer (P0). Additive — the default getTree path is untouched.
|
|
482
843
|
// Reuses the per-tab shared debugger: enables the Accessibility + DOM domains
|
|
483
844
|
// into the same reference-counted _cdpTabs entry as Network/Runtime, so teardown
|
|
@@ -508,7 +869,22 @@ class PageManager {
|
|
|
508
869
|
15000, 'get_tree(ax).attachHrefs'
|
|
509
870
|
);
|
|
510
871
|
}
|
|
511
|
-
|
|
872
|
+
const out = await this._finishAxTree(main, view, tid, opts);
|
|
873
|
+
// AX 树的 context 只有 modals/session/stats —— 登录墙、校验错误、必填提示
|
|
874
|
+
// 都在 DOM 扫描里。此前 ax:true 的调用方拿到的是一份"更薄"的上下文:
|
|
875
|
+
// 同一个页面,默认读法说"这是登录页",ax 读法什么都不说。这里把 DOM 侧
|
|
876
|
+
// 缺的字段补齐(不覆盖 AX 已有的),两条读法的 context 才可比。
|
|
877
|
+
if (out && out.context) {
|
|
878
|
+
try {
|
|
879
|
+
const ctx = await this._readContext(view);
|
|
880
|
+
for (const k of ['auth_wall', 'validation_errors', 'field_errors', 'required_hints', 'errors', 'hints', 'forms', 'cross_process_frames']) {
|
|
881
|
+
if (out.context[k] == null && ctx[k] != null) out.context[k] = ctx[k];
|
|
882
|
+
}
|
|
883
|
+
if (!out.context.session || !Object.keys(out.context.session).length) out.context.session = ctx.session || {};
|
|
884
|
+
if (out.context.stats && ctx.stats) Object.assign(out.context.stats, { inputs: ctx.stats.inputs, buttons: ctx.stats.buttons, links: ctx.stats.links, iframes: ctx.stats.iframes });
|
|
885
|
+
} catch (e) { /* context 补全失败不影响树 */ }
|
|
886
|
+
}
|
|
887
|
+
return out;
|
|
512
888
|
} catch (e) {
|
|
513
889
|
if (e && e.__deadline) {
|
|
514
890
|
return { tree: null, context: null, error: e.message + ' — the page main thread is likely blocked' };
|
|
@@ -526,6 +902,10 @@ class PageManager {
|
|
|
526
902
|
const frames = await this._readOopifFrames(view, tid);
|
|
527
903
|
if (frames.length && main && main.tree) {
|
|
528
904
|
main.tree = axExtractor.mergeFrameTrees(main.tree, frames);
|
|
905
|
+
if (main.context) {
|
|
906
|
+
const mergedCtx = frameProbe.mergeFrameContext(main.context, frames);
|
|
907
|
+
if (mergedCtx) Object.assign(main.context, mergedCtx);
|
|
908
|
+
}
|
|
529
909
|
}
|
|
530
910
|
// #4: incremental diff — return only newly appeared / hidden interactive
|
|
531
911
|
// nodes since the last ax read on this tab (feeds: small payload per scroll).
|
|
@@ -561,6 +941,34 @@ class PageManager {
|
|
|
561
941
|
return out;
|
|
562
942
|
}
|
|
563
943
|
|
|
944
|
+
// 子帧侧的上下文探测(在子帧自己的 JS 上下文里执行)。只做两件读操作:
|
|
945
|
+
// 收集"请输入/必填"类占位符,以及 aria-invalid 字段对应的错误文案。
|
|
946
|
+
// 规则与顶层 extractor 的 scanRequiredHints / validation_errors 同源 ——
|
|
947
|
+
// 两边给出不同口径的提示,等于让调用方自己判断信谁。
|
|
948
|
+
_frameCtxJs() {
|
|
949
|
+
return `(function(){
|
|
950
|
+
var hints=[], ves=[];
|
|
951
|
+
try{
|
|
952
|
+
var els=document.querySelectorAll('input[placeholder],textarea[placeholder],[contenteditable][data-placeholder]');
|
|
953
|
+
for(var i=0;i<els.length&&hints.length<10;i++){
|
|
954
|
+
var p=els[i].getAttribute('placeholder')||els[i].getAttribute('data-placeholder');
|
|
955
|
+
if(p&&/请\\s*(输入|填写|选择)|必填|必选|不能为空/.test(p)&&hints.indexOf(p)<0)hints.push(p.slice(0,50));
|
|
956
|
+
}
|
|
957
|
+
}catch(e){}
|
|
958
|
+
try{
|
|
959
|
+
var inv=document.querySelectorAll('[aria-invalid="true"]');
|
|
960
|
+
for(var j=0;j<inv.length&&ves.length<10;j++){
|
|
961
|
+
var el=inv[j],t='';
|
|
962
|
+
var ids=(el.getAttribute('aria-describedby')||'').split(/\\s+/);
|
|
963
|
+
for(var k=0;k<ids.length;k++){var d=ids[k]&&document.getElementById(ids[k]);if(d){t=(d.textContent||'').trim();if(t)break;}}
|
|
964
|
+
if(!t){var par=el.parentElement;if(par){var e=par.querySelector('[class*=error],[class*=err],[role=alert]');if(e)t=(e.textContent||'').trim();}}
|
|
965
|
+
ves.push({text:t.slice(0,120),near:el.id||el.name||el.getAttribute('aria-label')||el.getAttribute('placeholder')||''});
|
|
966
|
+
}
|
|
967
|
+
}catch(e){}
|
|
968
|
+
return JSON.stringify({required_hints:hints,validation_errors:ves});
|
|
969
|
+
})()`;
|
|
970
|
+
}
|
|
971
|
+
|
|
564
972
|
// Read editable/interactive fields out of each out-of-process sub-frame via
|
|
565
973
|
// Frame.executeJavaScript (runs in that frame's own context). Returns
|
|
566
974
|
// [{ key, label, tree }] — pure mergeFrameTrees input. Stamps idempotent
|
|
@@ -618,6 +1026,16 @@ class PageManager {
|
|
|
618
1026
|
return out.length?out:null;
|
|
619
1027
|
})(${JSON.stringify(prefix)})`);
|
|
620
1028
|
if (!dto || !dto.length) continue;
|
|
1029
|
+
// 子帧里的"该填什么 / 错在哪"。顶层 extractPageContext 只能看顶层文档,
|
|
1030
|
+
// 而 B站图文的标题框、掘金编辑器都住在 OOPIF 里 —— 上下文扫描不跟着
|
|
1031
|
+
// 走一遍子帧,required_hints / validation_errors 就会在**真正需要它们
|
|
1032
|
+
// 的地方**恰好是空的(实测:B站标题框 placeholder="请输入标题",顶层扫描
|
|
1033
|
+
// 结果为空)。失败不影响建树。
|
|
1034
|
+
let frameCtx = null;
|
|
1035
|
+
try {
|
|
1036
|
+
const rawCtx = await frame.executeJavaScript(this._frameCtxJs());
|
|
1037
|
+
frameCtx = typeof rawCtx === 'string' ? JSON.parse(rawCtx) : rawCtx;
|
|
1038
|
+
} catch (e) { /* 子帧上下文探测失败:树照常返回 */ }
|
|
621
1039
|
const tree = {
|
|
622
1040
|
id: key,
|
|
623
1041
|
role: 'generic',
|
|
@@ -636,7 +1054,7 @@ class PageManager {
|
|
|
636
1054
|
bounds: n.bounds,
|
|
637
1055
|
})),
|
|
638
1056
|
};
|
|
639
|
-
out.push({ key, label: frame.name || ('frame-' + frameSeq), tree });
|
|
1057
|
+
out.push({ key, label: frame.name || ('frame-' + frameSeq), tree, context: frameCtx });
|
|
640
1058
|
} catch (e) { /* skip unreachable frame */ }
|
|
641
1059
|
}
|
|
642
1060
|
return out;
|
|
@@ -665,23 +1083,14 @@ class PageManager {
|
|
|
665
1083
|
const view = this._getView(tid);
|
|
666
1084
|
if (!view) return { revealed: [], hidden: [], mode: 'hover', error: 'Tab not found' };
|
|
667
1085
|
if (!target) return { revealed: [], hidden: [], mode: 'hover', error: 'No target' };
|
|
668
|
-
// 取消上一次 peek 挂起的自动回撤。回撤是"把鼠标挪走"的延迟动作,若前一次的
|
|
669
|
-
// 定时器还在,它会在本次 peek 已经把鼠标放到新目标上之后才触发,把刚刚探出来的
|
|
670
|
-
// 菜单又关掉 —— 表现为 revertMs=0(语义:别回撤)依然失效。谁后动手谁说了算。
|
|
671
|
-
const staleRevert = this._revertTimers.get(tid);
|
|
672
|
-
if (staleRevert) { clearTimeout(staleRevert); this._revertTimers.delete(tid); }
|
|
673
1086
|
const out = { revealed: [], hidden: [], mode: 'hover' };
|
|
674
1087
|
try {
|
|
675
|
-
this._ensureCdp(tid, view, '
|
|
676
|
-
this._ensureCdp(tid, view, 'DOM');
|
|
1088
|
+
this._ensureCdp(tid, view, 'Page'); // 只为挂上 debugger(Input 域没有 enable)
|
|
677
1089
|
} catch (e) { return { ...out, error: 'cdp-unavailable' }; }
|
|
678
1090
|
const dbg = view.webContents.debugger;
|
|
679
1091
|
try {
|
|
680
|
-
const beforeRaw = await axExtractor.axRaw(dbg);
|
|
681
|
-
if (!beforeRaw.length) return out;
|
|
682
|
-
await axExtractor.ensureHandles(dbg); // give the target a stable append id
|
|
683
1092
|
const center = await this.evaluate(
|
|
684
|
-
`(function(){var el
|
|
1093
|
+
`(function(){var el=${findRefExpr(JSON.stringify(target))};if(!el)return null;` +
|
|
685
1094
|
`el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
|
|
686
1095
|
`var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`,
|
|
687
1096
|
tid);
|
|
@@ -691,31 +1100,71 @@ class PageManager {
|
|
|
691
1100
|
if (!center) {
|
|
692
1101
|
return { ...out, error: 'target_not_found: no element with data-ai-id=' + String(target) };
|
|
693
1102
|
}
|
|
1103
|
+
const send = (method, params) => dbg.sendCommand(method, params);
|
|
1104
|
+
// 区分"没传"与"显式传 0":0 是合法语义(hoverMs=0 不等、revertMs=0 不回撤)。
|
|
1105
|
+
// 原来的 `Number(x) || 450` 把 0 当 falsy 顶成默认值,revertMs=0 永远失效。
|
|
1106
|
+
const msOr = (v, dflt) => (v === undefined || v === null || v === '') ? dflt : Math.max(0, Number(v) || 0);
|
|
694
1107
|
try {
|
|
695
1108
|
// 抢焦点会打断人正在做的事(agent 干活时人几乎不能用电脑)。
|
|
696
1109
|
// 策略见 _focusWindow:默认只保证窗口可见,不把它拽到最前、不抢焦点。
|
|
697
1110
|
this._focusWindow(view);
|
|
698
1111
|
} catch (e) {}
|
|
699
|
-
|
|
1112
|
+
//
|
|
1113
|
+
// 顺序是「先悬停拍一张,再挪开拍一张」,不是反过来。
|
|
1114
|
+
//
|
|
1115
|
+
// 反过来(先挪开落基线、再悬停)要求基线是"干净的",也就是鼠标必须真的已经
|
|
1116
|
+
// 离开目标、且菜单已经收完。这条做不到:菜单是带动画收起的,等多久取决于
|
|
1117
|
+
// 站点 —— 实测 B站 头部「收藏」的下拉,鼠标挪开后 3 轮分别要 522 / 496 /
|
|
1118
|
+
// 491ms 才从 DOM 里消失。写死等待时长不是 150ms 只有 2/4 命中,就是 550ms
|
|
1119
|
+
// 也照样会漏(都试过)。
|
|
1120
|
+
//
|
|
1121
|
+
// 更麻烦的是"判断鼠标是否还在目标上"这一步本身也不可靠(el.matches(':hover')
|
|
1122
|
+
// 在连续调用时给出的结果会跳),于是连"要不要等"都判断错。
|
|
1123
|
+
//
|
|
1124
|
+
// 换成先悬停再挪开,两张快照都是自己亲手造成的,与调用前的鼠标位置无关:
|
|
1125
|
+
// A = 悬停着(菜单在) B = 挪开后(菜单不在)
|
|
1126
|
+
// revealed = A \ B → "悬停带来的东西",无论鼠标一开始在哪。
|
|
700
1127
|
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: center.x, y: center.y });
|
|
701
|
-
// 区分"没传"与"显式传 0":0 是合法语义(hoverMs=0 不等、revertMs=0 不回撤)。
|
|
702
|
-
// 原来的 `Number(x) || 450` 把 0 当 falsy 顶成默认值,revertMs=0 永远失效。
|
|
703
|
-
const msOr = (v, dflt) => (v === undefined || v === null || v === '') ? dflt : Math.max(0, Number(v) || 0);
|
|
704
1128
|
const hoverMs = msOr(opts.hoverMs, 450);
|
|
705
1129
|
await new Promise((r) => setTimeout(r, hoverMs));
|
|
706
|
-
const
|
|
707
|
-
|
|
1130
|
+
const snapWith = await this.getTree(false, tid);
|
|
1131
|
+
// 取不到快照就必须报错,不能拿空数组当基线 —— 那样另一张里的每一个节点都会
|
|
1132
|
+
// 变成"新出现的",一屏假结果比报错危险得多。
|
|
1133
|
+
if (!snapWith || !snapWith.tree) {
|
|
1134
|
+
return { ...out, error: 'snapshot_failed: 拿不到悬停时的语义树' };
|
|
1135
|
+
}
|
|
1136
|
+
const nodesWith = treeDiff.flattenTree(snapWith.tree);
|
|
1137
|
+
// 挪开鼠标后要等菜单真的收完,再拍第二张。
|
|
1138
|
+
//
|
|
1139
|
+
// 试过"轮询元素总数直到不再减少"来适配不同站点,行不通:页面自己一直在抖
|
|
1140
|
+
// (B站 首页有轮播与懒加载,元素总数常态波动),"稳定"永远达不到,每回都
|
|
1141
|
+
// 跑满 12 次轮询(1080ms)。所以回到固定时长 —— 但 600 这个数是实测出来的:
|
|
1142
|
+
// 轮询 B站 头部「收藏」下拉的收起耗时,3 轮分别是 491 / 496 / 522ms。
|
|
1143
|
+
// 想换更短的等待可以传 opts.settleMs。
|
|
1144
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 4, y: 4 });
|
|
1145
|
+
await new Promise((r) => setTimeout(r, msOr(opts.settleMs, 600)));
|
|
1146
|
+
const snapWithout = await this.getTree(false, tid);
|
|
1147
|
+
if (!snapWithout || !snapWithout.tree) {
|
|
1148
|
+
return { ...out, error: 'snapshot_failed: 拿不到挪开鼠标后的语义树' };
|
|
1149
|
+
}
|
|
1150
|
+
const nodesWithout = treeDiff.flattenTree(snapWithout.tree);
|
|
1151
|
+
const diff = treeDiff.diffTree(nodesWithout, nodesWith);
|
|
708
1152
|
out.revealed = diff.revealed;
|
|
709
1153
|
out.hidden = diff.hidden;
|
|
710
|
-
// revertMs=0 表示"别回撤,我要接着点" ——
|
|
711
|
-
//
|
|
1154
|
+
// revertMs=0 表示"别回撤,我要接着点" —— 这时把鼠标放回目标上,让菜单重新
|
|
1155
|
+
// 展开,调用方下一步可以直接点 revealed 里的东西。其余情况鼠标已经挪开了,
|
|
1156
|
+
// 天然就是"回撤",不需要延迟定时器(原先那套 per-tab 定时器也因此删掉了)。
|
|
1157
|
+
//
|
|
1158
|
+
// 移回之后必须重新拍一张并把 id 重绑:菜单重开时 DOM 是新造的节点,id 全变
|
|
1159
|
+
// 了,直接返回刚才那批 id 的话,调用方点下去只会拿到 target_not_found。
|
|
712
1160
|
const revertMs = msOr(opts.revertMs, 1500);
|
|
713
|
-
if (revertMs
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1161
|
+
if (revertMs === 0) {
|
|
1162
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: center.x, y: center.y });
|
|
1163
|
+
await new Promise((r) => setTimeout(r, msOr(opts.reopenMs, 350)));
|
|
1164
|
+
const snapBack = await this.getTree(false, tid);
|
|
1165
|
+
if (snapBack && snapBack.tree) {
|
|
1166
|
+
out.revealed = treeDiff.remapIds(out.revealed, treeDiff.flattenTree(snapBack.tree));
|
|
1167
|
+
}
|
|
719
1168
|
}
|
|
720
1169
|
} catch (e) {
|
|
721
1170
|
out.error = String((e && e.message) || e).slice(0, 200);
|
|
@@ -775,6 +1224,17 @@ class PageManager {
|
|
|
775
1224
|
if (!target && !targetOptional) return { success: false, error: 'No target' };
|
|
776
1225
|
target = target || '';
|
|
777
1226
|
|
|
1227
|
+
// target 形状体检:传了可见文字("发布")而不是 data-ai-id 时,这里就明说。
|
|
1228
|
+
// 否则要等到 `[data-ai-id="发布"]` 查空,回一句不带方向的
|
|
1229
|
+
// "Target not found",调用方很难想到真正原因是形状错了。
|
|
1230
|
+
if (target) {
|
|
1231
|
+
// upload 额外接受 CSS 选择器:上传控件经常没有 data-ai-id(纯 div 的
|
|
1232
|
+
// 「本地上传」),只能靠选择器定位。其它动作仍只认 ref —— 放行一个
|
|
1233
|
+
// 它们解析不了的选择器,只会把失败推到更远、信息更少的地方。
|
|
1234
|
+
const shapeErr = refGuard.refShapeError(target, { allowSelector: action === 'upload' });
|
|
1235
|
+
if (shapeErr) return { success: false, error: shapeErr, method: 'bad-target-shape' };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
778
1238
|
// 只读分支:取 node.value 的完整内容(树里只给前 200 code point)。
|
|
779
1239
|
// 不聚焦、不触发 input/change、不改页面,也不走输入动作的 fallback。
|
|
780
1240
|
if (action === 'get_value') return await this._getValue(view, target, params, tid);
|
|
@@ -824,14 +1284,29 @@ class PageManager {
|
|
|
824
1284
|
);
|
|
825
1285
|
if (r) return r;
|
|
826
1286
|
} catch (e) { /* fall through to preload */ }
|
|
1287
|
+
} else if (action === 'paste') {
|
|
1288
|
+
// 富文本粘贴:把 text/html 经合成 ClipboardEvent 送进编辑器,Draft.js /
|
|
1289
|
+
// ProseMirror / Quill 的粘贴处理器会完成格式转换(与真人粘贴等效)。
|
|
1290
|
+
// 支持 OOPIF 子帧(B站 read-editor 那种 ui.evaluate 到不了的上下文)。
|
|
1291
|
+
try {
|
|
1292
|
+
await this._ensureAxHandles(view, tid);
|
|
1293
|
+
const r = await this._pasteViaCdp(view, target, params, tid);
|
|
1294
|
+
if (r) return r;
|
|
1295
|
+
} catch (e) { /* fall through to preload */ }
|
|
827
1296
|
} else if (action === 'upload') {
|
|
828
1297
|
try {
|
|
829
1298
|
await this._ensureAxHandles(view, tid);
|
|
830
1299
|
const r = await this._uploadViaCdp(view, target, params.file || '', tid);
|
|
1300
|
+
if (r && r.success !== false) return r;
|
|
1301
|
+
} catch (e) { /* fall through */ }
|
|
1302
|
+
// CDP 的 DOM.setFileInputFiles 只能写顶层文档里的 input;跨进程 iframe 里的
|
|
1303
|
+
// 上传控件(B站封面、编辑器插图等都属于这一类)必须走帧内注入。
|
|
1304
|
+
try {
|
|
1305
|
+
const r = await this._uploadViaFrameJs(view, target, params.file || '', tid);
|
|
831
1306
|
if (r) return r;
|
|
832
|
-
} catch (e) { /* fall through
|
|
1307
|
+
} catch (e) { /* fall through */ }
|
|
833
1308
|
// 无 CDP 时 preload 无法真正赋值文件(受安全限制),返回失败让 agent 感知。
|
|
834
|
-
return { success: false, error: 'Upload requires CDP (input file set) — no file input found' };
|
|
1309
|
+
return { success: false, error: 'Upload requires CDP (input file set) — no file input found for target ' + JSON.stringify(String(target || '')) };
|
|
835
1310
|
} else if (action === 'drag') {
|
|
836
1311
|
try {
|
|
837
1312
|
const r = await this._cdpDrag(view, target, params, tid);
|
|
@@ -884,7 +1359,7 @@ class PageManager {
|
|
|
884
1359
|
// 通过 target 内容跳出字符串。
|
|
885
1360
|
const js =
|
|
886
1361
|
"(function(){"
|
|
887
|
-
+ "var el=
|
|
1362
|
+
+ "var el=" + findRefExpr(JSON.stringify(String(target))) + ";"
|
|
888
1363
|
+ "if(!el)return {__miss:true};"
|
|
889
1364
|
+ "var tag=(el.tagName||'').toUpperCase();"
|
|
890
1365
|
+ "if(tag==='INPUT'){var ty=String(el.type||'').toLowerCase();"
|
|
@@ -990,21 +1465,40 @@ class PageManager {
|
|
|
990
1465
|
return result?.tree?.focused_element_id || null;
|
|
991
1466
|
}
|
|
992
1467
|
|
|
993
|
-
async evaluate(js, tabId) {
|
|
1468
|
+
async evaluate(js, tabId, opts = {}) {
|
|
994
1469
|
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
995
1470
|
const view = this._getView(tid);
|
|
996
1471
|
if (!view) throw new Error('Tab not found');
|
|
997
1472
|
|
|
998
|
-
// OOPIF routing
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1473
|
+
// OOPIF routing. Two ways in, explicit beats implicit:
|
|
1474
|
+
// opts.frameRef — caller passes the axf-... ref directly. Reliable:
|
|
1475
|
+
// works no matter how the JS string was built.
|
|
1476
|
+
// literal match — the old behavior: /data-ai-id="(axf-...)/ inside the
|
|
1477
|
+
// JS source. Kept for compatibility, but it is a trap: a caller that
|
|
1478
|
+
// builds the selector by concatenation ('[data-ai-id="' + ref + '"]')
|
|
1479
|
+
// produces no literal, routing silently does not happen, and the
|
|
1480
|
+
// top-frame evaluate returns null for an element that exists.
|
|
1481
|
+
// Hit for real during the P1-2 rollout.
|
|
1482
|
+
const frameRef = opts.frameRef || (/data-ai-id="(axf-\d+-\d+)"/.exec(js) || [])[1];
|
|
1483
|
+
if (frameRef) {
|
|
1484
|
+
const frame = await this._owningFrame(view, frameRef);
|
|
1005
1485
|
if (frame) {
|
|
1006
1486
|
try { return await frame.executeJavaScript(js.startsWith('(') ? js : `(function(){return ${js};})()`); }
|
|
1007
|
-
catch (e) {
|
|
1487
|
+
catch (e) {
|
|
1488
|
+
// 帧找到了、但脚本自己抛了异常。不能继续走下面的 frame_not_found ——
|
|
1489
|
+
// 那会把"脚本报错"误报成"引用失效",把调用方引到"反复重读树"的死路上。
|
|
1490
|
+
// 实测踩过:上传脚本里一个 TypeError 被包装成 frame_not_found,白查了半天。
|
|
1491
|
+
if (opts.frameRef) {
|
|
1492
|
+
throw new Error('frame_eval_failed: ' + String((e && e.message) || e).slice(0, 300));
|
|
1493
|
+
}
|
|
1494
|
+
/* 自动推导出的 ref:允许回落到顶层路径 */
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// 显式给了 frameRef 却找不到帧:不能静默落到顶层 —— 调用方会拿到 null
|
|
1498
|
+
// 并以为"元素不存在"。明说是路由失败。
|
|
1499
|
+
if (opts.frameRef) {
|
|
1500
|
+
throw new Error('frame_not_found: no cross-process frame owns ' + String(opts.frameRef)
|
|
1501
|
+
+ ' — the ref may be stale (page re-rendered). Re-read the tree and retry.');
|
|
1008
1502
|
}
|
|
1009
1503
|
}
|
|
1010
1504
|
// Preferred path: CDP Runtime.evaluate. It runs on the DevTools protocol
|
|
@@ -1035,7 +1529,13 @@ class PageManager {
|
|
|
1035
1529
|
const cmd = dbg.sendCommand('Runtime.evaluate', {
|
|
1036
1530
|
expression: js,
|
|
1037
1531
|
returnByValue: true,
|
|
1038
|
-
awaitPromise:
|
|
1532
|
+
// awaitPromise:true —— 调用方在页面里写 async/await 是常态(等一个请求、
|
|
1533
|
+
// 等一帧渲染、等编辑器提交),此前是 false,于是 async IIFE 原样返回
|
|
1534
|
+
// 一个 Promise 对象,序列化后调用方看到 **{}**,会以为"页面什么都没
|
|
1535
|
+
// 返回"。实测症状:往掘金标签框写"已选中"的探测脚本明明 await 了网络
|
|
1536
|
+
// 响应,回执却是空对象,据此得出"标签没选上"的错误结论。
|
|
1537
|
+
// 对非 thenable 表达式此参数无副作用(CDP 直接返回值本身)。
|
|
1538
|
+
awaitPromise: true,
|
|
1039
1539
|
});
|
|
1040
1540
|
const timeout = new Promise((_, rej) =>
|
|
1041
1541
|
setTimeout(() => rej(new Error('cdp evaluate timeout')), 5000));
|
|
@@ -1077,7 +1577,13 @@ class PageManager {
|
|
|
1077
1577
|
} catch(e) { return false; }
|
|
1078
1578
|
}
|
|
1079
1579
|
|
|
1080
|
-
async _cdpKey(inputCmd, p) {
|
|
1580
|
+
async _cdpKey(inputCmd, p, opts) {
|
|
1581
|
+
// 「按键的默认动作」要的是 keypress,不是 keydown。决策本身抽在
|
|
1582
|
+
// shared/key_dispatch.cjs(纯函数,可单测),这里只负责按计划派发。
|
|
1583
|
+
// 两种语义:
|
|
1584
|
+
// opts.activate = true (ui.act press 用)→ 带 text 的 keyDown,产生默认动作
|
|
1585
|
+
// 否则(type 里的段间换行/删除)→ 保持 rawKeyDown,行为不变
|
|
1586
|
+
const plan = keyDispatchPlan(p, opts);
|
|
1081
1587
|
const base = {
|
|
1082
1588
|
windowsVirtualKeyCode: p.vk,
|
|
1083
1589
|
nativeVirtualKeyCode: p.vk,
|
|
@@ -1086,11 +1592,16 @@ class PageManager {
|
|
|
1086
1592
|
text: p.text || '',
|
|
1087
1593
|
modifiers: p.mod || 0,
|
|
1088
1594
|
};
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1595
|
+
if (plan.type === 'keyDown') {
|
|
1596
|
+
// keyDown 带 text:一次派发同时产出 keydown 与 char,等价真实按键。
|
|
1597
|
+
await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'keyDown', text: plan.text });
|
|
1598
|
+
} else {
|
|
1599
|
+
await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'rawKeyDown' });
|
|
1600
|
+
// CDP 的 rawKeyDown 会忽略 text —— 带字符的键(换行)必须再发一个带
|
|
1601
|
+
// text 的 char 事件才会真的把字符插进去,否则 Enter 在 textarea /
|
|
1602
|
+
// 原生 contenteditable 里不产生任何换行("a\nb\nc" 静默变成 "abc")。
|
|
1603
|
+
if (plan.charText) await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'char' });
|
|
1604
|
+
}
|
|
1094
1605
|
await inputCmd('Input.dispatchKeyEvent', { ...base, type: 'keyUp' });
|
|
1095
1606
|
}
|
|
1096
1607
|
|
|
@@ -1104,7 +1615,7 @@ class PageManager {
|
|
|
1104
1615
|
if (/^axf-/.test(aiId)) return await this._owningFrame(view, aiId);
|
|
1105
1616
|
try {
|
|
1106
1617
|
const inTop = await this.evaluate(
|
|
1107
|
-
|
|
1618
|
+
`!!${findRefExpr(JSON.stringify(aiId))}`,
|
|
1108
1619
|
tabId
|
|
1109
1620
|
);
|
|
1110
1621
|
if (inTop) return null;
|
|
@@ -1122,7 +1633,7 @@ class PageManager {
|
|
|
1122
1633
|
if (frame === root) continue;
|
|
1123
1634
|
try {
|
|
1124
1635
|
const has = await frame.executeJavaScript(
|
|
1125
|
-
|
|
1636
|
+
`!!${findRefExpr(JSON.stringify(aiId))}`
|
|
1126
1637
|
);
|
|
1127
1638
|
if (has) return frame;
|
|
1128
1639
|
} catch (e) { /* skip */ }
|
|
@@ -1191,7 +1702,7 @@ class PageManager {
|
|
|
1191
1702
|
} else {
|
|
1192
1703
|
try {
|
|
1193
1704
|
const ok = await owning.executeJavaScript(
|
|
1194
|
-
`(function(){var el
|
|
1705
|
+
`(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
|
|
1195
1706
|
`el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
|
|
1196
1707
|
`el.focus();el.click();return true;})()`);
|
|
1197
1708
|
return ok ? 'oopif-click' : null;
|
|
@@ -1201,11 +1712,11 @@ class PageManager {
|
|
|
1201
1712
|
// 定位 → 滚入视口居中 → 返回滚动后的中心点(frame 内局部坐标)。
|
|
1202
1713
|
const center = inFrame
|
|
1203
1714
|
? await owning.executeJavaScript(
|
|
1204
|
-
`(function(){var el
|
|
1715
|
+
`(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
|
|
1205
1716
|
`el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
|
|
1206
1717
|
`var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`)
|
|
1207
1718
|
: await this.evaluate(
|
|
1208
|
-
`(function(){var el
|
|
1719
|
+
`(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
|
|
1209
1720
|
`el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
|
|
1210
1721
|
`var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()`,
|
|
1211
1722
|
tabId);
|
|
@@ -1226,18 +1737,19 @@ class PageManager {
|
|
|
1226
1737
|
// 命中校验必须在派发【之前】做,且派发成功就不再回退 —— 否则一次 act 会点两次。
|
|
1227
1738
|
// 事后校验的致命漏洞:mousePressed/mouseReleased 一发出,点击就已生效;而"点了
|
|
1228
1739
|
// 之后自身消失/收起"的目标(话题候选收起列表、提交后置灰、菜单项、@提及、点赞)
|
|
1229
|
-
//
|
|
1740
|
+
// 此刻已经被自己点没了,命中探测必然拿不到 → 返回 null → executeAction
|
|
1230
1741
|
// 回退 preload 再点一次 → DOM 上出现两份插入/两次提交。派发前的坐标命中才是
|
|
1231
1742
|
// "这一下会不会落在目标上"的唯一可信判断;校验失败(被遮挡/屏外/已隐藏)才回退。
|
|
1743
|
+
//
|
|
1744
|
+
// 命中探测必须穿透 shadow:不穿透的话,影子内部的元素(B站 整块评论区装在
|
|
1745
|
+
// <bili-comments> 里)永远返回宿主 → contains 判定 false → 每次点击都静默
|
|
1746
|
+
// 降级成非受信的 el.click()。降级本身不报错,所以表现为"能点但对某些编辑器
|
|
1747
|
+
// 没反应"。hitTestExpr 同时把 contains 换成含 shadow 的祖先判定。
|
|
1232
1748
|
if (action === 'click') {
|
|
1749
|
+
const hitJs = hitTestExpr(JSON.stringify(aiId), String(center.x), String(center.y));
|
|
1233
1750
|
const hit = inFrame
|
|
1234
|
-
? await owning.executeJavaScript(
|
|
1235
|
-
|
|
1236
|
-
`var h=document.elementFromPoint(${center.x},${center.y});return !!(h&&(h===el||el.contains(h)));})()`)
|
|
1237
|
-
: await this.evaluate(
|
|
1238
|
-
`(function(){var el=document.querySelector('[data-ai-id="${aiId}"]');if(!el)return false;` +
|
|
1239
|
-
`var h=document.elementFromPoint(${center.x},${center.y});return !!(h&&(h===el||el.contains(h)));})()`,
|
|
1240
|
-
tabId);
|
|
1751
|
+
? await owning.executeJavaScript(hitJs)
|
|
1752
|
+
: await this.evaluate(hitJs, tabId);
|
|
1241
1753
|
if (!hit) return null; // 坐标落不到目标上 —— 交给 preload 的 focus()+click 兜底
|
|
1242
1754
|
}
|
|
1243
1755
|
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: vp.x, y: vp.y });
|
|
@@ -1274,8 +1786,10 @@ class PageManager {
|
|
|
1274
1786
|
if (params.at && Number.isFinite(Number(params.at.x))) {
|
|
1275
1787
|
at = { x: Math.round(Number(params.at.x)), y: Math.round(Number(params.at.y)) };
|
|
1276
1788
|
} else if (aiId) {
|
|
1789
|
+
// 穿透 shadow:目标在 Web Component 里时 document.querySelector 够不着,
|
|
1790
|
+
// 落点会静默退化成视口中心 —— 滚轮打到完全无关的地方,还报成功。
|
|
1277
1791
|
const c = await run(
|
|
1278
|
-
`var el
|
|
1792
|
+
`var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
|
|
1279
1793
|
`el.scrollIntoView({block:'center',inline:'center',behavior:'instant'});` +
|
|
1280
1794
|
`var r=el.getBoundingClientRect();return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};`
|
|
1281
1795
|
);
|
|
@@ -1349,9 +1863,12 @@ class PageManager {
|
|
|
1349
1863
|
// 连接点识别用 classList 逐 token 词边界匹配 —— 上一版用
|
|
1350
1864
|
// `[class*="anchor"]` 会把 `is-node-anchored` 这类无关类名当成连接点
|
|
1351
1865
|
// (实测抢到了"标签"按钮),拖拽起点完全错位。
|
|
1352
|
-
|
|
1866
|
+
// 传 aiId 而不是选择器:目标可能在 shadow 里,`document.querySelector` 够不着,
|
|
1867
|
+
// 报出来是 "drag: start element not found",读起来像元素不存在。定位统一走
|
|
1868
|
+
// findRefExpr(穿透影子根),与点击路径口径一致。
|
|
1869
|
+
const pointFor = async (aiId, anchor, offset) => {
|
|
1353
1870
|
const body =
|
|
1354
|
-
`var el
|
|
1871
|
+
`var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return null;` +
|
|
1355
1872
|
`var r=el.getBoundingClientRect();var side=${JSON.stringify(anchor)};` +
|
|
1356
1873
|
`var cx=r.x+r.width/2,cy=r.y+r.height/2;` +
|
|
1357
1874
|
`function isHandle(h){var cl=h.classList;for(var i=0;i<cl.length;i++){var t=cl[i];` +
|
|
@@ -1381,8 +1898,13 @@ class PageManager {
|
|
|
1381
1898
|
// 判定必须是"命中即是连接点,或连接点的子孙"。**不能**接受
|
|
1382
1899
|
// `hit.contains(handle)`:节点本体当然包含自己的连接点,那样会把这个
|
|
1383
1900
|
// 失败情形原样放行(第一版就是这么写的,实测仍然报 handle)。
|
|
1384
|
-
|
|
1385
|
-
|
|
1901
|
+
// 命中探测同样要穿透 shadow(连接点在 Web Component 里时,不穿透就
|
|
1902
|
+
// 恒判"没命中",于是永远回退到 via:'edge',把"按在连接点上"降级成
|
|
1903
|
+
// "按在节点边沿")。跨 shadow 后用含 shadow 的祖先判定。
|
|
1904
|
+
`if(best){var hit=${deepHitExpr('best.x', 'best.y')};` +
|
|
1905
|
+
`var ok=false,c=hit,g=0;` +
|
|
1906
|
+
`while(c&&g++<128){if(c===best.el){ok=true;break;}c=c.parentNode||c.host||null;}` +
|
|
1907
|
+
`if(!ok)best=null;}` +
|
|
1386
1908
|
`if(best){delete best.el;return best;}` +
|
|
1387
1909
|
`if(side==='right')return {x:r.right,y:r.y+r.height/2,via:'edge'};` +
|
|
1388
1910
|
`if(side==='left')return {x:r.left,y:r.y+r.height/2,via:'edge'};` +
|
|
@@ -1400,19 +1922,21 @@ class PageManager {
|
|
|
1400
1922
|
// 同时向上找最近的 data-ai-id:如果它**不是**本次目标,说明抓错了节点
|
|
1401
1923
|
// (实测踩过:两个节点视觉重叠时,拖 A 实际按在叠在上面的 B 的浮层上,
|
|
1402
1924
|
// 事件照常派发、success 照常返回,但什么都没动)。
|
|
1925
|
+
//
|
|
1926
|
+
// 穿透 shadow + 含 shadow 的上溯,理由同上。原来那版用 e.closest('[data-ai-id]'):
|
|
1927
|
+
// closest 走到影子树顶就停,影子内部的元素永远得到 id:null,于是 `is_target`
|
|
1928
|
+
// 恒为 true —— "抓错节点"的检测被静默跳过,正是它存在的意义所在。
|
|
1403
1929
|
const atPointJs = (p, selfId) =>
|
|
1404
|
-
`var
|
|
1405
|
-
`
|
|
1406
|
-
`
|
|
1407
|
-
`return {el:(e.tagName||'')+' '+(typeof e.className==='string'?e.className:'').slice(0,60),` +
|
|
1408
|
-
`id:id,is_target:(id===null||id===${JSON.stringify(String(selfId))})};`;
|
|
1930
|
+
`var info=${hitInfoExpr(String(p.x), String(p.y))};if(!info)return null;` +
|
|
1931
|
+
`return {el:info.el,id:info.id,` +
|
|
1932
|
+
`is_target:(info.id===null||info.id===${JSON.stringify(String(selfId))})};`;
|
|
1409
1933
|
|
|
1410
1934
|
// 起点
|
|
1411
1935
|
let from = null;
|
|
1412
1936
|
if (params.from && Number.isFinite(Number(params.from.x))) {
|
|
1413
1937
|
from = { x: Math.round(Number(params.from.x)), y: Math.round(Number(params.from.y)), via: 'explicit' };
|
|
1414
1938
|
} else if (aiId) {
|
|
1415
|
-
from = await pointFor(
|
|
1939
|
+
from = await pointFor(aiId, anchorOf(params.from_anchor), params.from_offset);
|
|
1416
1940
|
}
|
|
1417
1941
|
if (!from) return { success: false, error: 'drag: start element not found: ' + aiId };
|
|
1418
1942
|
|
|
@@ -1430,7 +1954,7 @@ class PageManager {
|
|
|
1430
1954
|
if (explicitTo && Number.isFinite(Number(explicitTo.x)) && Number.isFinite(Number(explicitTo.y))) {
|
|
1431
1955
|
to = { x: Math.round(Number(explicitTo.x)), y: Math.round(Number(explicitTo.y)), via: 'explicit' };
|
|
1432
1956
|
} else if (params.to_target) {
|
|
1433
|
-
to = await pointFor(
|
|
1957
|
+
to = await pointFor(params.to_target, anchorOf(params.to_anchor), params.to_offset);
|
|
1434
1958
|
if (!to) return { success: false, error: 'drag: to_target not found: ' + params.to_target };
|
|
1435
1959
|
} else if (Number.isFinite(Number(params.dx)) || Number.isFinite(Number(params.dy))) {
|
|
1436
1960
|
to = { x: Math.round(from.x + Number(params.dx || 0)), y: Math.round(from.y + Number(params.dy || 0)), via: 'delta' };
|
|
@@ -1537,7 +2061,10 @@ class PageManager {
|
|
|
1537
2061
|
this._focusWindow(view);
|
|
1538
2062
|
} catch (e) {}
|
|
1539
2063
|
if (aiId) {
|
|
1540
|
-
|
|
2064
|
+
// 穿透 shadow:目标在 Web Component 里时 querySelector 拿不到 el,
|
|
2065
|
+
// focus 这一步被静默跳过 —— 按键发给"当前焦点"(多半是 body),
|
|
2066
|
+
// 现象是快捷键完全没生效,但回执照常 success。
|
|
2067
|
+
await run(`var el=${findRefExpr(JSON.stringify(String(aiId)))};if(el&&el.focus)el.focus();return true;`);
|
|
1541
2068
|
}
|
|
1542
2069
|
const list = Array.isArray(params.keys) && params.keys.length
|
|
1543
2070
|
? params.keys
|
|
@@ -1548,13 +2075,113 @@ class PageManager {
|
|
|
1548
2075
|
for (const name of list) {
|
|
1549
2076
|
const spec = keySpec(name, params.modifiers);
|
|
1550
2077
|
if (!spec) return { success: false, error: 'press: unsupported key: ' + String(name), pressed: done };
|
|
1551
|
-
await this._cdpKey(send, spec);
|
|
2078
|
+
await this._cdpKey(send, spec, { activate: true });
|
|
1552
2079
|
done.push(spec.key);
|
|
1553
2080
|
await new Promise((r) => setTimeout(r, Math.max(20, Number(params.interval_ms) || 60)));
|
|
1554
2081
|
}
|
|
1555
2082
|
return { success: true, pressed_via: 'cdp-key', pressed: done };
|
|
1556
2083
|
}
|
|
1557
2084
|
|
|
2085
|
+
// 富文本粘贴(ui.act {action:'paste'})—— 把带格式内容(HTML)送进编辑器的
|
|
2086
|
+
// 唯一可靠通路。
|
|
2087
|
+
//
|
|
2088
|
+
// 为什么需要这个动作而不是让调用方自己合成事件:
|
|
2089
|
+
// 1) setContent/type 走 CDP 受信键盘,只能送**纯文本** —— 标题层级、代码块、
|
|
2090
|
+
// 加粗、列表全丢失。B站图文那篇只能发纯文本版,就是这个原因。
|
|
2091
|
+
// 2) 在 OOPIF 子帧里(B站 read-editor),ui.evaluate 到不了那个执行上下文,
|
|
2092
|
+
// 调用方连"自己搓一个 paste 事件"都做不到 —— 这是最疼的一条。
|
|
2093
|
+
// 3) 粘贴是编辑器的**一等公民路径**:Draft.js 的 DraftPasteProcessor、
|
|
2094
|
+
// ProseMirror 的 transformPastedHTML、Quill 的 clipboard 模块都会把
|
|
2095
|
+
// text/html 转成各自的内部文档模型。合成一个带 clipboardData 的
|
|
2096
|
+
// ClipboardEvent('paste') 正是触发这条路径的正规方式,与真人粘贴等效。
|
|
2097
|
+
//
|
|
2098
|
+
// 参数契约:params.html(优先)或 params.text。两者都空 → 明确报错,
|
|
2099
|
+
// 不静默成功(同 cdp-input-empty-text 的教训)。
|
|
2100
|
+
async _pasteViaCdp(view, aiId, params, tabId) {
|
|
2101
|
+
if (!this._canCdp(view)) return null;
|
|
2102
|
+
const html = typeof params.html === 'string' ? params.html : '';
|
|
2103
|
+
const text = typeof params.text === 'string' ? params.text : '';
|
|
2104
|
+
if (!html && !text) {
|
|
2105
|
+
return {
|
|
2106
|
+
success: false,
|
|
2107
|
+
error: 'paste called with neither html nor text — nothing to paste. '
|
|
2108
|
+
+ 'Pass params: {html: "<h2>...</h2>"} for rich text, or {text: "..."} for plain.',
|
|
2109
|
+
method: 'paste-empty',
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
const owning = await this._owningFrameFor(view, aiId, tabId);
|
|
2113
|
+
const inFrame = !!owning;
|
|
2114
|
+
const run = async (jsBody) => {
|
|
2115
|
+
const fn = `(function(){${jsBody}})()`;
|
|
2116
|
+
return inFrame ? owning.executeJavaScript(fn) : this.evaluate(fn, tabId);
|
|
2117
|
+
};
|
|
2118
|
+
try { this._focusWindow(view); } catch (e) {}
|
|
2119
|
+
|
|
2120
|
+
// 定位 + 聚焦 + 派发**必须在同一次帧内执行里完成**。拆成两步(先 prep 聚焦、
|
|
2121
|
+
// 再回来派发)会丢目标:OOPIF 里 focus() 之后 document.activeElement 的
|
|
2122
|
+
// 读回可能仍是旧值,且 Draft.js 这类编辑器把 paste 监听挂在**最内层的
|
|
2123
|
+
// contenteditable 面子节点**上(.public-DraftEditor-content)——事件从容器
|
|
2124
|
+
// 派发不会向下传播到子节点,监听器收不到,defaultPrevented 恒为 false。
|
|
2125
|
+
// 实测:B站 read-editor 拆两步派发 → 编辑器完全无反应;合并 + 选定最深层
|
|
2126
|
+
// 可编辑元素后成功。
|
|
2127
|
+
const payload = JSON.stringify({ html, text });
|
|
2128
|
+
const res = await run(
|
|
2129
|
+
`var p=${payload};` +
|
|
2130
|
+
`var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return {ok:false,err:'no-ref'};` +
|
|
2131
|
+
// 选定真正的编辑面:el 自己可编辑就用 el;否则取内部第一个可编辑后代
|
|
2132
|
+
// (Draft.js/ProseMirror 的编辑面都是容器里的一个子 div)。
|
|
2133
|
+
`var ed=el;` +
|
|
2134
|
+
`var elEditable=(ed.contentEditable==='true')||(ed.tagName==='TEXTAREA')||(ed.tagName==='INPUT');` +
|
|
2135
|
+
`if(!elEditable&&ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
2136
|
+
`var editable=(ed.contentEditable==='true')||(ed.tagName==='TEXTAREA')||(ed.tagName==='INPUT');` +
|
|
2137
|
+
`if(!editable)return {ok:false,err:'not-editable'};` +
|
|
2138
|
+
`ed.focus();` +
|
|
2139
|
+
`var dt=new DataTransfer();` +
|
|
2140
|
+
`if(p.html)dt.setData('text/html',p.html);` +
|
|
2141
|
+
`dt.setData('text/plain',p.text||p.html.replace(/<[^>]+>/g,' ').replace(/ /g,' ').replace(/\\s+/g,' ').trim());` +
|
|
2142
|
+
`var ev=new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true,composed:true});` +
|
|
2143
|
+
`var notPrevented=ed.dispatchEvent(ev);` +
|
|
2144
|
+
`return {ok:true,prevented:!notPrevented,tag:ed.tagName};`
|
|
2145
|
+
);
|
|
2146
|
+
if (!res || !res.ok) {
|
|
2147
|
+
return { success: false, error: (res && res.err) || 'paste failed', method: 'synthetic-clipboard-event' };
|
|
2148
|
+
}
|
|
2149
|
+
// 编辑器没接管粘贴(prevented=false)= 页面对合成粘贴不买账。如实报告,
|
|
2150
|
+
// 并给出可操作建议,而不是报 success 让调用方以为内容进去了。
|
|
2151
|
+
if (!res.prevented) {
|
|
2152
|
+
return {
|
|
2153
|
+
success: false,
|
|
2154
|
+
error: 'paste event was not consumed by the page (defaultPrevented=false) — this editor likely listens on a different surface (e.g. a child element or shadow root). Try pasting into a child node ref, or fall back to setContent for plain text.',
|
|
2155
|
+
method: 'synthetic-clipboard-event',
|
|
2156
|
+
consumed: false,
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
// 落盘校验:读回编辑器内容。归一化沿用 P0-2 修过的口径(换行会被编辑器
|
|
2160
|
+
// 膨胀,逐字比对必然失配)。只给 html 时无法文本比对,退而检查"有内容"。
|
|
2161
|
+
await new Promise((r) => setTimeout(r, 350)); // 编辑器处理粘贴多为异步(React 提交)
|
|
2162
|
+
const landed = await run(`var e=${deepActiveExpr()};return {text:(e&&(e.innerText||e.value||''))||'',html:(e&&e.innerHTML||'')};`);
|
|
2163
|
+
const landedText = (landed && landed.text) || '';
|
|
2164
|
+
const landedLen = landedText.replace(/\s+/g, '').length;
|
|
2165
|
+
if (!landedLen) {
|
|
2166
|
+
return {
|
|
2167
|
+
success: false,
|
|
2168
|
+
error: 'paste was consumed but nothing landed — the editor may have rejected the payload (unsanitized HTML?). Read the editor content back to confirm.',
|
|
2169
|
+
method: 'synthetic-clipboard-event',
|
|
2170
|
+
consumed: true,
|
|
2171
|
+
landed_chars: 0,
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
return {
|
|
2175
|
+
success: true,
|
|
2176
|
+
method: 'synthetic-clipboard-event',
|
|
2177
|
+
consumed: true,
|
|
2178
|
+
in_frame: inFrame,
|
|
2179
|
+
landed_chars: landedLen,
|
|
2180
|
+
// 只给 html 时调用方无法预知文本形态,把读回内容捎上,省一轮查询。
|
|
2181
|
+
...(html && !text ? { landed_preview: landedText.slice(0, 200) } : {}),
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
|
|
1558
2185
|
// 文件上传(封面/附件等,跨框架通用):HTMLInputElement[type=file] 只能由
|
|
1559
2186
|
// DevTools 的 DOM.setFileInputFiles 写入真实文件路径(网页脚本无法伪造
|
|
1560
2187
|
// FileList,preload 同样受限),对任何站点/上传组件一视同仁。找到目标 input
|
|
@@ -1568,7 +2195,7 @@ class PageManager {
|
|
|
1568
2195
|
// 用户在系统选择器选中);若再对它 click 会重新唤起系统文件框并清空 files,
|
|
1569
2196
|
// 导致刚写入的状态被覆盖。由调用方负责先让上传组件就绪。
|
|
1570
2197
|
const idRes = await dbg.sendCommand('Runtime.evaluate', {
|
|
1571
|
-
expression: `(function(){var el
|
|
2198
|
+
expression: `(function(){var el=${findRefExpr(JSON.stringify(aiId))};return el&&el.__proto__&&el.matches?el.matches('input[type=file],input')?el:null:null;})()`,
|
|
1572
2199
|
returnByValue: false,
|
|
1573
2200
|
});
|
|
1574
2201
|
const objId = idRes && idRes.result && idRes.result.objectId;
|
|
@@ -1576,7 +2203,7 @@ class PageManager {
|
|
|
1576
2203
|
await dbg.sendCommand('DOM.setFileInputFiles', { files: [filePath], objectId: objId });
|
|
1577
2204
|
// 兜底校验:确认 files 已写入
|
|
1578
2205
|
const ver = await dbg.sendCommand('Runtime.evaluate', {
|
|
1579
|
-
expression: `(function(){var f
|
|
2206
|
+
expression: `(function(){var f=${findRefExpr(JSON.stringify(aiId))}.files;return f&&f.length>0?{name:f[0].name,size:f[0].size}:null;})()`,
|
|
1580
2207
|
returnByValue: true,
|
|
1581
2208
|
});
|
|
1582
2209
|
const v = ver && ver.result && ver.result.value;
|
|
@@ -1584,6 +2211,189 @@ class PageManager {
|
|
|
1584
2211
|
return { success: true, uploaded: v.name, size: v.size };
|
|
1585
2212
|
}
|
|
1586
2213
|
|
|
2214
|
+
// 跨帧文件上传(含 OOPIF)。CDP 的 DOM.setFileInputFiles 需要顶层文档里的
|
|
2215
|
+
// nodeId/objectId,够不着 iframe 内部的 input —— B站封面上传、多数编辑器的
|
|
2216
|
+
// "插入图片"都在 iframe 里,实测只得出一条 "no file input found"。
|
|
2217
|
+
// 走到帧内:文件读成字节 → 在目标帧构造 File → DataTransfer 赋给 input.files
|
|
2218
|
+
// → 派发 change,与"用户在系统选择器里选中文件"等价。
|
|
2219
|
+
//
|
|
2220
|
+
// 三种状态的控件都要能对付(实测各站点轮流出现):
|
|
2221
|
+
// 1. 目标本身就是 input[type=file] —— 直接写;
|
|
2222
|
+
// 2. 目标是容器,input 预挂在里面 —— 取容器里的第一个 file input;
|
|
2223
|
+
// 3. 容器里**一个 input 都没有**(组件在"已上传"状态下把上传区整个卸载了,
|
|
2224
|
+
// CSDN 的封面就是:有封面时 .container-coverimage-box 里连
|
|
2225
|
+
// .el-upload__input 都不存在)—— 临时接管 HTMLInputElement.prototype.click
|
|
2226
|
+
// 再把容器点开,捕获"点击时才创建"的那个 input。
|
|
2227
|
+
// 第 3 种是 target 写成 `.box .el-upload__input` 这类"指向 input 本身的选择器"
|
|
2228
|
+
// 时最容易踩的:整条选择器查不到元素,但它的祖先还在。所以 target 先展开成
|
|
2229
|
+
// 由长到短的回退候选(selectorAncestors),用第一个命中的祖先当锚点。
|
|
2230
|
+
async _uploadViaFrameJs(view, aiId, filePath, tabId) {
|
|
2231
|
+
if (!aiId) return null;
|
|
2232
|
+
if (!filePath) throw new Error('upload requires a file path');
|
|
2233
|
+
// 上传控件经常没有语义节点(B站的「本地上传」就是个纯 div 菜单项,AX 树里
|
|
2234
|
+
// 根本没有它),此时调用方给不出 ref,只能给 CSS 选择器。所以 target 先按
|
|
2235
|
+
// data-ai-id 找,找不到再当作选择器:先找帧、再找顶层文档。
|
|
2236
|
+
const target = String(aiId);
|
|
2237
|
+
// 由长到短的候选:`.a .b` → ['.a .b', '.a']。data-ai-id 形状的 target
|
|
2238
|
+
// 走 selectorAncestors 会原样返回单元素,行为不变。
|
|
2239
|
+
const cands = refGuard.selectorAncestors(target);
|
|
2240
|
+
let owning = await this._owningFrameFor(view, aiId, tabId);
|
|
2241
|
+
let inTop = false;
|
|
2242
|
+
if (!owning) {
|
|
2243
|
+
const probe = `(function(){var C=${JSON.stringify(cands)};for(var i=0;i<C.length;i++){try{if(document.querySelector(C[i]))return true;}catch(e){}}return false;})()`;
|
|
2244
|
+
for (const frame of (view.webContents.mainFrame.framesInSubtree || [])) {
|
|
2245
|
+
if (frame === view.webContents.mainFrame) continue;
|
|
2246
|
+
try { if (await frame.executeJavaScript(probe)) { owning = frame; break; } }
|
|
2247
|
+
catch (e) { /* skip */ }
|
|
2248
|
+
}
|
|
2249
|
+
if (!owning) {
|
|
2250
|
+
try { inTop = !!(await this.evaluate(probe, tabId)); } catch (e) { inTop = false; }
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
if (!owning && !inTop) {
|
|
2254
|
+
// 连锚点都找不到:明确说"选择器没命中任何元素",而不是笼统的
|
|
2255
|
+
// "no file input found"(后者会让人以为选择器命中了但里面没 input)。
|
|
2256
|
+
return {
|
|
2257
|
+
success: false,
|
|
2258
|
+
method: 'frame-file-inject',
|
|
2259
|
+
error: 'upload target not found: no element matches '
|
|
2260
|
+
+ JSON.stringify(cands[0] || target) + (cands.length > 1 ? ' (nor its ancestors ' + JSON.stringify(cands.slice(1)) + ')' : '')
|
|
2261
|
+
+ ' — pass the control that OPENS the file picker, or a CSS selector for it',
|
|
2262
|
+
candidates: cands,
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
let buf;
|
|
2266
|
+
try { buf = fs.readFileSync(filePath); } catch (e) {
|
|
2267
|
+
return { success: false, method: 'frame-file-inject', error: 'cannot read file: ' + String((e && e.message) || e).slice(0, 160) };
|
|
2268
|
+
}
|
|
2269
|
+
const name = path.basename(filePath);
|
|
2270
|
+
const ext = name.toLowerCase().slice(name.lastIndexOf('.'));
|
|
2271
|
+
const mime = ext === '.png' ? 'image/png'
|
|
2272
|
+
: (ext === '.jpg' || ext === '.jpeg') ? 'image/jpeg'
|
|
2273
|
+
: ext === '.webp' ? 'image/webp'
|
|
2274
|
+
: ext === '.gif' ? 'image/gif' : 'application/octet-stream';
|
|
2275
|
+
// 内联 base64 直接走 frame.executeJavaScript —— 不受 WS 侧 evaluate 的
|
|
2276
|
+
// 5000 字符守卫约束(那个守卫只拦 ui.evaluate 的 js 参数)。
|
|
2277
|
+
const b64 = buf.toString('base64');
|
|
2278
|
+
const refSel = JSON.stringify('[data-ai-id="' + target + '"]');
|
|
2279
|
+
const js = `(function(){
|
|
2280
|
+
var C=${JSON.stringify(cands)};
|
|
2281
|
+
var REF=${refSel};
|
|
2282
|
+
var b64=${JSON.stringify(b64)};
|
|
2283
|
+
var name=${JSON.stringify(name)};
|
|
2284
|
+
var mime=${JSON.stringify(mime)};
|
|
2285
|
+
function isFileInput(e){return !!e&&e.tagName==='INPUT'&&e.type==='file';}
|
|
2286
|
+
function q(sel,root){try{return (root||document).querySelector(sel);}catch(e){return null;}}
|
|
2287
|
+
// 1) 先按 data-ai-id 找(ref 才是权威),再按选择器候选由长到短找。
|
|
2288
|
+
// 按 ref 找必须穿透 shadow:上传控件装在 Web Component 里时,顶层 querySelector
|
|
2289
|
+
// 拿不到,报出来是 "no-el" —— 看起来像没有上传入口,实际是查询路径够不着。
|
|
2290
|
+
var host=null,hostSel=null;
|
|
2291
|
+
var byRef=${findRefExpr('REF')};
|
|
2292
|
+
if(byRef){host=byRef;hostSel=REF;}
|
|
2293
|
+
for(var i=0;i<C.length&&!host;i++){var el=q(C[i]);if(el){host=el;hostSel=C[i];break;}}
|
|
2294
|
+
if(!host) return {ok:false,err:'no-el'};
|
|
2295
|
+
// <label for=...> 的点击会由浏览器直接激活目标 input、绕过 JS 层拦截 → 系统文件框
|
|
2296
|
+
// 会真的弹出来。所以遇到 label 先顺着 control 拿 input,拿不到就不点它。
|
|
2297
|
+
// 找后代同样要穿透 shadow(findDescExpr 会下钻 el.shadowRoot):宿主可能是
|
|
2298
|
+
// <bili-*> 之类,真正的 input[type=file] 在它自己的影子树里。
|
|
2299
|
+
function labelInput(el){try{var c=el.control;if(isFileInput(c))return c;var n=el.querySelector&&${findDescExpr('el', JSON.stringify('input[type=file]'))};if(n)return n;}catch(e){}return null;}
|
|
2300
|
+
function find(root){
|
|
2301
|
+
if(!root)return null;
|
|
2302
|
+
if(isFileInput(root))return root;
|
|
2303
|
+
if(root.tagName==='LABEL'){var li=labelInput(root);if(li)return li;}
|
|
2304
|
+
var r=null;try{r=${findDescExpr('root', JSON.stringify('input[type=file]'))};}catch(e){r=null;}
|
|
2305
|
+
return r;
|
|
2306
|
+
}
|
|
2307
|
+
var inp=find(host),via='direct';
|
|
2308
|
+
// 2) 点击捕获:接管原型 click(元素的 .click() 会走到这里),挡住系统框,
|
|
2309
|
+
// 同时暂时让 showOpenFilePicker 挂起,避免 File System Access API 那条路漏网。
|
|
2310
|
+
function captureOnce(t){
|
|
2311
|
+
if(!t)return null;
|
|
2312
|
+
var cap=null,proto=HTMLInputElement.prototype,orig=proto.click,origPicker=window.showOpenFilePicker;
|
|
2313
|
+
try{window.showOpenFilePicker=function(){return new Promise(function(){});};}catch(e){}
|
|
2314
|
+
proto.click=function(){if(this.type==='file'){cap=this;return;}try{return orig.apply(this,arguments);}catch(e){}};
|
|
2315
|
+
try{t.click();}catch(e){}
|
|
2316
|
+
try{proto.click=orig;}catch(e){}
|
|
2317
|
+
try{window.showOpenFilePicker=origPicker;}catch(e){}
|
|
2318
|
+
return cap;
|
|
2319
|
+
}
|
|
2320
|
+
var clicked=[];
|
|
2321
|
+
if(!inp){
|
|
2322
|
+
inp=captureOnce(host);
|
|
2323
|
+
if(inp)via='click';
|
|
2324
|
+
else{
|
|
2325
|
+
clicked.push((host.className||host.tagName||'').toString().slice(0,40));
|
|
2326
|
+
// 3) 锚点自己点不出东西就往下探一层:只挑"长得像触发器"的、且真正可见的
|
|
2327
|
+
// 后代,最多 8 次就收手(每次点击都可能改变页面状态,不能无限试)。
|
|
2328
|
+
// 负向词表必须有:封面区里常驻一个「删除封面」按钮,类名带 cover,
|
|
2329
|
+
// 正向匹配会命中它 —— 一旦可见就会被点掉用户的封面。
|
|
2330
|
+
var RX=/upload|file|add|plus|replace|change|select|image|cover/i;
|
|
2331
|
+
var BAD=/remove|delete|del-|trash|clear|cancel|close|drop/i;
|
|
2332
|
+
var nodes=[],all=[];
|
|
2333
|
+
try{all=host.querySelectorAll('button,label,[role=button],a,div,span,i');}catch(e){all=[];}
|
|
2334
|
+
for(var k=0;k<all.length&&nodes.length<8;k++){
|
|
2335
|
+
var e2=all[k],cls=String(e2.className||''),tag=e2.tagName,role=e2.getAttribute&&e2.getAttribute('role');
|
|
2336
|
+
if(BAD.test(cls))continue;
|
|
2337
|
+
if(!(RX.test(cls)||tag==='BUTTON'||tag==='LABEL'||tag==='A'||role==='button'))continue;
|
|
2338
|
+
var rc=e2.getBoundingClientRect();
|
|
2339
|
+
if(rc.width<2||rc.height<2)continue; // 不可见的不点(0×0 的多是布局占位)
|
|
2340
|
+
nodes.push(e2);
|
|
2341
|
+
}
|
|
2342
|
+
for(var j=0;j<nodes.length&&!inp;j++){
|
|
2343
|
+
var nd=nodes[j];
|
|
2344
|
+
if(nd.tagName==='LABEL'&&!labelInput(nd))continue;
|
|
2345
|
+
inp=captureOnce(nd);
|
|
2346
|
+
clicked.push((nd.className||nd.tagName||'').toString().slice(0,40));
|
|
2347
|
+
if(inp)via='click-descendant';
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
if(!inp)inp=find(host);
|
|
2352
|
+
if(!inp)return {ok:false,err:'no-file-input',host_sel:hostSel,clicked:clicked};
|
|
2353
|
+
if(!inp.isConnected){
|
|
2354
|
+
// 捕获到的 input 往往是"创建了但还没插进文档"(组件打算点完就丢),
|
|
2355
|
+
// 挂在锚点下、藏起来:change 才能沿组件自己的 DOM 树冒泡到它的委托处理器。
|
|
2356
|
+
try{inp.style.display='none';host.appendChild(inp);}catch(e){}
|
|
2357
|
+
}
|
|
2358
|
+
var bin=atob(b64),n=bin.length,arr=new Uint8Array(n);
|
|
2359
|
+
for(var m=0;m<n;m++)arr[m]=bin.charCodeAt(m);
|
|
2360
|
+
var file=new File([arr],name,{type:mime});
|
|
2361
|
+
var dt=new DataTransfer();dt.items.add(file);
|
|
2362
|
+
inp.files=dt.files;
|
|
2363
|
+
inp.dispatchEvent(new Event('change',{bubbles:true}));
|
|
2364
|
+
return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:clicked};
|
|
2365
|
+
})()`;
|
|
2366
|
+
const res = owning ? await owning.executeJavaScript(js) : await this.evaluate(js, tabId);
|
|
2367
|
+
if (!res || !res.ok) {
|
|
2368
|
+
const err = (res && res.err) || 'upload-failed';
|
|
2369
|
+
if (err === 'no-file-input') {
|
|
2370
|
+
return {
|
|
2371
|
+
success: false,
|
|
2372
|
+
method: 'frame-file-inject',
|
|
2373
|
+
error: 'found ' + JSON.stringify((res && res.host_sel) || target)
|
|
2374
|
+
+ ' but no <input type=file> appeared after clicking it'
|
|
2375
|
+
+ ' (clicked: ' + JSON.stringify((res && res.clicked) || []) + ')'
|
|
2376
|
+
+ ' — pass the element that OPENS the file picker (e.g. the "upload local file" menu item) as target',
|
|
2377
|
+
candidates: cands,
|
|
2378
|
+
};
|
|
2379
|
+
}
|
|
2380
|
+
return { success: false, method: 'frame-file-inject', error: err, candidates: cands };
|
|
2381
|
+
}
|
|
2382
|
+
// in_frame 如实反映实际执行位置:顶层文档上传(CSDN 那种)不应报 true。
|
|
2383
|
+
return {
|
|
2384
|
+
success: true,
|
|
2385
|
+
method: 'frame-file-inject',
|
|
2386
|
+
uploaded: res.name,
|
|
2387
|
+
size: res.size,
|
|
2388
|
+
in_frame: !!owning,
|
|
2389
|
+
// 上传组件可能有三种入口状态,回执如实说明用了哪条路:
|
|
2390
|
+
// direct = input 本来就在;click = 点了锚点才创建;
|
|
2391
|
+
// click-descendant = 锚点里再往下探了一层才点出 input。
|
|
2392
|
+
resolved_by: res.via || 'direct',
|
|
2393
|
+
host_sel: res.host_sel === refSel ? '[data-ai-id]' : (res.host_sel || null),
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
|
|
1587
2397
|
// 通用文本输入(type/setContent 共用):先让窗口/视图拿到焦点,再在页面里
|
|
1588
2398
|
// DOM 原生聚焦(一次 evaluate 同步完成)→ CDP 受信 Cmd+A 全选 + Delete 清空
|
|
1589
2399
|
// → 逐字符受信键入 + Enter。清空必须走编辑器自身的受信按键链路:Draft 等
|
|
@@ -1613,8 +2423,8 @@ class PageManager {
|
|
|
1613
2423
|
} catch(e) {}
|
|
1614
2424
|
// 定位可编辑区并原生聚焦(不做 DOM 全选——交给 Cmd+A 受信按键)
|
|
1615
2425
|
const prep = await run(
|
|
1616
|
-
`var el
|
|
1617
|
-
`var ed=el;if(ed.querySelector){var inner
|
|
2426
|
+
`var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return {ok:false,err:'no-ref'};` +
|
|
2427
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1618
2428
|
`var editable=(ed.contentEditable==='true')||(ed.tagName==='TEXTAREA')||(ed.tagName==='INPUT');` +
|
|
1619
2429
|
`if(!editable)return {ok:false,err:'not-editable'};` +
|
|
1620
2430
|
`ed.focus();` +
|
|
@@ -1627,8 +2437,11 @@ class PageManager {
|
|
|
1627
2437
|
// h2>blockquote>pre>ul>li 的套娃),editor_blocks 的 role 也随之失真。
|
|
1628
2438
|
// 只在"确实已经没有文本内容"时拆,有内容的块一个都不动。
|
|
1629
2439
|
const stripEmptyShells = () => run(
|
|
1630
|
-
|
|
1631
|
-
|
|
2440
|
+
// 与上面 prep 同一口径(穿透 shadow 的 findRefExpr)。两处必须一致:
|
|
2441
|
+
// prep 认得的元素、这里也要认得,否则"清空校验"读到空、把已经清干净的
|
|
2442
|
+
// 编辑器判成没清掉,接着又拆一遍。
|
|
2443
|
+
`var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
|
|
2444
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1632
2445
|
`if(ed.tagName==='INPUT'||ed.tagName==='TEXTAREA')return false;` +
|
|
1633
2446
|
`if(ed.contentEditable!=='true')return false;` +
|
|
1634
2447
|
`if((ed.innerText||'').trim()!=='')return false;` +
|
|
@@ -1645,15 +2458,23 @@ class PageManager {
|
|
|
1645
2458
|
// clear 时先拆一遍:编辑器可能已经是"空但带外壳"的状态,不拆的话下面的
|
|
1646
2459
|
// 幂等短路会把它当成"已经是空的"直接返回,外壳就永远留在那儿。
|
|
1647
2460
|
if (!text) await stripEmptyShells();
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
|
|
2461
|
+
// 校验用的归一化,依次做三件事:
|
|
2462
|
+
// 1. \r\n 统一成 \n(Windows 换行与受信 Enter 产出的 \r 不该算差异);
|
|
2463
|
+
// 2. 压掉"排版空白"(空格/tab),但换行本身必须参与比对 —— 把 \s 全剥掉
|
|
2464
|
+
// 会让 "a\nb\nc" 与 "abc" 等价,段落结构丢了还判成功,那是谎报;
|
|
2465
|
+
// 3. 连续换行压成一个。
|
|
2466
|
+
// 第 3 条来自实测:contenteditable 编辑器(Quill/ProseMirror/Slate…)普遍把
|
|
2467
|
+
// 一个语义换行渲染成一串 \n —— 往 B站 read-editor 输入 "AA\nBB",读回是
|
|
2468
|
+
// "AA\n\nBB"。逐字比对必然失配,于是**内容明明写进去了却回 success:false /
|
|
2469
|
+
// cdp-input-failed**,调用方据此重试或放弃,而页面其实已经是想要的样子。
|
|
2470
|
+
// 压缩换行仍然保留"有没有分段"这一层信息,只是不再纠结分了几层。
|
|
2471
|
+
const norm = normalizeInputText;
|
|
2472
|
+
const skeletal = skeletalInputText;
|
|
1652
2473
|
// 幂等短路:若可编辑区当前内容已恰好等于目标文本,直接返回而不做清空+重打。
|
|
1653
2474
|
// 每次全量重写都会把草稿标脏,重新触发编辑器自身的"草稿备份"/版本气泡——对
|
|
1654
2475
|
// 相同文本的重复调用(重试/校正)不应让它反复闪烁。
|
|
1655
2476
|
const curText = await run(
|
|
1656
|
-
`var e
|
|
2477
|
+
`var e=${deepActiveExpr()};return (e&&(e.innerText||e.value||''))||'';`
|
|
1657
2478
|
);
|
|
1658
2479
|
if (norm(curText) === norm(text)) {
|
|
1659
2480
|
// "目标就是当前内容" 与 "目标本来就是空的" 必须区分开。
|
|
@@ -1690,8 +2511,8 @@ class PageManager {
|
|
|
1690
2511
|
const at = opts.at === 'start' || opts.at === 'cursor' ? opts.at : 'end';
|
|
1691
2512
|
if (at !== 'cursor') {
|
|
1692
2513
|
await run(
|
|
1693
|
-
`var el
|
|
1694
|
-
`var ed=el;if(ed.querySelector){var inner
|
|
2514
|
+
`var el=${findRefExpr(JSON.stringify(String(aiId)))};if(!el)return false;` +
|
|
2515
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1695
2516
|
`if(ed.tagName==='INPUT'||ed.tagName==='TEXTAREA'){var L=(ed.value||'').length;` +
|
|
1696
2517
|
`var p=(${JSON.stringify(at)}==='start')?0:L;try{ed.setSelectionRange(p,p);}catch(e){}return true;}` +
|
|
1697
2518
|
`if(ed.contentEditable!=='true')return false;` +
|
|
@@ -1721,14 +2542,14 @@ class PageManager {
|
|
|
1721
2542
|
// 实测残留 "第一段<div>第二段</div><h2>第三段</h2>"。因此清完校验一次,
|
|
1722
2543
|
// 仍有文本就换编辑器原生 selectAll+delete 再清一遍。
|
|
1723
2544
|
const left = await run(
|
|
1724
|
-
`var el
|
|
1725
|
-
`var ed=el;if(ed.querySelector){var inner
|
|
2545
|
+
`var el=${findRefExpr(JSON.stringify(String(aiId)))};if(!el)return 0;` +
|
|
2546
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1726
2547
|
`return String((ed.innerText!=null?ed.innerText:ed.value)||'').trim().length;`
|
|
1727
2548
|
);
|
|
1728
2549
|
if (left > 0) {
|
|
1729
2550
|
await run(
|
|
1730
|
-
`var el
|
|
1731
|
-
`var ed=el;if(ed.querySelector){var inner
|
|
2551
|
+
`var el=${findRefExpr(JSON.stringify(String(aiId)))};if(!el)return false;` +
|
|
2552
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1732
2553
|
`try{ed.focus();document.execCommand('selectAll');document.execCommand('delete');}catch(e){}return true;`
|
|
1733
2554
|
);
|
|
1734
2555
|
await new Promise((r) => setTimeout(r, 150)); // 等编辑器状态提交
|
|
@@ -1739,7 +2560,7 @@ class PageManager {
|
|
|
1739
2560
|
const paras = String(text == null ? '' : text).split('\n');
|
|
1740
2561
|
// 段落内容快照:用来判断"这一下 Enter 到底有没有生效"。
|
|
1741
2562
|
const snap = () => run(
|
|
1742
|
-
`var e
|
|
2563
|
+
`var e=${deepActiveExpr()};var t=String((e&&(e.innerText||e.value))||'');` +
|
|
1743
2564
|
`var b=(e&&e.querySelectorAll)?e.querySelectorAll('p,div,br').length:0;` +
|
|
1744
2565
|
`return {len:t.length,nl:(t.match(/\\n/g)||[]).length,blocks:b};`
|
|
1745
2566
|
);
|
|
@@ -1765,16 +2586,29 @@ class PageManager {
|
|
|
1765
2586
|
// 通用校验:非空文本要求内容包含目标文本;空文本要求已清空(只允许空块残留)。
|
|
1766
2587
|
// 任一框架均可,无占位符/块数特判。
|
|
1767
2588
|
const after = await run(
|
|
1768
|
-
`var e
|
|
2589
|
+
`var e=${deepActiveExpr()};return {text:(e.innerText||e.value||'')};`
|
|
1769
2590
|
);
|
|
1770
2591
|
const want = norm(text);
|
|
1771
2592
|
const got = norm(after && after.text);
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
2593
|
+
// clear(空目标):允许残留空块外壳 —— 编辑器永远乐于留下一个空 <p>。
|
|
2594
|
+
const ok = want ? got.includes(want) : skeletal(got) === '';
|
|
2595
|
+
if (ok) {
|
|
2596
|
+
return { success: true, method: 'cdp-input', chars: (after && after.text) ? after.text.length : 0 };
|
|
2597
|
+
}
|
|
2598
|
+
// 诊断:忽略全部空白后再比一次。相等说明东西其实打进去了、只是被编辑器
|
|
2599
|
+
// 重新排了版;不等说明大概率压根没落上去。两者的处置方式完全相反。
|
|
2600
|
+
const landed = want ? skeletal(after && after.text) === skeletal(text) : false;
|
|
2601
|
+
return {
|
|
2602
|
+
success: false,
|
|
2603
|
+
error: 'Input not verified — please re-read the tree and retry',
|
|
2604
|
+
method: 'cdp-input-failed',
|
|
2605
|
+
expected_chars: want.length,
|
|
2606
|
+
actual_chars: got.length,
|
|
2607
|
+
content_landed_ignoring_whitespace: landed,
|
|
2608
|
+
hint: landed
|
|
2609
|
+
? 'the text matches once all whitespace is ignored — the editor most likely re-formatted line breaks/padding. Treat as applied and re-read the tree to confirm.'
|
|
2610
|
+
: 'the text does not match even after stripping whitespace — the input probably did not land. Check focus/target before retrying.',
|
|
2611
|
+
};
|
|
1778
2612
|
}
|
|
1779
2613
|
|
|
1780
2614
|
// OOPIF 内整段清空:在所属子帧上下文里聚焦 contenteditable,用 execCommand
|
|
@@ -1783,8 +2617,8 @@ class PageManager {
|
|
|
1783
2617
|
async _clearInFrame(frame, aiId) {
|
|
1784
2618
|
try {
|
|
1785
2619
|
const r = await frame.executeJavaScript(`(function(){
|
|
1786
|
-
var ref
|
|
1787
|
-
var e=ref;if(ref.querySelector){var inner
|
|
2620
|
+
var ref=${findRefExpr(JSON.stringify(aiId))};if(!ref)return {ok:false,reason:'no-ref'};
|
|
2621
|
+
var e=ref;if(ref.querySelector){var inner=${findDescExpr('ref', JSON.stringify('[contenteditable="true"],textarea,input[type="text"],input[type="search"],input[type="title"],input:not([type])'))};if(inner)e=inner;}
|
|
1788
2622
|
var isInput=(e.tagName==='INPUT'||e.tagName==='TEXTAREA');
|
|
1789
2623
|
var isCE=e.contentEditable==='true';
|
|
1790
2624
|
if(!(isInput||isCE))return {ok:false,reason:'not-editable'};
|
|
@@ -1808,8 +2642,8 @@ class PageManager {
|
|
|
1808
2642
|
async _selectAllFallback(view, aiId, tabId, frame) {
|
|
1809
2643
|
const r = frame
|
|
1810
2644
|
? await frame.executeJavaScript(
|
|
1811
|
-
`(function(){var el
|
|
1812
|
-
`var ed=el;if(ed.querySelector){var inner
|
|
2645
|
+
`(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
|
|
2646
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1813
2647
|
`var s=window.getSelection();` +
|
|
1814
2648
|
`if(s&&!s.isCollapsed&&s.toString().length>0)return true;` +
|
|
1815
2649
|
`if(ed.tagName==='TEXTAREA'||ed.tagName==='INPUT'){var v=ed.value||'';if(v){ed.focus();ed.setSelectionRange(0,v.length);}return v.length>0;}` +
|
|
@@ -1821,8 +2655,8 @@ class PageManager {
|
|
|
1821
2655
|
`s.removeAllRanges();s.addRange(rng);` +
|
|
1822
2656
|
`return rng.toString().length>0;})()`)
|
|
1823
2657
|
: await this.evaluate(
|
|
1824
|
-
`(function(){var el
|
|
1825
|
-
`var ed=el;if(ed.querySelector){var inner
|
|
2658
|
+
`(function(){var el=${findRefExpr(JSON.stringify(aiId))};if(!el)return false;` +
|
|
2659
|
+
`var ed=el;if(ed.querySelector){var inner=${findDescExpr('ed', JSON.stringify('[contenteditable=true],input,textarea'))};if(inner)ed=inner;}` +
|
|
1826
2660
|
`var s=window.getSelection();` +
|
|
1827
2661
|
`if(s&&!s.isCollapsed&&s.toString().length>0)return true;` +
|
|
1828
2662
|
`if(ed.tagName==='TEXTAREA'||ed.tagName==='INPUT'){var v=ed.value||'';if(v){ed.focus();ed.setSelectionRange(0,v.length);}return v.length>0;}` +
|
|
@@ -1952,9 +2786,86 @@ class PageManager {
|
|
|
1952
2786
|
// repaint — which was one of the paths that showed a stale view.
|
|
1953
2787
|
this._attachRepaintHooks(tabId, view);
|
|
1954
2788
|
}
|
|
2789
|
+
|
|
2790
|
+
this._attachLoadFailureHooks(tabId, view);
|
|
1955
2791
|
return tabId;
|
|
1956
2792
|
}
|
|
1957
2793
|
|
|
2794
|
+
/**
|
|
2795
|
+
* 导航失败 → 显示我们自己的错误页,而不是 Chromium 的 chrome-error:// 空白页。
|
|
2796
|
+
*
|
|
2797
|
+
* 为什么必须有:实测(2026-10-14)断网后 ui.navigate 到一个不存在的站点,
|
|
2798
|
+
* 页面停在 chrome-error://chromewebdata/,document.body 长度 0、
|
|
2799
|
+
* 截图 15KB(正常渲染 220KB),**白屏 8 秒不恢复、页面上零个字的提示**。
|
|
2800
|
+
* 这正是用户报的"打开 ai-browser 白屏":不是启动 bug,是连接失败后
|
|
2801
|
+
* 浏览器给了一个空白文档,而我们对这个事件零监听。
|
|
2802
|
+
*
|
|
2803
|
+
* 不替换成错误页的话,人(产品承诺人可以随时手动介入)看到一块白,
|
|
2804
|
+
* agent(ui.get_tree)也读不到任何东西 —— 两头都瞎。
|
|
2805
|
+
*
|
|
2806
|
+
* 只处理 isMainFrame 的失败:子资源(图片/字体/JS)加载失败太常见,
|
|
2807
|
+
* 不该把整个页面换成错误页。aborted 是页面自己取消的请求(通常是正常的
|
|
2808
|
+
* 跳转竞态),也不算失败。
|
|
2809
|
+
*/
|
|
2810
|
+
_attachLoadFailureHooks(tabId, view) {
|
|
2811
|
+
if (!view || !view.webContents || view.webContents.isDestroyed()) return;
|
|
2812
|
+
if (!this._failureHooked) this._failureHooked = new WeakSet();
|
|
2813
|
+
if (this._failureHooked.has(view.webContents)) return;
|
|
2814
|
+
this._failureHooked.add(view.webContents);
|
|
2815
|
+
|
|
2816
|
+
view.webContents.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
2817
|
+
if (!isMainFrame) return;
|
|
2818
|
+
// -3 = ERR_ABORTED:页面自己取消的请求,通常是导航竞态,不是真的失败。
|
|
2819
|
+
if (errorCode === -3) return;
|
|
2820
|
+
// 防重入:错误页自己加载失败时不要无限套娃。
|
|
2821
|
+
// 用普通 Set 而不是 WeakSet —— tabId 是整数,WeakSet.add(整数) 会抛
|
|
2822
|
+
// "Invalid value used in weak set",而那行异常会从 Electron 的事件回调里
|
|
2823
|
+
// 冒出去,把 loadURL 的 Promise 一起挂死(实测:navigate 到不通的端口,
|
|
2824
|
+
// WS 请求 10s 不返回、页面停在 chrome-error:// 白屏)。
|
|
2825
|
+
if (!this._failureGuard) this._failureGuard = new Set();
|
|
2826
|
+
if (this._failureGuard.has(tabId)) return;
|
|
2827
|
+
this._failureGuard.add(tabId);
|
|
2828
|
+
|
|
2829
|
+
let pagePath = null;
|
|
2830
|
+
try {
|
|
2831
|
+
pagePath = path.join(__dirname, '../renderer/error_page.html');
|
|
2832
|
+
} catch (_) { return; }
|
|
2833
|
+
|
|
2834
|
+
const q = new URLSearchParams({
|
|
2835
|
+
url: String(validatedURL || ''),
|
|
2836
|
+
code: String(errorCode),
|
|
2837
|
+
text: String(errorDescription || ''),
|
|
2838
|
+
});
|
|
2839
|
+
try {
|
|
2840
|
+
view.webContents.loadURL('file://' + pagePath + '?' + q.toString());
|
|
2841
|
+
} catch (e) {
|
|
2842
|
+
console.error('[page_manager] error page load failed:', e.message);
|
|
2843
|
+
this._failureGuard.delete(tabId);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
// 让 agent 也能读到失败(订阅了事件的会话),而不是只有人看得到。
|
|
2847
|
+
this._broadcast('load_failed', {
|
|
2848
|
+
tab: tabId,
|
|
2849
|
+
url: String(validatedURL || ''),
|
|
2850
|
+
error_code: errorCode,
|
|
2851
|
+
error: String(errorDescription || ''),
|
|
2852
|
+
});
|
|
2853
|
+
// 同一件事落盘。错误页在界面上看得见,但"哪个 tab、哪个网址、错误码多少"
|
|
2854
|
+
// 一旦进程退出就查不回来了(load_failed 只是一次内存广播)。
|
|
2855
|
+
recordError('nav_fail', {
|
|
2856
|
+
tab: tabId,
|
|
2857
|
+
url: String(validatedURL || ''),
|
|
2858
|
+
error_code: errorCode,
|
|
2859
|
+
error: String(errorDescription || ''),
|
|
2860
|
+
error_page: 'shown',
|
|
2861
|
+
});
|
|
2862
|
+
// 错误页本身加载成功后清掉防重入标记,否则同 tab 后续失败都不显示。
|
|
2863
|
+
view.webContents.once('did-finish-load', () => {
|
|
2864
|
+
if (this._failureGuard) this._failureGuard.delete(tabId);
|
|
2865
|
+
});
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
|
|
1958
2869
|
/**
|
|
1959
2870
|
* Attach compositor-repaint hooks to a tab's webContents.
|
|
1960
2871
|
* Idempotent per tab: re-registering would schedule redundant remove/add
|
|
@@ -2072,6 +2983,9 @@ class PageManager {
|
|
|
2072
2983
|
// switch to first remaining tab — must re-addBrowserView
|
|
2073
2984
|
const first = this.tabs.keys().next();
|
|
2074
2985
|
this.activeTab = first.done ? null : first.value;
|
|
2986
|
+
// 上面是直接改 activeTab(没走 setActive),monitor 不会自己知道;
|
|
2987
|
+
// 不同步的话它会一直拿已关闭的 tab 当"活跃 tab",淘汰顺序就反了。
|
|
2988
|
+
this.networkMonitor.setActiveTab(this.activeTab);
|
|
2075
2989
|
if (!first.done) {
|
|
2076
2990
|
const nextView = this._getView(this.activeTab);
|
|
2077
2991
|
this.window.addBrowserView(nextView);
|
|
@@ -2129,6 +3043,8 @@ class PageManager {
|
|
|
2129
3043
|
try { this.window.removeBrowserView(view); } catch(e) {}
|
|
2130
3044
|
}
|
|
2131
3045
|
this.activeTab = tabId;
|
|
3046
|
+
// body / 记录淘汰要"先牺牲后台 tab",所以必须让它知道谁是活跃的。
|
|
3047
|
+
this.networkMonitor.setActiveTab(tabId);
|
|
2132
3048
|
const view = this._getView(tabId);
|
|
2133
3049
|
if (view) {
|
|
2134
3050
|
this.window.addBrowserView(view);
|
|
@@ -2183,8 +3099,13 @@ class PageManager {
|
|
|
2183
3099
|
|
|
2184
3100
|
_layoutView(view) {
|
|
2185
3101
|
const bounds = this.window.getContentBounds();
|
|
2186
|
-
const
|
|
2187
|
-
view.setBounds({
|
|
3102
|
+
const inset = this._bottomInset || 0;
|
|
3103
|
+
view.setBounds({
|
|
3104
|
+
x: 0,
|
|
3105
|
+
y: config.tabBarHeight,
|
|
3106
|
+
width: bounds.width,
|
|
3107
|
+
height: Math.max(0, bounds.height - config.tabBarHeight - inset),
|
|
3108
|
+
});
|
|
2188
3109
|
}
|
|
2189
3110
|
|
|
2190
3111
|
addSubscription(sessionId, events) {
|
|
@@ -2690,6 +3611,10 @@ class PageManager {
|
|
|
2690
3611
|
sensitive_headers_optin: this.networkMonitor.sensitiveAllowed,
|
|
2691
3612
|
},
|
|
2692
3613
|
limits: this.networkMonitor.captureInfo(tabId).limits,
|
|
3614
|
+
// 诊断用:淘汰优先级与当前占用。回答"我的 body 为什么是 evicted"。
|
|
3615
|
+
active_tab: this.networkMonitor.captureInfo(tabId).active_tab,
|
|
3616
|
+
body_bytes: this.networkMonitor.captureInfo(tabId).body_bytes,
|
|
3617
|
+
max_body_bytes: this.networkMonitor.captureInfo(tabId).max_body_bytes,
|
|
2693
3618
|
note: 'retention limits are controlled by AI_BROWSER_NETWORK_* env only, never by this call',
|
|
2694
3619
|
};
|
|
2695
3620
|
}
|