diff --git a/apps/console/package.json b/apps/console/package.json index e1afd69..9d48ed3 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -9,7 +9,10 @@ "preview": "vite preview", "tauri": "tauri", "sidecar:dev": "node sidecar/main.mjs", - "sidecar:build": "node sidecar/build.mjs" + "sidecar:build": "node sidecar/build.mjs", + "prepare:node": "node ../../scripts/prepare-console.mjs", + "verify:bundle": "node scripts/verify-bundle.mjs", + "bundle": "pnpm sidecar:build && pnpm prepare:node && tauri build --config src-tauri/tauri.bundle.gen.json && pnpm verify:bundle" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/apps/console/scripts/verify-bundle.mjs b/apps/console/scripts/verify-bundle.mjs new file mode 100644 index 0000000..6975c88 --- /dev/null +++ b/apps/console/scripts/verify-bundle.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * 验证「控制台的打包产物能否自举」—— 即打包内的 Node 运行时能否跑起打包内的 sidecar。 + * + * 用法: + * node scripts/verify-bundle.mjs # 自动定位打包产物(macOS) + * node scripts/verify-bundle.mjs --app + * node scripts/verify-bundle.mjs --runtime # 只用 prepare 出的 runtime + sidecar.dist + * + * 为什么需要它 + * ---------- + * `pnpm bundle` 成功、`tauri build` 报告 Finished,**不代表产物能跑**: + * 2026-10-11 实测踩到 —— macOS 上 `strip` 会使 Mach-O 代码签名失效, + * 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,没有任何有用报错。 + * 这类问题只存在于「打包产物」中:源码与单测都正常,只有真跑一遍产物才会暴露。 + * 本脚本把那次人工排查固化成一条命令。 + * + * 它同时覆盖三件事(缺一不可): + * ① 打包内的 node 真能执行(签名 / strip / 可执行位都对) + * ② 打包内的 sidecar.mjs 真能被它加载(bundle 完整、无缺依赖) + * ③ 两者之间的行分隔 JSON-RPC 通道真的通 + */ + +import { spawn } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); // apps/console/scripts +const consoleDir = resolve(here, '..'); // apps/console +const isWin = process.platform === 'win32'; +const NODE_NAME = isWin ? 'node.exe' : 'node'; + +const arg = (n) => { + const i = process.argv.indexOf(`--${n}`); + return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') + ? process.argv[i + 1] + : undefined; +}; + +/** 定位打包产物(macOS:bundle/macos/*.app;Windows:无 .app,回落到 runtime)。 */ +function findApp() { + const explicit = arg('app'); + if (explicit) return resolve(explicit); + const dir = join(consoleDir, 'src-tauri/target/release/bundle/macos'); + if (existsSync(dir)) { + const app = readdirSync(dir).find((f) => f.endsWith('.app')); + if (app) return join(dir, app); + } + return undefined; +} + +let nodeBin; +let sidecar; +let source; + +const app = process.argv.includes('--runtime') ? undefined : findApp(); +if (app) { + // ⚠️ Tauri 的 resource 映射 `"runtime/node": "node"` 使产物落在 Contents/Resources/node + // —— 必须与 src-tauri/src/sidecar.rs 的 resolve_node/resolve_sidecar 保持一致。 + const res = join(app, 'Contents/Resources'); + nodeBin = join(res, NODE_NAME); + sidecar = join(res, 'sidecar.mjs'); + source = `打包产物 ${app}`; +} else { + nodeBin = join(consoleDir, `src-tauri/runtime/${NODE_NAME}`); + sidecar = join(consoleDir, 'sidecar/dist/sidecar.mjs'); + source = '准备产物(runtime + sidecar/dist)'; +} + +console.log(`=== 验证控制台产物自举 ===`); +console.log(` 来源:${source}`); + +for (const [label, p] of [ + ['Node 运行时', nodeBin], + ['sidecar', sidecar], +]) { + if (!existsSync(p)) { + console.error(`✗ 缺少 ${label}:${p}`); + console.error(' 提示:先跑 `pnpm bundle`(含 prepare:node),或 `pnpm prepare:node`。'); + process.exit(1); + } + console.log(` ✓ ${label}:${p}`); +} + +// ---------- ①②③ 合一的实测:真 spawn,真握手 ---------- +const child = spawn(nodeBin, [sidecar], { stdio: ['pipe', 'pipe', 'pipe'] }); +let out = ''; +let err = ''; +child.stdout.on('data', (d) => (out += d)); +child.stderr.on('data', (d) => (err += d)); + +// 用一个只读、无副作用的方法:未连接时应回 {"connected":false} +child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'state', params: {} }) + '\n'); + +const done = (code) => { + try { + child.kill(); + } catch { + /* 已退出 */ + } + process.exit(code); +}; + +setTimeout(() => { + let parsed = null; + const line = out.split('\n').find((l) => l.trim().startsWith('{')); + try { + parsed = JSON.parse(line); + } catch { + /* 下面统一报错 */ + } + + if (err.trim()) { + console.log(' sidecar stderr:' + err.trim().split('\n').slice(0, 3).join(' | ')); + } + + if (parsed && parsed.id === 1 && parsed.result && 'connected' in parsed.result) { + console.log( + `\n✓ 自举成功:打包内 node(${process.version} 级)真跑起打包内 sidecar` + + `(connected=${parsed.result.connected})`, + ); + done(0); + } else { + console.error('\n✗ 未收到预期的 JSON-RPC 响应 —— 产物可能无法自举'); + console.error(' stdout:' + (out.trim().slice(0, 300) || '(空)')); + console.error( + ' 若 node 直接运行即被 SIGKILL(137):macOS 上 strip 破坏了代码签名,' + + '需 adhoc 重签(见 scripts/prepare-console.mjs 的 ② 段注释)。', + ); + done(1); + } +}, 4000); diff --git a/apps/console/src-tauri/.gitignore b/apps/console/src-tauri/.gitignore index b21bd68..dec717b 100644 --- a/apps/console/src-tauri/.gitignore +++ b/apps/console/src-tauri/.gitignore @@ -5,3 +5,10 @@ # Generated by Tauri # will have schema files for capabilities auto-completion /gen/schemas + +# 随包分发的 Node 运行时(由 scripts/prepare-console.mjs 生成,~87MB;不入库) +# 体积原因 + 它是从本机 node 复制/剥离而来,不是源代码。 +/runtime/ + +# 构建期注入片段(生成物,引用 runtime/ 下的 node;不入库,见 prepare-console.mjs 注释) +/tauri.bundle.gen.json diff --git a/scripts/prepare-console.mjs b/scripts/prepare-console.mjs new file mode 100644 index 0000000..10f0445 --- /dev/null +++ b/scripts/prepare-console.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +/** + * 为桌面控制台(apps/console)准备**随包分发的 Node 运行时**,并生成构建期注入的配置。 + * + * 用法: + * node scripts/prepare-console.mjs [--node <路径>] [--out <目录>] [--no-strip] + * + * 背景 + * ---- + * 控制台的 sidecar(`apps/console/sidecar/dist/sidecar.mjs`,227KB)是 **.mjs**, + * 需要 Node 解释器才能运行;而 sidecar 是 `.mjs` 而非可执行文件, + * **不满足 Tauri `externalBin` 的要求**(externalBin 只接受可执行文件)。 + * 故采用与被控端 `scripts/pack.mjs` 一致的做法:**把 Node 运行时当作 Tauri resource 分发**。 + * + * 为什么必须走 `--config` 注入、而不写进默认 `tauri.conf.json` + * ------------------------------------------------------------ + * Tauri 的 `bundle.resources` 是**编译期契约**:`tauri-build`(build.rs)会校验 + * 声明的每个资源**必须存在**,否则 build.rs 直接失败(2026-10-10 真机踩过: + * 声明了尚未下载的 node 与尚未生成的 sidecar.mjs → "failed to run custom build command")。 + * 而本脚本的产物(Node 运行时)正是「运行期才需要、构建期未必存在」的东西, + * 故一律由本脚本生成一份**注入片段**,打包时用 `tauri build --config` 合并, + * 默认 conf 里**永远不出现** runtime 路径。 + * + * 产物 + * ---- + * 1. `apps/console/src-tauri/runtime/` Node 运行时(已 strip,见下) + * 2. `apps/console/src-tauri/tauri.bundle.gen.json` 构建期注入片段(生成物,不入库) + * + * 体积 + * ---- + * Node 二进制约 100MB 级(macOS arm64 原始 108MB,`strip -x` 后 91MB)—— + * 这是「开箱即用、不要求用户预装 Node」的代价,已在 docs/console-plan.md 记明。 + */ + +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SRC_TAURI = join(root, 'apps/console/src-tauri'); +const SIDECAR = join(root, 'apps/console/sidecar/dist/sidecar.mjs'); + +/** 极简参数解析(不引依赖)。 */ +function arg(name, dflt) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') + ? process.argv[i + 1] + : dflt; +} +const flag = (name) => process.argv.includes(`--${name}`); + +const isWin = process.platform === 'win32'; +const nodeName = isWin ? 'node.exe' : 'node'; + +const runtimeDir = resolve(arg('out', join(SRC_TAURI, 'runtime'))); +const srcNode = resolve(arg('node', process.execPath)); +const doStrip = !flag('no-strip') && !flag('noStrip') && process.platform === 'darwin'; + +const log = (m) => console.log(m); +const fail = (m) => { + console.error(`✗ ${m}`); + process.exit(1); +}; +const mb = (b) => `${(b / 1024 / 1024).toFixed(1)} MB`; + +// ---------- ⓪ 前置检查:sidecar 必须已构建(它才是随包分发的主体)---------- +if (!existsSync(SIDECAR)) { + fail( + `未找到 ${SIDECAR}\n` + + ' 先构建 sidecar:pnpm --filter nodeagent-console sidecar:build', + ); +} +if (!existsSync(srcNode)) fail(`Node 可执行文件不存在:${srcNode}`); + +log(`=== 准备控制台 Node 运行时(${process.platform}/${process.arch})===`); +log(` 源:${srcNode}(${mb(statSync(srcNode).size)})`); + +// ---------- ① 复制到 runtime/(幂等:同尺寸同路径则跳过)---------- +mkdirSync(runtimeDir, { recursive: true }); +const dstNode = join(runtimeDir, nodeName); +const stamp = join(runtimeDir, '.source.json'); +const fingerprint = { source: srcNode, size: statSync(srcNode).size, platform: process.platform, arch: process.arch }; + +let copied = false; +if (existsSync(dstNode) && existsSync(stamp)) { + try { + const prev = JSON.parse(readFileSync(stamp, 'utf8')); + const same = + prev.size === fingerprint.size && + prev.platform === fingerprint.platform && + prev.arch === fingerprint.arch; + if (same) log(` 目标已存在且一致,跳过复制:${dstNode}`); + else copied = true; + } catch { + copied = true; + } +} else { + copied = true; +} + +if (copied) { + if (existsSync(dstNode)) rmSync(dstNode); + copyFileSync(srcNode, dstNode); + chmodSync(dstNode, 0o755); + log(` 已复制 → ${dstNode}`); +} + +// ---------- ② strip(仅 macOS;Windows 无此步骤,且不得假设 unix 命令存在)---------- +// +// ⚠️ 致命细节(2026-10-11 实测踩到):strip 会**使 Mach-O 的代码签名失效**, +// 而 macOS 内核对签名无效的可执行文件直接 **SIGKILL(exit 137)**—— +// 不是报错、不是警告,是静默无法运行(本地实测:strip 后 `./node -e ...` → 137)。 +// 故 strip 之后**必须**重新 adhoc 签名(`codesign --force --sign -`)才能恢复可执行。 +// 实测对照:未 strip(保持原签名)可运行 ✓;strip + 重签 可运行 ✓;strip 不重签 ✗(137)。 +// 省 19% 体积(108MB → 87MB)值得,但重签这一步不能少。 +if (doStrip && copied) { + const before = statSync(dstNode).size; + let stripped = false; + try { + execFileSync('strip', ['-x', dstNode], { stdio: 'pipe' }); + stripped = true; + } catch (e) { + // strip 不是必须的(只是省体积);缺 Xcode CLT 时跳过,但不假装成功 + log(` ⚠ 跳过 strip(${String(e.message).split('\n')[0]})——不影响可用性,仅体积偏大`); + } + if (stripped) { + // 重签是 strip 的**必要配套**,不是可选优化:不重签则产物无法运行。 + execFileSync('codesign', ['--force', '--sign', '-', dstNode], { stdio: 'pipe' }); + const after = statSync(dstNode).size; + log( + ` strip -x:${mb(before)} → ${mb(after)}(省 ${(((before - after) / before) * 100).toFixed(0)}%)` + + ',已 adhoc 重签', + ); + } +} else if (!doStrip && process.platform === 'darwin' && copied) { + log(' 按 --no-strip 跳过符号剥离(保留原签名)'); +} + +// ---------- ②′ 自检:产物必须真的能跑 ---------- +// 「守卫不能是空转的」:strip / 重签任一步出问题,产物会静默变成一个 87MB 的**不可执行文件**, +// 直到用户在真机上双击应用才暴露(且报错信息毫无指向性)。故此处当场跑一次。 +// 复用已有产物(跳过复制)时同样自检 —— 否则上一轮的坏产物会被一直沿用。 +try { + const ver = execFileSync(dstNode, ['-e', 'process.stdout.write(process.version)'], { + stdio: 'pipe', + }) + .toString() + .trim(); + log(` 自检通过:${nodeName} 可执行(${ver})`); +} catch (e) { + rmSync(dstNode, { force: true }); + rmSync(stamp, { force: true }); + fail( + `产出的 Node 运行时无法执行(已删除产物,请重跑本脚本):${String(e.message).split('\n')[0]}\n` + + ' 提示:macOS 上 strip 会使代码签名失效,内核对签名无效的可执行文件直接 SIGKILL(exit 137),\n' + + ' 故 strip 之后必须 adhoc 重签(codesign --force --sign -)。', + ); +} + +// ---------- ③ 生成构建期注入片段 ---------- +// ⚠️ 两份资源都写明:既能保证「无论 Tauri 的 config 合并是深合并还是替换」都正确, +// 也让注入文件自解释(单看它就知道应用内会带哪些 resource)。 +// key 相对 tauri.conf.json 所在目录(src-tauri);value 相对应用内 Resources 目录。 +const gen = { + bundle: { + resources: { + '../sidecar/dist/sidecar.mjs': 'sidecar.mjs', + [`runtime/${nodeName}`]: nodeName, + }, + }, +}; +const genPath = join(SRC_TAURI, 'tauri.bundle.gen.json'); +writeFileSync(genPath, JSON.stringify(gen, null, 2) + '\n'); +log(` 注入片段 → ${genPath}`); +log(` ${JSON.stringify(gen.bundle.resources)}`); + +writeFileSync(stamp, JSON.stringify({ ...fingerprint, stripped: doStrip }, null, 2) + '\n'); + +// ---------- ④ 体积报告(让「代价」可见,而不是悄悄变大)---------- +const nodeSize = statSync(dstNode).size; +const sidecarSize = statSync(SIDECAR).size; +log(''); +log('=== 体积 ==='); +log(` Node 运行时 ${mb(nodeSize)}`); +log(` sidecar ${(sidecarSize / 1024).toFixed(0)} KB`); +log(` 合计 ${mb(nodeSize + sidecarSize)}(未压缩,即 .app 内的增量)`); +log(''); +log('下一步:'); +log(' pnpm --filter nodeagent-console bundle'); +log(' (= sidecar:build + prepare-console + tauri build --config src-tauri/tauri.bundle.gen.json)');