@glittering0717/ai-browser 1.2.2 → 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 +136 -19
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +7 -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 +276 -7
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/page_manager.js +936 -128
- package/src/main/ws_server.js +73 -12
- 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/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- 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 +8 -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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# data_snapshot.sh — ai-browser 推广数据每日快照
|
|
3
|
+
# 用法:bash scripts/marketing/data_snapshot.sh [备注]
|
|
4
|
+
# 输出:追加一行到 docs/marketing/DATA_LOG.md
|
|
5
|
+
set -u
|
|
6
|
+
REPO="/Users/Zhuanz/Documents/trae_projects/ai-browser"
|
|
7
|
+
LOG="$REPO/docs/marketing/DATA_LOG.md"
|
|
8
|
+
DATE=$(date +%Y-%m-%d)
|
|
9
|
+
TIME=$(date +%H:%M)
|
|
10
|
+
NOTE="${1:-}"
|
|
11
|
+
|
|
12
|
+
jqf() { # jqf <gh-api-path> <jq-filter> <default>
|
|
13
|
+
local out
|
|
14
|
+
out=$(gh api "$1" --jq "$2" 2>/dev/null) || out=""
|
|
15
|
+
[ -z "$out" ] && out="$3"
|
|
16
|
+
echo "$out"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# --- GitHub ---
|
|
20
|
+
STARS=$(jqf "repos/Glittering/ai-browser" '.stargazers_count' '?')
|
|
21
|
+
FORKS=$(jqf "repos/Glittering/ai-browser" '.forks_count' '?')
|
|
22
|
+
ISSUES=$(jqf "repos/Glittering/ai-browser" '.open_issues_count' '?')
|
|
23
|
+
VIEWS14=$(jqf "repos/Glittering/ai-browser/traffic/views" '.count' '?')
|
|
24
|
+
UNIQ14=$(jqf "repos/Glittering/ai-browser/traffic/views" '.uniques' '?')
|
|
25
|
+
CLONES14=$(jqf "repos/Glittering/ai-browser/traffic/clones" '.count' '?')
|
|
26
|
+
CLONEU14=$(jqf "repos/Glittering/ai-browser/traffic/clones" '.uniques' '?')
|
|
27
|
+
|
|
28
|
+
# --- npm ---
|
|
29
|
+
NPM_VER=$(curl -s --max-time 15 "https://registry.npmjs.org/@glittering0717%2Fai-browser" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)['dist-tags']['latest'])" 2>/dev/null || echo '?')
|
|
30
|
+
NPM_DL=$(curl -s --max-time 15 "https://api.npmjs.org/downloads/point/last-week/@glittering0717/ai-browser" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('downloads','N/A'))" 2>/dev/null || echo 'N/A')
|
|
31
|
+
|
|
32
|
+
# --- MCP Registry(取 isLatest 条目;API 偶发超时,带重试)---
|
|
33
|
+
mcp_fetch() {
|
|
34
|
+
for i in 1 2 3; do
|
|
35
|
+
local out
|
|
36
|
+
out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers?search=ai-browser" 2>/dev/null)
|
|
37
|
+
if [ -n "$out" ] && echo "$out" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
|
|
38
|
+
echo "$out"
|
|
39
|
+
return 0
|
|
40
|
+
fi
|
|
41
|
+
sleep 2
|
|
42
|
+
done
|
|
43
|
+
echo ""
|
|
44
|
+
}
|
|
45
|
+
MCP_INFO=$(mcp_fetch | python3 -c "
|
|
46
|
+
import json,sys
|
|
47
|
+
try:
|
|
48
|
+
d=json.load(sys.stdin)
|
|
49
|
+
for item in d.get('servers',[]):
|
|
50
|
+
s=item.get('server',{})
|
|
51
|
+
if s.get('name')=='io.github.Glittering/ai-browser':
|
|
52
|
+
m=item.get('_meta',{}).get('io.modelcontextprotocol.registry/official',{})
|
|
53
|
+
if m.get('isLatest'):
|
|
54
|
+
print(s.get('version','?'), m.get('status','?'))
|
|
55
|
+
break
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
" 2>/dev/null)
|
|
59
|
+
MCP_VER=$(echo "$MCP_INFO" | awk '{print $1}')
|
|
60
|
+
MCP_STATUS=$(echo "$MCP_INFO" | awk '{print $2}')
|
|
61
|
+
[ -z "$MCP_VER" ] && MCP_VER='?'
|
|
62
|
+
[ -z "$MCP_STATUS" ] && MCP_STATUS='?'
|
|
63
|
+
|
|
64
|
+
# --- 文章渠道状态(经 ai-browser 巡查时更新;此处留空由人工/agent 回填)---
|
|
65
|
+
printf '| %s %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s |\n' \
|
|
66
|
+
"$DATE" "$TIME" "$STARS" "$FORKS" "$ISSUES" "$VIEWS14" "$UNIQ14" "$CLONES14/$CLONEU14" "$NPM_VER" "$NPM_DL" "$MCP_VER" "$MCP_STATUS" "${NOTE:-}" >> "$LOG"
|
|
67
|
+
|
|
68
|
+
echo "快照已写入 $LOG"
|
|
69
|
+
tail -1 "$LOG"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/render_icon.cjs — 从 build/icon.svg 一键重建整条图标链:
|
|
3
|
+
// SVG → 2048×2048 PNG(icon@2x.png)→ 1024 PNG(icon.png)
|
|
4
|
+
// → icon.iconset/(10 个尺寸)→ icon.icns(packager 的输入)
|
|
5
|
+
//
|
|
6
|
+
// # 为什么不用 qlmanage / rsvg-convert / ImageMagick
|
|
7
|
+
// qlmanage 在受限沙箱里直接 "sandbox initialization failed: Operation not permitted";
|
|
8
|
+
// rsvg-convert / convert / inkscape 本机都没装。为一个图标引入一串系统依赖不划算。
|
|
9
|
+
// 而本项目**本来就自带一个完整的 Chromium**(electron 依赖),拿它渲染 SVG 是天经地义的事,
|
|
10
|
+
// 零新增依赖,且渲染结果和图标最终运行的引擎完全一致。
|
|
11
|
+
//
|
|
12
|
+
// # 为什么 icns 要提交、中间 png 不提交
|
|
13
|
+
// build_dmg.cjs 的 icon 选项只读 build/icon.icns;icon.svg 是母版;
|
|
14
|
+
// 其余 png/iconset 都是本脚本的再生产物,改了 SVG 重跑本脚本即可。
|
|
15
|
+
//
|
|
16
|
+
// # 用法(必须在 Electron 下运行,不是 node;本沙箱还要 --no-sandbox --disable-gpu)
|
|
17
|
+
// env -u ELECTRON_RUN_AS_NODE \
|
|
18
|
+
// node_modules/electron/dist/Electron.app/Contents/MacOS/Electron \
|
|
19
|
+
// --no-sandbox --disable-gpu scripts/render_icon.cjs
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { spawnSync } = require('node:child_process');
|
|
25
|
+
|
|
26
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
27
|
+
const SVG = path.join(ROOT, 'build', 'icon.svg');
|
|
28
|
+
const PNG_2X = path.join(ROOT, 'build', 'icon@2x.png'); // 2048×2048,母版位图
|
|
29
|
+
const PNG = path.join(ROOT, 'build', 'icon.png'); // 1024×1024,预览用
|
|
30
|
+
const ICONSET = path.join(ROOT, 'build', 'icon.iconset');
|
|
31
|
+
const ICNS = path.join(ROOT, 'build', 'icon.icns');
|
|
32
|
+
|
|
33
|
+
// icns 要求的全部尺寸(源图是 2048²,一律从它缩)
|
|
34
|
+
const SIZES = [
|
|
35
|
+
[16, 'icon_16x16.png'],
|
|
36
|
+
[32, 'icon_16x16@2x.png'],
|
|
37
|
+
[32, 'icon_32x32.png'],
|
|
38
|
+
[64, 'icon_32x32@2x.png'],
|
|
39
|
+
[128, 'icon_128x128.png'],
|
|
40
|
+
[256, 'icon_128x128@2x.png'],
|
|
41
|
+
[256, 'icon_256x256.png'],
|
|
42
|
+
[512, 'icon_256x256@2x.png'],
|
|
43
|
+
[512, 'icon_512x512.png'],
|
|
44
|
+
[1024, 'icon_512x512@2x.png'],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// 在普通 node 下 require('electron') 拿到的是二进制路径字符串,提前拦掉给出提示
|
|
48
|
+
let electron;
|
|
49
|
+
try {
|
|
50
|
+
electron = require('electron');
|
|
51
|
+
} catch (_) {
|
|
52
|
+
electron = null;
|
|
53
|
+
}
|
|
54
|
+
if (!electron || typeof electron.app === 'undefined') {
|
|
55
|
+
console.error('[icon] 本脚本必须在 Electron 下运行(见文件头用法),node 直接跑不了。');
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { app, BrowserWindow } = electron;
|
|
60
|
+
|
|
61
|
+
app.commandLine.appendSwitch('disable-gpu'); // 无头环境没有 GL,别让 GPU 进程拖后腿
|
|
62
|
+
|
|
63
|
+
function run(cmd, args) {
|
|
64
|
+
const r = spawnSync(cmd, args, { cwd: ROOT });
|
|
65
|
+
if (r.status !== 0) {
|
|
66
|
+
throw new Error(`${cmd} ${args.join(' ')} exited with code ${r.status}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
app.whenReady().then(async () => {
|
|
71
|
+
// 窗口 1024,capturePage 在 Retina 下返回 2x(2048×2048)——这就是母版位图
|
|
72
|
+
const SIZE = 1024;
|
|
73
|
+
const win = new BrowserWindow({
|
|
74
|
+
width: SIZE,
|
|
75
|
+
height: SIZE,
|
|
76
|
+
show: false,
|
|
77
|
+
transparent: true,
|
|
78
|
+
backgroundColor: '#00000000',
|
|
79
|
+
useContentSize: true,
|
|
80
|
+
webPreferences: { offscreen: false },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await win.loadFile(SVG);
|
|
85
|
+
// 等一帧,确保 SVG 已经完成布局与绘制(loadFile 只保证 DOM ready)
|
|
86
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
87
|
+
|
|
88
|
+
const img = await win.webContents.capturePage();
|
|
89
|
+
if (img.isEmpty()) throw new Error('capturePage returned an empty image');
|
|
90
|
+
|
|
91
|
+
const png = img.toPNG();
|
|
92
|
+
fs.writeFileSync(PNG_2X, png);
|
|
93
|
+
const w = img.getSize().width;
|
|
94
|
+
const h = img.getSize().height;
|
|
95
|
+
console.log(`[icon] ${path.relative(ROOT, SVG)} -> ${path.relative(ROOT, PNG_2X)} (${w}x${h}, ${png.length} bytes)`);
|
|
96
|
+
|
|
97
|
+
// ---- 以下三步是 macOS 自带工具,不联网、不加依赖 ----
|
|
98
|
+
// 1) 1024 预览图
|
|
99
|
+
run('sips', ['-Z', '1024', PNG_2X, '-o', PNG]);
|
|
100
|
+
// 2) 10 个 icns 尺寸
|
|
101
|
+
fs.rmSync(ICONSET, { recursive: true, force: true });
|
|
102
|
+
fs.mkdirSync(ICONSET, { recursive: true });
|
|
103
|
+
for (const [size, name] of SIZES) {
|
|
104
|
+
run('sips', ['-Z', String(size), PNG_2X, '-o', path.join(ICONSET, name)]);
|
|
105
|
+
}
|
|
106
|
+
// 3) 打 icns
|
|
107
|
+
fs.rmSync(ICNS, { force: true });
|
|
108
|
+
run('iconutil', ['-c', 'icns', ICONSET, '-o', ICNS]);
|
|
109
|
+
|
|
110
|
+
const icnsSize = fs.statSync(ICNS).size;
|
|
111
|
+
console.log(`[icon] iconset: ${SIZES.length} sizes -> ${path.relative(ROOT, ICNS)} (${icnsSize} bytes)`);
|
|
112
|
+
console.log('[icon] done. build_dmg.cjs 会读取 build/icon.icns');
|
|
113
|
+
app.exit(0);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error(`[icon] FAILED: ${e.message}`);
|
|
116
|
+
app.exit(1);
|
|
117
|
+
}
|
|
118
|
+
});
|
package/src/main/axExtractor.js
CHANGED
|
@@ -72,8 +72,23 @@ function statesFor(node) {
|
|
|
72
72
|
if (!v || v.type !== 'boolean' || v.value !== true) continue;
|
|
73
73
|
if (p.name === 'read-only') { states.push('readonly'); continue; }
|
|
74
74
|
if (p.name === 'hidden') continue;
|
|
75
|
+
// required 在下面单独处理(要给出与 DOM 路径同一份语义)。这里跳过,
|
|
76
|
+
// 否则 states 里会出现两个 "required"。
|
|
77
|
+
if (p.name === 'required') continue;
|
|
78
|
+
if (p.name === 'invalid') continue; // 字符串属性,下面单独处理
|
|
75
79
|
states.push(p.name); // focusable/disabled/focused/checked/selected/settable/multiline/visible...
|
|
76
80
|
}
|
|
81
|
+
// 必填 / 无效(P2-2)。AX 把 aria-required 和原生 required 都折成 `required`,
|
|
82
|
+
// 把 aria-invalid 折成 `invalid`(字符串:false | true | grammar | spelling)。
|
|
83
|
+
// 这两个信号此前完全不在 states 里,于是"掘金红*必填"和"字段已判错"都只能靠
|
|
84
|
+
// 截图猜 —— 而红色星号在无样式/色盲场景下本来就不是可靠信号。
|
|
85
|
+
const req = prop(nodeStore, node.nodeId, 'required');
|
|
86
|
+
if (req && req.value === true) states.push('required');
|
|
87
|
+
const invalid = prop(nodeStore, node.nodeId, 'invalid');
|
|
88
|
+
if (invalid && typeof invalid.value === 'string' && invalid.value !== 'false') {
|
|
89
|
+
states.push('invalid');
|
|
90
|
+
if (invalid.value !== 'true') states.push('invalid=' + invalid.value); // grammar / spelling
|
|
91
|
+
}
|
|
77
92
|
// raw `editable` stays available to callers that need the primitive signal
|
|
78
93
|
const editable = nodeStore ? valueOf(prop(nodeStore, node.nodeId, 'editable')) : null;
|
|
79
94
|
if (editable) states.push('editable=' + editable);
|
|
@@ -81,6 +96,27 @@ function statesFor(node) {
|
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
let nodeStore = null; // current normalize pass's node map (see normalizeAxTree)
|
|
99
|
+
// Nodes already emitted in this pass. Needed because we walk *through* ignored
|
|
100
|
+
// wrappers (§buildThroughIgnored); an AX node that a wrapper re-parents must not
|
|
101
|
+
// be emitted twice.
|
|
102
|
+
let seenNodes = null;
|
|
103
|
+
|
|
104
|
+
// Descendants of an AX node that is `ignored: true`. AX marks presentational
|
|
105
|
+
// wrappers (a `span` in a flex row, `<b>` inside a paragraph) as ignored, but
|
|
106
|
+
// the text under them is real content — dropping the wrapper must not drop the
|
|
107
|
+
// subtree. This is the P2-1 cause: a list row's title lives in such a span, so
|
|
108
|
+
// the row came back as `[button] 编辑` and nothing else.
|
|
109
|
+
function buildThroughIgnored(node, idFor) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const cid of node.childIds || []) {
|
|
112
|
+
const c = nodeStore.get(cid);
|
|
113
|
+
if (!c || seenNodes.has(c.nodeId)) continue;
|
|
114
|
+
if (c.ignored) { out.push(...buildThroughIgnored(c, idFor)); continue; }
|
|
115
|
+
seenNodes.add(c.nodeId);
|
|
116
|
+
out.push(build(c, idFor));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
84
120
|
|
|
85
121
|
function build(node, idFor) {
|
|
86
122
|
const rawRole = (node.role && node.role.value) || 'generic';
|
|
@@ -130,7 +166,12 @@ function build(node, idFor) {
|
|
|
130
166
|
const kids = [];
|
|
131
167
|
for (const cid of node.childIds || []) {
|
|
132
168
|
const c = nodeStore.get(cid);
|
|
133
|
-
if (c
|
|
169
|
+
if (!c || seenNodes.has(c.nodeId)) continue;
|
|
170
|
+
// 被 AX 标为 ignored 的只是包裹层(见 buildThroughIgnored);它的子节点
|
|
171
|
+
// 才是内容,必须被提升上来而不是连同子树一起丢掉。
|
|
172
|
+
if (c.ignored) { kids.push(...buildThroughIgnored(c, idFor)); continue; }
|
|
173
|
+
seenNodes.add(c.nodeId);
|
|
174
|
+
kids.push(build(c, idFor));
|
|
134
175
|
}
|
|
135
176
|
if (kids.length) tn.children = kids;
|
|
136
177
|
return tn;
|
|
@@ -141,21 +182,41 @@ let readValue = null; // opts.readValue — per-node DOM read hook for textbox v
|
|
|
141
182
|
// Public pure API.
|
|
142
183
|
// opts: { idFor: (node)=>string, readValue: (node)=>string }
|
|
143
184
|
export function normalizeAxTree(rawNodes, opts = {}) {
|
|
144
|
-
|
|
145
|
-
|
|
185
|
+
// nodeStore keeps ignored nodes too: we never *emit* them, but we must walk
|
|
186
|
+
// through them (see buildThroughIgnored). Indexing only the live ones was the
|
|
187
|
+
// second half of the P2-1 bug — the wrapper was in the map lookup's blind spot
|
|
188
|
+
// and its children became unreachable.
|
|
189
|
+
const all = (rawNodes || []).filter(Boolean);
|
|
190
|
+
const live = all.filter((n) => !n.ignored);
|
|
191
|
+
nodeStore = new Map(all.map((n) => [n.nodeId, n]));
|
|
192
|
+
seenNodes = new Set();
|
|
146
193
|
readValue = opts.readValue || null;
|
|
147
194
|
const idFor = opts.idFor || ((n) => 'ax-' + n.nodeId);
|
|
148
195
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
196
|
+
// Parent map over ALL nodes, so "is this a root?" accounts for ignored
|
|
197
|
+
// ancestors: a live node under an ignored wrapper is not a root.
|
|
198
|
+
const parentOf = new Map();
|
|
199
|
+
for (const n of all) for (const c of n.childIds || []) if (c != null && !parentOf.has(c)) parentOf.set(c, n.nodeId);
|
|
200
|
+
const hasLiveAncestor = (n) => {
|
|
201
|
+
let p = parentOf.get(n.nodeId); let guard = 0;
|
|
202
|
+
while (p != null && guard++ < 64) {
|
|
203
|
+
const pn = nodeStore.get(p);
|
|
204
|
+
if (!pn) return false;
|
|
205
|
+
if (!pn.ignored) return true;
|
|
206
|
+
p = parentOf.get(p);
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
};
|
|
210
|
+
const roots = live.filter((n) => !hasLiveAncestor(n));
|
|
152
211
|
|
|
212
|
+
for (const r of roots) seenNodes.add(r.nodeId);
|
|
153
213
|
const built = roots.map((n) => build(n, idFor));
|
|
154
214
|
const tree = built.length === 1
|
|
155
215
|
? built[0]
|
|
156
216
|
: { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
|
|
157
217
|
tree._debug = { layer: 'ax', version: VERSION_TAG };
|
|
158
218
|
nodeStore = null;
|
|
219
|
+
seenNodes = null;
|
|
159
220
|
readValue = null;
|
|
160
221
|
return tree;
|
|
161
222
|
}
|
|
@@ -215,8 +276,11 @@ export function mergeFrameTrees(mainTree, frames = []) {
|
|
|
215
276
|
// AX role=dialog|alertdialog that is visible, plus its descendant buttons — the
|
|
216
277
|
// AX-native replacement for the class-keyword modal table (extractor's modalSelectors).
|
|
217
278
|
export function modalsFromAx(rawNodes) {
|
|
218
|
-
const
|
|
219
|
-
|
|
279
|
+
const all = (rawNodes || []).filter(Boolean);
|
|
280
|
+
const live = all.filter((n) => !n.ignored);
|
|
281
|
+
// 与 normalizeAxTree 同一条规则:ignored 只是包裹层,遍历要穿过去(否则
|
|
282
|
+
// ignored span 里的按钮永远数不到)。
|
|
283
|
+
nodeStore = new Map(all.map((n) => [n.nodeId, n]));
|
|
220
284
|
const dialogs = live.filter((n) => {
|
|
221
285
|
const r = n.role && n.role.value;
|
|
222
286
|
if (r !== 'dialog' && r !== 'alertdialog') return false;
|
|
@@ -228,7 +292,8 @@ export function modalsFromAx(rawNodes) {
|
|
|
228
292
|
const buttons = [];
|
|
229
293
|
const walk = (id) => {
|
|
230
294
|
const n = nodeStore.get(id);
|
|
231
|
-
if (!n
|
|
295
|
+
if (!n) return;
|
|
296
|
+
if (n.ignored) { for (const cid of n.childIds || []) walk(cid); return; }
|
|
232
297
|
const r = n.role && n.role.value;
|
|
233
298
|
if (r === 'button') {
|
|
234
299
|
const btn = {
|
|
@@ -279,13 +344,6 @@ async function axRaw(dbg) {
|
|
|
279
344
|
return raw;
|
|
280
345
|
}
|
|
281
346
|
|
|
282
|
-
export function interactiveIdSet(raw) {
|
|
283
|
-
const byId = new Map(raw.filter((n) => !n.ignored).map((n) => [n.nodeId, n]));
|
|
284
|
-
const s = new Set();
|
|
285
|
-
for (const n of raw) if (!n.ignored && isInteractiveNode(n, byId)) s.add(n.nodeId);
|
|
286
|
-
return s;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
347
|
// C: PURE READ. Same {tree, context} shape as the default getTree, but derived
|
|
290
348
|
// from AX alone. No DOM mutation, ids = ax-{backendDOMNodeId} (stable).
|
|
291
349
|
export async function axRead(dbg) {
|
|
@@ -330,24 +388,10 @@ export async function ensureHandles(dbg) {
|
|
|
330
388
|
return out;
|
|
331
389
|
}
|
|
332
390
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const am = new Map((afterRaw || []).map((n) => [n.nodeId, n]));
|
|
338
|
-
const bi = interactiveIdSet(beforeRaw || []);
|
|
339
|
-
const ai = interactiveIdSet(afterRaw || []);
|
|
340
|
-
const toLite = (n) => ({
|
|
341
|
-
id: axId(n),
|
|
342
|
-
role: (n.role && n.role.value) || 'generic',
|
|
343
|
-
label: (n.name && n.name.value) || '',
|
|
344
|
-
backendDOMNodeId: n.backendDOMNodeId != null ? n.backendDOMNodeId : undefined,
|
|
345
|
-
});
|
|
346
|
-
const revealed = [], hidden = [];
|
|
347
|
-
for (const id of ai) if (!bi.has(id)) { const n = am.get(id); if (n && !n.ignored) revealed.push(toLite(n)); }
|
|
348
|
-
for (const id of bi) if (!ai.has(id)) { const n = bm.get(id); if (n && !n.ignored) hidden.push(toLite(n)); }
|
|
349
|
-
return { revealed, hidden };
|
|
350
|
-
}
|
|
391
|
+
// 注意:这里原本有基于 AX 的 diffAx(ui.peek 用的差异比较)。它已删除,原因是
|
|
392
|
+
// 自绘菜单在 AX 里是"无名的 generic"、进不了 isInteractiveNode 的判据,peek 因此
|
|
393
|
+
// 对这类菜单一律报空;且它返回的 ax-* id 动作层解析不了,探到了也点不了。
|
|
394
|
+
// ui.peek 现在改用 DOM 语义树做差异,实现在 src/shared/tree_diff.cjs。
|
|
351
395
|
|
|
352
396
|
// C: read-only href resolution for actionable nodes. The AX read layer only knows
|
|
353
397
|
// "this is clickable", not "where it goes" (AX has no href). We lazily decorate
|
|
@@ -386,6 +430,24 @@ export async function attachHrefs(dbg, tree) {
|
|
|
386
430
|
// descendants); drops layout-only branches. Pure and generic (role/actions
|
|
387
431
|
// based, no site heuristics) — the read-lowering mode for long feeds.
|
|
388
432
|
function isInteractive(tree) { return !!tree.editor_type || (tree.actions && tree.actions.length > 0) || !!tree.url; }
|
|
433
|
+
|
|
434
|
+
// 行容器(列表项 / 表格行 / 树项)在 prune 后常常只剩一个操作按钮:
|
|
435
|
+
// 标题是被剪掉的纯文本,而**纯文本正是"这是哪一行"的唯一线索**。B站稿件
|
|
436
|
+
// 管理列表因此每行只剩 `[button] 编辑`,无法定位目标条目(P2-1)。
|
|
437
|
+
// 对这类角色,若剪完 label 为空,就把首个文本后代的文字提升成行 label。
|
|
438
|
+
const ROW_ROLES = { listitem: 1, row: 1, treeitem: 1, cell: 1, gridcell: 1, article: 1 };
|
|
439
|
+
const MAX_ROW_LABEL = 120;
|
|
440
|
+
function firstTextOf(node) {
|
|
441
|
+
if (!node) return '';
|
|
442
|
+
const role = node.role || '';
|
|
443
|
+
if (role === 'StaticText' || role === 'InlineTextBox') return String(node.label || '').trim();
|
|
444
|
+
for (const c of node.children || []) {
|
|
445
|
+
const t = firstTextOf(c);
|
|
446
|
+
if (t) return t;
|
|
447
|
+
}
|
|
448
|
+
return '';
|
|
449
|
+
}
|
|
450
|
+
|
|
389
451
|
export function filterInteractive(tree) {
|
|
390
452
|
if (!tree) return tree;
|
|
391
453
|
const keep = (t) => {
|
|
@@ -393,7 +455,12 @@ export function filterInteractive(tree) {
|
|
|
393
455
|
const kids = (t.children || []).map(keep).filter(Boolean);
|
|
394
456
|
if (kids.length || isInteractive(t)) {
|
|
395
457
|
if (!kids.length) return t;
|
|
396
|
-
|
|
458
|
+
const node = { ...t, children: kids };
|
|
459
|
+
if (ROW_ROLES[t.role] && !node.label) {
|
|
460
|
+
const txt = firstTextOf(t);
|
|
461
|
+
if (txt) node.label = txt.slice(0, MAX_ROW_LABEL);
|
|
462
|
+
}
|
|
463
|
+
return node;
|
|
397
464
|
}
|
|
398
465
|
return null;
|
|
399
466
|
};
|
|
@@ -433,4 +500,4 @@ export function diffLite(prevLite, newLite) {
|
|
|
433
500
|
return { revealed, hidden };
|
|
434
501
|
}
|
|
435
502
|
|
|
436
|
-
export default { normalizeAxTree, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles,
|
|
503
|
+
export default { normalizeAxTree, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// src/main/error_log.js — 主进程错误日志(JSONL,滚动,可回读)。
|
|
2
|
+
//
|
|
3
|
+
// 存在理由(2026-10-17 实测):主进程此前**一处错误处理都没有**。任何未捕获
|
|
4
|
+
// 异常都走 Electron 的默认路径 —— 弹一个原生对话框(标题就是 "A JavaScript
|
|
5
|
+
// error occurred in the main process"),栈只在框里、点掉就没了,进程随即退出。
|
|
6
|
+
// 用户看到"弹了个框说 js 崩溃",而事后**没有任何地方能查到它崩在哪** ——
|
|
7
|
+
// 连"刚才是不是真崩过"都证不了。这不是"日志不够详细",是根本没有。
|
|
8
|
+
//
|
|
9
|
+
// 三条设计约束:
|
|
10
|
+
// 1. **日志本身绝不能再抛异常**。所有磁盘操作 try/catch 吞掉:为了记录崩溃
|
|
11
|
+
// 而制造新崩溃,是最难查的那种 bug。
|
|
12
|
+
// 2. **同步写**(appendFileSync)。异步写等到 flush 时进程可能已经没了 ——
|
|
13
|
+
// 崩溃现场最需要的恰恰是最后一刻那几行。
|
|
14
|
+
// 3. **异常条目自带最近的调用上下文**。只有栈是不够的:栈会告诉你崩在
|
|
15
|
+
// `selectorAncestors`,但不会告诉你"是哪个 ui.act 在什么 target 上触发的"。
|
|
16
|
+
// 所以维护一个 WS 调用环形缓冲,落盘时把它一并附上。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
// 单文件上限与保留份数。2MB ×(1+4) 对纯文本日志是几个月量级的事件量;
|
|
22
|
+
// 再大就说明是循环报错,那种情况靠滚动还不如早点被看见。
|
|
23
|
+
// 允许 init 时覆盖:磁盘紧张的环境可以调小,测试也靠它把滚动路径跑起来。
|
|
24
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
const DEFAULT_KEEP = 4;
|
|
26
|
+
let maxBytes = DEFAULT_MAX_BYTES;
|
|
27
|
+
let keepFiles = DEFAULT_KEEP;
|
|
28
|
+
// 内存里留最近多少条,供 ui.errors 直接读(不碰磁盘,快)
|
|
29
|
+
const RING_MAX = 200;
|
|
30
|
+
// 最近多少次 WS 调用参与"崩溃上下文"
|
|
31
|
+
const CALL_RING_MAX = 40;
|
|
32
|
+
// 单条日志里字符串字段的截断长度
|
|
33
|
+
const FIELD_MAX = 4000;
|
|
34
|
+
|
|
35
|
+
let logFile = null;
|
|
36
|
+
let logDir = null;
|
|
37
|
+
let wroteBytes = 0;
|
|
38
|
+
let dropped = 0;
|
|
39
|
+
|
|
40
|
+
const ring = [];
|
|
41
|
+
const callRing = [];
|
|
42
|
+
|
|
43
|
+
/** 把任意值压成"可安全 JSON.stringify 的一行摘要"。 */
|
|
44
|
+
function summarize(v, depth = 0) {
|
|
45
|
+
if (v === null || v === undefined) return v;
|
|
46
|
+
const t = typeof v;
|
|
47
|
+
if (t === 'string') return v.length > FIELD_MAX ? v.slice(0, FIELD_MAX) + '…(' + v.length + ' chars)' : v;
|
|
48
|
+
if (t === 'number' || t === 'boolean') return v;
|
|
49
|
+
if (t === 'bigint') return String(v);
|
|
50
|
+
if (t === 'function') return '[function ' + (v.name || 'anonymous') + ']';
|
|
51
|
+
if (t === 'symbol') return String(v);
|
|
52
|
+
if (v instanceof Error) {
|
|
53
|
+
return { name: v.name, message: v.message, stack: String(v.stack || '').slice(0, 8000) };
|
|
54
|
+
}
|
|
55
|
+
if (depth >= 3) return Array.isArray(v) ? '[array ' + v.length + ']' : '[object]';
|
|
56
|
+
if (Array.isArray(v)) {
|
|
57
|
+
const head = v.slice(0, 12).map((x) => summarize(x, depth + 1));
|
|
58
|
+
return v.length > 12 ? head.concat(['…+' + (v.length - 12)]) : head;
|
|
59
|
+
}
|
|
60
|
+
const out = {};
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const k of Object.keys(v)) {
|
|
63
|
+
if (n++ >= 20) { out['…'] = 'more keys omitted'; break; }
|
|
64
|
+
out[k] = summarize(v[k], depth + 1);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function nowIso() {
|
|
70
|
+
return new Date().toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rotateIfNeeded(nextLen) {
|
|
74
|
+
try {
|
|
75
|
+
if (!logFile) return;
|
|
76
|
+
let size = wroteBytes;
|
|
77
|
+
if (!size) {
|
|
78
|
+
try { size = fs.statSync(logFile).size; } catch { size = 0; }
|
|
79
|
+
}
|
|
80
|
+
if (size + nextLen <= maxBytes) return;
|
|
81
|
+
// error.jsonl → error.jsonl.1 → error.jsonl.2 …
|
|
82
|
+
for (let i = keepFiles - 1; i >= 1; i--) {
|
|
83
|
+
const from = i === 1 ? logFile : logFile + '.' + (i - 1);
|
|
84
|
+
const to = logFile + '.' + i;
|
|
85
|
+
try { if (fs.existsSync(from)) fs.renameSync(from, to); } catch {}
|
|
86
|
+
}
|
|
87
|
+
// 轮转后旧内容已经在 .1,当前文件从零开始
|
|
88
|
+
try { if (fs.existsSync(logFile)) fs.unlinkSync(logFile); } catch {}
|
|
89
|
+
wroteBytes = 0;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendLine(obj) {
|
|
94
|
+
const line = JSON.stringify(obj) + '\n';
|
|
95
|
+
rotateIfNeeded(Buffer.byteLength(line));
|
|
96
|
+
try {
|
|
97
|
+
fs.appendFileSync(logFile, line);
|
|
98
|
+
wroteBytes += Buffer.byteLength(line);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// 写不下去就记账,靠 ui.errors 的 dropped 字段让调用方知道"这里缺了东西"
|
|
101
|
+
dropped += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 初始化日志目录。**必须在 app ready 之前调用**,否则早期崩溃(最常见的那类)
|
|
107
|
+
* 落不了盘。
|
|
108
|
+
* @param {{dir: string, meta?: object}} opts dir 为日志目录;meta 附在启动行里
|
|
109
|
+
* (版本、端口、userData…),方便事后分辨"这份日志是哪个实例的"。
|
|
110
|
+
*/
|
|
111
|
+
export function initErrorLog(opts) {
|
|
112
|
+
const dir = (opts && opts.dir) || null;
|
|
113
|
+
if (!dir) return null;
|
|
114
|
+
logDir = dir;
|
|
115
|
+
if (opts && Number(opts.maxBytes) > 0) maxBytes = Number(opts.maxBytes);
|
|
116
|
+
if (opts && Number(opts.keep) > 0) keepFiles = Math.max(1, Number(opts.keep));
|
|
117
|
+
logFile = path.join(dir, 'error.jsonl');
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
try { wroteBytes = fs.statSync(logFile).size; } catch { wroteBytes = 0; }
|
|
121
|
+
// 进程边界的标记行:读日志时能一眼看出"这是哪一次启动",以及上一次
|
|
122
|
+
// 是不是异常结束(没有 shutdown 行就说明是硬退)
|
|
123
|
+
appendLine({
|
|
124
|
+
ts: nowIso(),
|
|
125
|
+
kind: 'boot',
|
|
126
|
+
pid: process.pid,
|
|
127
|
+
node: process.versions && process.versions.node,
|
|
128
|
+
electron: process.versions && process.versions.electron,
|
|
129
|
+
platform: process.platform + ' ' + process.arch,
|
|
130
|
+
...summarize((opts && opts.meta) || {}),
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
logFile = null;
|
|
134
|
+
}
|
|
135
|
+
return logFile;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getLogPath() {
|
|
139
|
+
return logFile;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function getLogDir() {
|
|
143
|
+
return logDir;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 记一条错误。永不抛。
|
|
148
|
+
* @param {string} kind 事件类别:uncaught_exception / unhandled_rejection /
|
|
149
|
+
* render_process_gone / child_process_gone / nav_fail / ws_error / …
|
|
150
|
+
* @param {object} info 任意附加字段(message/stack/url/tab/…)
|
|
151
|
+
*/
|
|
152
|
+
export function recordError(kind, info) {
|
|
153
|
+
const entry = {
|
|
154
|
+
ts: nowIso(),
|
|
155
|
+
kind: String(kind || 'unknown'),
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
uptime_ms: Math.round(process.uptime() * 1000),
|
|
158
|
+
...summarize(info || {}),
|
|
159
|
+
};
|
|
160
|
+
// 崩溃上下文:最近若干次 WS 调用。放在条目里而不是另起一行,读的时候
|
|
161
|
+
// 不用自己按时间拼。
|
|
162
|
+
if (callRing.length) entry.recent_calls = callRing.slice(-20);
|
|
163
|
+
ring.push(entry);
|
|
164
|
+
while (ring.length > RING_MAX) ring.shift();
|
|
165
|
+
appendLine(entry);
|
|
166
|
+
return entry;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 记一次 WS 调用(进环形缓冲,不落盘 —— 只作为崩溃时的上下文)。
|
|
171
|
+
* @param {string} method 方法名
|
|
172
|
+
* @param {object} params 原始参数(内部会摘要化,大字段自动截断)
|
|
173
|
+
* @param {number} ms 耗时
|
|
174
|
+
* @param {boolean|string} outcome true/false 或错误摘要
|
|
175
|
+
*/
|
|
176
|
+
export function noteCall(method, params, ms, outcome) {
|
|
177
|
+
try {
|
|
178
|
+
const brief = summarize(params || {});
|
|
179
|
+
// 只留能定位的信息:text/html 这类大字段压成"长度"
|
|
180
|
+
const slim = {};
|
|
181
|
+
if (brief && typeof brief === 'object') {
|
|
182
|
+
for (const k of Object.keys(brief)) {
|
|
183
|
+
if (k === 'text' || k === 'html') {
|
|
184
|
+
// 正文类字段只留长度:几十 KB 的稿子灌进日志,一条就把文件写满
|
|
185
|
+
const v = (params || {})[k];
|
|
186
|
+
slim[k] = typeof v === 'string' ? '<' + v.length + ' chars>' : brief[k];
|
|
187
|
+
} else if (k === 'js') {
|
|
188
|
+
// 脚本保留开头:它是定位"崩在哪段注入代码"的关键,只留长度等于没留。
|
|
189
|
+
// 但也不能整段进日志(内联 base64 图片会让一条日志到 MB 级)。
|
|
190
|
+
const v = (params || {})[k];
|
|
191
|
+
slim[k] = typeof v === 'string'
|
|
192
|
+
? (v.length > 200 ? v.slice(0, 200) + '…<' + v.length + ' chars>' : v)
|
|
193
|
+
: brief[k];
|
|
194
|
+
} else {
|
|
195
|
+
slim[k] = brief[k];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
callRing.push({
|
|
200
|
+
ts: nowIso(),
|
|
201
|
+
method: String(method || ''),
|
|
202
|
+
params: slim,
|
|
203
|
+
ms: typeof ms === 'number' ? ms : null,
|
|
204
|
+
outcome: outcome === undefined ? null : summarize(outcome),
|
|
205
|
+
});
|
|
206
|
+
while (callRing.length > CALL_RING_MAX) callRing.shift();
|
|
207
|
+
} catch {
|
|
208
|
+
/* 记调用上下文失败绝不影响调用本身 */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 读最近错误(内存里的本次进程记录)。文件才是权威历史 —— 进程一崩,这里
|
|
214
|
+
* 就没了,所以回执里带 path 让调用方自己去翻。
|
|
215
|
+
*/
|
|
216
|
+
export function recentErrors(limit = 50, kind = null) {
|
|
217
|
+
const n = Math.max(1, Math.min(RING_MAX, Number(limit) || 50));
|
|
218
|
+
// boot / shutdown 是进程边界标记,不是"错误"。只有明确按 kind 查时才返回它们。
|
|
219
|
+
const MARKERS = new Set(['boot', 'shutdown']);
|
|
220
|
+
let items = kind ? ring.filter((e) => e.kind === kind) : ring.filter((e) => !MARKERS.has(e.kind));
|
|
221
|
+
return {
|
|
222
|
+
path: logFile,
|
|
223
|
+
dir: logDir,
|
|
224
|
+
count_in_memory: items.length,
|
|
225
|
+
dropped_writes: dropped,
|
|
226
|
+
items: items.slice(-n),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 正常退出时补一行收尾 —— 日志里没有 shutdown 行 = 上次是硬退。 */
|
|
231
|
+
export function markShutdown(reason) {
|
|
232
|
+
recordError('shutdown', { reason: reason || 'normal' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 仅供测试:重置模块内状态,避免用例之间互相污染。
|
|
236
|
+
export function __resetForTests() {
|
|
237
|
+
logFile = null;
|
|
238
|
+
logDir = null;
|
|
239
|
+
wroteBytes = 0;
|
|
240
|
+
dropped = 0;
|
|
241
|
+
maxBytes = DEFAULT_MAX_BYTES;
|
|
242
|
+
keepFiles = DEFAULT_KEEP;
|
|
243
|
+
ring.length = 0;
|
|
244
|
+
callRing.length = 0;
|
|
245
|
+
}
|