目标:双击应用即用,全程不碰命令行。
要求 落点
不再打开命令行窗口 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。
⚠️ 托盘、关窗最小化、开机自启、界面点按钮仍需图形环境人工实点。
240 lines
11 KiB
JavaScript
240 lines
11 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/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)');
|