@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.
Files changed (40) hide show
  1. package/README.md +155 -20
  2. package/bin/ai-browser.cjs +1 -1
  3. package/examples/AGENT_INSTRUCTIONS.md +22 -2
  4. package/llms-install.md +14 -4
  5. package/package.json +9 -5
  6. package/scripts/build_dmg.cjs +220 -0
  7. package/scripts/marketing/data_snapshot.sh +69 -0
  8. package/scripts/render_icon.cjs +118 -0
  9. package/src/main/axExtractor.js +103 -36
  10. package/src/main/error_log.js +245 -0
  11. package/src/main/index.js +487 -16
  12. package/src/main/mcp_server.js +39 -1
  13. package/src/main/mcp_tools.js +64 -10
  14. package/src/main/network_monitor.js +86 -23
  15. package/src/main/page_manager.js +1040 -115
  16. package/src/main/ws_server.js +127 -16
  17. package/src/preload/actions.cjs +61 -2
  18. package/src/preload/bridge.cjs +31 -1
  19. package/src/preload/extractor.cjs +520 -25
  20. package/src/renderer/error_banner.html +90 -0
  21. package/src/renderer/error_page.html +125 -0
  22. package/src/renderer/tab_bar.html +116 -56
  23. package/src/shared/act_params.cjs +53 -0
  24. package/src/shared/auth_wall.cjs +162 -0
  25. package/src/shared/config.js +8 -2
  26. package/src/shared/deadline.js +6 -0
  27. package/src/shared/deep_active.cjs +31 -0
  28. package/src/shared/error_banner.cjs +43 -0
  29. package/src/shared/find_ref.cjs +44 -0
  30. package/src/shared/frame_probe.cjs +112 -0
  31. package/src/shared/hit_point.cjs +80 -0
  32. package/src/shared/input_verify.js +47 -0
  33. package/src/shared/key_dispatch.cjs +37 -0
  34. package/src/shared/nav_wait.cjs +47 -0
  35. package/src/shared/protocol.js +15 -0
  36. package/src/shared/ref_guard.cjs +133 -0
  37. package/src/shared/screenshot_opts.cjs +130 -0
  38. package/src/shared/tree_diff.cjs +105 -0
  39. package/src/shared/version.js +1 -1
  40. package/tools/coverage_probe.cjs +239 -0
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+ // scripts/build_dmg.cjs — 把 AI Browser 打成 macOS .app,再压成 .dmg。
3
+ //
4
+ // # 为什么用 @electron/packager + hdiutil,而不是 electron-builder
5
+ //
6
+ // electron-builder 有一条硬校验(app-builder-lib/out/util/packageMetadata.js):
7
+ // const deps = ["electron", "electron-prebuilt", "electron-rebuild"];
8
+ // for (const name of deps) if (name in dependencies) errors.push(...)
9
+ // `electron` 出现在 dependencies 里就直接报错,**没有任何环境变量能绕过**
10
+ // (ALLOW_ELECTRON_BUILDER_AS_PRODUCTION_DEPENDENCY 只管 electron-builder 自己)。
11
+ //
12
+ // 这条校验的假设是"Electron 应用不该把 electron 当运行时依赖分发"。而本项目的
13
+ // npm 渠道恰恰是这么设计的:`npx -y @glittering0717/ai-browser mcp` 要把 electron
14
+ // 装进 node_modules 再拉起来。所以两个渠道对 package.json 的要求是冲突的。
15
+ //
16
+ // 眼前的产物只是一个**未签名、不自动更新**的 dmg,electron-builder 的核心卖点
17
+ // (签名/公证/自动更新/NSIS/Squirrel)一个都用不上,却要为此引入架构冲突和
18
+ // 一堆从 GitHub 下载的二进制。@electron/packager 只做"打包"这一件事,
19
+ // hdiutil 是 macOS 自带,不需要下载任何外部工具。
20
+ //
21
+ // 如果将来真要签名+公证+自动更新,正确的做法是改用 electron-builder 官方推荐的
22
+ // 双 package.json 布局(根目录放开发依赖,app/ 子目录放应用清单),而不是在这里
23
+ // 加 hack。
24
+ //
25
+ // # 用法
26
+ // node scripts/build_dmg.cjs # 打当前架构
27
+ // node scripts/build_dmg.cjs --arch arm64 # 指定架构
28
+ // node scripts/build_dmg.cjs --skip-dmg # 只出 .app,不压 dmg
29
+ 'use strict';
30
+
31
+ const { spawnSync } = require('node:child_process');
32
+ const fs = require('node:fs');
33
+ const os = require('node:os');
34
+ const path = require('node:path');
35
+
36
+ const root = path.resolve(__dirname, '..');
37
+ const pkg = require('../package.json');
38
+
39
+ const APP_NAME = 'AI Browser';
40
+ const OUT_DIR = path.join(root, 'dist');
41
+
42
+ function parseArgs(argv) {
43
+ const args = { arch: process.arch, skipDmg: false };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ if (argv[i] === '--arch') args.arch = argv[++i];
46
+ else if (argv[i] === '--skip-dmg') args.skipDmg = true;
47
+ else {
48
+ console.error(`[build] unknown argument: ${argv[i]}`);
49
+ process.exit(2);
50
+ }
51
+ }
52
+ return args;
53
+ }
54
+
55
+ // 黑名单。用黑名单而不是白名单是因为 packager 只有 ignore 这一个过滤手段,
56
+ // 所以每一条都要写清"为什么它不该进安装包"——
57
+ // outputs/ 28MB 截图证据,纯本地产物
58
+ // docs/ 内部文档,含营销资料
59
+ // e2e/ tests/ 测试代码与夹具
60
+ // tools/_oneoff/ 一次性调试脚本(dep_closure / peek_* 等)
61
+ // dist/ 上一次的构建产物
62
+ // .git/ 版本库
63
+ // node_modules/.cache|.bin npm 缓存与可执行文件软链
64
+ // node_modules/electron 243MB 的 Electron 发行版——打包后的可执行文件
65
+ // **就是** Electron,asar 里再放一份纯属浪费;src 里所有
66
+ // `from 'electron'` 都由 Electron 内置模块系统解析,不经过
67
+ // node_modules。(这一点和 electron-builder 的默认行为一致。)
68
+ // build/ 图标母版与中间产物。打包只需要 build/icon.icns 这一个
69
+ // 文件(由下面的 icon 选项显式指定,从源目录读取),
70
+ // 目录里的 svg/png/iconset 都不该随包分发;src 里也没有
71
+ // 任何地方引用 build/。
72
+ const IGNORE = new RegExp(
73
+ '(^|\\/)(\\.git|outputs|docs|e2e|tests|dist|build|node_modules\\/\\.cache|node_modules\\/\\.bin|node_modules\\/electron|tools\\/_oneoff)(\\/|$)'
74
+ );
75
+
76
+ function run(cmd, args, opts = {}) {
77
+ const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', ...opts });
78
+ if (r.error) throw new Error(`${cmd} failed to start: ${r.error.message}`);
79
+ if (r.status !== 0) throw new Error(`${cmd} exited with code ${r.status}`);
80
+ return r;
81
+ }
82
+
83
+ function dirSize(p) {
84
+ let total = 0;
85
+ const stack = [p];
86
+ while (stack.length) {
87
+ const cur = stack.pop();
88
+ let entries;
89
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch (e) { continue; }
90
+ for (const e of entries) {
91
+ const full = path.join(cur, e.name);
92
+ if (e.isDirectory()) stack.push(full);
93
+ else { try { total += fs.statSync(full).size; } catch (_) {} }
94
+ }
95
+ }
96
+ return total;
97
+ }
98
+
99
+ function mb(bytes) {
100
+ return (bytes / 1024 / 1024).toFixed(1) + ' MB';
101
+ }
102
+
103
+ // @electron/packager 默认走 downloadElectronZip 从 GitHub 拉 Electron zip。
104
+ // 本机到 GitHub 的连接不稳定(间歇性 502 / fetch failed),而
105
+ // ~/Library/Caches/electron/<hash>/ 下通常已经有下载好的 zip。
106
+ // 这里探测缓存:有对应版本的 zip 就用 electronZipDir 指过去(packager 的
107
+ // getElectronZipPath 见到这个选项就直接用本地文件,完全不联网),
108
+ // 没有才回落到在线下载。注意 electronZipDir 要求目录里躺着
109
+ // electron-v<version>-<platform>-<arch>.zip。
110
+ function findElectronZipDir(version, platform, arch) {
111
+ const cacheRoot = path.join(os.homedir(), 'Library', 'Caches', 'electron');
112
+ const zipName = `electron-v${version}-${platform}-${arch}.zip`;
113
+ let entries = [];
114
+ try {
115
+ entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
116
+ } catch (_) {
117
+ return null;
118
+ }
119
+ for (const e of entries) {
120
+ if (!e.isDirectory()) continue;
121
+ const dir = path.join(cacheRoot, e.name);
122
+ if (fs.existsSync(path.join(dir, zipName))) return dir;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function main() {
128
+ const args = parseArgs(process.argv.slice(2));
129
+ // @electron/packager v20 是纯 ESM("type": "module" + exports 只指向 dist/index.js),
130
+ // require() 拿到的是模块命名空间而不是函数,必须用动态 import。
131
+ // 它的导出是命名导出 `packager`,没有 default —— 别写成 mod.default。
132
+ import('@electron/packager')
133
+ .then((mod) => build(mod.packager, args))
134
+ .catch((e) => {
135
+ console.error(`[build] FAILED: ${e.message}`);
136
+ process.exit(1);
137
+ });
138
+ }
139
+
140
+ function build(packager, args) {
141
+ fs.rmSync(OUT_DIR, { recursive: true, force: true });
142
+ fs.mkdirSync(OUT_DIR, { recursive: true });
143
+
144
+ console.log(`[build] ${APP_NAME} ${pkg.version} — darwin/${args.arch}`);
145
+ console.log(`[build] ignore: ${IGNORE}`);
146
+
147
+ // electron 的实际版本以 node_modules 里装的那份为准(package.json 里是 ^ 范围)
148
+ const electronVersion = require('electron/package.json').version;
149
+ const zipDir = findElectronZipDir(electronVersion, 'darwin', args.arch);
150
+ if (zipDir) {
151
+ console.log(`[build] electron zip: 使用本地缓存 ${zipDir}(不联网)`);
152
+ } else {
153
+ console.log(`[build] electron zip: 本地无缓存,将从 GitHub 下载 v${electronVersion}`);
154
+ }
155
+
156
+ packager({
157
+ dir: root,
158
+ name: APP_NAME,
159
+ platform: 'darwin',
160
+ arch: args.arch,
161
+ out: OUT_DIR,
162
+ overwrite: true,
163
+ ignore: IGNORE,
164
+ electronZipDir: zipDir || undefined,
165
+ // 只打生产依赖:npm prune --production 会把 devDependencies(vitest、jsdom、
166
+ // @electron/packager 自己)从安装包里剔掉。用户侧少一整套测试工具链。
167
+ prune: true,
168
+ asar: true,
169
+ appBundleId: 'com.glittering.ai-browser',
170
+ appCategoryType: 'public.app-categories.productivity',
171
+ // 图标必须显式传 icon 选项。@electron/packager 的 mac.js copyIcon() 在
172
+ // `!this.opts.icon` 时直接 return —— 它**不会**自动去 build/ 里找
173
+ // icon.icns(老版 electron-packager 的行为,v20 已经没有了)。
174
+ // normalizeIconExtension 只在源目录路径上 existsSync,与 IGNORE 无关,
175
+ // 所以 build/ 可以同时留在 ignore 列表里,不进安装包。
176
+ icon: path.join(root, 'build', 'icon.icns'),
177
+ // 图标母版是 build/icon.svg,由 scripts/render_icon.cjs 用项目自带的
178
+ // Chromium 栅格化,再经 sips + iconutil 打成 icns。改图标走这条链,
179
+ // 不要直接改 png。
180
+ })
181
+ .then((paths) => {
182
+ const appPath = paths[0];
183
+ const size = dirSize(appPath);
184
+ console.log(`[build] app bundle: ${appPath}`);
185
+ console.log(`[build] app size: ${mb(size)}`);
186
+
187
+ if (args.skipDmg) {
188
+ console.log('[build] --skip-dmg,不压 dmg');
189
+ return;
190
+ }
191
+
192
+ const dmg = path.join(OUT_DIR, `AI-Browser-${pkg.version}-${args.arch}.dmg`);
193
+ console.log(`[build] creating dmg: ${dmg}`);
194
+ // hdiutil 是 macOS 自带的,不需要 electron-builder 那套从 GitHub 下载的
195
+ // dmg-license 等二进制——在国内网络下那一步经常卡住。
196
+ // UDZO = zlib 压缩,比 UDBZ(bzip2) 快得多,体积只大一点。
197
+ run('hdiutil', [
198
+ 'create',
199
+ '-volname', `${APP_NAME} ${pkg.version}`,
200
+ '-srcfolder', appPath,
201
+ '-ov',
202
+ '-format', 'UDZO',
203
+ dmg,
204
+ ]);
205
+
206
+ const dmgSize = fs.statSync(dmg).size;
207
+ console.log('');
208
+ console.log('[build] ===== 结果 =====');
209
+ console.log(`[build] dmg: ${dmg}`);
210
+ console.log(`[build] size: ${mb(dmgSize)}`);
211
+ console.log(`[build] app: ${mb(size)}(解压后占用)`);
212
+ console.log('[build] 未签名、未公证:用户需要"右键 → 打开",见 docs/ 说明');
213
+ })
214
+ .catch((e) => {
215
+ console.error(`[build] FAILED: ${e.message}`);
216
+ process.exit(1);
217
+ });
218
+ }
219
+
220
+ if (require.main === module) main();
@@ -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
+ });
@@ -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 && !c.ignored) kids.push(build(c, idFor));
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
- const live = (rawNodes || []).filter((n) => n && !n.ignored);
145
- nodeStore = new Map(live.map((n) => [n.nodeId, n]));
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
- const childIds = new Set();
150
- for (const n of live) for (const c of n.childIds || []) if (c != null) childIds.add(c);
151
- const roots = live.filter((n) => !childIds.has(n.nodeId));
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 live = (rawNodes || []).filter((n) => n && !n.ignored);
219
- nodeStore = new Map(live.map((n) => [n.nodeId, n]));
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 || n.ignored) return;
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
- // A: PURE diff of two AX snapshots (pre/post a reveal) by nodeId. Returns only
334
- // interactive nodes that newly appeared (revealed) or disappeared (hidden).
335
- export function diffAx(beforeRaw, afterRaw) {
336
- const bm = new Map((beforeRaw || []).map((n) => [n.nodeId, n]));
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
- return { ...t, children: kids };
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, diffAx, interactiveIdSet, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };
503
+ export default { normalizeAxTree, mergeFrameTrees, modalsFromAx, extractFromDebugger, axRead, axRaw, ensureHandles, attachHrefs, filterInteractive, liteSnapshot, diffLite, VERSION_TAG };