@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
|
@@ -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
|
+
});
|
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 };
|