Files
nodeagent/docs/console-plan.md
T
jkooe 02887e265b fix(agent): 图形能力在高 DPI(200% 缩放)下坐标错位
真机(Parallels 27 + Win11 ARM64,主机 200% 缩放、物理 3600×2020)暴露:
被控端 PowerShell 助手进程**默认 DPI 不感知**,同一进程里并存两套坐标系 ——

| 调用 | 不感知时 |
|---|---|
| Screen.Bounds / VirtualScreen | 逻辑 1800×1010 |
| CopyFromScreen | 按物理裁剪 → 缺省只截到屏幕左上 1/4 |
| SetCursorPos | 被系统 ×2 |
| UIA(screen.find) | 物理(与进程是否感知无关) |

净效果:screen.find 给出的物理矩形**不能**直接喂 input.mouse.*,必须人工除以 scale
(上一轮靠这个临时绕法才点中主题按钮 —— 能绕,但属用户侧的隐式约定,必须消灭)。

- 新增 apps/agent/src/util/dpi.ts:PS_DPI_AWARE(SetProcessDPIAware 前置段)。
- 注入全部坐标相关脚本:screen.ts / input.ts / window.ts(WIN_HELPER_PRELUDE,覆盖
  window.list 与 window.focus)/ record.ts;并把它从 win-ps/prelude-ocr.ts 的 try{} 里
  移出 —— 放在 try 内一旦抛错就被 catch 吞掉,助手会静默退回不感知。
- screen.ts 显式改用 $d.Bounds.Width(已是物理),scale 仅作参考。
- 新增守卫 tests/unit/dpi-awareness.test.mjs(8 项):断言**实际下发的脚本文本**,
  而非「源码里出现过常量」—— 首版按源码扫描,注入故障实测不会变红;改为断言
  buildInputPsScript / buildListDisplaysScript / buildCaptureScript / WIN_HELPER_PRELUDE
  的脚本串后,二次注入故障精准变红。

真机终验(零换算):
- screen.info 3600×2020,与裸 GetSystemMetrics 一致;
- input.mouse.move(x,y) 读回物理光标 == (x,y)(4 处抽样全中);
- screen.find 物理矩形中心**直接** click → 主题单选 UIA selected 正确翻转且可逆。

文档:VM-VERIFICATION.md 新增 §10(坐标口径缺陷本体 / 复验配方 / 输入注入失效的逐条
排除与判决证据 / 两条 prlctl 通道的语义差异);console-plan.md 补 ARM64 真机安装冒烟与
界面实点记录(含新发现 P2:控制台无单实例保护)。

⚠️ CHANGELOG 备注:本批**属被控端变更**,与上一节「本轮不升版本号」的结论不同 ——
发版必须升 PATCH(→ 2.0.3),否则 agent.mjs 会谎报一个并不包含本修复的版本。

测试:dpi 守卫 8/8;pnpm test:unit 290/290。
2026-10-12 03:11:34 +08:00

