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