8 Commits
Author SHA1 Message Date
jkooe f85acad5db chore(release): v2.0.2 2026-10-11 10:18:28 +08:00
jkooe f558e3e7df chore(release): 交付物架构断言 + CHANGELOG 归置 v2.0.2
已公开发布的 nodeagent-win-arm64.zip 内装的其实是 x64 的 node.exe
(两包内 node.exe 字节完全相同,PE Machine 均为 0x8664),该包在 ARM Windows 上
能启动、能 listen,却 TLS 握手不完成 —— 被控端实际不可用,而 macOS 侧 100% 复现不出。

- 新增 scripts/lib/zip-pe-arch.mjs:纯内置 zlib 手解 zip、读 PE 头判架构。
  不依赖 unzip / Expand-Archive(Windows runner 上没有 unzip)。
  含 CLI 自检入口,已注入故障实测会红(arm64 包去要 x64 期望 → 退出码 1)。
- release.mjs:生成资产前调用之,不符即中止发版 —— 拦在 tag/push 之前,
  是「交付物本身」的断言,与 pack.mjs 的「流程内部」断言分两层。
- windows-e2e.yml:把原先内联的 PowerShell 校验实现改为复用同一模块,
  消除两套实现各自漂移的可能。
- CHANGELOG:[未发布] 归入 v2.0.2。
2026-10-11 10:17:30 +08:00
jkooe 87b74fd015 docs(vm): B5 按实测改写 —— arm64 包原生可用 / win-x64 包 TLS 握手不通
原 B5 的假设是「x64 包在 ARM 上也许能跑」,本轮实测证伪:能启动、能 listen,
但 TLS 握手不完成(E_TIMEOUT),被控端实际不可用。补对照数据与复验步骤;
同步更新检测表、前置准备(③④ 改用 arm64 包)、结论标注规则与新增 §7 实测记录。
2026-10-11 09:48:10 +08:00
jkooe b2404291b0 fix(pack): arm64 包内实为 x64 node —— 缓存名不含架构 + 补 PE 架构断言
release.mjs 先打 x64 时会把运行时缓存为 node-runtime.zip(该名不含架构),
随后打 arm64 时误判「已缓存」直接复用 → arm64 包内 node.exe 实为 x86-64
(两份公开包内 node.exe 字节完全相同,sha256 同为 fdddbf45…,PE Machine=0x8664)。
该包在 ARM Windows 上能启动、能 listen,但 TLS 握手不完成(控制端只见 E_TIMEOUT),
且 macOS 侧完全复现不出。

- pack.mjs: 缓存名改为 node-runtime-<arch>.zip(按架构隔离)
- pack.mjs: 解包后断言 node.exe 的 PE Machine 与 --arch 一致(防呆 C,已注入故障实测会红)
- ci: windows-e2e 补「双架构包 PE 架构校验」,直读最终 zip 内 node.exe 的 PE 头
      (此前 CI 只打 x64 且只验 x64,arm64 打包零覆盖 —— 该 bug 必然逃过 CI)
- docs: CHANGELOG 记录 ARM64 真机对照实测

真机实测(ARM64 Windows 虚拟机,同机同参对照):
  arm64 → TLS 握手成功 + PSK + 51 项能力两端一致 + exec/审计全通
  x64   → E_TIMEOUT(复现「能 listen 但 TLS 握手不完成」)
2026-10-11 09:42:32 +08:00
jkooe e816c1fd39 fix(ci): Windows 上补 sidecar:build —— 新加的步骤漏了前置产物
本轮新增的「控制台 Node 运行时(Windows 分支)与自举验证」一步在真实 runner 上失败:
  ✗ 未找到 D:\a\nodeagent\nodeagent\apps\console\sidecar\dist\sidecar.mjs
根因:prepare-console.mjs 的前置检查要求 sidecar 已构建,而该步被排在了构建 sidecar 之前
(rust.yml 里恰好已有「构建 sidecar」在前,所以那边没暴露)。

教训(本地跑过 ≠ CI 跑得过,又一例):本地验证时 sidecar/dist 早已存在,
把「步骤之间的产物依赖」这一层完全掩盖了。**新增 CI 步骤时,应逐条核对
「该步依赖的产物由哪一步生成、顺序是否确实在其后」,而不是在已有产物的本地环境里跑一遍就认为通过。**
2026-10-11 09:00:09 +08:00
jkooe a6be917637 docs: 记录控制台 Node 运行时分发与体积口径调整
- console-plan.md:
  · §8.2 打包体积指标口径由「Mac .app < 30MB」改为「**分发件** Mac .dmg < 40MB」
    —— 原数字是 Tauri + Rust client 时代定的(当时应用内不含 JS 运行时);
    改 sidecar 方案后应用必须自带 Node 运行时,.app 因此到 90.8MB,但用户下载的 dmg 仅 33.8MB。
    已明确标注「口径调整,待确认」。附本机 Electron 运行时本体 207MB 的实测对照。
  · 新增「Node 运行时分发」节:做法、三处实测约束(编译期契约 / strip 须重签 / 映射须对齐
    resolve_node)、验证方式(verify-bundle 真跑产物)、实测体积。
  · 「已知待办」由两条收敛为一条(剩 GUI 端到端人工验证,需图形环境)。
- CHANGELOG.md:新增 [未发布] 节,记录运行时分发功能与两处规避(SIGKILL 137、编译期契约)。
2026-10-11 08:55:45 +08:00
jkooe 8664ec2926 ci: 覆盖 Node 运行时准备与「产物自举」(Linux + Windows 双覆盖)
新增 runtime 准备脚本后,两件事此前零覆盖:脚本本身在 Windows 上的分支、
以及「打包产物能不能真跑」。补上:

- rust.yml:prepare:node 真跑(验证脚本可用 + 产物自检)→ 断言注入片段声明两份资源
  → `verify-bundle --runtime` 真 spawn 运行时跑 sidecar 走一次 JSON-RPC。
  paths 触发加上 scripts/prepare-console.mjs。
  注:Linux 上不走 strip 分支(macOS 专属),故「strip 必须 adhoc 重签」由 macOS 本机实测覆盖。
- windows-e2e.yml:新增一步跑 prepare:node + verify-bundle --runtime —— 该脚本在 Windows
  上走的是「复制 node.exe、无 strip」的另一条分支,此前从未被真实 runner 验证过。
2026-10-11 08:55:38 +08:00
jkooe 22bc732334 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 —— 不到一半。
2026-10-11 08:55:31 +08:00
20 changed files with 725 additions and 31 deletions

No files matched your search

+26
View File
@@ -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:
+29
View File
@@ -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: |
+67
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/agent",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/cli",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+5 -2
View File
@@ -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",
+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
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/hub",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/client",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nodeagent/protocol",
"version": "2.0.1",
"version": "2.0.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+107
View File
@@ -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
View File
@@ -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'));
+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)');
+28
View File
@@ -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');