@glittering0717/ai-browser 0.0.0-stage → 1.2.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,241 @@
1
+ // scripts/electron_resolve.cjs — resolve a runnable Electron binary, self-healing.
2
+ //
3
+ // Why this exists (2026-10-13, npm 11 + Node 26):
4
+ //
5
+ // ① npm 11 blocks dependency install scripts by default. The `electron`
6
+ // package's postinstall (install.js) is what downloads the ~100 MB Chromium
7
+ // binary and writes path.txt. With the script blocked — the default for
8
+ // plain `npm install`, `npm install -g`, and `npx` on npm >= 11 —
9
+ // require('electron') throws "Electron failed to install correctly".
10
+ // Making the postinstall a hard requirement would break every MCP client's
11
+ // `npx -y @glittering0717/ai-browser mcp`.
12
+ //
13
+ // ② Even when install.js runs, its extract-zip/yauzl pipeline silently
14
+ // truncates on Node 26 (observed: 21 of 255 entries, exit code 0, no
15
+ // Frameworks/). The zip itself is fine. So after the official installer we
16
+ // VERIFY the result and, if incomplete, re-extract the already-downloaded
17
+ // cached zip with the platform's native archiver, which preserves symlinks
18
+ // and permissions (ditto/unzip/bsdtar).
19
+ //
20
+ // Strategy (same idea as Playwright's on-demand browser download): detect the
21
+ // missing binary on first launch, let electron's installer download, then make
22
+ // extraction reliable. ELECTRON_MIRROR is honored by the download step, so
23
+ // users on slow networks can point it at a mirror exactly like the git path.
24
+ 'use strict';
25
+
26
+ const { spawnSync } = require('node:child_process');
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ /**
32
+ * Return the absolute path to the Electron binary, running electron's bundled
33
+ * installer first if the binary is missing.
34
+ *
35
+ * @param {object} [opts]
36
+ * @param {(msg: string) => void} [opts.log] sink for progress lines (stderr-safe)
37
+ * @returns {string} absolute path to the electron executable
38
+ */
39
+ function resolveElectronBinary(opts = {}) {
40
+ const log = opts.log || ((m) => console.error(m));
41
+
42
+ const direct = tryReadElectronPath();
43
+ if (direct) return direct;
44
+
45
+ // Locate the electron package alongside this package. It may be hoisted to
46
+ // a parent node_modules — require.resolve walks the same chain Node uses.
47
+ let electronDir;
48
+ let electronVersion;
49
+ try {
50
+ const pkgJsonPath = require.resolve('electron/package.json');
51
+ electronDir = path.dirname(pkgJsonPath);
52
+ electronVersion = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).version;
53
+ } catch (e) {
54
+ throw new Error(
55
+ 'the "electron" package is not installed — the @glittering0717/ai-browser ' +
56
+ 'installation is incomplete. Reinstall the package. Underlying error: ' + e.message
57
+ );
58
+ }
59
+
60
+ const installer = path.join(electronDir, 'install.js');
61
+ if (!fs.existsSync(installer)) {
62
+ throw new Error(
63
+ `electron's installer is missing (${installer}) — the package install is corrupt. ` +
64
+ 'Reinstall @glittering0717/ai-browser.'
65
+ );
66
+ }
67
+
68
+ log('[ai-browser] Electron binary missing (npm 11+ skips dependency install scripts by default).');
69
+ log('[ai-browser] Preparing the Electron engine now (~100 MB download, once). Slow network? Set:');
70
+ log('[ai-browser] ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/');
71
+
72
+ const env = { ...process.env };
73
+ // If we ourselves are running under an Electron-as-Node shim, the installer
74
+ // would run with the wrong runtime. Not present in real-user shells.
75
+ delete env.ELECTRON_RUN_AS_NODE;
76
+
77
+ // Step 1: electron's own installer — downloads the zip and (usually) extracts.
78
+ const result = spawnSync(process.execPath, [installer], {
79
+ cwd: electronDir,
80
+ stdio: 'inherit',
81
+ env,
82
+ });
83
+
84
+ if (result.error) throw new Error(`failed to run the Electron installer: ${result.error.message}`);
85
+ if (result.status !== 0) {
86
+ throw new Error(
87
+ `Electron installer exited with code ${result.status}. ` +
88
+ 'If the download from GitHub releases is blocked/slow, retry with ' +
89
+ 'ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ set in the environment.'
90
+ );
91
+ }
92
+
93
+ // Step 2: verify. extract-zip can silently leave a partial app bundle.
94
+ let resolved = tryReadElectronPath();
95
+ if (!resolved || !installLooksComplete(electronDir)) {
96
+ log('[ai-browser] The official installer left a partial app bundle (known extract-zip issue on newer Node).');
97
+ log('[ai-browser] Re-extracting with the native system archiver…');
98
+ nativeReextract({ electronDir, electronVersion, env, log });
99
+ resolved = tryReadElectronPath();
100
+ }
101
+
102
+ if (!resolved || !installLooksComplete(electronDir)) {
103
+ throw new Error(
104
+ 'could not prepare a runnable Electron binary under ' +
105
+ path.join(electronDir, 'dist') +
106
+ '. Check network access (or set ELECTRON_MIRROR) and disk space, then retry.'
107
+ );
108
+ }
109
+ return resolved;
110
+ }
111
+
112
+ /**
113
+ * Mirror electron/index.js without throwing: read path.txt and verify the
114
+ * binary actually exists. Returns null when the install is incomplete.
115
+ */
116
+ function tryReadElectronPath() {
117
+ try {
118
+ const electronDir = path.dirname(require.resolve('electron/package.json'));
119
+ const pathFile = path.join(electronDir, 'path.txt');
120
+ if (!fs.existsSync(pathFile)) return null;
121
+ const executable = fs.readFileSync(pathFile, 'utf8').trim();
122
+ if (!executable) return null;
123
+ const full = process.env.ELECTRON_OVERRIDE_DIST_PATH
124
+ ? path.join(process.env.ELECTRON_OVERRIDE_DIST_PATH, executable)
125
+ : path.join(electronDir, 'dist', executable);
126
+ return fs.existsSync(full) ? full : null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Platform-specific completeness checks. The launcher binary existing is not
134
+ * enough — on macOS the multi-hundred-MB framework lives under Frameworks/.
135
+ */
136
+ function installLooksComplete(electronDir) {
137
+ const dist = process.env.ELECTRON_OVERRIDE_DIST_PATH || path.join(electronDir, 'dist');
138
+ if (process.platform === 'darwin') {
139
+ return fs.existsSync(path.join(dist, 'Electron.app', 'Contents', 'Frameworks'));
140
+ }
141
+ if (process.platform === 'win32') {
142
+ return fs.existsSync(path.join(dist, 'electron.exe')) && fs.existsSync(path.join(dist, 'resources'));
143
+ }
144
+ return fs.existsSync(path.join(dist, 'electron')) && fs.existsSync(path.join(dist, 'resources'));
145
+ }
146
+
147
+ /**
148
+ * Find the zip @electron/get just downloaded. Layout (v2 get):
149
+ * <cacheRoot>/<sha>/electron-v<version>-<platform>-<arch>.zip
150
+ * cacheRoot honors electron_config_cache (what install.js itself reads), then
151
+ * the per-OS env-paths default.
152
+ */
153
+ function findCachedZip(version) {
154
+ const roots = [];
155
+ if (process.env.electron_config_cache) roots.push(process.env.electron_config_cache);
156
+ if (process.env.ELECTRON_CACHE) roots.push(process.env.ELECTRON_CACHE);
157
+ if (process.platform === 'darwin') roots.push(path.join(os.homedir(), 'Library', 'Caches', 'electron'));
158
+ else if (process.platform === 'win32') roots.push(path.join(process.env.LOCALAPPDATA || '', 'electron', 'Cache'));
159
+ else roots.push(path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'electron'));
160
+
161
+ const platform = process.env.npm_config_platform || process.platform;
162
+ let arch = process.env.npm_config_arch || process.arch;
163
+ // Mirror install.js: x64 darwin under Rosetta should use arm64.
164
+ if (platform === 'darwin' && arch === 'x64' && process.env.npm_config_arch === undefined) {
165
+ try {
166
+ const r = spawnSync('sysctl', ['-in', 'sysctl.proc_translated'], { encoding: 'utf8' });
167
+ if (r.status === 0 && r.stdout.trim() === '1') arch = 'arm64';
168
+ } catch { /* assume x64 */ }
169
+ }
170
+ const wanted = `electron-v${version}-${platform}-${arch}.zip`;
171
+
172
+ const hits = [];
173
+ for (const root of roots) {
174
+ if (!root || !fs.existsSync(root)) continue;
175
+ walk(root, (file) => {
176
+ if (path.basename(file) === wanted) {
177
+ try { hits.push({ file, mtime: fs.statSync(file).mtimeMs }); } catch { /* race */ }
178
+ }
179
+ });
180
+ }
181
+ hits.sort((a, b) => b.mtime - a.mtime);
182
+ return hits[0]?.file || null;
183
+ }
184
+
185
+ function walk(dir, onFile) {
186
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
187
+ const full = path.join(dir, entry.name);
188
+ if (entry.isDirectory()) walk(full, onFile);
189
+ else if (entry.isFile()) onFile(full);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Re-extract the cached zip with a native archiver into electron/dist and
195
+ * write the path.txt electron/index.js expects.
196
+ */
197
+ function nativeReextract({ electronDir, electronVersion, env, log }) {
198
+ const zip = findCachedZip(electronVersion);
199
+ if (!zip) {
200
+ throw new Error(
201
+ 'the Electron zip was not found in the download cache — the download step did not complete. ' +
202
+ 'Check the network (or set ELECTRON_MIRROR) and retry.'
203
+ );
204
+ }
205
+
206
+ const dist = process.env.ELECTRON_OVERRIDE_DIST_PATH || path.join(electronDir, 'dist');
207
+ fs.rmSync(dist, { recursive: true, force: true });
208
+ fs.mkdirSync(dist, { recursive: true });
209
+
210
+ const run = (cmd, args) => {
211
+ const r = spawnSync(cmd, args, { stdio: 'inherit', env });
212
+ if (r.error) throw new Error(`${cmd} is not available: ${r.error.message}`);
213
+ if (r.status !== 0) throw new Error(`${cmd} failed with exit code ${r.status}`);
214
+ };
215
+
216
+ if (process.platform === 'darwin') {
217
+ // ditto preserves symlinks (Electron Framework.framework/Versions/Current)
218
+ // and executable bits; it is the canonical macOS zip tool.
219
+ run('ditto', ['-x', '-k', zip, dist]);
220
+ } else if (process.platform === 'win32') {
221
+ // bsdtar ships built-in on Windows 10+ and handles zip.
222
+ run('tar', ['-xf', zip, '-C', dist]);
223
+ } else {
224
+ // Prefer unzip; fall back to bsdtar where unzip is absent.
225
+ try {
226
+ run('unzip', ['-q', '-o', zip, '-d', dist]);
227
+ } catch (e) {
228
+ log(`[ai-browser] unzip unavailable (${e.message}), trying tar…`);
229
+ run('tar', ['-xf', zip, '-C', dist]);
230
+ }
231
+ }
232
+
233
+ const subPath = process.platform === 'darwin'
234
+ ? 'Electron.app/Contents/MacOS/Electron'
235
+ : process.platform === 'win32'
236
+ ? 'electron.exe'
237
+ : 'electron';
238
+ fs.writeFileSync(path.join(electronDir, 'path.txt'), subPath);
239
+ }
240
+
241
+ module.exports = { resolveElectronBinary, tryReadElectronPath };
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // scripts/start.cjs — 启动 Electron,并清掉会让它退化成 Node 的环境变量。
3
+ //
4
+ // # 为什么需要这个文件(2026-10-12 实测)
5
+ // 本机(以及某些 agent 运行环境)带着 `ELECTRON_RUN_AS_NODE=1`。这个变量会让
6
+ // Electron 的可执行文件**当作普通 Node 运行**,于是 `electron .` 变成"用 Node 去
7
+ // 加载 ESM 入口",直接崩:
8
+ //
9
+ // node:internal/modules/esm/translators:379
10
+ // TypeError: Cannot read properties of undefined (reading 'exports')
11
+ // at cjsPreparseModuleExports (node:internal/modules/esm/translators:379:81)
12
+ //
13
+ // 端口永不监听,应用等于没起来。而**报错完全不提 Electron** —— 看到这栈的人会去
14
+ // 查 Node 的 ESM 加载器,方向整个错掉。本机 shell 就带着这个变量,`npm start`
15
+ // 100% 复现。
16
+ //
17
+ // 之前 e2e 脚本全都 `delete childEnv.ELECTRON_RUN_AS_NODE`,但 `npm start` 本身
18
+ // 和 MCP 的自动拉起漏了。放在 launcher 里一次解决,且跨平台(不依赖 shell 的
19
+ // `env -u`,Windows 上没有)。
20
+ 'use strict';
21
+
22
+ const { spawn } = require('node:child_process');
23
+ const path = require('node:path');
24
+
25
+ const root = path.resolve(__dirname, '..');
26
+
27
+ /**
28
+ * Launch the Electron app in the foreground (stdio inherited).
29
+ *
30
+ * Works both from a git checkout and from an installed npm package: it resolves
31
+ * the electron binary through Node's own resolution and points it at the app
32
+ * root explicitly instead of relying on `electron .` + cwd conventions.
33
+ *
34
+ * Returns the child process. Callers may attach their own exit handling; when
35
+ * run as a script the exit code is forwarded so npm/npx sees real failures.
36
+ */
37
+ function launchElectron() {
38
+ const { resolveElectronBinary } = require('./electron_resolve.cjs');
39
+ const electron = resolveElectronBinary();
40
+
41
+ const env = { ...process.env };
42
+ delete env.ELECTRON_RUN_AS_NODE;
43
+
44
+ return spawn(electron, [root], { cwd: root, stdio: 'inherit', env });
45
+ }
46
+
47
+ module.exports = { launchElectron, appRoot: root };
48
+
49
+ if (require.main === module) {
50
+ const child = launchElectron();
51
+
52
+ child.on('exit', (code, signal) => {
53
+ // 子进程被信号杀死时 code 是 null,用 1 退出,避免 npm 认为成功。
54
+ process.exit(code === null && signal ? 1 : (code ?? 0));
55
+ });
56
+ child.on('error', (e) => {
57
+ console.error('[start] failed to launch Electron:', e.message);
58
+ process.exit(1);
59
+ });
60
+ }