@glittering0717/ai-browser 2.1.3 → 2.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
4
4
  "description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
5
5
  "main": "src/main/index.js",
6
6
  "bin": {
@@ -76,7 +76,10 @@
76
76
  "e2e:all": "node e2e/run_all.cjs",
77
77
  "readcov": "node e2e/readcov_ws.cjs",
78
78
  "winopen": "node e2e/window_open_ws.cjs",
79
- "coverage": "node tools/coverage_probe.cjs"
79
+ "coverage": "node tools/coverage_probe.cjs",
80
+ "build": "node scripts/build_dmg.cjs",
81
+ "verify:build": "node scripts/verify_build.cjs",
82
+ "install:app": "node scripts/install_app.cjs"
80
83
  },
81
84
  "dependencies": {
82
85
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -124,6 +124,33 @@ function findElectronZipDir(version, platform, arch) {
124
124
  return null;
125
125
  }
126
126
 
127
+ // 把 build/icon.icns 装进 app bundle。
128
+ //
129
+ // 为什么不用 packager 的 icon 选项就够了:@electron/packager v20 在 macOS 26
130
+ // 上会优先找 Icon Composer 的 `.icon` 资源(日志里那句 "with extension
131
+ // '.icon', skipping this app icon format"),icns 被跳过,最后 Info.plist 里
132
+ // 仍是 CFBundleIconFile=electron.icns —— 用户看到的是默认 Electron 图标。
133
+ // 这里直接做最终态:拷 icns + 改 plist + touch 让 Finder 刷新图标缓存。
134
+ // 用外部 cp 而不是 fs.copyFileSync:受管环境的 Node fs 可能被文件系统代理
135
+ // 拦截(见 build() 里 STAGE 的注释),外部命令不经过它。
136
+ function installIcon(appPath) {
137
+ const src = path.join(root, 'build', 'icon.icns');
138
+ if (!fs.existsSync(src)) {
139
+ console.log('[build] 跳过图标:build/icon.icns 不存在(先跑 node scripts/render_icon.cjs)');
140
+ return;
141
+ }
142
+ const resources = path.join(appPath, 'Contents', 'Resources');
143
+ const dest = path.join(resources, 'icon.icns');
144
+ run('cp', [src, dest]);
145
+ run('/usr/libexec/PlistBuddy', [
146
+ '-c', 'Set :CFBundleIconFile icon.icns',
147
+ path.join(appPath, 'Contents', 'Info.plist'),
148
+ ]);
149
+ // touch 让 LaunchServices / Finder 重新读图标,否则换图后仍显示旧缓存
150
+ run('touch', [appPath]);
151
+ console.log('[build] 图标已安装:icon.icns');
152
+ }
153
+
127
154
  function main() {
128
155
  const args = parseArgs(process.argv.slice(2));
129
156
  // @electron/packager v20 是纯 ESM("type": "module" + exports 只指向 dist/index.js),
@@ -133,14 +160,39 @@ function main() {
133
160
  .then((mod) => build(mod.packager, args))
134
161
  .catch((e) => {
135
162
  console.error(`[build] FAILED: ${e.message}`);
163
+ // AIB_BUILD_DEBUG=1 打印完整栈 —— 打包失败的报错常常只有一句话
164
+ // ("modify backup failed" 这种),没有栈根本判断不出是哪一步、哪个文件。
165
+ if (process.env.AIB_BUILD_DEBUG) console.error(e.stack);
136
166
  process.exit(1);
137
167
  });
138
168
  }
139
169
 
140
170
  function build(packager, args) {
141
- fs.rmSync(OUT_DIR, { recursive: true, force: true });
171
+ // 上一次的产物要清掉,否则 packager 会在旧目录上原地合并,留下上一版的
172
+ // 文件(旧 dmg、旧 asar 里的旧资源)。用 rename 到临时目录而不是递归删除:
173
+ // 删除一个几百 MB、上千文件的目录容易撞上沙箱/安全工具的批量删除保护,
174
+ // 而这个目录本来就不重要(构建产物),挪走即可;系统会自己回收 tmp。
175
+ if (fs.existsSync(OUT_DIR)) {
176
+ const trash = path.join(os.tmpdir(), `ai-browser-dist-${Date.now()}`);
177
+ try {
178
+ fs.renameSync(OUT_DIR, trash);
179
+ console.log(`[build] 旧产物已挪到 ${trash}`);
180
+ } catch (e) {
181
+ throw new Error(`无法移动旧 dist(${e.message})——请手工删除后再构建`);
182
+ }
183
+ }
142
184
  fs.mkdirSync(OUT_DIR, { recursive: true });
143
185
 
186
+ // packager 的输出目录放在系统临时目录,最后由 ditto 搬进 dist/。
187
+ //
188
+ // 为什么不直接输出到 dist/:asar 打包阶段会用 fs.createWriteStream 写
189
+ // app.asar(十几 MB)。在受管环境里,Node 的 fs 调用可能被文件系统代理
190
+ // 拦住(本机实测:createWriteStream 被 node-brokered-fs-shim 拦截,报
191
+ // "Brokered file token refused: modify backup failed",且没有栈的时候
192
+ // 完全看不出是哪一步)。代理只包 Node 的 fs,不拦外部命令,所以:
193
+ // 打包在 tmp 里做(Node 写 tmp 不受限),落地用 ditto(外部命令)。
194
+ const STAGE = path.join(os.tmpdir(), `aib-build-${Date.now()}`);
195
+
144
196
  console.log(`[build] ${APP_NAME} ${pkg.version} — darwin/${args.arch}`);
145
197
  console.log(`[build] ignore: ${IGNORE}`);
146
198
 
@@ -158,7 +210,7 @@ function build(packager, args) {
158
210
  name: APP_NAME,
159
211
  platform: 'darwin',
160
212
  arch: args.arch,
161
- out: OUT_DIR,
213
+ out: STAGE,
162
214
  overwrite: true,
163
215
  ignore: IGNORE,
164
216
  electronZipDir: zipDir || undefined,
@@ -179,10 +231,25 @@ function build(packager, args) {
179
231
  // 不要直接改 png。
180
232
  })
181
233
  .then((paths) => {
182
- const appPath = paths[0];
234
+ // packager 返回的是外层目录(<out>/AI Browser-darwin-arm64/),
235
+ // 真正的 .app 在它里面。dmg 用外层目录当源(会带 LICENSE /
236
+ // LICENSES.chromium.html,和 macOS 分发惯例一致),但改图标必须
237
+ // 精确落到 .app 上 —— 之前把外层目录当 bundle,cp 直接 ENOENT。
238
+ const stagedRoot = paths[0];
239
+ // ditto 而不是 cp -R:保留资源分支(bundle 里的符号链接、扩展属性、
240
+ // 权限位)。cp -R 复制 .app 会打坏 Electron 的 framework 结构。
241
+ const destRoot = path.join(OUT_DIR, path.basename(stagedRoot));
242
+ console.log(`[build] 搬运到 dist: ${destRoot}`);
243
+ run('ditto', [stagedRoot, destRoot]);
244
+
245
+ const bundledApp = fs.readdirSync(destRoot).find((n) => n.endsWith('.app'));
246
+ if (!bundledApp) throw new Error(`dist 里没有 .app:${destRoot}`);
247
+ const appPath = path.join(destRoot, bundledApp);
248
+
183
249
  const size = dirSize(appPath);
184
250
  console.log(`[build] app bundle: ${appPath}`);
185
251
  console.log(`[build] app size: ${mb(size)}`);
252
+ installIcon(appPath);
186
253
 
187
254
  if (args.skipDmg) {
188
255
  console.log('[build] --skip-dmg,不压 dmg');
@@ -197,7 +264,7 @@ function build(packager, args) {
197
264
  run('hdiutil', [
198
265
  'create',
199
266
  '-volname', `${APP_NAME} ${pkg.version}`,
200
- '-srcfolder', appPath,
267
+ '-srcfolder', destRoot,
201
268
  '-ov',
202
269
  '-format', 'UDZO',
203
270
  dmg,
@@ -213,6 +280,9 @@ function build(packager, args) {
213
280
  })
214
281
  .catch((e) => {
215
282
  console.error(`[build] FAILED: ${e.message}`);
283
+ // AIB_BUILD_DEBUG=1 打印完整栈 —— 打包失败的报错常常只有一句话
284
+ // ("modify backup failed" 这种),没有栈根本判断不出是哪一步、哪个文件。
285
+ if (process.env.AIB_BUILD_DEBUG) console.error(e.stack);
216
286
  process.exit(1);
217
287
  });
218
288
  }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // scripts/install_app.cjs — 把 dist 里的 .app 装到 /Applications,替换旧版本。
3
+ //
4
+ // # 为什么需要这一步而不是"让用户自己拖"
5
+ //
6
+ // 本机验证链条是:改代码 → npm/GitHub/MCP 发布 → **桌面 App**。前三步都在
7
+ // 自动化里,最后一步靠人手拖拽,于是就有了 2026-10-24 那次事故——所有渠道
8
+ // 都是 v2.1.4,用户桌面上的 App 还是 10-19 打的 v1.3.1,看到的现象是"你根本
9
+ // 没修好"。只要"装应用"还是一次手工操作,这个漂移就会重复发生。
10
+ //
11
+ // # 安全约束
12
+ //
13
+ // · 不删除任何东西:旧 .app 用 mv 移到临时目录(可回滚),不 rm。
14
+ // · 不碰用户数据:~/.ai-browser(登录态、history)完全不动,所以换版本后
15
+ // 仍然是登录状态。
16
+ // · 先退出旧进程再替换,否则 ditto 会写进正在运行的文件里。
17
+ //
18
+ // 用法:
19
+ // node scripts/install_app.cjs # 装当前 dist 产物
20
+ // node scripts/install_app.cjs --dry # 只打印会做什么
21
+ 'use strict';
22
+
23
+ const { spawnSync, execFileSync } = require('node:child_process');
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ const ROOT = path.resolve(__dirname, '..');
29
+ const APP_NAME = 'AI Browser';
30
+ const TARGET = path.join('/Applications', `${APP_NAME}.app`);
31
+ const dry = process.argv.includes('--dry');
32
+ const pkg = require(path.join(ROOT, 'package.json'));
33
+
34
+ const ok = (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`);
35
+ const warn = (m) => console.log(`\x1b[33m!\x1b[0m ${m}`);
36
+ const fail = (m) => { console.log(`\x1b[31m✗\x1b[0m ${m}`); process.exit(1); };
37
+
38
+ function plistVersion(appPath) {
39
+ try {
40
+ return execFileSync('/usr/libexec/PlistBuddy',
41
+ ['-c', 'Print :CFBundleShortVersionString', path.join(appPath, 'Contents', 'Info.plist')],
42
+ { encoding: 'utf8' }).trim();
43
+ } catch { return null; }
44
+ }
45
+
46
+ // ---- 找源 .app -------------------------------------------------------------
47
+ const distDir = path.join(ROOT, 'dist');
48
+ if (!fs.existsSync(distDir)) fail('dist/ 不存在,先 node scripts/build_dmg.cjs');
49
+ const rootDirName = fs.readdirSync(distDir).find((n) => n.endsWith('-darwin-arm64'));
50
+ if (!rootDirName) fail('dist/ 里没有 *-darwin-arm64 目录');
51
+ const srcApp = path.join(distDir, rootDirName, `${APP_NAME}.app`);
52
+ if (!fs.existsSync(srcApp)) fail(`找不到 ${srcApp}`);
53
+
54
+ const srcVer = plistVersion(srcApp);
55
+ if (srcVer !== pkg.version) {
56
+ fail(`dist 里的 app 是 ${srcVer},package.json 是 ${pkg.version} —— 先重新打包`);
57
+ }
58
+
59
+ // ---- 退出旧进程 -----------------------------------------------------------
60
+ // 用 osascript 优雅退出(会 flush profile);不走 kill -9,避免 SQLite 里的
61
+ // 会话/cookie 写一半。
62
+ if (fs.existsSync(TARGET)) {
63
+ const oldVer = plistVersion(TARGET);
64
+ console.log(`当前已安装:${oldVer || '未知版本'} → 目标:${srcVer}`);
65
+ if (oldVer === srcVer && !process.argv.includes('--force')) {
66
+ ok(`已安装的就是 ${srcVer},无需替换(--force 可强制)`);
67
+ process.exit(0);
68
+ }
69
+ if (dry) { warn('--dry:将退出 App、移走旧包、装新包'); process.exit(0); }
70
+ spawnSync('osascript', ['-e', `tell application "${APP_NAME}" to quit`], { encoding: 'utf8' });
71
+ // 给它 3 秒落盘;仍在跑就按端口找到的进程再等一轮
72
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
73
+ try {
74
+ execFileSync('pkill', ['-f', `${TARGET}/Contents/MacOS`]);
75
+ } catch { /* 已经退了 */ }
76
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
77
+ }
78
+
79
+ // ---- 替换 ----------------------------------------------------------------
80
+ if (fs.existsSync(TARGET)) {
81
+ const backup = path.join(os.tmpdir(), `${APP_NAME}-${plistVersion(TARGET) || 'old'}-${Date.now()}.app`);
82
+ fs.renameSync(TARGET, backup);
83
+ console.log(`旧包已移到 ${backup}(可回滚,未被删除)`);
84
+ }
85
+ const r = spawnSync('ditto', [srcApp, TARGET], { encoding: 'utf8' });
86
+ if (r.status !== 0) fail(`ditto 失败:${(r.stderr || '').trim()}`);
87
+ ok(`已安装 ${srcVer} → ${TARGET}`);
88
+
89
+ // ---- 启动 ----------------------------------------------------------------
90
+ if (!process.argv.includes('--no-open')) {
91
+ spawnSync('open', [TARGET], { encoding: 'utf8' });
92
+ ok('已启动');
93
+ }
@@ -160,7 +160,50 @@ try {
160
160
  warn(`本地 commit 与 tag 都在,网络恢复后执行:git push origin main dev v${to}`);
161
161
  }
162
162
 
163
- // ---- 5. npm ---------------------------------------------------------------
163
+ // ---- 5. 打包(桌面 App)---------------------------------------------------
164
+ // 放在 npm 之前:打包失败就不该把版本发出去(npm 上发出去的版本撤不回来,
165
+ // 只能靠再发一个版本补救)。但 MCP registry 又要求 npm 先有版本,所以顺序
166
+ // 只能是 打包 → npm → MCP。
167
+ //
168
+ // 为什么"打包"必须在发版流程里(2026-10-24 事故):修好标签栏滚动、测试
169
+ // 过、npm/GitHub/MCP 全绿发了 v2.1.4,但用户桌面上的 App 还是 10-19 打的
170
+ // v1.3.1 —— 因为打包是手工的。用户看到的是"你根本没修好"。任何"源码已修"
171
+ // 的说法,没有产物同步就是不成立的。
172
+ step('打包桌面 App');
173
+ {
174
+ // NODE_OPTIONS 会被受管环境注入一个 fs 代理 shim,它拦截 asar 的
175
+ // createWriteStream 写入(报 "Brokered file token refused: modify backup
176
+ // failed",且没有栈)。打包本身不联网、不碰沙箱,直接摘掉这个预加载。
177
+ const buildEnv = { ...process.env };
178
+ delete buildEnv.NODE_OPTIONS;
179
+ const b = spawnSync(process.execPath, ['scripts/build_dmg.cjs'], {
180
+ cwd: ROOT, encoding: 'utf8', env: buildEnv,
181
+ });
182
+ if (b.status === 0) {
183
+ const line = (b.stdout || '').split('\n').filter((l) => /dmg:|size:/.test(l)).join(' ');
184
+ ok('打包完成:' + line.trim());
185
+ mark('打包');
186
+ } else {
187
+ fail('打包失败:' + (b.stdout || b.stderr || '').split('\n').filter(Boolean).slice(-4).join(' | '));
188
+ warn('补救:修好后执行 node scripts/build_dmg.cjs (npm 尚未发布,可继续发)');
189
+ }
190
+
191
+ // 产物与源码逐字节比对 + 版本一致性。这一步就是"防止再出现 App 落后
192
+ // 好几个版本"的闸门:不通过就不该继续发布。
193
+ const v = spawnSync(process.execPath, ['scripts/verify_build.cjs', '--quiet'], {
194
+ cwd: ROOT, encoding: 'utf8',
195
+ });
196
+ if (v.status === 0) {
197
+ ok('打包产物与源码一致');
198
+ mark('打包校验');
199
+ } else {
200
+ fail('打包产物与源码不一致,发版中止(否则用户装的还是旧代码):');
201
+ console.log((v.stdout || '').split('\n').map((l) => ' ' + l).join('\n'));
202
+ process.exit(1);
203
+ }
204
+ }
205
+
206
+ // ---- 6. npm ---------------------------------------------------------------
164
207
  step('发布到 npm');
165
208
  const npm = spawnSync('npm', ['publish'], { cwd: ROOT, encoding: 'utf8' });
166
209
  if (npm.status === 0) {
@@ -222,19 +265,61 @@ if (!publisher) {
222
265
  if (pub.status === 0 && /successfully published/i.test(pubOut)) {
223
266
  ok('MCP registry 已发布');
224
267
  mark('mcp registry');
268
+ } else if (pub.status !== 0 && /was not found \(status: 404\)/i.test(pubOut)) {
269
+ // npm 刚发完,registry 的校验端还看不到新版本 —— 这是 npm CDN 的传播
270
+ // 延迟(实测 1-3 分钟),不是真的没发上去。连续两次发版都在这里被打断
271
+ // 叫回来手工重跑,所以直接自动等待重试。
272
+ let ok404 = false;
273
+ for (let i = 1; i <= 4; i++) {
274
+ warn(`registry 还没看到 npm 上的 v${to}(传播延迟),45s 后重试 ${i}/4…`);
275
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 45000);
276
+ pub = doPublish();
277
+ pubOut = `${pub.stdout || ''}${pub.stderr || ''}`;
278
+ if (pub.status === 0 && /successfully published/i.test(pubOut)) { ok404 = true; break; }
279
+ // 错误形态变了(不再是传播延迟)就交回统一处理,别空转
280
+ if (!/was not found \(status: 404\)/i.test(pubOut)) break;
281
+ }
282
+ if (ok404) { ok('MCP registry 已发布'); mark('mcp registry'); }
283
+ else {
284
+ const pkgName = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).name;
285
+ fail('MCP registry 发布失败:' + pubOut.split('\n').filter(Boolean).slice(-6).join(' | '));
286
+ warn(`补救:确认 npm 上有 ${to}(npm view ${pkgName} version)后执行 mcp-publisher publish`);
287
+ }
225
288
  } else {
226
289
  fail('MCP registry 发布失败:' + pubOut.split('\n').filter(Boolean).slice(-6).join(' | '));
227
- if (/was not found \(status: 404\)/i.test(pubOut)) {
228
- warn('registry 要求 npm 上先有该版本 —— 先把 npm 那步修好,再重跑本脚本');
290
+ if (/auth|login|token|unauthor/i.test(pubOut)) {
291
+ warn('需要先登录一次:mcp-publisher login github(device flow,点一下浏览器)');
229
292
  }
230
- warn('补救:mcp-publisher publish');
293
+ warn('补救:登录后执行 mcp-publisher publish');
294
+ }
295
+ }
296
+
297
+ // ---- 7. 装到本机 /Applications ---------------------------------------------
298
+ // 这是"用户真正会打开的那个副本"。不做这一步,前面所有步骤再绿,桌面上
299
+ // 跑的可能还是几个版本前的包(2026-10-24 事故)。
300
+ step('安装到 /Applications');
301
+ if (process.platform !== 'darwin') {
302
+ warn('非 macOS,跳过(dmg 在 dist/)');
303
+ } else if (args.includes('--no-install')) {
304
+ warn('--no-install:跳过安装,dist/ 里的 dmg 已就绪');
305
+ } else {
306
+ const ins = spawnSync(process.execPath, ['scripts/install_app.cjs'], {
307
+ cwd: ROOT, encoding: 'utf8',
308
+ });
309
+ const insOut = `${ins.stdout || ''}${ins.stderr || ''}`;
310
+ if (ins.status === 0) {
311
+ ok('已安装并启动(用户数据 ~/.ai-browser 未动,登录态保留)');
312
+ mark('安装');
313
+ } else {
314
+ fail('安装失败:' + insOut.split('\n').filter(Boolean).slice(-3).join(' | '));
315
+ warn('补救:node scripts/install_app.cjs 或手工拖 dist 里的 dmg');
231
316
  }
232
317
  }
233
318
 
234
319
  // ---- 摘要 -----------------------------------------------------------------
235
320
  console.log(`\n${c(36)}── v${to} 发版摘要 ──${c(0)}`);
236
321
  console.log(' 已完成: ' + (done.length ? done.join(' → ') : '(无)'));
237
- const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', 'npm publish', 'mcp registry']
322
+ const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', '打包', '打包校验', 'npm publish', 'mcp registry', '安装']
238
323
  .filter((s) => !done.includes(s));
239
324
  if (skipped.length) console.log(` 未完成: ${skipped.join(', ')}`);
240
325
  process.exit(skipped.length ? 2 : 0);
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+ // scripts/verify_build.cjs — 校验"打包产物"和"源码"是同一份东西。
3
+ //
4
+ // # 为什么需要这个
5
+ //
6
+ // 2026-10-24 的事故:标签栏滚动 bug 修好了、测试过了、npm/GitHub/MCP 全发了
7
+ // v2.1.4,但用户桌面上的 App 还是 10-19 打的 v1.3.1 —— 因为**打包不在发版
8
+ // 流程里**。用户看到的现象是"你根本没修好",而所有自动化渠道都是绿的。
9
+ //
10
+ // 这类漂移(source ≠ package)是所有桌面分发共同的问题,靠"记得重新打包"
11
+ // 解决不了,必须能机器判定。这里做三件事:
12
+ // 1. 版本一致:app bundle 的 Info.plist 版本 == package.json 版本
13
+ // 2. 内容一致:asar 里的 src/** 与磁盘上的 src/** 逐字节相同(少一个文件、
14
+ // 多一个文件、内容有 diff 都算失败)
15
+ // 3. 产物存在:当前版本的 .dmg 在 dist/
16
+ //
17
+ // # 用法
18
+ // node scripts/verify_build.cjs # 失败退出码 1
19
+ // node scripts/verify_build.cjs --quiet # 只输出结论
20
+ 'use strict';
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+ const crypto = require('node:crypto');
25
+
26
+ const ROOT = path.resolve(__dirname, '..');
27
+ const quiet = process.argv.includes('--quiet');
28
+ const pkg = require(path.join(ROOT, 'package.json'));
29
+
30
+ const c = (s) => `\x1b[${s}m`;
31
+ const ok = (m) => console.log(`${c(32)}✓${c(0)} ${m}`);
32
+ const fail = (m) => console.log(`${c(31)}✗${c(0)} ${m}`);
33
+ const problems = [];
34
+
35
+ function sha(buf) {
36
+ return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16);
37
+ }
38
+
39
+ // ---- 1. 找到 app bundle / dmg ---------------------------------------------
40
+ const distDir = path.join(ROOT, 'dist');
41
+ if (!fs.existsSync(distDir)) {
42
+ fail('dist/ 不存在 —— 还没打过包(node scripts/build_dmg.cjs)');
43
+ process.exit(1);
44
+ }
45
+ const appDirName = fs.readdirSync(distDir).find((n) => n.endsWith('-darwin-arm64'));
46
+ if (!appDirName) {
47
+ fail('dist/ 里没有 *-darwin-arm64 目录');
48
+ process.exit(1);
49
+ }
50
+ const appPath = path.join(distDir, appDirName, 'AI Browser.app');
51
+ const asarPath = path.join(appPath, 'Contents', 'Resources', 'app.asar');
52
+ if (!fs.existsSync(asarPath)) {
53
+ fail(`asar 不存在:${asarPath}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ // ---- 2. 版本一致 -----------------------------------------------------------
58
+ const plistPath = path.join(appPath, 'Contents', 'Info.plist');
59
+ const plistVer = (() => {
60
+ try {
61
+ const out = require('node:child_process').execFileSync(
62
+ '/usr/libexec/PlistBuddy',
63
+ ['-c', 'Print :CFBundleShortVersionString', plistPath],
64
+ { encoding: 'utf8' }
65
+ ).trim();
66
+ return out;
67
+ } catch { return null; }
68
+ })();
69
+
70
+ if (plistVer === pkg.version) {
71
+ ok(`app 版本 ${plistVer} 与 package.json 一致`);
72
+ } else {
73
+ problems.push(`app 版本 ${plistVer || '?'} ≠ package.json ${pkg.version} —— 产物是旧版,需要重新打包`);
74
+ fail(`app 版本 ${plistVer || '?'} ≠ package.json ${pkg.version}`);
75
+ }
76
+
77
+ // ---- 3. 内容一致:asar 里的 src/** vs 磁盘 src/** --------------------------
78
+ // @electron/asar 的 extractFile 只接受 asar 内的相对路径,且对不存在的路径抛错;
79
+ // 用它既能比对内容,也能发现"asar 里少了某个新文件"。
80
+ const asar = require('@electron/asar');
81
+
82
+ function walk(dir, base = dir, out = []) {
83
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
84
+ // node_modules 在 asar 里被 prune 过,且体量大,不参与比对
85
+ if (e.name === 'node_modules' || e.name === '.DS_Store') continue;
86
+ const full = path.join(dir, e.name);
87
+ if (e.isDirectory()) walk(full, base, out);
88
+ else out.push(path.relative(base, full));
89
+ }
90
+ return out;
91
+ }
92
+
93
+ const srcFiles = walk(path.join(ROOT, 'src'));
94
+ let same = 0;
95
+ const drifted = [];
96
+ const missing = [];
97
+ for (const rel of srcFiles) {
98
+ const diskBuf = fs.readFileSync(path.join(ROOT, 'src', rel));
99
+ const relInAsar = path.join('src', rel);
100
+ let packed = null;
101
+ try { packed = asar.extractFile(asarPath, relInAsar); } catch { /* 不存在 */ }
102
+ if (!packed) { missing.push(rel); continue; }
103
+ if (Buffer.compare(diskBuf, packed) === 0) same++;
104
+ else drifted.push(`${rel} (disk ${sha(diskBuf)} / packed ${sha(packed)})`);
105
+ }
106
+
107
+ if (missing.length) {
108
+ problems.push(`asar 缺 ${missing.length} 个源文件(产物比源码旧):${missing.slice(0, 5).join(', ')}`);
109
+ fail(`asar 缺 ${missing.length} 个源文件,例:${missing.slice(0, 3).join(', ')}`);
110
+ }
111
+ if (drifted.length) {
112
+ problems.push(`asar 有 ${drifted.length} 个文件与源码内容不同:${drifted.slice(0, 5).join(' | ')}`);
113
+ fail(`asar 内容与源码不同(${drifted.length} 个):`);
114
+ for (const d of drifted.slice(0, 8)) console.log(` ${d}`);
115
+ }
116
+ if (!missing.length && !drifted.length) {
117
+ ok(`asar 内 src/** 与源码逐字节一致(${same} 个文件)`);
118
+ }
119
+
120
+ // ---- 4. dmg 存在且版本正确 -------------------------------------------------
121
+ const dmgName = `AI-Browser-${pkg.version}-arm64.dmg`;
122
+ const dmgPath = path.join(distDir, dmgName);
123
+ if (fs.existsSync(dmgPath)) {
124
+ const mb = (fs.statSync(dmgPath).size / 1024 / 1024).toFixed(1);
125
+ ok(`dmg 就绪:${dmgName}(${mb} MB)`);
126
+ } else {
127
+ problems.push(`dist/${dmgName} 不存在(dist 里是:${fs.readdirSync(distDir).filter((n) => n.endsWith('.dmg')).join(', ') || '无 dmg'})`);
128
+ fail(`缺 ${dmgName}`);
129
+ }
130
+
131
+ // ---- 结论 -----------------------------------------------------------------
132
+ if (problems.length) {
133
+ console.log('');
134
+ fail(`打包产物与源码不同步(${problems.length} 项)`);
135
+ if (!quiet) {
136
+ console.log(' 用户装上的是旧代码,任何"已修复"的说法都不成立。');
137
+ console.log(' 修复:node scripts/build_dmg.cjs');
138
+ }
139
+ process.exit(1);
140
+ }
141
+ console.log('');
142
+ ok(`打包产物与源码完全同步(v${pkg.version})`);
@@ -18,9 +18,24 @@
18
18
  }
19
19
  /* ---- 第一行:tab 条 ---- */
20
20
  #tabs-row { display: flex; align-items: stretch; height: 36px; flex: none; }
21
- #tabs { display: flex; flex: 1; overflow: hidden; }
21
+ /* 标签超过可视宽度时必须能横向滚动,否则后面的标签点不到、也关不掉。
22
+ 原来是 overflow:hidden —— 超出部分被裁掉且没有任何滚动途径,标签一多
23
+ 就等于"后半截标签永久不可用"。 */
24
+ #tabs {
25
+ display: flex; flex: 1; min-width: 0;
26
+ overflow-x: auto; overflow-y: hidden;
27
+ -webkit-app-region: no-drag;
28
+ }
29
+ /* 细滚动条:既提示"还有更多标签",又不占视觉高度 */
30
+ #tabs::-webkit-scrollbar { height: 3px; }
31
+ #tabs::-webkit-scrollbar-track { background: transparent; }
32
+ #tabs::-webkit-scrollbar-thumb { background: #3a3a3a; border-radius: 2px; }
33
+ #tabs::-webkit-scrollbar-thumb:hover { background: #4d4d4d; }
22
34
  .tab {
23
35
  display: flex; align-items: center;
36
+ /* flex:none:不参与收缩。否则标签会被一路压到 min-width 再溢出,
37
+ 配合滚动后应该保持各自宽度、让容器去滚。 */
38
+ flex: none;
24
39
  max-width: 200px; min-width: 80px; padding: 0 12px; height: 36px;
25
40
  cursor: pointer; border-right: 1px solid #2a2a2a;
26
41
  transition: background 0.15s;
@@ -91,7 +106,15 @@
91
106
  </div>
92
107
 
93
108
  <script>
94
- const { ipcRenderer } = require('electron');
109
+ // ipcRenderer 在 Electron 主窗口里才有。直接用浏览器/测试页打开本文件时
110
+ // require 会抛错——以前这会让整个 <script> 中断,连布局和滚动监听都不生效
111
+ // (UI 完全死掉)。现在降级成空操作:交互没了,但标签栏仍然看得见、滚得动。
112
+ let ipcRenderer = null;
113
+ try { ({ ipcRenderer } = require('electron')); } catch (e) { /* 非 Electron 环境 */ }
114
+ function send(ch, payload) { try { ipcRenderer.send(ch, payload); } catch (e) {} }
115
+ function invoke(ch, payload) {
116
+ return ipcRenderer ? ipcRenderer.invoke(ch, payload) : Promise.reject(new Error('no ipc'));
117
+ }
95
118
 
96
119
  const $ = (id) => document.getElementById(id);
97
120
  const tabsEl = $('tabs'), addrEl = $('addr'), statusEl = $('status');
@@ -115,28 +138,78 @@ function applyNavState(s) {
115
138
  addrEl.style.borderColor = s.loading ? '#e0a33e' : '';
116
139
  }
117
140
 
141
+ let lastIds = ''; // 上次渲染的 tab id 序列 —— 只有它变了才重建 DOM
142
+ let lastActiveId = null;
143
+
144
+ // 把指定 tab 滚进可视区(只在它确实在视区外时才动,避免无谓跳动)。
145
+ // 用 getBoundingClientRect 而不是 offsetLeft:后者依赖 offsetParent,
146
+ // 在 flex + 滚动容器里算出来的是错的值。
147
+ function scrollTabIntoView(id) {
148
+ const el = tabsEl.querySelector('.tab[data-id="' + id + '"]');
149
+ if (!el) return;
150
+ const cr = tabsEl.getBoundingClientRect(), er = el.getBoundingClientRect();
151
+ if (er.left < cr.left) tabsEl.scrollLeft -= (cr.left - er.left) + 8;
152
+ else if (er.right > cr.right) tabsEl.scrollLeft += (er.right - cr.right) + 8;
153
+ }
154
+
118
155
  async function renderTabs() {
119
156
  try {
120
- const tabs = await ipcRenderer.invoke('tab:list');
121
- tabsEl.innerHTML = '';
122
- for (const tab of tabs) {
123
- const el = document.createElement('div');
124
- el.className = 'tab' + (tab.active ? ' active' : '');
125
- const title = (tab.title || tab.url || 'New Tab').slice(0, 30);
126
- el.innerHTML = '<span class="tab-id">[' + tab.id + ']</span>' +
127
- '<span class="tab-title">' + escapeHtml(title) + '</span>' +
128
- '<span class="tab-close" data-id="' + tab.id + '">&times;</span>';
129
- el.addEventListener('click', function (e) {
130
- if (e.target.classList.contains('tab-close')) {
131
- ipcRenderer.send('tab:close', parseInt(e.target.dataset.id));
132
- } else {
133
- ipcRenderer.send('tab:activate', tab.id);
134
- }
135
- });
136
- tabsEl.appendChild(el);
157
+ const tabs = await invoke('tab:list');
158
+ const ids = tabs.map(function (t) { return t.id; }).join(',');
159
+
160
+ if (ids !== lastIds) {
161
+ // 结构变了(增/删 tab):重建 DOM,但必须保住横向滚动位置 ——
162
+ // 否则在右端关掉一个标签后视图会跳回最左,后面的标签又摸不着了。
163
+ const prevScroll = tabsEl.scrollLeft;
164
+ tabsEl.innerHTML = '';
165
+ for (const tab of tabs) {
166
+ const el = document.createElement('div');
167
+ el.className = 'tab' + (tab.active ? ' active' : '');
168
+ el.dataset.id = tab.id;
169
+ el.innerHTML = '<span class="tab-id">[' + tab.id + ']</span>' +
170
+ '<span class="tab-title">' + escapeHtml((tab.title || tab.url || 'New Tab').slice(0, 30)) + '</span>' +
171
+ '<span class="tab-close" data-id="' + tab.id + '">&times;</span>';
172
+ el.addEventListener('click', function (e) {
173
+ // 从 this.dataset 取 id,不用闭包里的 tab —— 增量更新时闭包会过期。
174
+ const id = parseInt(this.dataset.id);
175
+ if (e.target.classList.contains('tab-close')) send('tab:close', id);
176
+ else send('tab:activate', id);
177
+ });
178
+ tabsEl.appendChild(el);
179
+ }
180
+ lastIds = ids;
181
+ tabsEl.scrollLeft = prevScroll;
182
+ } else {
183
+ // 结构没变:只改标题和激活态。绝不重建 —— 这里每 1.5s 就跑一次,
184
+ // 一旦重建,用户刚滚到的位置就被重置,标签一多就永远滚不回去。
185
+ for (let i = 0; i < tabs.length && i < tabsEl.children.length; i++) {
186
+ const t = tabs[i], el = tabsEl.children[i];
187
+ el.classList.toggle('active', !!t.active);
188
+ const titleEl = el.querySelector('.tab-title');
189
+ const title = (t.title || t.url || 'New Tab').slice(0, 30);
190
+ if (titleEl && titleEl.textContent !== title) titleEl.textContent = title;
191
+ }
137
192
  }
193
+
194
+ // 激活的 tab 换人了(含 agent 用 browse_tabs 切的情况)就把它滚进来;
195
+ // 已经可见时上面的计算不会产生位移。
196
+ const active = tabs.find(function (t) { return t.active; });
197
+ const activeId = active ? active.id : null;
198
+ if (activeId !== null && activeId !== lastActiveId) scrollTabIntoView(activeId);
199
+ if (activeId !== null) lastActiveId = activeId;
138
200
  } catch (e) { /* ipcRenderer 尚未就绪 */ }
139
201
  }
202
+
203
+ // 横向滚动:鼠标滚轮是垂直的,标签栏要的是左右滚(浏览器惯例)。
204
+ // overflow 改成 auto 只是"能滚",没有这一条仍然只能拖滚动条/用触控板横扫。
205
+ tabsEl.addEventListener('wheel', function (e) {
206
+ if (tabsEl.scrollWidth <= tabsEl.clientWidth) return;
207
+ const d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
208
+ if (!d) return;
209
+ tabsEl.scrollLeft += d;
210
+ e.preventDefault();
211
+ }, { passive: false });
212
+
140
213
  function escapeHtml(s) {
141
214
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
142
215
  }
@@ -147,27 +220,27 @@ function escapeHtml(s) {
147
220
  // 所以显示数据全部由主进程推送,本地不缓存。
148
221
  let activeTab = null;
149
222
 
150
- ipcRenderer.on('nav:state', (_e, s) => applyNavState(s));
223
+ if (ipcRenderer) ipcRenderer.on('nav:state', (_e, s) => applyNavState(s));
151
224
 
152
- backBtn.addEventListener('click', () => ipcRenderer.send('nav:back'));
153
- fwdBtn.addEventListener('click', () => ipcRenderer.send('nav:forward'));
154
- reloadBtn.addEventListener('click', () => ipcRenderer.send('nav:reload'));
155
- stopBtn.addEventListener('click', () => ipcRenderer.send('nav:stop'));
225
+ backBtn.addEventListener('click', () => send('nav:back'));
226
+ fwdBtn.addEventListener('click', () => send('nav:forward'));
227
+ reloadBtn.addEventListener('click', () => send('nav:reload'));
228
+ stopBtn.addEventListener('click', () => send('nav:stop'));
156
229
 
157
230
  addrEl.addEventListener('keydown', (e) => {
158
231
  if (e.key !== 'Enter') return;
159
232
  const v = addrEl.value.trim();
160
233
  if (!v) return;
161
234
  addrEl.blur();
162
- ipcRenderer.send('nav:go', v);
235
+ send('nav:go', v);
163
236
  });
164
237
  // 聚焦时全选,方便直接输入覆盖(浏览器惯例)
165
238
  addrEl.addEventListener('focus', () => addrEl.select());
166
239
 
167
- $('new-tab').addEventListener('click', () => ipcRenderer.send('tab:new'));
240
+ $('new-tab').addEventListener('click', () => send('tab:new'));
168
241
 
169
242
  // 初始拉一次状态,然后 tab 条照旧定时刷新
170
- ipcRenderer.invoke('nav:state').then((s) => { if (s) applyNavState(s); });
243
+ invoke('nav:state').then((s) => { if (s) applyNavState(s); }).catch(() => {});
171
244
  renderTabs();
172
245
  setInterval(renderTabs, 1500);
173
246
  </script>
@@ -2,4 +2,4 @@
2
2
  // Kept in JS (not imported from package.json) to avoid Node import-attribute
3
3
  // (JSON module) compatibility issues inside Electron. package.json "version"
4
4
  // is manually synced to this value; tests assert they match.
5
- export const VERSION = '2.1.3';
5
+ export const VERSION = '2.1.5';