Files
nodeagent/scripts/prepare-console.mjs
T
jkooe 2eb6a6f8bb feat(console): 本机被控端托管 —— 启停/重启/日志/自更新/托盘/开机自启
目标:双击应用即用,全程不碰命令行。

  要求                落点
  不再打开命令行窗口   agent.rs 的 CREATE_NO_WINDOW + 自启拉控制台而非裸 node
  启动/停止/重启       AgentManager 五态状态机 + 界面按钮 + 托盘菜单
  检查更新             check_update / download / apply_update
  日志功能             stdout/stderr 双泵 + 环形缓冲 + 实时推送 + 过滤/导出
  后台运行             托盘常驻,关窗最小化而非退出
  开机自启             macOS LaunchAgent / Windows 计划任务

新增「本机服务」页(/local,默认首页)与「服务日志」页(/logs)。
默认路由由 /nodes 改为 /local:打开应用先看到本机被控端的启停面板,
而不是先要求用户填一个连接地址。

四条实测踩出来的关键约束(均已在代码里注明原因):

1. 退出监控用轮询 try_wait,绝不把 Child 交给 wait()
   —— wait() 会取得 Child 所有权,此后 stop() 再也拿不到它去 kill。
2. 日志游标用独立单调 seq,不用位置下标
   —— 环形缓冲淘汰旧行后位置下标会漂移,前端增量拉取静默错位(不报错)。
3. start() / restart() 的接收者必须是 self: &Arc<Self>
   —— 监控任务要持自持引用。
4. macOS gui/<uid> 的 uid 运行时 id -u 现取,不硬编码 501。

播种(seeding)防降级:只有「随包版本 > 已安装版本」才覆盖数据目录里的脚本,
且版本比较按数值逐段(2.0.10 > 2.0.9)。若写成「同版本才跳过」,
用户自更新过被控端后下次开控制台会被静默降回旧版。

「实例是否在跑」不靠单一信号:管理器状态与 TCP 端口探测交叉判定,
以区分「本控制台托管的实例」与「手工安装的非托管实例」并给出不同处置。

消灭黑窗有三条措施,第 2 条最易漏:Rust 的 std::process::Command 默认不设
CREATE_NO_WINDOW,于是「界面上点一下启动」= 屏幕上闪一个控制台窗口。

控制台现在随包分发被控端脚本(资源 2 份 → 3 份:sidecar.mjs / node / agent.mjs),
用户装完控制台本机被控端即已就绪。

新增 tests/unit/console-commands.test.mjs(6 用例)钉住三类「不报错的失效」:
invoke ⇄ generate_handler! 双向、emit ⇄ listen 双向(这类失效会让界面永不刷新且零报错)、
bundle 资源名 ⇄ Rust resolve_* 字面量、sidecar 方法名、路由表 ⇄ 实际 view 文件。
已注入故障实测会红(改命令名与事件名 → 当场 2 条失败),非空转守卫。

验证:cargo check 0 错 0 警 / cargo test --lib 10·10 通过(含真起 node 子进程)/
clippy -D warnings 通过 / vue-tsc 通过 / 单测 280·280(274 → 280)/
verify-bundle 真 spawn 打包内 node 跑通打包内 sidecar。

⚠️ 托盘、关窗最小化、开机自启、界面点按钮仍需图形环境人工实点。
2026-10-11 11:41:56 +08:00

240 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/runtime/agent.mjs` 被控端单文件(架构无关的 JS)
* 3. `apps/console/src-tauri/tauri.bundle.gen.json` 构建期注入片段(生成物,不入库)
*
* 为什么要带被控端(agent.mjs)
* ----------------------------
* 用户要的是「打开应用就能启动 / 停止 / 重启被控端」,不该再让他去别处下载一个包。
* 被控端是 `.mjs`(架构无关),复用同一个 Node 运行时即可,增量仅约 1MB。
* 打包配置复用 `scripts/lib/bundle-agent.mjs`(与 `pack.mjs` 同一份实现)。
*
* 体积
* ----
* 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';
import { bundleAgent } from './lib/bundle-agent.mjs';
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 -)。',
);
}
// ---------- ②″ 打包被控端 agent.mjs(控制台要能托起本机被控端)----------
//
// 为什么控制台也要带被控端:用户要的是「打开应用就能启动/停止/重启被控端」,
// 不该再让他去别处下载一个包。故把 agent.mjs(架构无关的 JS,约 1MB)也随包分发。
// esbuild 配置复用 `lib/bundle-agent.mjs`(与 pack.mjs 同一份实现,避免漂移)。
const AGENT_OUT = join(runtimeDir, 'agent.mjs');
log('\n=== 打包被控端(随包资源)===');
let agentBytes = 0;
try {
const r = await bundleAgent({ outfile: AGENT_OUT, root, logLevel: 'warning' });
agentBytes = r.bytes;
log(` agent.mjs v${r.version} @ ${r.commit} → ${AGENT_OUT}(${(r.bytes / 1024).toFixed(0)} KB)`);
} catch (e) {
fail(String(e.message ?? e));
}
// 自检:产物必须是可解析的 ES 模块(不是空文件、不是半截)
try {
const txt = readFileSync(AGENT_OUT, 'utf8');
if (txt.length < 10_000) fail(`agent.mjs 体积异常小(${txt.length} 字节),构建可能未完成`);
if (!txt.includes('__AGENT_VERSION__') && !/CAPABILITY_MANIFEST|node_id/.test(txt)) {
fail('agent.mjs 内容不像被控端产物(未找到预期标识)——拒绝打进包');
}
log(` 自检通过:agent.mjs ${(txt.length / 1024).toFixed(0)} KB,含被控端标识`);
} catch (e) {
fail(`agent.mjs 自检失败:${String(e.message ?? e)}`);
}
// ---------- ③ 生成构建期注入片段 ----------
// ⚠️ 两份资源都写明:既能保证「无论 Tauri 的 config 合并是深合并还是替换」都正确,
// 也让注入文件自解释(单看它就知道应用内会带哪些 resource)。
// key 相对 tauri.conf.json 所在目录(src-tauri);value 相对应用内 Resources 目录。
const gen = {
bundle: {
resources: {
'../sidecar/dist/sidecar.mjs': 'sidecar.mjs',
[`runtime/${nodeName}`]: nodeName,
'runtime/agent.mjs': 'agent.mjs',
},
},
};
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 sizeOf = (p) => (existsSync(p) ? statSync(p).size : 0);
const sidecarSize = sizeOf(SIDECAR);
log('');
log('=== 体积 ===');
log(` Node 运行时 ${mb(nodeSize)}`);
log(` sidecar ${(sidecarSize / 1024).toFixed(0)} KB`);
log(` agent.mjs ${(agentBytes / 1024).toFixed(0)} KB`);
log(` 合计 ${mb(nodeSize + sidecarSize + agentBytes)}(未压缩,即 .app 内的增量)`);
log('');
log('下一步:');
log(' pnpm --filter nodeagent-console bundle');
log(' (= sidecar:build + prepare-console + tauri build --config src-tauri/tauri.bundle.gen.json)');