Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85acad5db | ||
|
|
f558e3e7df | ||
|
|
87b74fd015 | ||
|
|
b2404291b0 | ||
|
|
e816c1fd39 | ||
|
|
a6be917637 | ||
|
|
8664ec2926 | ||
|
|
22bc732334 |
No files matched your search
@@ -12,11 +12,13 @@ on:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- '.github/workflows/rust.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/console/src-tauri/**'
|
||||
- 'apps/console/sidecar/**'
|
||||
- 'scripts/prepare-console.mjs'
|
||||
- '.github/workflows/rust.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -66,6 +68,30 @@ jobs:
|
||||
- name: 构建 sidecar(resources 引用的产物)
|
||||
run: pnpm --filter nodeagent-console sidecar:build
|
||||
|
||||
# 随包分发的 Node 运行时由该脚本准备;它同时产出「构建期注入片段」。
|
||||
# 在此真跑一遍:既验证脚本本身可用(含产物自检:node 必须真能执行),
|
||||
# 也验证注入片段声明的两份 resource 确实就绪。
|
||||
# 注意 Linux 上不会走 strip 分支(那是 macOS 专属,见脚本内注释),
|
||||
# 故「strip 后必须 adhoc 重签」这条由 macOS 本机实测覆盖,CI 不重复验证。
|
||||
- name: 准备 Node 运行时(验证 prepare-console.mjs)
|
||||
run: pnpm --filter nodeagent-console prepare:node
|
||||
|
||||
- name: 注入片段应声明两份资源
|
||||
run: |
|
||||
node -e '
|
||||
const g = require("./apps/console/src-tauri/tauri.bundle.gen.json");
|
||||
const r = g.bundle.resources;
|
||||
const keys = Object.keys(r);
|
||||
if (keys.length !== 2) { console.error("资源项应为 2,实际", keys); process.exit(1); }
|
||||
console.log("✓", JSON.stringify(r));
|
||||
'
|
||||
|
||||
# 更强的一道:不是「文件在不在」,而是「产物能不能自举」——
|
||||
# 真 spawn 打包内的 node 跑 sidecar 并走一次 JSON-RPC 握手。
|
||||
# 这类问题只在产物中暴露(源码与单测都正常)。
|
||||
- name: 验证运行时 + sidecar 可自举
|
||||
run: node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
- name: 缓存 cargo 构建产物
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
|
||||
@@ -48,6 +48,18 @@ jobs:
|
||||
|
||||
- name: 类型检查
|
||||
run: pnpm typecheck
|
||||
|
||||
# 控制台「随包分发的 Node 运行时」在 Windows 上的分支此前零覆盖
|
||||
# (准备脚本本机只在 macOS 上跑过)。此处真跑:复制 node.exe(无 strip 分支)→
|
||||
# 自检可执行 → 再验证它与 sidecar 能否走通一次 JSON-RPC 自举。
|
||||
# ⚠️ 必须先 sidecar:build —— prepare-console.mjs 的前置检查要求 sidecar.mjs 存在
|
||||
# (首次推送时漏了这步,CI 在 Windows 上当场报「未找到 sidecar/dist/sidecar.mjs」)。
|
||||
- name: 控制台 Node 运行时(Windows 分支)与自举验证
|
||||
run: |
|
||||
pnpm --filter nodeagent-console sidecar:build
|
||||
pnpm --filter nodeagent-console prepare:node
|
||||
node apps/console/scripts/verify-bundle.mjs --runtime
|
||||
|
||||
- name: 单元测试 — 协议纯函数 / 清单守护 / 脚本生成
|
||||
# 来自 ci/workflow-unit-tests 分支(4d73d6e)的实质贡献;该分支基于老 main,
|
||||
# 直接 merge 会回退上万行,故只取其提交内容手工并入。
|
||||
@@ -141,6 +153,23 @@ jobs:
|
||||
}
|
||||
Write-Host "OK - package complete; .ps1 has BOM / .cmd has none; no key shipped"
|
||||
} finally { $z.Dispose() }
|
||||
- name: 冒烟 — 双架构包(node.exe PE 架构校验)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
# 真机教训(Parallels 27 + Win11 ARM):曾出现 arm64 包里装的其实是 x64 的 node.exe
|
||||
# —— 两份包的 node.exe 字节完全相同。x64 node 在 ARM Windows 上**能 listen,
|
||||
# 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。
|
||||
# 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构,
|
||||
# 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。
|
||||
# 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/pack.mjs --arch $arch
|
||||
}
|
||||
foreach ($arch in @('x64','arm64')) {
|
||||
node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch
|
||||
if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" }
|
||||
}
|
||||
- name: 冒烟 — 被控端监听
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,73 @@
|
||||
|
||||
---
|
||||
|
||||
## [未发布]
|
||||
|
||||
(暂无)
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] —— 2026-10-11
|
||||
|
||||
> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。
|
||||
> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** ——
|
||||
> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`),
|
||||
> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。
|
||||
|
||||
### 新增
|
||||
|
||||
- **控制台自带 Node 运行时,目标机器无需预装 Node**(`pnpm --filter nodeagent-console bundle`)
|
||||
—— 控制台的 sidecar 是 `.mjs`,此前只能在装了 Node 的机器上运行(`resolve_node` 回退系统 node)。
|
||||
现由 `scripts/prepare-console.mjs` 把 Node 运行时准备为 Tauri resource 随包分发。
|
||||
实测:`.app` **90.8 MiB**(含运行时 86MB)/`.dmg` **33.78 MiB**(分发件,仍低于 40MB 目标)。
|
||||
作为对照,本机实测 Electron 运行时本体(`Electron Framework.framework`)为 207MB ——
|
||||
即 Tauri + Node 的运行时开销不到 Electron 的一半。
|
||||
- **`apps/console/scripts/verify-bundle.mjs`** —— 验证「打包产物能否自举」:真 spawn 打包内的 node
|
||||
去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手。源码与单测都正常、只在产物中暴露的问题靠它拦下。
|
||||
|
||||
### 修复 / 规避(两处实测踩出来的约束)
|
||||
|
||||
- **macOS 上 `strip` 会使 Mach-O 代码签名失效 → 内核直接 `SIGKILL`(exit 137)**,
|
||||
且没有任何有用报错(不是警告、不是异常,就是静默无法执行)。
|
||||
实测对照:原样复制 ✓ / strip 不重签 ✗(137) / strip + `codesign --force --sign -` ✓。
|
||||
故准备脚本在 strip 之后**必须** adhoc 重签(省 20% 体积值得,但这步不能少)。
|
||||
- **Node 运行时不得声明进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**
|
||||
(`tauri-build` 校验声明的文件必须存在),声明「运行期才就绪」的资源会让 `cargo check/test`
|
||||
在 build.rs 阶段直接失败。一律走 `tauri build --config` 注入。
|
||||
|
||||
### 修复 —— 被控端打包(ARM64)
|
||||
|
||||
- **`nodeagent-win-arm64.zip` 里装的其实是 x64 的 `node.exe`**。
|
||||
`pack.mjs` 下载 Node 运行时的缓存文件名是固定的 `node-runtime.zip`(**不含架构**):
|
||||
`release.mjs` 先打 x64 时把它缓存下来,随后打 arm64 时误判「已缓存」直接复用
|
||||
→ **两份公开包内的 `node.exe` 字节完全相同**(sha256 同为 `fdddbf45…`,PE Machine = `0x8664`)。
|
||||
后果极隐蔽:该包在 ARM Windows 上**能启动、能 listen**,但 **TLS 握手不完成**
|
||||
(控制端只见 `E_TIMEOUT`)—— 被控端实际不可用,而 macOS 侧 100% 复现不出来。
|
||||
修法:① 缓存文件名带上架构(`node-runtime-<arch>.zip`);
|
||||
② 解包后**断言 `node.exe` 的 PE Machine 与 `--arch` 一致**(缓存串包 / 手工塞错文件当场红)。
|
||||
- **CI 补「双架构包 PE 架构校验」**(`.github/workflows/windows-e2e.yml`)——
|
||||
此前 CI 只跑 `pnpm pack:win`(默认 x64)且只校验 x64 包,**arm64 打包零覆盖**,
|
||||
故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 `node.exe` 的 PE 头比对。
|
||||
- **发版脚本补「交付物架构断言」**(`scripts/lib/zip-pe-arch.mjs`)——
|
||||
`release.mjs` 生成资产前,直接解出**最终 zip 内**的 `node.exe`、读 PE 头比对架构,
|
||||
不符即**中止发版**。与 `pack.mjs` 的断言刻意分两层:后者是打包**流程内部**的自洽,
|
||||
这里钉的是**交付物本身**的性质。纯内置 `zlib` 实现,不依赖 `unzip` / `Expand-Archive`
|
||||
(Windows runner 上没有 `unzip`),另留 CLI 自检入口:
|
||||
`node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>`(已注入故障实测会红)。
|
||||
|
||||
### 真机实测(ARM64 Windows 虚拟机)
|
||||
|
||||
- **arm64 包**:TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→
|
||||
`system.info` / `status` / `process.list` / `service.list` / `shell.exec` /
|
||||
`audit verify` 全部正常,`arch` 报告为 `arm64`(原生,非模拟层)。
|
||||
- **x64 包(对照,同机同参)**:`connect` 报 `E_TIMEOUT` —— **复现**了「能 listen 但握手不完成」。
|
||||
→ 证实「按架构出包」不是理论洁癖,而是 ARM Windows 上的硬需求。
|
||||
- 已知边界:被控端若以 **SYSTEM(会话 0)** 手动启动,`screen.capture` 等图形能力会失败
|
||||
(会话 0 无交互桌面)。正式安装路径不受影响 —— `install.ps1` 的计划任务 principal
|
||||
用的是登录用户 + `LogonType Interactive`,注释已明写「图形能力可用」。
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] —— 2026-10-11
|
||||
|
||||
> 一轮**真机验证**(Parallels 27 + Win11 ARM)带来的修复批次:在补齐 6 项待验证项的过程中,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/agent",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/cli",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent-console",
|
||||
"private": true,
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "nodeagent-console",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"identifier": "cn.jkooe.nodeagent-console",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/hub",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/mcp",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
+43
-9
@@ -19,7 +19,7 @@
|
||||
| **刻意不可验** | 第三方杀软拦截(火绒等) | ❌ VM 里只有 Defender |
|
||||
| | 显示 / DPI / 多显示器 / 坐标校准 | ❌ VM 显示栈不同 |
|
||||
| | 性能指标(P95 时延等) | ❌ VM 数字不可比 |
|
||||
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ⚠️ 能跑但慢;**这本身是值得记录的兼容性结论**(见 B5) |
|
||||
| **需注意** | `win-x64` 包在 ARM Windows 上走 **Prism 模拟** | ❌ **实测结论(2026-10-11):能启动、能 listen,但 TLS 握手不完成**(控制端只见 `E_TIMEOUT`)→ 被控端实际不可用。ARM Windows **必须**用 `nodeagent-win-arm64.zip`(见 B5) |
|
||||
|
||||
---
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
└ 共享/NAT 下 VM 拿不到同网段 IP → UDP 广播发现(v4)测不了,Mac 也未必能直连
|
||||
|
||||
③ VM 内装 Node:优先 **ARM64 版**(避开 Prism 模拟层)
|
||||
└ 若想验分发包,另见 B5(用项目打的 win-x64 包)
|
||||
└ 若想验分发包,同样**必须用 `nodeagent-win-arm64.zip`** —— x64 包在 ARM 上握手不通(见 B5)
|
||||
|
||||
④ VM 内起被控端(二选一)
|
||||
a. 免安装:双击 run-portable.cmd (不注册计划任务,适合快速验能力)
|
||||
b. 正式装:解压 nodeagent-win-x64.zip → 双击 install.cmd(自提权,会建计划任务)
|
||||
b. 正式装:解压 nodeagent-win-arm64.zip → 双击 install.cmd(自提权,会建计划任务)
|
||||
|
||||
⑤ Mac 端登记设备并连接
|
||||
nodeagent --node vm connect <VM的IP> --key <密钥> --insecure
|
||||
@@ -133,14 +133,35 @@ node scripts/anchor-audit.mjs verify --anchors /tmp/vm-anchor.jsonl --node vm
|
||||
```
|
||||
**判定**:`collect` 能拿到链头、云盘上传成功、`verify` 返回 `ok:true`。
|
||||
|
||||
#### B5. `win-x64` 包在 ARM Windows 上的兼容性(额外结论)
|
||||
#### B5. 分发包在 ARM Windows 上的架构适配(✅ 已验,2026-10-11)
|
||||
|
||||
**结论:ARM Windows 上必须用 `nodeagent-win-arm64.zip`;`win-x64` 包不可用。**
|
||||
|
||||
同机、同密钥、同参数的对照实测:
|
||||
|
||||
| 包 | 内含 `node.exe` | 结果 |
|
||||
|---|---|---|
|
||||
| `nodeagent-win-arm64.zip` | ARM64(PE `0xaa64`) | ✅ TLS 握手成功 → PSK 认证 → 声明 51 项能力(两端一致)→ `info`/`status`/`process.list`/`service.list`/`shell.exec`/`audit verify` 全通;`system.info.arch` 报 `arm64`(原生,非模拟层) |
|
||||
| `nodeagent-win-x64.zip` | x86-64(PE `0x8664`) | ❌ 能启动、能 listen,但 `connect` 报 `E_TIMEOUT` —— **TLS 握手不完成** |
|
||||
|
||||
> 踩坑记录:v2.0.1 的 arm64 资产曾**误装 x64 node**(`pack.mjs` 的运行时缓存文件名不含架构 →
|
||||
> 打 arm64 时复用了 x64 的缓存),两份公开包内 `node.exe` 字节完全相同(同 sha256)。
|
||||
> 该缺陷在 macOS 上 **100% 复现不出**,只有 ARM 目标机才暴露。
|
||||
> 现 `pack.mjs` 解包后断言 PE Machine,CI 亦直接校验最终产物内 `node.exe` 的 PE 头。
|
||||
|
||||
**复验方式**:
|
||||
|
||||
```bash
|
||||
# 把项目打的包解压到 VM,用**包内自带的 x64 Node**起被控端(走 Prism 模拟)
|
||||
# 期望:能起、能被连接、基础能调用
|
||||
# ① VM 内,用包内自带 node 起被控端(务必 arm64 包)
|
||||
C:\nodeagent-test\node.exe C:\nodeagent-test\agent.mjs
|
||||
# 注意:以 SYSTEM(会话 0)启动时 screen.capture 等图形能力必失败(会话 0 无交互桌面),
|
||||
# 正式安装路径不受影响 —— install.ps1 的计划任务用登录用户 + LogonType Interactive。
|
||||
|
||||
# ② Mac 侧连入
|
||||
nodeagent connect <VM的IP> --port 8765 --key <密钥> --insecure --id vm
|
||||
```
|
||||
**判定**:能跑 → 说明分发包对 ARM Windows 也友好(**值得写进 README 的加分结论**);
|
||||
不能跑或异常 → 记录现象,并在文档里注明"分发包需 ARM64 版本方能原生运行"。
|
||||
|
||||
**判定**:`connect` 成功并列出 51 项能力 = 通。若只见 `E_TIMEOUT`,先查包内 `node.exe` 是不是 ARM64。
|
||||
|
||||
---
|
||||
|
||||
@@ -165,7 +186,7 @@ A 基础验收(verify.mjs)
|
||||
| 架构无关项(B2/B3/B4)在 VM 验过 | `✅ 已验证(VM · Windows 11 ARM)` |
|
||||
| 输入/系统能力(A/B1)在 VM 验过 | `✅ 已验证(VM)`,**若涉及坐标/DPI 则加注「坐标类待真机复验」** |
|
||||
| 杀软交互 / 显示 / 性能 | `⏳ 待真机`(VM 不可替代,**不得**因 VM 通过就标已验) |
|
||||
| x64 包在 ARM 上跑通(B5) | `✅ 兼容性结论:win-x64 包可在 ARM Windows 运行(经 Prism)` |
|
||||
| 分发包架构适配(B5) | `✅ 已验证(VM · Windows 11 ARM):arm64 包原生可用;win-x64 包 TLS 握手不完成、不可用` |
|
||||
|
||||
> **一条纪律**:VM 与真机**架构不同**(真机 i7-12650H = x86_64)。任何"VM 通过"
|
||||
> 都不能自动推出"真机通过";涉及**原生二进制、驱动、杀软、显示栈**的部分尤其如此。
|
||||
@@ -181,6 +202,19 @@ A 基础验收(verify.mjs)
|
||||
| 分辨率/坐标与真机不符 | VM 显示栈不同 | 只做逻辑验证,坐标待真机 |
|
||||
| 性能数字明显偏低 | VM + 可能的 Prism 模拟 | **不要**采信 VM 的性能指标 |
|
||||
| 杀软相关错误复现不了 | VM 里只有 Defender | 需真机(或另行安装第三方杀软) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 实测记录(VM)
|
||||
|
||||
| 日期 | 项 | 结论 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 2026-10-11 | **B5 架构适配** | ✅ arm64 包原生可用;❌ win-x64 包不可用 | 同机同参对照:arm64 握手成功 / x64 `E_TIMEOUT` |
|
||||
| 2026-10-11 | 基础能力(A 子集) | ✅ | `system.info`(`arch=arm64`、能力 51 项两端一致)/ `status` / `process.list` / `service.list` / `shell.exec`(退出码 0)/ `audit.list` / `audit verify`(链完整)均正常 |
|
||||
| 2026-10-11 | 图形能力 | ⏳ 待登录态 | 会话 0(SYSTEM)下 `screen.capture` 报 `E_EXECUTION_FAILED`;需以登录用户 Interactive 运行后复验 |
|
||||
|
||||
> 本轮**尚未执行**:B1(v1.5 四项输入)/ B2(v23 三防护)/ B3(v26 重启清理)/ B4(审计锚点端到端)。
|
||||
> 另注:VM 的 `prlctl exec` 默认以 **SYSTEM(会话 0)** 身份执行,图形类能力须换登录用户会话方可复验。
|
||||
| x64 Node 启动慢/异常 | 走 Prism 模拟 | 换 ARM64 Node 复核 |
|
||||
|
||||
---
|
||||
|
||||
+50
-10
@@ -224,9 +224,18 @@ nodeagent/
|
||||
| 命令闭环成功率 | 成功返回 ÷ 发起 | ≥ 95% |
|
||||
| 内网调用 P95 时延 | 前端点击到结果渲染 | < 3s |
|
||||
| 应用内存占用 | Mac 版空闲常驻 RSS | < 150MB(远低于 Electron) |
|
||||
| 打包体积 | 单端安装包 | Mac `.app` < 30MB / Win 安装包 < 40MB |
|
||||
| 打包体积 | **用户实际下载的分发件** | Mac `.dmg` < 40MB(实测 **33.8MB** ✓)/Win 安装包 < 40MB(待实测) |
|
||||
| 双端编译 | `pnpm build` 一次产出两端 | 100% |
|
||||
|
||||
> **⚠️ 口径调整(2026-10-11,待确认)**:原指标写的是「Mac `.app` < 30MB / Win 安装包 < 40MB」。
|
||||
> 那条是 **Tauri + Rust client** 时代的数字 —— 当时应用内不含 JS 运行时。
|
||||
> 改为 Node sidecar 方案后,应用必须自带 Node 运行时(sidecar 是 `.mjs`,不能假定用户装了 Node),
|
||||
> 故 `.app` 实测 **90.8 MiB**(其中 Node 运行时 86MB)。
|
||||
> **但用户下载的是 `.dmg`,实测 33.78 MiB** —— 仍低于原定 40MB。
|
||||
> 故本表口径改为「分发件体积」。作为对照:本机实测 Electron 运行时本体
|
||||
> (`Electron Framework.framework`)为 **207MB**,即 Tauri + Node 的运行时开销
|
||||
> 仍不到 Electron 的 **一半**(86MB vs 207MB)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 里程碑拆分
|
||||
@@ -414,16 +423,47 @@ Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node
|
||||
**真跑 Node 子进程**验证 spawn / 请求-响应匹配 / 错误不崩 —— 不必等到人工开窗口才敢确认。
|
||||
`cargo test` 实测:**3/3 通过**(0.49s)。
|
||||
|
||||
### ⚠️ 两处已知待办(本轮未做,均需真机/图形环境)
|
||||
### Node 运行时分发(2026-10-11 完成)
|
||||
|
||||
1. **Node 运行时随包分发**:sidecar 是 `.mjs`,目标机器需有 Node。开发期用系统 node
|
||||
(`resolve_node` 会回退到 PATH);**分发给他人时**需把 Node 运行时作为 resource 打进包。
|
||||
计划:写 `scripts/prepare-console.mjs` 下载对应平台 node 到 `src-tauri/resources/`,
|
||||
并在打包时用 `tauri build --config` 注入 `bundle.resources` 的 node 项
|
||||
(**不能写死在默认 conf 里** —— `tauri-build` 会在编译期校验文件存在性,
|
||||
声明未下载的文件会让 `cargo check/test` 直接失败,本轮已踩并修复)。
|
||||
2. **GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
|
||||
沙箱无法运行图形应用,故本轮的验证止于 `cargo test`(真跑 sidecar)+ 单测/构建全绿。
|
||||
sidecar 是 `.mjs`(不是可执行文件,故**不满足 Tauri `externalBin` 的要求**),
|
||||
分发给他人时须把 Node 运行时一并打进包。做法沿袭被控端 `scripts/pack.mjs` 的既有路子:
|
||||
**把 Node 运行时当作 Tauri resource**。
|
||||
|
||||
```
|
||||
scripts/prepare-console.mjs ① 从本机 node 复制 → apps/console/src-tauri/runtime/node
|
||||
② macOS:strip -x(108MB→86MB)**并 adhoc 重签**
|
||||
③ 生成 apps/console/src-tauri/tauri.bundle.gen.json
|
||||
{ "bundle": { "resources": {
|
||||
"../sidecar/dist/sidecar.mjs": "sidecar.mjs",
|
||||
"runtime/node": "node" } } }
|
||||
pnpm --filter nodeagent-console bundle
|
||||
= sidecar:build → prepare:node → tauri build --config tauri.bundle.gen.json → verify:bundle
|
||||
```
|
||||
|
||||
**三处必须记住的约束**(都是实测踩出来的):
|
||||
|
||||
1. **绝不写进默认 `tauri.conf.json`** —— `bundle.resources` 是**编译期契约**,
|
||||
`tauri-build` 会校验声明的每个文件必须存在;声明一个「运行期才准备好」的文件会让
|
||||
`cargo check/test` 直接失败(2026-10-10 已踩)。故一律走 `--config` 注入。
|
||||
2. **macOS 上 strip 之后必须 adhoc 重签** —— `strip` 会使 Mach-O 代码签名失效,
|
||||
而 macOS 内核对签名无效的可执行文件**直接 SIGKILL(exit 137)**,无任何有用报错。
|
||||
实测对照:原样复制 ✓|strip 不重签 ✗(137)|strip + `codesign --force --sign -` ✓。
|
||||
3. **`resolve_node` 与 resource 映射必须对齐** —— 映射 `runtime/node → node`
|
||||
使产物落在 `Contents/Resources/node`,正是 `src-tauri/src/sidecar.rs` 里
|
||||
`resolve_node(resource_dir)` 查找的路径。
|
||||
|
||||
**验证(不只看「打包成功」)**:`apps/console/scripts/verify-bundle.mjs` 真 spawn 打包内的
|
||||
node 去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手 —— 源码与单测都正常、
|
||||
只在产物中暴露的问题(如上述 137)靠它拦下。已接入 CI(Linux 与 Windows 双覆盖)。
|
||||
|
||||
**实测体积**:`.app` **90.8 MiB**(6.75 MiB → 90.8 MiB,增量即 Node 运行时 86MB)/
|
||||
`.dmg` **33.78 MiB**(分发件,仍 < 40MB 目标)。
|
||||
|
||||
### ⚠️ 剩余一处已知待办(需图形环境)
|
||||
|
||||
**GUI 端到端人工验证**:开窗口 → 连接被控端 → 操作界面。
|
||||
沙箱无法运行图形应用,故验证止于 `cargo test`(真跑 sidecar)+ `verify:bundle`(真跑产物)
|
||||
+ 应用进程可启动(实测存活、无崩溃输出)。**界面交互仍需在图形环境点一遍。**
|
||||
|
||||
### 一个易踩的构建约束(本轮 CI 失败后查明)
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nodeagent",
|
||||
"private": true,
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "跨机 AI 接管框架 —— 让 Mac 上的 AI 无缝接管 Windows,装软件、查状态",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/client",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nodeagent/protocol",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 交付物级断言:直读 zip 内的可执行文件、取 PE 头判断目标架构。
|
||||
*
|
||||
* 为什么单独成模块(2026-10-11 事故复盘):
|
||||
* `nodeagent-win-arm64.zip` 里曾装进 x64 的 `node.exe`,而该包在 ARM Windows 上
|
||||
* **能启动、能 listen,只是 TLS 握手不完成**(控制端只见 E_TIMEOUT)——
|
||||
* 单测、CI、macOS 侧全都抓不到,两个公开包内 `node.exe` 的 sha256 还完全相同。
|
||||
* 打包流程内部(pack.mjs)已有断言,但那仍是「中间逻辑」的断言;
|
||||
* 真正该钉死的是**交付物本身**。故这里只做一件事:解出最终 zip 里的 exe,读 PE 头。
|
||||
*
|
||||
* 实现约束:只用 Node 内置能力(`node:zlib` 手解 zip)。
|
||||
* 不依赖 `unzip` / `Expand-Archive` —— Windows runner 上没有 unzip,
|
||||
* 而发版与 CI 都要在 Windows 上跑。
|
||||
*
|
||||
* 自检用法:
|
||||
* node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>
|
||||
* (退出码 0 = 架构相符;1 = 不符;2 = 读不了文件/不是有效 zip)
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
/** PE `Machine` 字段 → 人类可读名 */
|
||||
export const PE_MACHINE = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
|
||||
/** 本项目的架构标识 → 期望的 PE Machine */
|
||||
export const ARCH_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
|
||||
|
||||
/** 从 zip 中取出指定条目的内容(支持 stored / deflate;zip64 不适用本仓产物) */
|
||||
export function readZipEntry(zipPath, entryName) {
|
||||
const buf = readFileSync(zipPath);
|
||||
let eocd = -1;
|
||||
const minPos = Math.max(0, buf.length - 22 - 65535);
|
||||
for (let i = buf.length - 22; i >= minPos; i -= 1) {
|
||||
if (buf.readUInt32LE(i) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error(`未找到 EOCD(不是有效 zip?): ${zipPath}`);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let off = buf.readUInt32LE(eocd + 16);
|
||||
for (let n = 0; n < count; n += 1) {
|
||||
if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error(`中央目录签名异常: ${zipPath}`);
|
||||
const method = buf.readUInt16LE(off + 10);
|
||||
const compSize = buf.readUInt32LE(off + 20);
|
||||
const nameLen = buf.readUInt16LE(off + 28);
|
||||
const extraLen = buf.readUInt16LE(off + 30);
|
||||
const commentLen = buf.readUInt16LE(off + 32);
|
||||
const localOff = buf.readUInt32LE(off + 42);
|
||||
const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
|
||||
if (name === entryName) {
|
||||
const lNameLen = buf.readUInt16LE(localOff + 26);
|
||||
const lExtraLen = buf.readUInt16LE(localOff + 28);
|
||||
const start = localOff + 30 + lNameLen + lExtraLen;
|
||||
const raw = buf.subarray(start, start + compSize);
|
||||
return method === 0 ? raw : inflateRawSync(raw);
|
||||
}
|
||||
off += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
throw new Error(`zip 内未找到条目 ${entryName}: ${zipPath}`);
|
||||
}
|
||||
|
||||
/** 读 PE 头的 Machine 字段 */
|
||||
export function peMachine(buf) {
|
||||
const peOff = buf.readUInt32LE(0x3c);
|
||||
if (buf.readUInt32LE(peOff) !== 0x00004550) throw new Error('不是有效的 PE 文件');
|
||||
return buf.readUInt16LE(peOff + 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 zip 内条目的架构。
|
||||
* @returns {{ok:boolean, got:number, want:number, gotName:string, wantName:string}}
|
||||
*/
|
||||
export function checkZipArch(zipPath, arch, entryName = 'node.exe') {
|
||||
const want = ARCH_MACHINE[arch];
|
||||
if (!want) throw new Error(`未知架构标识: ${arch}(可选 ${Object.keys(ARCH_MACHINE).join('/')})`);
|
||||
const got = peMachine(readZipEntry(zipPath, entryName));
|
||||
return {
|
||||
ok: got === want,
|
||||
got,
|
||||
want,
|
||||
gotName: PE_MACHINE[got] ?? `0x${got.toString(16)}`,
|
||||
wantName: PE_MACHINE[want],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- CLI 自检:可被人工/CI 直接调用 ----------
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const [, , zipPath, arch] = process.argv;
|
||||
if (!zipPath || !arch) {
|
||||
console.error('用法: node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>');
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const r = checkZipArch(zipPath, arch);
|
||||
if (r.ok) {
|
||||
console.log(`✓ ${zipPath} → node.exe = ${r.gotName}(期望 ${arch})`);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`✗ ${zipPath} → node.exe = ${r.gotName},但期望 ${arch}(${r.wantName})`);
|
||||
process.exit(1);
|
||||
} catch (err) {
|
||||
console.error(`✗ ${err.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -116,8 +116,11 @@ async function main() {
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
log('\n=== ③ 下载 Node.js Windows 运行时 ===');
|
||||
const tmpZip = join(OUT_DIR, 'node-runtime.zip');
|
||||
log(`\n=== ③ 下载 Node.js Windows 运行时(${NODE_ARCH})===`);
|
||||
// 缓存文件名**必须含架构**。曾用固定名 node-runtime.zip:release 先打 x64(下载并缓存),
|
||||
// 再打 arm64 时误判「已缓存」→ 跳过下载 → 把 x64 的 node.exe 装进了 arm64 包。
|
||||
// (真例:v2.0.1 的 nodeagent-win-arm64.zip 内 node.exe 实为 x86-64。)
|
||||
const tmpZip = join(OUT_DIR, `node-runtime-${NODE_ARCH}.zip`);
|
||||
if (existsSync(tmpZip)) {
|
||||
log(' 已缓存,跳过下载');
|
||||
} else {
|
||||
@@ -128,6 +131,23 @@ async function main() {
|
||||
rmSync(PKG_DIR, { recursive: true, force: true });
|
||||
mkdirSync(PKG_DIR, { recursive: true });
|
||||
execSync(`unzip -o -j "${tmpZip}" "*/node.exe" -d "${PKG_DIR}"`, { stdio: 'inherit' });
|
||||
|
||||
// 防呆 C:产物 node.exe 的 PE 架构必须与 --arch 一致。
|
||||
// 这是**根治性守卫**:缓存串包、镜像发错、手工塞错文件,都会在此当场红,
|
||||
// 而不是拖到「被控端在 ARM Windows 上 TLS 握手不完成」才由真机暴露。
|
||||
// PE 头:偏移 0x3c 处是 PE 签名偏移,+4 是 Machine 字段(0x8664=x86-64,0xaa64=ARM64)。
|
||||
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
|
||||
const PE_NAME = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
|
||||
const nodeExe = join(PKG_DIR, 'node.exe');
|
||||
{
|
||||
const head = readFileSync(nodeExe).subarray(0, 4096);
|
||||
const machine = head.readUInt16LE(head.readUInt32LE(0x3c) + 4);
|
||||
if (machine !== PE_MACHINE[NODE_ARCH]) {
|
||||
fail(`node.exe 架构不符:--arch=${NODE_ARCH},实际 ${PE_NAME[machine] ?? `0x${machine.toString(16)}`}。` +
|
||||
`多半是缓存串包 —— 删掉 ${OUT_DIR}/node-runtime-*.zip 后重试。`);
|
||||
}
|
||||
log(` node.exe 架构校验通过(${NODE_ARCH} / ${PE_NAME[machine]})`);
|
||||
}
|
||||
copyFileSync(bundleOut, join(PKG_DIR, 'agent.mjs'));
|
||||
copyFileSync(join(root, 'scripts', 'install.ps1'), join(PKG_DIR, 'install.ps1'));
|
||||
copyFileSync(join(root, 'scripts', 'control.ps1'), join(PKG_DIR, 'control.ps1'));
|
||||
|
||||
@@ -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)');
|
||||
@@ -18,6 +18,7 @@ import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkZipArch } from './lib/zip-pe-arch.mjs';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -157,6 +158,33 @@ for (const z of [zipX64, zipArm64]) {
|
||||
if (!existsSync(z)) fail(`找不到 zip 产物: ${z}`);
|
||||
}
|
||||
|
||||
// ---------- ③.5 交付物断言:直读**最终 zip 内**的 node.exe 架构 ----------
|
||||
// 2026-10-11 事故复盘:arm64 包里曾装进 x64 的 node.exe,而该包在 ARM Windows 上
|
||||
// **能启动、能 listen,只是 TLS 握手不完成**(控制端只见 E_TIMEOUT)—— 单测、CI、
|
||||
// macOS 侧全都抓不到,两个公开包内 node.exe 的 sha256 还完全相同。
|
||||
// pack.mjs 里已有断言,但那仍是**打包流程内部**的断言;真正该钉死的是**交付物本身**
|
||||
// 的性质。故此处解出最终 zip 里的 node.exe、读 PE 头,与架构期望比对。
|
||||
// 实现见 scripts/lib/zip-pe-arch.mjs(纯内置 zlib,不依赖 unzip / Expand-Archive)。
|
||||
console.log('=== 交付物断言(zip 内 node.exe 架构)===');
|
||||
for (const [arch, zipPath] of [
|
||||
['x64', zipX64],
|
||||
['arm64', zipArm64],
|
||||
]) {
|
||||
let r;
|
||||
try {
|
||||
r = checkZipArch(zipPath, arch);
|
||||
} catch (err) {
|
||||
fail(`无法校验 ${zipPath}:${err.message}`);
|
||||
}
|
||||
if (!r.ok) {
|
||||
fail(
|
||||
`${zipPath} 内的 node.exe 架构不符:期望 ${arch}(${r.wantName}),实际 ${r.gotName}。\n` +
|
||||
' 该错误包在 ARM Windows 上能启动/listen,却 TLS 握手不通 —— 严禁发布。',
|
||||
);
|
||||
}
|
||||
console.log(` ✓ nodeagent-win-${arch}.zip → node.exe = ${r.gotName}`);
|
||||
}
|
||||
|
||||
// ---------- ④ 生成资产(校验和 + latest.json)----------
|
||||
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
const distDir = join(root, 'release', 'dist');
|
||||
|
||||
Reference in new issue
Block a user