目标:双击应用即用,全程不碰命令行。
要求 落点
不再打开命令行窗口 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。
⚠️ 托盘、关窗最小化、开机自启、界面点按钮仍需图形环境人工实点。
155 lines
5.9 KiB
JavaScript
155 lines
5.9 KiB
JavaScript
#!/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, spawnSync } 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 agentScript;
|
||
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 保持一致;
|
||
// `"runtime/agent.mjs": "agent.mjs"` 则落在 Contents/Resources/agent.mjs,
|
||
// 与 src-tauri/src/agent.rs 的 resolve_bundled_agent 一致。
|
||
const res = join(app, 'Contents/Resources');
|
||
nodeBin = join(res, NODE_NAME);
|
||
sidecar = join(res, 'sidecar.mjs');
|
||
agentScript = join(res, 'agent.mjs');
|
||
source = `打包产物 ${app}`;
|
||
} else {
|
||
nodeBin = join(consoleDir, `src-tauri/runtime/${NODE_NAME}`);
|
||
sidecar = join(consoleDir, 'sidecar/dist/sidecar.mjs');
|
||
agentScript = join(consoleDir, 'src-tauri/runtime/agent.mjs');
|
||
source = '准备产物(runtime + sidecar/dist)';
|
||
}
|
||
|
||
console.log(`=== 验证控制台产物自举 ===`);
|
||
console.log(` 来源:${source}`);
|
||
|
||
for (const [label, p] of [
|
||
['Node 运行时', nodeBin],
|
||
['sidecar', sidecar],
|
||
['被控端 agent.mjs', agentScript],
|
||
]) {
|
||
if (!existsSync(p)) {
|
||
console.error(`✗ 缺少 ${label}:${p}`);
|
||
console.error(' 提示:先跑 `pnpm bundle`(含 prepare:node),或 `pnpm prepare:node`。');
|
||
process.exit(1);
|
||
}
|
||
console.log(` ✓ ${label}:${p}`);
|
||
}
|
||
|
||
// ---------- ③ 打包内的 node 必须能真正**解析**打包内的 agent.mjs ----------
|
||
// 只查「文件存在」是不够的:截断、半写、依赖缺失都会让文件在、却加载不了,
|
||
// 而那要到用户点「启动」时才炸。`node --check` 只做语法/模块解析、不执行,
|
||
// 无副作用、不占端口,适合流水线。
|
||
{
|
||
const r = spawnSync(nodeBin, ['--check', agentScript], { encoding: 'utf8' });
|
||
if (r.status !== 0) {
|
||
console.error('\n✗ 打包内的 node 无法解析 agent.mjs —— 被控端资源已损坏');
|
||
console.error(' ' + (r.stderr || r.stdout || '').trim().split('\n').slice(0, 4).join('\n '));
|
||
console.error(' 提示:重跑 `pnpm prepare:node`(会重新 esbuild 打包被控端)。');
|
||
process.exit(1);
|
||
}
|
||
console.log(' ✓ 打包内 node 可解析 agent.mjs(语法/模块解析通过)');
|
||
}
|
||
|
||
// ---------- ①②③ 合一的实测:真 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);
|