867 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# nodeagent 大前端开发方案(桌面操作台)
> ⚠️ **本文档 §2 选型、§5.1 的 Rust 版 client、§6 目录结构描述的是「原方案」**,
> **已于 2026-10 被文末「实施决策」取代**(改为 Node sidecar,退役 `crates/nodeagent-client`)。
> 保留原文是为了可回溯决策过程;实现以「实施决策」节与代码为准。
> 版本 v1.0 | 日期 2026-10-07 | 状态:待皇上评审
> 定位:为「Mac 控 Windows」补上**人肉可视化操作台**——CLI 是脚本底座、MCP 是 AI 落点、本方案是**皇上自己点按钮**的桌面界面。
---
## 1. 一句话结论
用 **Tauri 2 + Vue 3 + TypeScript + Vite** 造一套**跨端桌面应用**,同一份代码编译出 **Mac 版(控制端操作台)**与 **Windows 版(被控端本地面板)**;Rust 后端复刻 `@nodeagent/client` 的握手/RPC/事件逻辑,经 Tauri command 暴露给 Vue 前端。首期覆盖 **核心管控 + 文件管理 + 审计**。
---
## 2. 技术栈选型(含取舍)
| 层 | 选型 | 版本 | 选型理由 |
|---|---|---|---|
| 桌面壳 | **Tauri 2** | 2.x | 跨端(Mac/Win)+ 性能好 + 体积小;Rust 后端契合皇上「Rust 加速」技术栈 |
| 前端框架 | **Vue 3** | 3.5+ | 皇上既有 Vue2/Vue3 经验(reader、FinDataHub),迁移成本最低 |
| 语言 | **TypeScript** | 5.x | 与 monorepo 既有 TS 生态对齐,复用协议类型定义 |
| 构建 | **Vite** | 6.x | Tauri 官方模板默认,HMR 快 |
| 状态管理 | **Pinia** | 2.x | Vue3 官方推荐,模块化 store 天然契合多面板 |
| 路由 | **Vue Router** | 4.x | 多页面(总览/终端/文件/审计…)需要 |
| 组件库 | **Naive UI** | 2.x | 暗色主题友好、TS 类型完整、可自定义涨红跌绿 |
| 图表 | **ECharts** | 5.x | 资源趋势/磁盘/指标可视化,涨红跌绿可配 |
| Rust 客户端 | tokio + tokio-tungstenite + rustls | — | 复刻 client.ts 的 WebSocket 握手与 RPC |
| Rust 密码学 | hmac + sha2 + ed25519-dalek + x509-parser | — | HMAC 挑战应答、ed25519 签名、证书指纹钉住 |
### 为什么 Tauri 而不是 Electron / 纯 Web
| 维度 | Tauri 2 | Electron | 纯 Web(浏览器) |
|---|---|---|---|
| 跨端 Mac+Win | ✅ | ✅ | ✅(但无桌面壳) |
| 打包体积 | ~10MB 级 | ~100MB+ | 无 |
| 内存占用 | 低(复用系统 WebView) | 高(自建 Chromium) | 视浏览器 |
| 性能 | 好(Rust 后端) | 中 | 受浏览器沙箱限制 |
| 直接复用 Node SDK | ❌(需 Rust 版 client) | ✅(主进程可 import) | ❌(需浏览器版) |
**取舍**:Tauri 需要新写一份 Rust 版 client(协议本身是 JSON-RPC over WebSocket,语言无关,逻辑对齐已跑通的 client.ts 即可),换取体积、内存、性能上的长期优势——与皇上「性能好 + 跨端」的要求一致。
---
## 3. 整体架构
```
┌─────────────────────────── apps/console(桌面应用,同一份代码两端编译) ───────────────────────────┐
│ │
│ Vue 3 前端(src/) Tauri Rust 壳(src-tauri/) │
│ ┌─────────────────────┐ IPC invoke ┌──────────────────────────────┐ │
│ │ 视图 Views │ ────────────► │ commands/*.rs(薄命令层) │ │
│ │ 组件 Components │ ◄──────────── │ │ │ │
│ │ Pinia stores │ event 推送 │ ▼ │ │
│ │ api 调用层 │ ◄──────────── │ state.rs(连接管理器) │ │
│ └─────────────────────┘ │ │ │ │
│ │ ▼ │ │
│ │ crates/nodeagent-client │ ── WebSocket ──► 被控端 │
│ │ (Rust 版 client:握手/RPC/ │ (wss://IP:8765) Windows │
│ │ 事件/重连/证书钉住/ed25519) │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
```
关键设计:
1. **薄壳厚前端**:Rust 壳只做「连接管理 + 协议桥接」,业务展示逻辑全在 Vue 前端。
2. **事件推送走 Tauri event**:被控端主动事件(v12 `event.*`)、连接状态变化,由 Rust 后端转发为 Tauri event,前端 `listen()` 订阅,避免轮询。
3. **能力调用统一 `invoke`**:所有 42 项能力都走一个泛型 `invoke(capability, args)` 通道,前端按能力清单动态渲染参数表单,**不逐能力手写 command**。
---
## 4. 功能模块与页面清单(首期 = 核心管控 + 文件 + 审计)
| # | 页面/模块 | 路由 | 支撑能力 | 功能点 |
|---|---|---|---|---|
| 1 | **设备管理** | `/nodes` | `discover`、`nodes` 配置、`connect` | 局域网发现、设备列表、连接/断开、增删改节点、设默认设备、证书指纹展示与重钉 |
| 2 | **状态总览 Dashboard** | `/overview` | `system.info` / `system.status` / `system.metrics` / `system.audio.get` | 连接状态、主机名/OS/CPU/内存/开机时长、磁盘用量环图、网络适配器、PRD 成功指标(闭环率/P95/拦截率) |
| 3 | **命令执行(终端)** | `/terminal` | `system.shell.exec`、`system.task.*` | 交互式 PowerShell 终端、退出码/耗时、超时可配、长命令后台化 + 任务列表/续读/终止 |
| 4 | **进程与服务** | `/processes` | `system.process.list` / `system.service.list` | 进程/服务列表,按 CPU/内存/PID/名称排序,按名筛选,按状态筛选 |
| 5 | **软件管理** | `/software` | `app.list` / `app.install` | 已装软件列表与版本、winget 静默安装(输入包名/ID,返回结果+版本) |
| 6 | **文件管理** | `/files` | `fs.list` / `fs.stat` / `fs.read` / `fs.write` | 目录树浏览、文件预览(分块读)、上传/下载(分块 + 进度)、新建/覆盖/追加写入、元信息 |
| 7 | **审计日志** | `/audit` | `system.audit.list` / `system.audit.verify` | 审计列表分页、按类型/调用方/时间筛选、完整性校验(verify)、导出 JSONL |
> **Windows 版侧重**(待确认):同一套代码,Windows 版默认聚焦**被控端本地面板**——本机连接状态、授权(ACL/`allow_input` 输入开关)、本机资源、日志、自更新入口。因协议点对点对称,Windows 版同样可作控制端。
---
## 5. 数据接口与状态管理
### 5.1 Rust 版 client(`crates/nodeagent-client`)对外 API
| 方法 | 说明 | 对齐 client.ts |
|---|---|---|
| `Client::connect(profile) -> NodeInfo` | 握手:hello → challenge → auth → auth_ok | `connect()` |
| `Client::invoke(capability, args, timeout) -> InvokeResult` | 泛型能力调用 | `invoke()` |
| `Client::list_capabilities()` / `list_authorized()` | 能力/授权清单 | `listCapabilities()` |
| `Client::set_event_handler(fn)` | 订阅被控端事件(无 id 通知) | `onEvent` |
| `Client::on_state_change(fn)` | 连接/重连/关闭状态回调 | `onStateChange` |
| `Client::close()` | 主动断开(停自动重连) | `close()` |
| `Client::peer_cert_fingerprint()` | 对端证书指纹(TOFU 钉住) | `getPeerCertFingerprint()` |
复刻要点(已跑通,勿重蹈):指数退避重连 1s→30s ±15% 抖动;证书指纹 sha256 比对(`E_CERT_MISMATCH`);ed25519 模式需私钥;Hub 配对透传。
### 5.2 Tauri command(Rust 壳 → 前端 IPC)
| Command | 入参 | 返回 | 说明 |
|---|---|---|---|
| `discover` | `wait_ms` | `Node[]` | UDP 8766 发现 |
| `connect_node` | `node_id` | `NodeInfo` | 连接并握手 |
| `disconnect` | — | `()` | 断开 |
| `invoke` | `capability, args, timeout_ms?` | `InvokeResult` | 泛型能力调用 |
| `list_nodes` / `add_node` / `remove_node` / `set_current` | profile 结构 | 配置 | 设备 CRUD(写 `~/.nodeagent/config.json`) |
| `get_conn_state` | — | `ConnState` | 当前连接状态 |
| `get_capabilities` | — | `Capability[]` | 能力/授权清单 |
### 5.3 事件推送(Tauri event,Rust → 前端)
| 事件名 | 载荷 | 触发 |
|---|---|---|
| `node-event` | 被控端事件(`kind/action/target/detail`) | 被控端 `event.*` 主动推送 |
| `conn-state` | `connected/reconnecting/closed` | 连接状态变化 |
| `task-progress` | `task_id, offset, output` | 长任务增量输出 |
### 5.4 Pinia store 设计
| Store | 职责 | 关键 state |
|---|---|---|
| `useConnStore` | 连接状态、当前节点、能力/授权清单、证书指纹 | `state, nodeId, capabilities, authorized, certSha256` |
| `useNodeStore` | 设备配置列表、发现结果、分组 | `nodes, current, groups, discovered` |
| `useSysStore` | 系统信息/状态/指标缓存(可手动刷新) | `info, status, metrics` |
| `useFileStore` | 当前目录、文件列表、选中项、上传下载进度 | `cwd, entries, selection, transfers` |
| `useAuditStore` | 审计分页与筛选 | `items, page, filter` |
| `useEventStore` | 事件流(订阅 + 游标续拉) | `watches, events, cursor` |
| `useTaskStore` | 后台长命令任务 | `tasks` |
**数据流**:组件 → Pinia store → `api/`(封装 `invoke`)→ Tauri command → Rust client → 被控端;反向经 Tauri event → store 更新 → 响应式视图。高频指标(status/metrics)由 store 内定时器轮询,可配置周期;事件类数据走推送,不轮询。
---
## 6. 代码结构(monorepo 新增)
```
nodeagent/
├── apps/
│ └── console/ # 桌面应用(新增)
│ ├── package.json
│ ├── vite.config.ts
│ ├── index.html
│ ├── src/ # Vue 3 前端
│ │ ├── main.ts / App.vue
│ │ ├── router/index.ts # 7 条路由
│ │ ├── views/ # Nodes / Overview / Terminal / Processes / Software / Files / Audit
│ │ ├── components/ # 可复用组件(设备卡片、参数表单、状态徽章、进度条…)
│ │ ├── stores/ # Pinia(5.4 节 7 个 store)
│ │ ├── api/ # invoke 封装 + 类型(对齐 Rust command)
│ │ ├── types/ # 能力清单、InvokeResult、NodeProfile 等类型镜像
│ │ └── utils/ # 格式化、涨红跌绿、分块编排
│ └── src-tauri/ # Rust 壳
│ ├── Cargo.toml
│ ├── tauri.conf.json
│ └── src/
│ ├── main.rs / lib.rs
│ ├── state.rs # 连接管理器(持有 client)
│ └── commands/ # 5.2 节 command 实现
├── crates/
│ ├── nodeagent-client/ # Rust 版 client(新增,5.1 节)
│ │ ├── Cargo.toml
│ │ └── src/{lib.rs, client.rs, handshake.rs, tls.rs, events.rs}
│ └── nodeagent-protocol/ # (可选)协议类型/能力清单的 Rust 镜像
└── packages/… apps/{agent,cli,hub,mcp} # 现有,不动
```
约束:Rust client 与 Node client(`packages/client`)**行为一致**,两端共享 `~/.nodeagent/config.json` 与 `keys/`,不新造存储位置;能力清单以 `packages/protocol/src/capabilities/manifest` 为唯一事实源,Rust 侧做类型镜像并对齐。
---
## 7. 交付范围
### In scope(首期 v1)
| # | 交付物 | 说明 |
|---|---|---|
| 1 | `crates/nodeagent-client` | Rust 版 client,对齐 client.ts 全部行为,含单元测试 |
| 2 | `apps/console` 桌面应用 | Tauri + Vue3,7 个页面,Mac/Win 双端可编译 |
| 3 | 核心管控 | 设备管理、总览、终端、进程/服务、软件安装 |
| 4 | 文件管理 | 浏览/预览/上传/下载(分块)/写入 |
| 5 | 审计 | 列表分页筛选、完整性校验、导出 |
| 6 | 打包脚本 | Mac `.app` + Windows `.msi/.exe`(复用现有 `pack.mjs` 风格) |
### Out of scope(后续版本)
| # | 内容 | 归属 |
|---|---|---|
| 1 | 图形接管 UI(实时截图/键鼠/窗口定位/宏回放) | v2 |
| 2 | 网络两阶段变更、音频控制、自更新面板 | v2 |
| 3 | 事件流订阅的可视化(watch/list/poll 面板) | v2 |
| 4 | 应用签名/公证/自动更新 | 独立发布项 |
| 5 | 多设备批量下发(fanout/分组)面板 | v2 |
---
## 8. 验收标准
### 8.1 功能验收(Given/When/Then)
| # | 验收项 | 标准 |
|---|---|---|
| A1 | 设备连接 | Given 已配置被控端,When 点击连接,Then 3s 内返回能力清单并进入总览;密钥错误返回 `E_AUTH_FAILED` 且界面明确提示 |
| A2 | 状态总览 | Given 已连接,When 打开总览,Then 正确展示 CPU/内存/磁盘/网络与 PRD 指标,且数据带时间戳 |
| A3 | 命令执行 | Given 已连接,When 输入合法 PowerShell,Then 返回 `exit_code/stdout/stderr/duration_ms`;超时命令返回 `E_TIMEOUT` 且无残留进程 |
| A4 | 软件安装 | Given winget 可用,When 输入包名,Then 返回安装结果+版本;不存在时给出明确失败原因 |
| A5 | 文件上传/下载 | Given 已连接,When 上传大文件(>8MB),Then 分块写入成功且校验 sha256 一致;下载支持断点续读 |
| A6 | 审计 | Given 已连接,When 打开审计页,Then 分页展示并可筛选;verify 返回完整性结论 |
| A7 | 断线重连 | Given 已连接,When 拔网再恢复,Then 指数退避自动重连并回到 connected,界面状态同步 |
### 8.2 指标验收
| 指标 | 口径 | 目标 |
|---|---|---|
| 命令闭环成功率 | 成功返回 ÷ 发起 | ≥ 95% |
| 内网调用 P95 时延 | 前端点击到结果渲染 | < 3s |
| 应用内存占用 | Mac 版空闲常驻 RSS | < 150MB(远低于 Electron) |
| 打包体积 | **用户实际下载的分发件** | Mac `.dmg` < 40MB(实测 **33.8MB** ✓)/Win 安装包 < 40MB(实测 ✓ `.msi` **34.2 MiB**、NSIS **19.5~23.2 MiB**) |
| 双端编译 | `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. 里程碑拆分
| 阶段 | 内容 | 退出标准 |
|---|---|---|
| M1 | Rust client crate + 握手/RPC/事件/重连 + 单测 | 与现有 `test:e2e` 协议用例对齐通过 |
| M2 | Tauri 壳 + command 层 + 设备管理/总览/终端三页 | 真机连接成功、能跑命令、总览数据正确 |
| M3 | 文件管理 + 审计 + 软件/进程服务页 | A5/A6/A4 验收通过 |
| M4 | Mac/Win 双端打包脚本 + 文档 | A 全部通过、指标达标 |
---
## 9.1 M3 实测:契约探针与漂移修正(2026-10-07)
M3 七页写完后,用 `crates/nodeagent-client/examples/probe.rs` 对**真实被控端**逐项调用,
输出**字段契约**(顶层键 + 数组首元素键)而非数据预览,再与前端类型逐项比对。
```
cargo run --example probe -- ws://127.0.0.1:8765 <key> /tmp/nodeagent-e2e
═══ 通过 10 · 失败 0 ═══
```
> 便捷入口:`pnpm probe -- ws://127.0.0.1:8765 <key> /tmp/nodeagent-e2e`
> (等价于 `cargo run --manifest-path crates/nodeagent-client/Cargo.toml --example probe -- <url> <key> [base]`;
> `--full` 可打印完整 JSON)
> 起本地被控端:`NODEAGENT_HOME=/tmp/nodeagent-e2e node apps/agent/dist/index.js`
**探针结果**(被控端 = macOS,故 Windows 专属能力预期失败):
| 能力 | 结果 | 实测字段 |
|---|---|---|
| `fs.list` | ✓ | `entries[{name,path,type,size,mtime}]`, `total`, `truncated` |
| `fs.stat` | ✓ | `exists,mtime,path,size,type` |
| `fs.read` | ✓ | `bytes,data,encoding,eof,offset,sha256,total_bytes` |
| `fs.write` | ✓ | `path,total_bytes,written` |
| `system.audit.list` | ✓ | `entries[{args_digest,capability,client_id,duration_ms,hash,prev,remote,status,ts,type}]`, `total`, `file` |
| `system.audit.verify` | ✓ | `checked,legacy,ok` |
| `app.list` / `app.install` | ✗ 预期 | `E_UNSUPPORTED_PLATFORM`(非 Windows) |
| `system.process.list` | ✓ | `processes[{cpu_pct,memory_bytes,name,pid,started_at}]` |
| `system.service.list` | ✗ 预期 | `E_UNSUPPORTED_PLATFORM`(非 Windows) |
| `system.info` | ✓ | `os` 返回 `macOS`(**平台门控的判断锚点**) |
| `system.status` | ✓ | `disks[{drive,free,total,used_pct}]`, `net[{adapter,ip,up}]` |
| `system.shell.exec` | ✓ | `duration_ms,exit_code,killed,stderr,stdout,truncated` |
### 抓到的漂移(均为**静默空列**,非运行时报错)
| 页面 | 前端原读 | 契约真源 | 后果 |
|---|---|---|---|
| 进程表 CPU 列 | `r.cpu` | `cpu_pct` | 恒为 `-` |
| 进程表 内存列 | `r.memory` | `memory_bytes` | 恒为 `-` |
| 进程表 已运行 | (无) | `started_at` | 缺列 |
| 服务表 状态列 | `r.status` | `state` | 恒为 `-` |
| 总览 磁盘盘名 | `d.mount \|\| d.name` | `drive` | 恒空白 |
| 总览 磁盘已用 | `d.used` | **无该字段**,须算 `total - free` | 恒为 `-` |
| 总览 网卡 | `{name,rx_bytes,tx_bytes}` | `{adapter,ip,up}` | 整块错位 |
| 软件表 ID 列 | `a.id` | `publisher`(结果里**没有 id**) | 整列恒空 |
| 审计断点 | `broken_at` 当数字渲染 | `{file,line,reason}` 对象 | 渲染成 `[object Object]` |
### 修正与固化
1. **类型镜像** `apps/console/src/types/index.ts` 全部按契约改写;
`ProcessEntry`/`ServiceEntry`/`DiskInfo`/`NetInfo`/`AppEntry`/`AuditEntry`/`BuildInfo` 等。
2. **平台门控**:`conn.ts` 导出 `WINDOWS_ONLY_CAPS`,连接后自动探测 `system.info.os` → `isWindows`/`platformReady`;
软件页与服务 Tab **事前禁用并说明原因**,而不是发出注定失败的请求再解释错误。
3. **结构化错误**:`api.CapabilityFailure` 携带 `error.name`(协议真源 `InvokeResult.error` 只有
`{name,message,data?}`,**没有 code**),提供 `isUnsupportedPlatform` / `isAclDenied` / `isRateLimited`。
4. **守卫测试** `tests/unit/console-contract.test.mjs`(20 用例):
- 正向:console 声明的字段 **⊆** 该能力 `returns_schema` 的字段 → 抓「声明了不存在的字段」
- 反向:`returns_schema` 字段 **⊆** console 声明 → 抓「契约加了字段、前端没跟上」
- 平台门控清单与 agent 侧实际抛 `UNSUPPORTED_PLATFORM` 的能力集合一致
- 审计 `AuditType` 覆盖 `apps/agent/src/audit.ts` 的全部取值
- **已注入漂移实测**:把 `cpu_pct` 改回 `cpu` → 正向+反向各挂 1 项,确认非空转守卫。
---
## 10. 风险与对策
| # | 风险 | 概率 | 影响 | 对策 |
|---|---|---|---|---|
| R1 | Rust 版 client 与 Node 版行为漂移 | 中 | 高 | 以 `packages/protocol` 能力清单为唯一事实源;协议用例双端共跑 |
| R2 | Tauri 下自签证书/TLS 处理差异 | 中 | 中 | 复刻 client.ts 的 `insecure` + 指纹钉住逻辑,单测覆盖 `E_CERT_MISMATCH` |
| R3 | Windows 端定位歧义 | 中 | 中 | 见第 4 节「待确认」,评审时定死再做 |
| R4 | 大文件分块上传稳定性 | 中 | 中 | 复用已有分块能力;前端做进度+断点,M3 真机压测 |
---
## 11. 待确认项(请皇上评审时一并拍板)
| # | 事项 | 臣的建议 |
|---|---|---|
| C1 | Windows 端桌面应用的定位 | 被控端本地面板(默认侧重)+ 对称可作控制端 |
| C2 | 组件库 | Naive UI(暗色友好、TS 完整);若皇上惯用 Element Plus 可换 |
| C3 | 图表库 | ECharts(资源趋势/磁盘);确认后纳入 M2 |
| C4 | 是否同步做图形接管 UI | 建议 v2 再做,首期聚焦闭环 |
> 仅供参考,本方案为技术规划,不构成任何投资建议。
---
## 实施决策(2026-10-10):采纳「Tauri + Node sidecar」,退役 Rust client
### 为什么改
原计划(本文档 §2)让 Rust 壳**自己实现一份 client**(`crates/nodeagent-client`),
代价是「**两套协议实现**」—— Rust 版与 TS 版会**静默漂移**:改了一处漏另一处时,
构建通过、单测通过,只在真机运行时才暴露。这是本项目已经反复吃过的一类坑
(见 `RETROSPECTIVE.md` 的「两份真相」教训)。
**sidecar 方案**让 Rust 壳退化为**进程管理者 + 消息转发者**,协议实现只剩 TS 一份:
```
┌──────────────┐ Tauri command(签名不变) ┌─────────────────┐
│ Vue 前端 │ ───────────────────────────► │ Rust 壳 │
└──────────────┘ ◄──── Tauri event ────────── │(启动/转发/监督) │
└────────┬────────┘
stdin/stdout(JSON-RPC 2.0,行分隔)
▼
┌───────────────────────────┐
│ Node sidecar(sidecar.mjs)│
│ 复用 @nodeagent/client │
└───────────────────────────┘
```
### sidecar 协议
请求(Rust → sidecar):
```jsonc
{"jsonrpc":"2.0","id":1,"method":"connect","params":{url,key,client_id,insecure,auth_mode?,private_key?,cert_sha256?,hub?}}
{"jsonrpc":"2.0","id":2,"method":"invoke","params":{capability,args,timeout_ms?}}
{"jsonrpc":"2.0","id":3,"method":"state"}
{"jsonrpc":"2.0","id":4,"method":"disconnect"}
```
通知(sidecar → Rust,用 `params.kind` 区分,Rust 侧再桥接成 Tauri event):
```jsonc
{"jsonrpc":"2.0","method":"event","params":{"kind":"state","state":"connected"}}
{"jsonrpc":"2.0","method":"event","params":{"kind":"log","message":"..."}}
{"jsonrpc":"2.0","method":"event","params":{"kind":"agent_event","event":{…}}}
```
**两条纪律**:① **stdout 只写协议消息**(日志一律 stderr,混入非 JSON 行会让 Rust 侧解析失败);
② 任何异常都转成响应,**绝不因单个请求崩进程**。
### 错误形态归一(真机实测中修正)
被控端对「未知能力 / ACL 拒绝 / 超时」是回 **JSON-RPC error**,`client.invoke()` 因此**抛异常**。
若原样上抛,前端要处理两种形态。sidecar 已把它归一成 `InvokeResult` 的 failed 形态:
```jsonc
{"result":{"status":"failed","error":{"name":"E_CAPABILITY_NOT_FOUND","message":"…"}}}
```
「调用方式不对」(未连接 / 缺参数)仍走**协议错误**(带明确 code),二者语义不同、不混淆。
### 进度
| 步骤 | 状态 |
|---|---|
| ① sidecar 实现(`apps/console/sidecar/main.mjs`) | ✅ 190 行 |
| ② 真实链路 e2e(本地 agent → connect → invoke → 事件 → 错误 → disconnect) | ✅ **8/8 通过** |
| ③ 单文件打包(`sidecar/build.mjs`,esbuild JS API + createRequire shim) | ✅ 227KB,**仓库外可独立运行** |
| ④~⑥ 见下 | ✅ 详见「Rust 壳改造」一节 |
| ④ Rust 壳改造(`sidecar.rs` + 薄转发 `lib.rs`,5 个 command 签名与 3 个 event 名不变) | ✅ |
| ⑤ `tauri.conf.json` 的 resource 配置(sidecar.mjs)+ `beforeBuildCommand` 串 sidecar:build | ✅ |
| ⑥ 退役 `crates/nodeagent-client` | ✅ 已删 crates/;`rust.yml` 改为验证 console 壳 |
### 打包说明(步骤 ⑤ 的既定方向)
sidecar 是 `.mjs`,需要 Node 解释器 —— **不满足 Tauri `externalBin`(要求可执行文件)**。
采用与 `pack:win` 一致的既有做法:**sidecar.mjs + Node 运行时都作为 Tauri resource**,
Rust 侧从 resource 目录解析路径后 spawn(开发期直接用系统 node)。
(`externalBin` 若要走,需 Node SEA 打成真二进制,但其动态 require 限制与 `ws` 有冲突,故不采用。)
### Rust 壳改造(步骤 ④⑤⑥ 详情)
| 文件 | 变更 |
|---|---|
| `src-tauri/src/sidecar.rs`(新增 ~290 行) | `Sidecar` 管理器:spawn node + 行分隔 JSON-RPC 请求/响应匹配(oneshot)+ 通知回调;`resolve_node`/`resolve_sidecar` 路径解析(env → resource → 开发期系统 node) |
| `src-tauri/src/lib.rs`(重写) | 退化为薄转发:`ensure_sidecar` 懒启动;**5 个 command 签名与 3 个 event 名一律不变**;`get_capabilities`/`get_state` 读本地缓存以**保持同步签名**;`RunEvent::Exit` 时优雅 `shutdown` |
| `Cargo.toml` | 移除 `nodeagent-client` 依赖;加 `tokio`(process/io-util/sync/time/macros);版本 0.1.0 → **2.0.0**(原先与 tauri.conf.json 不一致) |
| `crates/`(整目录) | **删除**(12 个文件) |
| `.github/workflows/rust.yml` | 由「Rust client 单测」改为「**Rust 壳单测(真跑 sidecar)**」:装 Tauri 系统依赖 + Node + pnpm,跑 clippy -D warnings 与 `cargo test` |
| `package.json` | `test:rust` 改指 console 壳;移除 `probe`(其"对真机逐项核对字段"的用途已由 CLI `nodeagent invoke` 覆盖) |
**可测试性设计(关键)**:sidecar 管理器**不依赖 Tauri GUI**,故其单测能在 CI 里
**真跑 Node 子进程**验证 spawn / 请求-响应匹配 / 错误不崩 —— 不必等到人工开窗口才敢确认。
`cargo test` 实测:**3/3 通过**(0.49s)。
### Node 运行时分发(2026-10-11 完成)
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 失败后查明)
`tauri-build`(build.rs)会**在编译期校验** `tauri.conf.json` 里 `bundle.resources`
声明的每个文件是否存在。因此:
- 只声明**构建期必会生成**的产物(如 `sidecar.mjs`,由 beforeBuildCommand 产出)
- CI 里在 clippy/test **之前**必须先生成它,否则 build.rs 阶段就失败
- 本地复现:删掉 `sidecar/dist/` → `cargo check` 报 "failed to run custom build command" ✓
---
## 本机服务托管(2026-10-11):把「命令行那一步」从用户手里拿走
### 需求原文与逐条落点
> 「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
> 有日志功能,可以后台运行和开机自启等等功能」
| # | 要求 | 落点 | 实现位置 |
|---|---|---|---|
| 1 | **不再打开命令行窗口** | 三处(见下表) | `agent.rs` / `autostart.rs` / Tauri 自身 |
| 2 | 启动 / 停止 / 重启 | 「本机服务」页三按钮 + 托盘菜单 + 侧栏快捷按钮 | `AgentManager` |
| 3 | 检查更新 | 「更新」卡片:检查 → 更新 → 打开 Release 页 | `check_update` / `download` / `apply_update` |
| 4 | 日志功能 | 「服务日志」页(实时推送 + 过滤 + 暂停 + 导出) | `na-agent-log` 事件 + `agent_logs` |
| 5 | 后台运行 | 托盘常驻;关窗 → 最小化到托盘 | `build_tray()` + `on_window_event` |
| 6 | 开机自启 | 「运行方式」卡片开关 | `autostart.rs` |
### 消灭命令行窗口:三条措施缺一不可
| # | 措施 | 不加会怎样 |
|---|---|---|
| 1 | 控制台本身是 GUI 子系统程序 | 打开应用先弹一个黑窗 |
| 2 | spawn `node.exe` 时带 `CREATE_NO_WINDOW`(`0x0800_0000`) | **每次点「启动」都闪一个黑窗** —— 正是要消灭的现象 |
| 3 | 开机自启拉起的是控制台(`--agent-only`),不是裸 `node` | 开机时黑窗一闪而过 |
> 第 2 条是最容易被漏掉的一条:Rust 的 `std::process::Command` **默认不设**这个标志,
> 于是「界面上点一下启动」= 屏幕上闪一个控制台窗口。它不报错、不影响功能,
> 所以只有在**真的看着屏幕点**的时候才会发现。
### 架构:控制台自己就是被控端的「进程管理者」
```
┌──────────────────── apps/console ────────────────────┐
│ Vue:本机服务页 / 服务日志页 │
│ │ invoke ▲ event │
│ ▼ │ na-agent-state │
│ Rust:AgentManager ──────────────────────────────┐ │
│ │ ① spawn node agent.mjs(CREATE_NO_WINDOW)│ │
│ │ ② 800ms 轮询 try_wait(不把 Child 交给 wait()) │
│ │ ③ stdout/stderr 双泵 → 环形 4000 行 + seq │ │
│ ▼ │ │
│ Node 被控端(本机,loopback 8765)◄──── 同一个 agent 复用 ─┘
└──────────────────────────────────────────────────────┘
```
**复用同一个被控端实现**:控制台托管的这台本机被控端,与控制端连远端用的是**同一份 `agent.mjs`**,
不做「控制台内嵌一个第二套被控端」。这与 §「主控被控一体」的既有决策一致。
### 四条实测踩出来的关键约束
| # | 约束 | 为什么(踩坑记录) |
|---|---|---|
| 1 | **退出监控用轮询 `try_wait`,绝不把 `Child` 交给 `wait()`** | `wait()` 会**取得 `Child` 的所有权**,此后 `stop()` 再也拿不到它去 kill → 点「停止」无效 |
| 2 | **日志游标用独立单调 `seq`,不用位置下标** | 环形缓冲淘汰旧行后位置下标会漂移 → 前端增量拉取**静默错位**(重复行 / 丢行,且不报错) |
| 3 | **`start()` / `restart()` 的接收者必须是 `self: &Arc<Self>`** | 监控任务要持有自持引用;`&self` 拿不到 `Arc`,编译期 E0599/E0502 |
| 4 | **macOS `gui/<uid>` 的 uid 运行时 `id -u` 现取** | 硬编码 `501` 只是首个本地用户的常见值,多用户机不成立 |
### 播种(seeding):为什么必须有版本比较
控制台随包带一份 `agent.mjs`,首次运行复制到数据目录。规则:
```
随包版本 > 已安装版本 → 覆盖(用户拿到了更新的控制台,应带上更新的被控端)
随包版本 ≤ 已安装版本 → 保留数据目录里的(用户可能已自更新到更新版本)
```
若写成「同版本才跳过」,则**用户自更新过被控端后,下次开控制台会被静默降回旧版** ——
无任何提示,能力凭空变少,且极难归因。已用单测钉死「随包版本更低时不得覆盖」。
版本比较按**数值逐段**(`2.0.10 > 2.0.9`):字符串比较会把前者判小。
### 判定「实例是否在跑」为什么不靠单一信号
控制台同时面对两种被控端:
| 情况 | 判定方式 | 界面处置 |
|---|---|---|
| 本控制台托管的实例 | 管理器状态 = `RUNNING` | 可停止 / 重启 |
| **手工安装的非托管实例**(用户先跑过 `install.ps1` / `install-macos.sh`) | 管理器状态 = `STOPPED` **但** TCP 探测端口已被占用 | **明确告警**:这不是本控制台启的,无法代它停止;给出两条出路 —— 手工停掉,或用「连接本机」直接复用 |
只看管理器状态会把第二种情况判成「未运行」,然后用户点「启动」得到一个端口冲突;
只看端口探测则会把「别人的服务」误报成本控制台托管的实例。**两个信号交叉判**才能给出正确建议。
### 契约守卫:钉住三类「不报错的失效」
界面点按钮没反应、日志不刷新,往往**没有任何异常** —— 因为失效发生在「两边名字不一致」这种
编译器和类型系统都看不见的地方。新增 `tests/unit/console-commands.test.mjs`(8 用例)覆盖:
| 守卫 | 若缺失会怎样 |
|---|---|
| `invoke("x")` ⇄ Rust `generate_handler!` 双向 | 前端调不存在的命令;或改名后残留死命令 |
| `emit("na-*")` ⇄ `listen("na-*")` 双向 | **界面永不刷新,零报错**(最阴的一类) |
| `tauri.bundle.gen.json` 资源名 ⇄ Rust `resolve_*` 字面量 | 生产包内找不到 sidecar / node / agent |
| sidecar 方法名 ⇄ Rust `.request("x")` | 跨语言调用错名 |
| 路由表 ⇄ 实际 view 文件 + 默认路由 | 写了页面进不去;循环依赖导致路由推导成 `any` |
| `var(--na-*)` 使用点 ⇄ `cssVars()` 定义 | 拼错变量名**不报错**,颜色静默落回继承值 |
| `index.html` 首帧兜底色 ⇄ 调色板 `bg` | 首帧兜底是全应用唯一允许的色值重复处,允许重复就必须有守卫 |
> 该测试**已注入故障实测会红**:① 改 `agent_start` → `agent_startX`、`na-agent-state` → `na-agent-statee`
> 当场 2 条失败;② 把 `var(--na-sunken)` 拼成 `var(--na-sunkenTypo)`(含大写)与 `var(--na-sunkenx)`,
> 变量闭合那条均失败;③ 把 `index.html` 的 `#f4f4f5` 改回 `#ffffff`,首帧底色那条失败。
> 全部复原后 `#pass = 8 / #fail = 0`,文件逐字节还原。
>
> 第 ② 条暴露过一次**守卫自身漏报**:正则原写 `[a-z0-9-]+`,遇到含大写的拼错会在中途停下导致
> 整个模式不匹配 —— 于是「写错的变量名」反而逃过检查。放宽到 `[^)\s,;]+` 后才真正生效。
> 教训:**注入故障不只是验证守卫有没有,还要验证它是否覆盖了目标形态**。
## Windows 端控制台(2026-10-11)
### 起因
控制台此前**只在 macOS 上出过包**(`.app` / `.dmg`)。而它的主要用户场景恰恰是
「在 Windows 机器上管控其他机器」——只出 macOS 版等于把主战场让开了。
排查后发现:代码层面对 Windows 其实是备好的(`cfg!(windows)` 分支、`node.exe` 路径、
`creation_flags`、PowerShell 计划任务、`.ico` 图标、`prepare-console.mjs` 的平台自适应都在),
**缺的只是「真在 Windows 上构建一次」这一环**。
> 这里有个容易被忽视的点:macOS 上的 `cargo check` **不会编译 `cfg(windows)` 代码**,
> 所以那些 Windows 分支在此之前**从未被编译器看过一眼**。而 `cfg!(windows)` 这种运行时宏
> 虽然两边都会编译,却也只是类型检查、从未产出过 Windows 产物。
### 为什么必须双架构
被控端发布包出过「arm64 包里装的其实是 x64 `node.exe`」的事故,后果是:
在 ARM Windows 上**能启动、能 listen,但 TLS 握手不完成**(客户端只见超时),
且 macOS 侧 100% 复现不出。
控制台同理,而且更甚 —— 它内含 node 运行时,还要用这个 node **托起本机被控端**,
即自己就是 TLS 服务端。所以 x64 / arm64 必须各出一套,不能只出默认架构。
### 为什么用原生 ARM runner,而不是交叉编译
GitHub 提供 ARM64 Windows runner(`windows-11-arm`,公共仓库免费),
本仓库是 public,故 arm64 可以在它上面**原生构建**。
交叉编译(在 x64 runner 上编 arm64)看似省事,实则要付两笔代价:
1. 需要 VS 的 ARM64 工具链,且 Tauri 的 NSIS/MSI bundler 对 arm64 支持面较窄;
2. 更关键:`prepare-console.mjs` 的**自检要真执行 `node.exe`**
(x64 机器跑不了 arm64 二进制),会逼着引入「跳过自检」这类**削弱验证**的开关。
而当产出的是「架构错的运行时」时,自检正是唯一能拦住它的那道闸。
宁可等原生 runner,也不引入削弱验证的分支。
### 三条断言是必需项,不是顺手检查
| 断言 | 为什么不能省 |
|---|---|
| **构建机架构 ⇄ 目标架构**(`node -p process.arch`) | ARM runner 镜像里确实存在 x64 模拟的组件(社区实测 python 即为 AMD64)。一旦 Node 也是模拟版,`process.arch` 会是 `x64` → 把 x64 的 `node.exe` 打进 arm64 安装包 → **真机上能启动但 TLS 握手不完成,而 CI 全绿** |
| **随包 `node.exe` 的 PE Machine**(打包前,查 `src-tauri/runtime/`) | 与 `pack.mjs` 的双架构校验同源:不只看「脚本跑完了」,而是直接读最终要打进包的那个二进制头。中间逻辑再自洽,也可能因上游给了错架构的二进制而产出错包。放在打包前,是为了让失败点更靠近原因 |
| **安装包里确实装着三样东西**(打包后,解开安装包查内容) | 见下节。它才是**最终交付物级**的断言 |
### 首个 run 双双失败:错的不是构建,是断言本身
`console-windows.yml` 的第一个 run(`38151366534`)两个 job 全红,且**失败点完全一致**。
但取日志后发现,**构建链路一路全绿**:
| 步骤 | x64 job | arm64 job |
|---|---|---|
| 构建机架构 | ✓ | ✓ `process.arch = arm64`(原生 ARM runner 属实) |
| 随包 `node.exe` PE | ✓ `0x8664` | ✓ `0xaa64`(73.5 MB) |
| `tauri build` | ✓ `.msi` 34.2 MiB + `-setup.exe` 23.2 MiB | ✓ `-setup.exe` 19.5 MiB |
| 产物自举 | ✓ 打包内 node v22.23.3 真跑起打包内 sidecar | ✓ |
| **体积断言** | ✗ `34.2 < 50` | ✗ `19.5 < 50` |
死的是最后一步,而这一步是**守卫自己写错了**。原断言是「安装包体积 > 50MB」,
> 另断言安装包体积 > 50MB:含 Node 运行时时 x64 约 82MB / arm64 约 72MB,
> 体积过小意味着运行时没打进去 —— 那种包能装,但一启动就找不到 node。
错在把**未压缩的目录形态**(`.app` ≈ 92 MiB)当成了**压缩安装包的体积**。
安装包是压缩容器:LZMA 下 `node.exe` 自身就能压到 1/3~1/4,实测 `.msi` 34.2 MiB、
NSIS 19.5~23.2 MiB,阈值 50MB 必然误杀。
> 更根本的问题:**体积阈值属于「中间逻辑自洽」式断言**。包大不等于 `node.exe` 真在里面
> (可能打进的是别的大文件),包小也不等于没在里面(压缩率会随 Node 版本漂移)。
> 这与「被控端 zip 里塞了错架构的 node」那条教训是同一类 —— 判据必须落在**产物内容**上,
> 不能落在**间接指标**上。
改为**解开安装包、查内容**(`.msi` 走 `msiexec /a` 管理安装、NSIS 走 `/S /D=` 静默安装,
两者都是各自格式的核心能力,不引外部依赖),一次钉死三件事:
| 检查 | 挡住什么 |
|---|---|
| `node.exe` / `sidecar.mjs` / `agent.mjs` **三样都在** | 缺任一,真机上就是「应用能开、但托不起被控端」 |
| `node.exe` 的 **PE Machine = 目标架构** | 「arm64 包里装的是 x64 node.exe」那类**只在真机暴露**的事故 |
| `node.exe` 解压后 **> 40MB** | 塞了个占位文件(这里是「体积」的**正确用法**:作为内容已验明后的自明性检查,而不是作为「运行时在不在」的**替代判据**) |
附带收益:**真装一遍本身就是安装器冒烟** —— 比只读目录更强。
另给该步加 `timeout-minutes: 8`,避免静默安装万一卡住把 job 拖满 60 分钟。
### 第二个 run 仍红:`&` 调 GUI 程序不等待,也不设退出码
第二个 run(`38152850819`)两个 job **仍同点红**,但已是另一处原因,且同样是**静默失效**。
日志里只有一行关键信息:
```
msiexec /a 失败(退出码 ):nodeagent-console_2.0.2_x64_en-US.msi
```
`$LASTEXITCODE` 是**空的**,而整步从开始到抛错只过了 **0.8 秒** —— 根本没等安装器跑完。
**根因**:`msiexec.exe` 与 NSIS 安装包都是 **GUI 子系统程序**,PowerShell 用 `&` 调用时
**既不等待其结束、也不设置 `$LASTEXITCODE`**。于是 `$null -ne 0` 判真,
在安装器还没跑完时就当场抛错 —— 报错读起来像「安装失败」,实为「压根没等」。
(arm64 那条没有 MSI、直奔 NSIS,失败形态一模一样,正是同一根因的第二个样本。)
**修法**:
- 两处都改 `Start-Process -Wait -PassThru`,读 `$p.ExitCode`(确定性等待),并打印实际退出码;
- 参数拼成**单个字符串**传 `-ArgumentList`,避免数组被 PowerShell 自动加引号 ——
那会破坏 NSIS `/D=`「必须原样、不加引号、且是最后一个参数」的硬要求;
- 新增 `Wait-PayloadRoot`(最多 30 秒收敛):安装器可能「自己先退出、由子进程继续装」,
避免竞态误报「找不到 node.exe」;
- `msiexec` 退出码 3010(成功但需重启)按成功处理。
### 第三个 run:双绿,且断言给出了产物实证
第三个 run(`38154282653`)**两个 job 全绿**,第 14 步的实际输出:
| 检查对象 | 安装包体积 | 安装器退出码 | 解开后 `node.exe` | PE Machine | 另两样 |
|---|---|---|---|---|---|
| x64 `.msi` | 34.2 MiB | 0 | **82.9 MB** | **`0x8664`** ✓ | sidecar 232 KB / agent 1060 KB |
| x64 `-setup.exe`(NSIS) | 23.2 MiB | 0 | **82.9 MB** | **`0x8664`** ✓ | 同上 |
| arm64 `-setup.exe`(NSIS) | 19.5 MiB | 0 | **73.5 MB** | **`0xaa64`** ✓ | 同上 |
这一步的价值在于:**它证明的正是「19.5MB 的压缩包里真的装着 73.5MB 的 arm64 `node.exe`」** ——
这恰是体积断言永远无法区分的那件事(19.5MB 与「没打运行时」的包在体积上无法区分)。
另:`/D=` 确实生效(安装位置即 `pkg-extract\nsis`),故 NSIS 的默认安装位置候选列表是备用保险。
### 产物形态
| 架构 | runner | 安装包 | 实测体积 |
|---|---|---|---|
| x64 | `windows-latest` | `.msi`(企业批量部署)+ `-setup.exe`(NSIS,普通用户双击) | **34.2 MiB** / **23.2 MiB** |
| arm64 | `windows-11-arm` | `-setup.exe`(NSIS)—— WiX 的 arm64 MSI 支持面较窄,不值得为它引入不确定性 | **19.5 MiB** |
对照 macOS:`.app` 92.2 MiB(目录形态)/`.dmg` 33.8 MiB(压缩分发件)。
**两者不可直接比** —— 这正是上面那条错断言的来源。
工作流 `console-windows.yml` 与 `windows-e2e.yml` **刻意分开**:
后者是「被控端在 Windows 上的行为验证」,跑一遍约 4 分钟、含安装/卸载真机冒烟;
前者要装 Rust 工具链、编 Tauri(冷编 10 分钟级)。目标不同、耗时量级不同,
混在一起会让「改一行前端也要等 15 分钟」。
### 仍未做
- **控制台尚未接入 `scripts/release.mjs`**(该脚本目前只出被控端包)。
控制台的安装包目前只作为 CI artifact 产出,未随 Release 发布。
- 真机安装冒烟(在真实 Windows 上装一遍、点一遍)仍需图形环境人工执行。
## 界面观感约定(2026-10-11)
两轮纯界面调整,方向都是「少即是多」。理由记下来,免得日后被当成「没做完」再补回去。
### 一、一律不使用图标
触发原因是一次真实的渲染事故:侧栏的 emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**。
试过三法,分别无效或更糟 —— 给图标指定 `Apple Color Emoji`、全局字体栈加 `!important`、
乃至 `font-variant-emoji: emoji`(最后一招把 ASCII 数字也拆成了 emoji 呈现)。
「哪套字体栈一定带 emoji」跨平台并不成立 —— **与其赌字形,不如不用**。
去掉的范围比"看得见的图标"宽得多(下面这三类都算):
| 类别 | 处数 | 关法 |
|---|---|---|
| 侧栏 emoji | 9 | 路由表 `meta.icon` 字段**整体删除**,`NavMeta` 接口同步收窄 |
| `n-empty` 空态**插画** | **16** | `:show-icon="false"` —— 插画比图标更「图形」,散在 9 个页面,**最易漏** |
| `n-alert` 类型图标 | 7 | naive-ui 的 `showIcon` **默认就是 `true`**,不显式关它就一直都在 |
| `n-popconfirm` 询问图标 | 3 | 同上 |
| 文案里的符号 | 1 | 终端页「⚠ 高权限操作,请谨慎」保留纯文字 |
顺带清掉 `public/{vite,tauri}.svg` 与 `src/assets/vue.svg`:`public/` 下的文件会被**整体复制进产物**,
此前等于把 Vite / Tauri 的 logo 一并发了出去(且 `index.html` 的 favicon 正指向它)。
`index.html` 的 `<title>` 还是模板默认的「Tauri + Vue + Typescript App」,也一并改齐
(原生窗口标题取自 `tauri.conf.json`,故此前只是 dev 态标签页不一致)。
**刻意未动**:① 托盘图标 —— 菜单栏没图标就点不到,关窗后应用无法寻回,属**功能必需**;
② 被控端 CLI 自己打印的文本标记(`⚠️`/`ℹ️`/`✅`)—— 是**程序输出内容**,会原样出现在「服务日志」页里,
但不是界面元素;③ 控件的结构性部件(下拉/折叠箭头、输入框清除钮、表格排序箭头)。
### 二、配色收敛为两套中性灰阶主题
`简约白`(浅)/`高级灰`(深),侧栏底部一行切换,选择存 `localStorage`。
| 决定 | 理由 |
|---|---|
| **主色位改中性灰** | naive-ui 默认主色是**绿色**、原设计的品牌字是**青绿 `#63e2b7`** —— 都是"颜色",与「简单些」相悖。主按钮/开关激活/进度条/勾选框统一吃这一个值 |
| 白主题**页底不是纯白**(`#f4f4f5`) | 卡片本身是白的,两者同色就只能靠 1px 分线撑层次,整页发「糊」。压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主 |
| 语义色**降饱和**而非去掉 | 这是运维工具,「运行中 / 出错」必须一眼可辨,全部灰化会导致误读。故它们只出现在徽章与告警这类**小面积**上,底色观感仍是「白 + 灰」 |
| **色值只定义一份** | `src/theme/index.ts` 的 `Palette` 同时喂 naive-ui 的 `themeOverrides` 与 `--na-*` CSS 变量(`App.vue` 运行期注入)。两处各写一份,换主题时会**一半新一半旧且不报错、类型也抓不到** |
| 主题**只存 `localStorage`**,不落 Rust settings | Rust 那套 `Settings` 是给「仅服务模式」(无窗口、常驻托盘)也要生效的偏好用的;主题是纯图形偏好,**没有窗口时毫无意义**。为一件纯前端的事去动 Rust 命令面与契约守卫不划算 |
清掉散在 **8 个文件**里的 **17 处写死色值**(日志三流着色、代码块/预览底色、节点选中边框、历史悬停…),
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
**首帧防闪**:`index.html` 内联一小段脚本,在 Vue 接管前先把上次主题的底色刷上
(否则「高级灰」用户每次启动都会看到一帧刺眼的白)。
此处**刻意重复了 2 个底色值**,已在 `index.html` 与 `theme/index.ts` 两处注明「改底色时同步」——
因为在它执行之前没有任何模块可依赖。除此之外全应用再无第二份色值。
### 本轮验证记录
| 条目 | 结果 |
|---|---|
| `cargo check --all-targets` | ✅ 0 错 0 警 |
| `cargo test --lib` | ✅ **10/10**(含真起 node 子进程:启动/日志/停止/崩溃自愈/重复启动拒绝) |
| `cargo clippy --all-targets -- -D warnings` | ✅ 0 警告 |
| `npx vue-tsc --noEmit` | ✅ 通过 |
| `node tests/unit/console-commands.test.mjs` | ✅ 8/8(三条注入故障均验证会红) |
| `pnpm test:unit` | ✅ **282/282**(274 → 282) |
| `node scripts/prepare-console.mjs` | ✅ agent.mjs 1059 KB + Node 运行时 86.3 MB = 87.6 MB |
| `node apps/console/scripts/verify-bundle.mjs --runtime` | ✅ 打包内 node 真跑起打包内 sidecar(`agent.mjs` 语法解析通过) |
### ⚠️ 仍需图形环境人工验证的部分
沙箱内无法运行图形应用,故以下**只做了代码级与产物级验证,未实点**:
| 待验 | 怎么验 |
|---|---|
| 托盘图标 / 菜单 / 左键唤出 | 打开打包后的 `.app`,点图标与菜单项 |
| 关窗 → 最小化到托盘(不退出) | 点窗口关闭按钮,确认进程仍在、图标仍在 |
| 界面点「启动/停止/重启」 | 点三按钮,看状态与日志是否实时变化 |
| 开机自启实际生效 | macOS:登出再登入 / 重启;Windows:重启后看计划任务 |
| 黑窗是否真的没有 | **全程盯着屏幕操作一遍**(这条只能靠眼睛) |
---
## ARM64 真机安装冒烟与界面实点(2026-10-12)
上一节那张表里的条目,本轮在 **Parallels 27 + Win11 ARM64** 上逐条做完;
过程中还挖出并修掉一个**只在真机上才暴露**的缺陷(见本节末「真机暴露的缺陷」)。
### 怎么把包送进去
目标机的 SSH / 445 / 3389 / 5985 全被防火墙挡着,**只有 8765 开着**。
于是让**产品自己去搬产品**:`fs.write`(分块 base64)传包 → `system.shell.exec` 静默安装 → `fs.read` 取回结果。
这条路子本身也顺带验证了分块传输与 base64 往返的正确性。
| 项 | 实测 |
|---|---|
| 传输 | **20,445,580 B,0 块损坏**(两端 sha256 一致) |
| 静默安装 | `EXIT=0` |
| 包内 `node.exe` | PE `aa64` / subsystem **3 (CONSOLE)** |
| 包内 `nodeagent-console.exe` | PE `aa64` / subsystem **2 (GUI)** ← **结构上就不可能弹黑窗** |
| 包内 `uninstall.exe` | PE `014c` / subsystem 2 |
| 随包 Node 自报 | `v22.23.3` / `arm64`(v2.0.2 的修复确实生效) |
| 安装位置 | `C:\Users\<user>\AppData\Local\nodeagent-console`(**按用户安装**,非 Program Files) |
### 界面实点结果
| 条目 | 结果 | 判据 |
|---|---|---|
| 启动 | ✅ `pid=9052 / session=1`,工作集 **30~32 MB**(目标 <150 MB) | 进程表 |
| **主题切换** | ✅ 切到「高级灰」→ 选中态翻转 + 整窗深色,且**可逆** | UIA `selected` 翻转(不是看截图猜的)+ 截图 |
| **关窗 → 托盘** | ✅ 发 `WM_CLOSE` 后窗口从 `window.list` 消失、**进程仍存活 32 MB** | 窗口列表对比 |
| **无黑窗** | ✅ 结构性(PE subsystem=2)+ 视觉确认 | PE 头 + 截图 |
| 输入默认禁 | ✅ `input.mouse.move` → `E_CAPABILITY_DISABLED` | 错误名 |
| 开机自启 | ✅ 计划任务 `cn_jkooe.nodeagent-console` **不存在**,与 UI 开关=关 一致 | `schtasks` |
| 数据目录显示 | ✅ UI 显示 `C:\Users\pdverify\.nodeagent`,与真实生效目录一致 | 配置比对 |
| 「最近日志」 | ✅ 正确记下「端口 8765 已被占用」并给出降级提示 | 截图 |
### ⚠️ 本轮新发现(**未修**):控制台没有单实例保护
复现:关窗到托盘(进程仍在)→ **再次双击启动** → **起出第二个控制台进程**(9052 + 9232 并存)。
`apps/console/src-tauri/Cargo.toml` 里只有 `tauri-plugin-opener`,**没有单实例插件**。
后果:两份托盘图标、两套互不知情的状态机,且**都会去抢被控端的 8765**。
建议:引入 `tauri-plugin-single-instance`,第二次启动只 `show()` + `set_focus()` 已有窗口。
### 真机暴露的缺陷:200% 缩放下图形能力坐标错位(**本机已修**)
同一台机器上还实测到一个**只在真机暴露、单测与 CI 全绿**的缺陷:
被控端的 PowerShell 助手进程**默认不 DPI 感知**,于是在 200% 缩放下同一个进程里并存两套坐标系 ——
| 调用 | 不感知时的结果 |
|---|---|
| `Screen.Bounds` / `SystemInformation.VirtualScreen` | 报**逻辑**尺寸 1800×1010 |
| `CopyFromScreen` | 按**物理**像素裁剪 → `screen.capture` 缺省只截到左上 1/4 |
| `SetCursorPos` | 被系统 ×2(喂 100,100 → 光标到 200,200) |
| UIA(`screen.find`) | 报**物理**坐标(与进程是否感知无关) |
净效果:`screen.find` 给出的物理矩形**不能**直接喂给 `input.mouse.*`,
必须人工除以 `scale` —— 上一轮就是靠这个临时绕法才点中的主题按钮。
修法:新增 `apps/agent/src/util/dpi.ts`,把 `SetProcessDPIAware()` 注入到**全部坐标相关脚本的最前面**
(5 个文件),并配 8 条单测守卫。详见证见 `CHANGELOG.md` 与 `docs/VM-VERIFICATION.md` §10。
> 仅供技术参考,不构成任何投资建议。