feat(console): 控制台随包分发 Node 运行时(目标机器无需预装 Node)

控制台的 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 —— 不到一半。
This commit is contained in:
jkooe committed 2026-10-11 08:55:31 +08:00
1 parent 4f4d8913b5
commit 22bc732334
4 files changed
+344 -1

No files matched your search

+4 -1
View File
@@ -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",
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* 验证「控制台的打包产物能否自举」—— 即打包内的 Node 运行时能否跑起打包内的 sidecar。
*
* 用法:
* node scripts/verify-bundle.mjs # 自动定位打包产物(macOS)
* node scripts/verify-bundle.mjs --app <x.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);
+7
View File
@@ -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
+200
View File
@@ -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|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)');