控制台的 sidecar 是 .mjs,此前只能在装了 Node 的机器上跑(resolve_node 会回退系统 node)。 新增 scripts/prepare-console.mjs 把 Node 运行时准备为 Tauri resource,随包分发给最终用户。 - prepare-console.mjs:复制本机 node → apps/console/src-tauri/runtime/,macOS 上 strip -x (108MB→86MB)**并 adhoc 重签**,再生成注入片段 tauri.bundle.gen.json;带幂等(同源跳过) 与**产物自检**(真跑一次 node,失败即删除产物并报错,避免坏产物被静默复用)。 - apps/console/scripts/verify-bundle.mjs:验证「打包产物能否自举」——真 spawn 打包内的 node 去跑打包内的 sidecar 并走一次真实 JSON-RPC 握手。 - package.json:新增 prepare:node / verify:bundle,bundle = sidecar:build → prepare:node → tauri build --config → verify:bundle(一条命令产出可分发件并自证可用)。 - src-tauri/.gitignore:忽略 /runtime/(~86MB)与生成的注入片段(生成物不入库)。 三处实测踩出来的约束(均已写进脚本注释与 console-plan.md): 1. **macOS 上 strip 会使 Mach-O 代码签名失效 → 内核直接 SIGKILL(exit 137)**,且无任何有用报错。 实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。 故 strip 之后的 adhoc 重签是**必要配套**,不是可选优化。 2. **Node 运行时不得声明进默认 tauri.conf.json** —— bundle.resources 是编译期契约 (tauri-build 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 cargo check/test 在 build.rs 阶段直接失败(2026-10-10 已踩)。故一律走 `tauri build --config` 注入。 3. **resource 映射必须与 resolve_node 对齐** —— `runtime/node → node` 使产物落在 Contents/Resources/node,正是 src-tauri/src/sidecar.rs 里 resolve_node 查找的路径。 实测(macOS arm64):.app **90.8 MiB**(原 6.75 MiB,增量即运行时 86MB)/ .dmg **33.78 MiB**(分发件,仍低于 40MB 目标);应用进程实测可启动且无崩溃输出。 对照:本机实测 Electron 运行时本体(Electron Framework.framework)207MB —— 不到一半。
201 lines
8.5 KiB
JavaScript
201 lines
8.5 KiB
JavaScript
#!/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|node.exe>` 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)');
|