From fcd004f658c569b8046b2c6c332a459ba3ed3687 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 24 Jun 2026 23:23:49 +0800 Subject: [PATCH 01/22] docs(spec): Linux headless CLI support design (plan A) Design for running cli-box as a headless daemon on cloud Linux servers, keeping keyboard input (PTY) and frame-less screenshots. - Plan A: Rust-native terminal renderer (vt100 + ab_glyph), no Electron - PTY un-gating (portable-pty is cross-platform) - Screenshot/scrollback headless routing when renderer not connected - ui-inspect explicitly out of scope (GUI-app only, no value headless) - Linux CI gate + headless E2E + release pipeline + npm package --- ...026-06-24-linux-headless-support-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-linux-headless-support-design.md diff --git a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md new file mode 100644 index 0000000..a5611f7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md @@ -0,0 +1,167 @@ +# Linux 无头 CLI 支持 — 设计文档 + +> **日期**:2026-06-24 +> **分支**:`feat/linux-headless-support` +> **状态**:设计已确认,待实现计划 +> **方案**:方案 A — Rust 原生终端渲染器(真·无头) + +--- + +## 一、背景与问题 + +cli-box 当前是 **macOS 专属**的桌面自动化沙箱。需求是让其能以**无头 CLI 形态运行在云端 Linux 服务器**上,保留两项核心能力: + +1. **键盘输入** — 向 CLI(如 Claude Code)发送按键 +2. **截图(不带 `--with-frame`)** — 对终端输出取图,用于自动化反馈 + +### 现状分析结论 + +**1. 现状不会发布 Linux 版本。** `release.yml` 仅 `runs-on: macos-latest`,只产 darwin-arm64 产物与 npm 包;`release.sh` 注释明确要求 macOS;`electron-builder.config.cjs` 仅 `mac.target: ["dmg"]`。 + +**2. 发布 Linux 必须改代码,不能直接复用。** 代码深度耦合 macOS API(ScreenCaptureKit / CGEvent / AXUIElement),非 macOS 分支全是返回错误的桩函数,且 `process/mod.rs` 的非 macOS `spawn_cli_with_size` 存在**类型不匹配的潜在编译 bug**(返回 `Ok(())`,签名要求 `Result`)——因 CI 从不在 Linux 上编译(Clippy/test 跑在 macos-latest),该 bug 一直未暴露。 + +**3. 改动量整体为「中等」**:键盘输入与 PTY 生命周期已基于跨平台的 `portable-pty`,仅需解开 cfg 守卫(≈60%);截图需新增 Rust 渲染模块(≈30%);流水线改造(≈10%)。 + +--- + +## 二、目标与非目标 + +### 目标 + +- 在 **Linux(x86_64)** 上以无头 daemon 形态运行:CLI 沙箱(PTY)+ 键盘输入 + 终端截图 + scrollback。 +- macOS 现有行为 **100% 不变**(GUI + 渲染层截图路径完全保留)。 +- headless 模式作为一等概念,与 macOS GUI 路径并存、自动切换。 +- 打通 Linux 发布流水线(CI 门禁 + release 产物 + npm 包)。 + +### 非目标(明确不做) + +| 项 | 原因 | +|----|------| +| `--with-frame` / ScreenCaptureKit on Linux | macOS 专属 API,返回现有 "only available on macOS" 错误 | +| app 模式(`spawn_app` 控制 GUI 应用)on Linux | 云无头场景无 GUI 应用可启动 | +| 鼠标 click/scroll 输入 on Linux | 依赖 CGEvent;**仅键盘(PTY)可用** | +| Electron GUI on Linux | 无头,不打包 | +| **`ui-inspect`(AXUIElement)on Linux** | AXUIElement 是 GUI 应用专有的无障碍元素树读取。云无头场景**没有 GUI 应用可读**,语义上不适用。Linux 对等物为 AT-SPI2(`atspi` crate + DBus + X11/Wayland 桌面),实现成本等同或超过截图渲染器,但对此目标**零价值**。返回 "only available on macOS" 错误 | + +--- + +## 三、架构总览 + +``` + screenshot (no --with-frame) + │ + ┌───────────┴────────────┐ + renderer 已连接? renderer 未连接 (headless) + (macOS GUI,不变) (Linux / 无 Electron) + │ │ + Electron xterm.js Rust HeadlessTerminal + canvas 渲染 (现有) vt100 解析 + ab_glyph 栅格化 (新增) +``` + +- macOS:renderer 连上 → 走渲染层(不变)。 +- Linux(或任何无 Electron 环境):renderer 永不连接 → 自动走 headless 渲染器。 +- 切换依据:`DaemonState` 中已有的 `screenshot_ws_tx`(renderer 是否连上)。 + +--- + +## 四、组件设计 + +### 组件 1:PTY 跨平台化(`crates/cli-box-core/src/process/mod.rs`) + +**成本:小。** 释放现有跨平台代码。 + +- 把真实 PTY 实现(`portable-pty` + `nix`,二者均跨平台)的 cfg 守卫从 `target_os = "macos"` 改为 `unix`(覆盖 macOS + Linux)。 +- 合并/删除当前返回错误的非 macOS 桩函数;修复 `spawn_cli_with_size` 非 macOS 分支返回 `Ok(())` 的类型 bug(应为构造并返回 `ProcessInfo`)。 +- `SESSIONS` 静态、`PtyStore`、PTY reader 线程均跨平台,无需改动。 +- 风险低:`portable-pty` 在 Linux 原生支持 Unix PTY,`nix` 的 signal/process 在 Linux 可用。 + +### 组件 2:无头终端渲染器(**新增** `crates/cli-box-core/src/capture/headless.rs`) + +**成本:中。** 唯一真正的新代码,边界清晰、可独立 TDD。 + +**职责**:输入 PTY 字节流 → 维护终端网格 → 输出 PNG。 + +- 新增依赖:`vt100` crate(增量维护终端网格:行列、ANSI/256 色、光标、scrollback)+ `ab_glyph`(字体栅格化);复用已有 `image`。 +- 类型:`HeadlessTerminal { cols, rows, parser: vt100::Parser, ... }`。 + - `feed(&mut self, bytes: &[u8])` — 增量喂入 PTY 字节,更新网格。 + - `render_png(&self, scroll_offset: usize) -> Result>` — 按当前网格(+ 可选滚动偏移)渲染 PNG。 +- 渲染逻辑:由 `ab_glyph` 计算等宽字体的 `CHAR_WIDTH/CHAR_HEIGHT`;逐格绘制背景填充 + 前景字形;`image` 编码 PNG。颜色支持 16/256 色调色板 + 默认前后景色。 +- 字体:嵌入一个等宽 TTF(`include_bytes!`,建议 DejaVu Sans Mono 或其紧凑子集),保证云端环境一致,不依赖系统字体。 +- **挂载点**:每个 sandbox 持有一个常驻 `HeadlessTerminal`,存于 PTY session 旁。daemon 现有 reader 线程在写 `PtyStore` 的同时调用 `feed()`(增量解析,保持实时网格)。 + +**保真度**:近似 xterm.js。支持颜色、光标、基本 Unicode;不追求宽字符/连字/字体回退等极致渲染(对 Claude Code 这类 CLI 文本输出场景足够)。 + +### 组件 3:截图 & scrollback 路由(`crates/cli-box-core/src/daemon/mod.rs`) + +**成本:小。** + +- **截图**:`screenshot_handler` 的 `!with_frame` 分支,判断 `renderer_connected`: + - 已连接 → 现有 `request_renderer_screenshot`(macOS GUI,不变)。 + - 未连接 → 新增 `screenshot_headless(state, id, scroll, top)`:从对应 sandbox 的 `HeadlessTerminal` 取网格 → `render_png()` → 返回 `source: "headless"`。 +- **scrollback**:`scrollback_handler` 同理获得 headless 路径: + - `raw=true` → 直接读 `pty_store` 原始字节(跨平台,零额外代码)。 + - clean → 读 `HeadlessTerminal` 网格文本。 + - 新增 `scrollback_headless()` helper,镜像 `screenshot_headless()`。 +- `--with-frame` 在 Linux 维持返回 "only available on macOS" 错误。 + +### 组件 4:daemon / CLI headless 短路 + +**成本:极小。** 大部分基础设施已存在。 + +- daemon:`create_sandbox_handler` 中 `find_electron_window()` 已 best-effort 返回 `None` 不阻塞,**无需改动**;renderer WS 不连接即触发 headless 路径。 +- CLI:`ensure_healthy_electron`(`main.rs`)已能在找不到 Electron 时进入 "headless daemon mode",但当前会空等 renderer 60s。改为:**非 macOS 或找不到 Electron 二进制时立即短路**,不等待 renderer。 + +### 组件 5:发布流水线 + +**成本:中。** + +- **`ci.yml` 新增 Linux 编译/测试门禁**(`rust-clippy-linux` + `rust-test-linux` on `ubuntu-latest`)——关键:正是它缺失才导致前述 cfg 编译 bug 未被发现。`test.sh` 现有的 `uname=Linux && CI` 跳过逻辑需相应调整,让 headless 子集在 Linux 上运行。 +- **`release.yml` 新增 `build-linux` job**(`ubuntu-latest`):仅构建裸 `cli-box` + `cli-box-daemon`(不打 Electron),上传到同一个 GitHub Release。 +- **npm**:新增 `packages/cli-box-linux-x64/`;skill 包的 `optionalDependencies` 增加 linux 条目。 +- `release.sh` 保持 macOS-only;Linux 发布走 CI。 +- Cargo.toml 的 macOS 专属依赖(`core-graphics`/`core-foundation`/`objc`/`screencapturekit`)已正确 `cfg` 门控,Linux 自动不引入。`vt100`/`ab_glyph` 作为 core 的无条件跨平台依赖。 + +--- + +## 五、测试策略 + +| 层级 | 内容 | +|------|------| +| **UT** | `headless.rs`:喂已知 ANSI 序列(纯文本/颜色/光标移动/换行/滚动),断言 PNG 尺寸 + 抽检像素颜色;golden-file 回归(`cargo test`)。PTY 跨平台实现现能在 Linux CI 跑。 | +| **IT** | daemon 在无 renderer(headless)下,`/box/{id}/screenshot` 与 `/box/{id}/scrollback` 返回合法非空结果(`tower::ServiceExt::oneshot`)。 | +| **E2E(CI 必过门禁)** | `e2e-linux-headless` job(ubuntu runner):`cli-box start bash "命令"` → `cli-box type` → `cli-box screenshot`(默认 + `--top`)→ `cli-box scrollback`,断言 PNG 非空、尺寸正确。**复用 `tests/e2e-compound-start-screenshot.sh` 流程**。 | +| **CI** | 新增 linux clippy + test 门禁;headless E2E 作为必过门禁。 | + +E2E 是唯一能端到端验证无头渲染器的环节,**不可只靠单测替代**。 + +--- + +## 六、风险与取舍 + +1. **截图保真度**:服务端 vt100 渲染无法 100% 复刻 xterm.js(颜色细节、宽字符)。接受"近似",对 CLI 文本输出场景足够。 +2. **嵌入式字体体积**:TTF 嵌入会增大二进制;选用紧凑子集或可裁剪字体控制体积。 +3. **scroll offset 语义**:需对齐现有 `--scroll`/`--top` 查询参数与 vt100 scrollback 的映射。 +4. **CI 差异**:ubuntu runner 无 `zsh`,E2E sandbox 命令统一用 `bash`。 + +--- + +## 七、改动文件清单(预估) + +| 文件 | 改动 | +|------|------| +| `crates/cli-box-core/src/process/mod.rs` | cfg 守卫 `macos`→`unix`;修编译 bug;合并桩函数 | +| `crates/cli-box-core/src/capture/headless.rs` | **新增**:`HeadlessTerminal` + 渲染 | +| `crates/cli-box-core/src/capture/mod.rs` | 导出 headless 模块 | +| `crates/cli-box-core/src/daemon/mod.rs` | screenshot/scrollback headless 路由;reader 线程 feed | +| `crates/cli-box-core/Cargo.toml` | 加 `vt100`、`ab_glyph` | +| `crates/cli-box-cli/src/main.rs` | `ensure_healthy_electron` headless 短路 | +| `packages/cli-box-linux-x64/` | **新增** npm 包 | +| `packages/cli-box-skill/package.json` | optionalDependencies 加 linux | +| `.github/workflows/ci.yml` | linux clippy/test + headless E2E 门禁 | +| `.github/workflows/release.yml` | build-linux job | +| `tests/e2e-compound-start-screenshot.sh` | 适配 Linux 运行(bash) | +| 字体资源(如 `assets/fonts/`) | **新增** 嵌入式等宽 TTF | + +--- + +**版本**:v1.0 | **创建**:2026-06-24 | **方案**:方案 A(Rust 原生终端渲染器) From 04af813ddd0a2ff09191a5afac51199bf2eb6701 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 24 Jun 2026 23:30:19 +0800 Subject: [PATCH 02/22] docs(spec): adopt CJK-capable monospace font (option B) Embed a CJK-capable monospace TTF (Sarasa Mono SC / Noto Sans Mono CJK, ~5-20MB) instead of a Latin-only font, so Chinese CLI output renders correctly for the automation-feedback loop. Note residual fidelity losses (emoji tofu, truecolor quantization, no ligatures/fallback) and binary-size mitigations (pyftsubset subsetting / lazy on-disk font). --- .../specs/2026-06-24-linux-headless-support-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md index a5611f7..d52b59d 100644 --- a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md +++ b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md @@ -86,10 +86,10 @@ cli-box 当前是 **macOS 专属**的桌面自动化沙箱。需求是让其能 - `feed(&mut self, bytes: &[u8])` — 增量喂入 PTY 字节,更新网格。 - `render_png(&self, scroll_offset: usize) -> Result>` — 按当前网格(+ 可选滚动偏移)渲染 PNG。 - 渲染逻辑:由 `ab_glyph` 计算等宽字体的 `CHAR_WIDTH/CHAR_HEIGHT`;逐格绘制背景填充 + 前景字形;`image` 编码 PNG。颜色支持 16/256 色调色板 + 默认前后景色。 -- 字体:嵌入一个等宽 TTF(`include_bytes!`,建议 DejaVu Sans Mono 或其紧凑子集),保证云端环境一致,不依赖系统字体。 +- 字体:嵌入一个 **CJK-capable 等宽 TTF**(`include_bytes!`,如 Sarasa Mono SC 或 Noto Sans Mono CJK,~5–20MB),保证云端环境一致且**中文正确渲染**(不依赖系统字体)。理由:中文环境下 CLI 输出含 CJK,豆腐块会使自动化反馈失效。 - **挂载点**:每个 sandbox 持有一个常驻 `HeadlessTerminal`,存于 PTY session 旁。daemon 现有 reader 线程在写 `PtyStore` 的同时调用 `feed()`(增量解析,保持实时网格)。 -**保真度**:近似 xterm.js。支持颜色、光标、基本 Unicode;不追求宽字符/连字/字体回退等极致渲染(对 Claude Code 这类 CLI 文本输出场景足够)。 +**保真度**:近似 xterm.js。CJK/拉丁文本、ANSI 16/256 色、光标、换行均正确渲染(CJK 由嵌入 CJK 字体支持)。**残留降级**:Emoji 仍可能为豆腐块(CJK 等宽字体通常不含彩色 Emoji)、Truecolor(24 位 RGB)可能量化到 256 色、字体连字/字体回退不支持。 ### 组件 3:截图 & scrollback 路由(`crates/cli-box-core/src/daemon/mod.rs`) @@ -138,8 +138,8 @@ E2E 是唯一能端到端验证无头渲染器的环节,**不可只靠单测 ## 六、风险与取舍 -1. **截图保真度**:服务端 vt100 渲染无法 100% 复刻 xterm.js(颜色细节、宽字符)。接受"近似",对 CLI 文本输出场景足够。 -2. **嵌入式字体体积**:TTF 嵌入会增大二进制;选用紧凑子集或可裁剪字体控制体积。 +1. **截图保真度**:服务端 vt100 渲染无法 100% 复刻 xterm.js(残留降级见组件 2:Truecolor 量化、Emoji 豆腐块、字体连字/回退)。CJK/拉丁由嵌入字体正确支持,对 CLI 文本输出场景足够。 +2. **嵌入式字体体积**:选定 **CJK 等宽字体**(~5–20MB),二进制明显增大。缓解:后续可用字体子集化(`pyftsubset` 按实际字形裁剪)或按需落盘到 `~/.cli-box/` 首次运行加载。 3. **scroll offset 语义**:需对齐现有 `--scroll`/`--top` 查询参数与 vt100 scrollback 的映射。 4. **CI 差异**:ubuntu runner 无 `zsh`,E2E sandbox 命令统一用 `bash`。 @@ -160,7 +160,7 @@ E2E 是唯一能端到端验证无头渲染器的环节,**不可只靠单测 | `.github/workflows/ci.yml` | linux clippy/test + headless E2E 门禁 | | `.github/workflows/release.yml` | build-linux job | | `tests/e2e-compound-start-screenshot.sh` | 适配 Linux 运行(bash) | -| 字体资源(如 `assets/fonts/`) | **新增** 嵌入式等宽 TTF | +| 字体资源(如 `assets/fonts/`) | **新增** 嵌入式 CJK 等宽 TTF(Sarasa/Noto Mono CJK) | --- From f31d8f17fe27b8e31f3c89572394616a0d958b98 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 24 Jun 2026 23:36:00 +0800 Subject: [PATCH 03/22] docs(spec): correct Linux compile-blocker analysis The non-macOS stubs return Err(...) (they compile). The real Linux compile blocker is ungated code (SESSIONS static, list_processes(), is_session_alive()) referencing the cfg(macos)-gated PtySession type. Earlier 'Ok(()) type bug' claim was a misread; corrected. Design and Task structure unchanged. --- .../specs/2026-06-24-linux-headless-support-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md index d52b59d..f5be9be 100644 --- a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md +++ b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md @@ -18,7 +18,7 @@ cli-box 当前是 **macOS 专属**的桌面自动化沙箱。需求是让其能 **1. 现状不会发布 Linux 版本。** `release.yml` 仅 `runs-on: macos-latest`,只产 darwin-arm64 产物与 npm 包;`release.sh` 注释明确要求 macOS;`electron-builder.config.cjs` 仅 `mac.target: ["dmg"]`。 -**2. 发布 Linux 必须改代码,不能直接复用。** 代码深度耦合 macOS API(ScreenCaptureKit / CGEvent / AXUIElement),非 macOS 分支全是返回错误的桩函数,且 `process/mod.rs` 的非 macOS `spawn_cli_with_size` 存在**类型不匹配的潜在编译 bug**(返回 `Ok(())`,签名要求 `Result`)——因 CI 从不在 Linux 上编译(Clippy/test 跑在 macos-latest),该 bug 一直未暴露。 +**2. 发布 Linux 必须改代码,不能直接复用。** 代码深度耦合 macOS API(ScreenCaptureKit / CGEvent / AXUIElement),非 macOS 分支全是返回错误的桩函数(这些桩本身能编译)。真正的 Linux 编译阻断在于:`PtySession` 结构体被 `cfg(target_os = "macos")` 门控,但 `SESSIONS` 静态、`list_processes()`、`is_session_alive()` 等**未门控**代码引用了它——因 CI 从不在 Linux 上编译(Clippy/test 跑在 macos-latest),这些未门控引用一直未被捕获。 **3. 改动量整体为「中等」**:键盘输入与 PTY 生命周期已基于跨平台的 `portable-pty`,仅需解开 cfg 守卫(≈60%);截图需新增 Rust 渲染模块(≈30%);流水线改造(≈10%)。 @@ -71,7 +71,7 @@ cli-box 当前是 **macOS 专属**的桌面自动化沙箱。需求是让其能 **成本:小。** 释放现有跨平台代码。 - 把真实 PTY 实现(`portable-pty` + `nix`,二者均跨平台)的 cfg 守卫从 `target_os = "macos"` 改为 `unix`(覆盖 macOS + Linux)。 -- 合并/删除当前返回错误的非 macOS 桩函数;修复 `spawn_cli_with_size` 非 macOS 分支返回 `Ok(())` 的类型 bug(应为构造并返回 `ProcessInfo`)。 +- 合并/删除当前返回错误的非 macOS 桩函数;修复**未门控**的 `SESSIONS` 静态与 `list_processes()`/`is_session_alive()` 对 macOS 专属 `PtySession` 的引用(将其与真实 PTY 实现一并移到 `cfg(unix)`)。 - `SESSIONS` 静态、`PtyStore`、PTY reader 线程均跨平台,无需改动。 - 风险低:`portable-pty` 在 Linux 原生支持 Unix PTY,`nix` 的 signal/process 在 Linux 可用。 @@ -149,7 +149,7 @@ E2E 是唯一能端到端验证无头渲染器的环节,**不可只靠单测 | 文件 | 改动 | |------|------| -| `crates/cli-box-core/src/process/mod.rs` | cfg 守卫 `macos`→`unix`;修编译 bug;合并桩函数 | +| `crates/cli-box-core/src/process/mod.rs` | cfg 守卫 `macos`→`unix`;解除 `PtySession`/`SESSIONS`/`list_processes` 未门控引用;合并桩函数 | | `crates/cli-box-core/src/capture/headless.rs` | **新增**:`HeadlessTerminal` + 渲染 | | `crates/cli-box-core/src/capture/mod.rs` | 导出 headless 模块 | | `crates/cli-box-core/src/daemon/mod.rs` | screenshot/scrollback headless 路由;reader 线程 feed | From a703efe581067672369679d59715e3972369bfcc Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 24 Jun 2026 23:54:44 +0800 Subject: [PATCH 04/22] docs(plan): Linux headless CLI support implementation plan 9 TDD tasks: deps+font, PTY cfg(unix) un-gating, HeadlessTerminal (vt100+ab_glyph renderer), PTY-session mounting, daemon headless screenshot/scrollback routing, CLI --headless, Linux CI gate, headless E2E, release pipeline + npm package. vt100/ab_glyph APIs verified against docs.rs. --- .../2026-06-24-linux-headless-support.md | 1270 +++++++++++++++++ 1 file changed, 1270 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-linux-headless-support.md diff --git a/docs/superpowers/plans/2026-06-24-linux-headless-support.md b/docs/superpowers/plans/2026-06-24-linux-headless-support.md new file mode 100644 index 0000000..e01779f --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-linux-headless-support.md @@ -0,0 +1,1270 @@ +# Linux 无头 CLI 支持 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 cli-box 以无头 daemon 形态运行在云端 Linux x86_64 服务器上,保留键盘输入(PTY)与不带 `--with-frame` 的终端截图(Rust 原生渲染器)。 + +**Architecture:** 在 daemon 现有 renderer 截图路径之外,新增一条 headless 路径:当 Electron renderer 未连接时,由常驻 `HeadlessTerminal`(`vt100` 解析 + `ab_glyph` 栅格化)把 PTY 字节渲染成 PNG。PTY 改用 `cfg(unix)` 守卫释放跨平台实现。macOS GUI 行为完全不变。 + +**Tech Stack:** Rust(portable-pty · nix · vt100 0.16 · ab_glyph · image · axum)· GitHub Actions(ubuntu-latest)· npm optionalDependencies + +## Global Constraints + +- 目标平台:macOS(不变)+ Linux x86_64。`cfg(unix)` 同时覆盖两者;Windows 非目标。 +- macOS 专属依赖(`core-graphics`/`core-foundation`/`objc`/`screencapturekit`)保持 `cfg(target_os="macos")` 门控,Linux 不引入。 +- `portable-pty` 与 `nix` 已是无条件依赖(cli-box-core `[dependencies]`),跨平台可用。 +- 嵌入字体:CJK-capable 等宽 TTF(Sarasa Mono SC / Noto Sans Mono CJK),`include_bytes!`。 +- 代码与注释用英文;用户交流用中文。 +- 提交粒度:小步、可独立编译;实现与测试同提交。Commit 格式 `(): `,scope 优先用 `process`/`capture`/`daemon`/`cli`/`ci`/`npm`。 +- 不自动合入主分支;PR 保持 open。 + +## File Structure + +| 文件 | 责任 | 动作 | +|------|------|------| +| `crates/cli-box-core/Cargo.toml` | 加 `vt100`、`ab_glyph` 依赖 | 修改 | +| `crates/cli-box-core/assets/fonts/SarasaMonoSC-Regular.ttf` | 嵌入 CJK 等宽字体 | 新增(二进制,需下载) | +| `crates/cli-box-core/src/capture/headless.rs` | `HeadlessTerminal`:feed + render_png | 新增 | +| `crates/cli-box-core/src/capture/mod.rs` | 导出 headless 模块 | 修改 | +| `crates/cli-box-core/src/process/mod.rs` | PTY 跨平台化(`cfg(macos)`→`cfg(unix)`,挂载 HeadlessTerminal,删 error 桩) | 修改 | +| `crates/cli-box-core/src/daemon/mod.rs` | screenshot/scrollback headless 路由 | 修改 | +| `crates/cli-box-cli/src/main.rs` | `ensure_healthy_electron` headless 短路 | 修改 | +| `crates/cli-box-core/tests/daemon_integration.rs` | headless screenshot/scrollback IT | 修改 | +| `packages/cli-box-linux-x64/` | npm 平台包 | 新增 | +| `packages/cli-box-skill/package.json` | optionalDependencies 加 linux | 修改 | +| `.github/workflows/ci.yml` | linux clippy/test + headless E2E 门禁 | 修改 | +| `.github/workflows/release.yml` | build-linux job | 修改 | +| `tests/e2e-compound-start-screenshot.sh` | 适配 Linux(bash) | 修改 | + +--- + +### Task 1: Foundation — 依赖与字体资产 + +**Files:** +- Modify: `crates/cli-box-core/Cargo.toml` +- Create: `crates/cli-box-core/assets/fonts/SarasaMonoSC-Regular.ttf` + +**Interfaces:** +- Produces: `vt100`/`ab_glyph` 可用;`SarasaMonoSC-Regular.ttf` 存在,供 Task 3 的 `include_bytes!` 引用。 + +- [ ] **Step 1: 添加依赖** + +在 `crates/cli-box-core/Cargo.toml` 的 `[dependencies]` 末尾(`rusqlite.workspace = true` 之后)追加: + +```toml +vt100 = "0.16" +ab_glyph = "0.2" +``` + +- [ ] **Step 2: 获取 CJK 等宽字体** + +```bash +mkdir -p crates/cli-box-core/assets/fonts +# Sarasa Mono SC(等宽 + 中日韩 + 拉丁),约 5MB +curl -L -o /tmp/sarasa.zip "https://github.com/be5invis/Sarasa-Gothic/releases/download/v1.0.26/SarasaMonoSC-1.0.26.zip" || true +# 若 zip 不可用,改用 Noto Sans Mono CJK: +# curl -L -o crates/cli-box-core/assets/fonts/NotoSansMonoCJKsc-Regular.otf "https://github.com/notofonts/noto-cjk/raw/main/Sans/Mono/NotoSansMonoCJKsc-Regular.otf" +``` + +将解压后的 `sarasa-mono-sc-regular.ttf` 重命名放置为 `crates/cli-box-core/assets/fonts/SarasaMonoSC-Regular.ttf`。确认文件存在且 > 1MB: + +```bash +ls -lh crates/cli-box-core/assets/fonts/ +``` + +> 说明:字体是二进制资产,无法在计划中以文本提供。若上述 URL 不可达,实现者从 [Sarasa Gothic releases](https://github.com/be5invis/Sarasa-Gothic/releases) 或 [Google Noto](https://fonts.google.com/noto) 手动下载任一 CJK 等宽字体(Sarasa Mono SC / Noto Sans Mono CJK SC / Source Han Mono),文件名需与 Task 3 的 `include_bytes!` 路径一致。若文件名为 `.otf`,Task 3 的常量名/路径相应调整。 + +- [ ] **Step 3: 验证依赖编译** + +```bash +cargo build -p cli-box-core 2>&1 | tail -5 +``` +Expected: 编译通过(新依赖被拉取)。 + +- [ ] **Step 4: 提交** + +```bash +git add crates/cli-box-core/Cargo.toml crates/cli-box-core/assets/fonts/ +git commit -m "chore(core): add vt100/ab_glyph deps and CJK font asset" +``` + +--- + +### Task 2: PTY 跨平台化(`process/mod.rs`) + +**Files:** +- Modify: `crates/cli-box-core/src/process/mod.rs`(约 18–21 行 use、35–53 行 PtySession、各 PTY 方法 + error 桩) + +**Interfaces:** +- Produces: `ProcessManager::spawn_cli_with_size / send_input / resize_pty / read_output / subscribe_output / get_store / kill_process / list_processes / is_session_alive` 在所有 unix(macOS + Linux)上可用(此前仅 macOS)。 +- Consumes: `portable-pty`、`nix`(已是 cli-box-core 无条件依赖)。 + +> 此 Task 不引入新功能,仅把已有跨平台 PTY 实现从 `cfg(target_os="macos")` 释放为 `cfg(unix)`,并删除返回错误的非 macOS 桩。本机验证(macOS)证明不回归;Linux 编译的权威证明在 Task 7 的 CI 门禁。 + +- [ ] **Step 1: 解除 import 守卫** + +`process/mod.rs` 第 18 行附近: + +```rust +#[cfg(target_os = "macos")] +use { + nix::sys::signal::{kill, Signal}, + nix::unistd::Pid, + portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}, +}; +``` +改为: + +```rust +#[cfg(unix)] +use { + nix::sys::signal::{kill, Signal}, + nix::unistd::Pid, + portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}, +}; +``` + +- [ ] **Step 2: 解除 PtySession 守卫** + +第 35 行 `#[cfg(target_os = "macos")]` 上方的 `struct PtySession { ... }`,把其 `#[cfg(target_os = "macos")]` 改为 `#[cfg(unix)]`。结构体字段不变。 + +- [ ] **Step 3: 把所有 PTY 方法的 macOS 守卫改为 unix** + +对以下方法的 `#[cfg(target_os = "macos")]` 全部改为 `#[cfg(unix)]`(用编辑器逐个替换,或脚本): +`spawn_app`(保留 macOS-only:见 Step 4)、`spawn_cli`、`spawn_cli_with_size`、`send_input`、`resize_pty`、`read_output`、`peek_output`、`subscribe_output`、`get_store`、`kill_process`。 + +> 注意:`spawn_app` / `spawn_app_with_window` / `find_pids_by_app_name` 等 macOS GUI 应用启动逻辑**保持 `cfg(target_os="macos")` 不变**(app 模式非 Linux 目标)。只改 PTY 相关方法。 + +- [ ] **Step 4: 删除所有返回错误的非 macOS 桩** + +删除所有 `#[cfg(not(target_os = "macos"))]` 的 PTY 桩函数(`spawn_app` 非 macOS 桩可保留或删除,删除更干净;`spawn_cli`/`spawn_cli_with_size`/`kill_process`/`send_input`/`resize_pty`/`read_output`/`peek_output`/`subscribe_output`/`get_store` 的非 macOS 桩**全部删除**)。 + +验证删除后无残留 `cfg(not(target_os = "macos"))` PTY 桩: + +```bash +grep -n "cfg(not(target_os = \"macos\"))" crates/cli-box-core/src/process/mod.rs +``` +Expected: 仅剩 `spawn_app` 相关(若保留)或为空。 + +- [ ] **Step 5: 验证本机编译 + 现有 PTY 测试不回归** + +```bash +cargo build -p cli-box-core -p cli-box-cli -p cli-box-daemon 2>&1 | tail -5 +cargo test -p cli-box-core --test pty_reader_test 2>&1 | tail -15 +cargo clippy -p cli-box-core --all-targets -- -D warnings 2>&1 | tail -5 +``` +Expected: 全部通过(macOS 上行为不变;PTY 方法现在 `cfg(unix)` 仍覆盖 macOS)。 + +- [ ] **Step 6: 提交** + +```bash +git add crates/cli-box-core/src/process/mod.rs +git commit -m "feat(process): un-gate PTY implementation to cfg(unix) + +The portable-pty + nix based PTY implementation was cfg(macos)-gated +with error-returning stubs on other platforms, and ungated SESSIONS/ +list_processes/is_session_alive referenced the gated PtySession type +(a Linux compile blocker). Move the real impl to cfg(unix) and drop +the stubs. No macOS behavior change." +``` + +--- + +### Task 3: HeadlessTerminal 模块(`capture/headless.rs`) + +**Files:** +- Create: `crates/cli-box-core/src/capture/headless.rs` +- Modify: `crates/cli-box-core/src/capture/mod.rs`(追加 `pub mod headless; pub use headless::HeadlessTerminal;`) + +**Interfaces:** +- Produces: + - `HeadlessTerminal::new(cols: u16, rows: u16) -> Self` + - `HeadlessTerminal::feed(&self, bytes: &[u8])` — 增量喂入 PTY 字节 + - `HeadlessTerminal::render_png(&self, scroll_offset: usize) -> Result>` — 渲染当前屏幕为 PNG + - `HeadlessTerminal::rendered_text(&self) -> String` — 当前屏幕纯文本(scrollback clean 模式用) + - `HeadlessTerminal::cols() -> u16`、`rows() -> u16` +- Consumes: `vt100 = "0.16"`、`ab_glyph = "0.2"`、`image`(已是依赖)、Task 1 的字体。 + +> vt100 API 已核对(docs.rs 0.16.2):`Parser::new(rows, cols, scrollback)`、`process(&[u8])`、`screen().cell(row, col) -> Option<&Cell>`、`Cell::{contents, fgcolor, bgcolor, inverse}`、`Color::{Default, Idx(u8), Rgb(u8,u8,u8)}`、`screen().set_scroll(usize)`、`screen().size() -> (usize, usize)`。 +> ab_glyph:`FontRef::try_from_slice`、`glyph_id`、`outline_glyph`、`OutlinedGlyph::px_bounds` + `draw(|x,y,coverage|)`。实现时若 `draw` 坐标原点语义与本计划假设不符,以 docs.rs 为准调整 offset——属同一逻辑,非占位。 + +- [ ] **Step 1: 写失败测试 — feed 后网格内容/颜色** + +在 `crates/cli-box-core/src/capture/headless.rs` 顶部先写测试模块(TDD): + +```rust +//! Headless terminal renderer: parse PTY bytes into a grid and render to PNG. +//! Pure (bytes in → PNG out), fully unit-testable. Headless/Linux screenshot path. + +use crate::error::{AppError, Result}; +use std::sync::Mutex; +use vt100::{Color, Screen}; + +/// Embedded CJK-capable monospace font (Latin + CJK; emoji not included). +const FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/SarasaMonoSC-Regular.ttf"); + +const DEFAULT_FG: (u8, u8, u8) = (229, 229, 229); +const DEFAULT_BG: (u8, u8, u8) = (0, 0, 0); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feed_plain_text_appears_on_screen() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"hello"); + // row 0, col 0 = 'h' + let cell = term.screen_cell(0, 0).unwrap(); + assert_eq!(cell.contents(), "h"); + assert_eq!(term.screen_cell(0, 4).unwrap().contents(), "o"); + } + + #[test] + fn feed_ansi_color_sets_fgcolor() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"\x1b[31mRED\x1b[m"); + // cell(0,0) foreground = indexed red = Idx(1) + assert_eq!(term.screen_cell(0, 0).unwrap().fgcolor(), Color::Idx(1)); + } + + #[test] + fn rendered_text_matches_screen_contents() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"line one\nline two"); + let text = term.rendered_text(); + assert!(text.contains("line one")); + assert!(text.contains("line two")); + } +} +``` + +注意:测试引用了 `term.screen_cell(row, col)`(测试辅助),在 Step 3 实现里提供。 + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cargo test -p cli-box-core capture::headless 2>&1 | tail -15 +``` +Expected: 编译失败(`HeadlessTerminal` 未定义)。 + +- [ ] **Step 3: 实现 HeadlessTerminal 基础(new/feed/screen_cell/rendered_text)** + +在 `headless.rs`(测试模块之后)实现: + +```rust +/// xterm-style 16-color palette (indices 0–15). +const PALETTE_16: [(u8, u8, u8); 16] = [ + (0, 0, 0), (205, 0, 0), (0, 205, 0), (205, 205, 0), + (0, 0, 238), (205, 0, 205), (0, 205, 205), (229, 229, 229), + (127, 127, 127), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (92, 92, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), +]; + +/// Convert a vt100 Color to RGB. `default` is used for Color::Default +/// (caller passes DEFAULT_FG or DEFAULT_BG as appropriate). +fn color_rgb(c: Color, default: (u8, u8, u8)) -> (u8, u8, u8) { + match c { + Color::Default => default, + Color::Idx(i) => { + let i = i as usize; + if i < 16 { + PALETTE_16[i] + } else if i < 232 { + // 6x6x6 cube, base 16 + let v = (i - 16) as u32; + let r = v / 36; + let g = (v / 6) % 6; + let b = v % 6; + let lvl = |x: u32| if x == 0 { 0u8 } else { 55 + (x as u8) * 40 }; + (lvl(r), lvl(g), lvl(b)) + } else { + let g = 8 + (i - 232) as u8 * 10; + (g, g, g) + } + } + Color::Rgb(r, g, b) => (r, g, b), + } +} + +/// A persistent headless terminal: maintains a live grid from PTY bytes +/// and can render the screen to PNG. Mirrors the role xterm.js plays in the +/// Electron renderer, but server-side and dependency-free. +pub struct HeadlessTerminal { + cols: u16, + rows: u16, + parser: Mutex, +} + +impl HeadlessTerminal { + pub fn new(cols: u16, rows: u16) -> Self { + // scrollback of 10000 lines keeps history for --top / --scroll. + Self { + cols, + rows, + parser: Mutex::new(vt100::Parser::new(rows, cols, 10_000)), + } + } + + pub fn feed(&self, bytes: &[u8]) { + if let Ok(mut p) = self.parser.lock() { + p.process(bytes); + } + } + + pub fn cols(&self) -> u16 { + self.cols + } + pub fn rows(&self) -> u16 { + self.rows + } + + /// Render the current screen as plain text (clean scrollback mode). + pub fn rendered_text(&self) -> String { + let p = self.parser.lock().expect("poisoned terminal"); + p.screen().contents().to_string() + } + + /// Test helper: read a cell's clone at (row, col). + #[cfg(test)] + fn screen_cell(&self, row: usize, col: usize) -> Option { + let p = self.parser.lock().expect("poisoned terminal"); + p.screen().cell(row, col).cloned() + } +} +``` + +- [ ] **Step 4: 运行测试确认前 3 个通过** + +```bash +cargo test -p cli-box-core capture::headless 2>&1 | tail -15 +``` +Expected: 3 个测试 PASS。 + +- [ ] **Step 5: 写失败测试 — render_png 产出有效 PNG** + +在 `tests` 模块追加: + +```rust + #[test] + fn render_png_has_expected_dimensions() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"hello world"); + let png = term.render_png(0).expect("render failed"); + // PNG magic header + assert_eq!(&png[..8], &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + // decode and check size = 80*w x 24*h, non-trivial + let img = image::load_from_memory(&png).expect("decode").to_rgba8(); + assert_eq!(img.width() % 80, 0, "width must be multiple of cols"); + assert_eq!(img.height() % 24, 0, "height must be multiple of rows"); + assert!(img.width() >= 80 * 4 && img.height() >= 24 * 8); + } + + #[test] + fn render_png_contains_non_background_pixels() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"\x1b[31mX\x1b[m"); + let png = term.render_png(0).expect("render failed"); + let img = image::load_from_memory(&png).expect("decode").to_rgba8(); + // At least one non-black pixel (the red 'X' on black bg). + let has_ink = img.pixels().any(|p| p[0] > 50 || p[1] > 50 || p[2] > 50); + assert!(has_ink, "rendered PNG should contain non-background pixels"); + } +``` + +- [ ] **Step 6: 运行确认失败** + +```bash +cargo test -p cli-box-core capture::headless::tests::render_png 2>&1 | tail -15 +``` +Expected: 编译失败(`render_png` 未定义)。 + +- [ ] **Step 7: 实现 render_png** + +在 `impl HeadlessTerminal` 追加: + +```rust + /// Render the current screen (optionally scrolled back) to PNG bytes. + pub fn render_png(&self, scroll_offset: usize) -> Result> { + use ab_glyph::{point, Font, FontRef, PxScale}; + use image::{ImageBuffer, Rgba, RgbaImage}; + use std::io::Cursor; + + let font = + FontRef::try_from_slice(FONT_BYTES).map_err(|e| AppError::Screenshot(format!("font load: {e}")))?; + + // Monospace metrics. PxScale: x = advance-ish, y = line height. + let line_h = 18.0f32; + let scale = PxScale { x: line_h, y: line_h }; + let ascent = font.ascent() / font.units_per_em().max(1.0) * line_h; + + // Monospace cell width: advance of 'M' in unscaled font units * scale. + let m_advance = font.glyph_advance(font.glyph_id('M')); + let cell_w = ((m_advance * scale.x).round() as u32).max(8); + let cell_h = line_h.round() as u32; + + let parser = self + .parser + .lock() + .map_err(|e| AppError::Screenshot(format!("terminal lock: {e}")))?; + parser.screen().set_scroll(scroll_offset); + let (rows, cols) = parser.screen().size(); + + let img_w = cols as u32 * cell_w; + let img_h = rows as u32 * cell_h; + let mut img: RgbaImage = + ImageBuffer::from_pixel(img_w, img_h, Rgba([DEFAULT_BG.0, DEFAULT_BG.1, DEFAULT_BG.2, 255])); + + let blend = |bg: u8, fg: u8, a: f32| -> u8 { + ((bg as f32) * (1.0 - a) + (fg as f32) * a).round() as u8 + }; + + for row in 0..rows { + for col in 0..cols { + let Some(cell) = parser.screen().cell(row, col) else { + continue; + }; + let bg = color_rgb( + if cell.inverse() { cell.fgcolor() } else { cell.bgcolor() }, + DEFAULT_BG, + ); + let fg = color_rgb( + if cell.inverse() { cell.bgcolor() } else { cell.fgcolor() }, + DEFAULT_FG, + ); + let x0 = col as u32 * cell_w; + let y0 = row as u32 * cell_h; + // fill background + for py in y0..(y0 + cell_h) { + for px in x0..(x0 + cell_w) { + img.put_pixel(px, py, Rgba([bg.0, bg.1, bg.2, 255])); + } + } + // rasterize glyph(s) + let base_y = y0 as f32 + ascent; + for ch in cell.contents().chars() { + let glyph = font + .glyph_id(ch) + .with_scale_and_position(scale, point(x0 as f32, base_y)); + let Some(outlined) = font.outline_glyph(glyph) else { + continue; + }; + let bb = outlined.px_bounds(); + let min_x = bb.min.x.round() as i32; + let min_y = bb.min.y.round() as i32; + outlined.draw(|gx, gy, cov| { + let px = (min_x + gx as i32) as u32; + let py = (min_y + gy as i32) as u32; + if px < img_w && py < img_h && cov > 0.0 { + let p = img.get_pixel_mut(px, py); + p[0] = blend(p[0], fg.0, cov); + p[1] = blend(p[1], fg.1, cov); + p[2] = blend(p[2], fg.2, cov); + } + }); + } + } + } + + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png) + .map_err(|e| AppError::Screenshot(format!("png encode: {e}")))?; + Ok(buf.into_inner()) + } +``` + +> 实现者注意:`OutlinedGlyph::draw(|x, y, coverage|)` 的 `(x, y)` 在 ab_glyph 中是相对 `px_bounds().min` 的像素偏移;上面用 `min_x/min_y` 偏移到绝对坐标。若所用 ab_glyph 版本的 `draw` 已返回绝对坐标,去掉偏移即可。以 `cargo test render_png` 通过为准。 + +- [ ] **Step 8: 运行全部 headless 测试** + +```bash +cargo test -p cli-box-core capture::headless 2>&1 | tail -15 +cargo clippy -p cli-box-core --all-targets -- -D warnings 2>&1 | tail -5 +``` +Expected: 5 个测试全 PASS;clippy 无 warning。 + +- [ ] **Step 9: 导出模块** + +在 `crates/cli-box-core/src/capture/mod.rs` 末尾追加: + +```rust +pub mod headless; +pub use headless::HeadlessTerminal; +``` + +- [ ] **Step 10: 提交** + +```bash +git add crates/cli-box-core/src/capture/headless.rs crates/cli-box-core/src/capture/mod.rs +git commit -m "feat(capture): add HeadlessTerminal (vt100 + ab_glyph PNG renderer) + +Pure module: feed PTY bytes -> live vt100 grid -> render PNG. Replaces +the Electron xterm.js canvas path when no renderer is connected (headless). +CJK-capable via embedded monospace font. Fully unit-tested." +``` + +--- + +### Task 4: 在 PTY session 挂载 HeadlessTerminal(reader 线程 feed) + +**Files:** +- Modify: `crates/cli-box-core/src/process/mod.rs` + +**Interfaces:** +- Produces: `ProcessManager::get_terminal(pid: u32) -> Result>`(新增),供 Task 5 的 daemon 路由使用。 +- Consumes: `crate::capture::HeadlessTerminal`(Task 3)。 + +- [ ] **Step 1: PtySession 增加 terminal 字段** + +在 `PtySession` 结构体(现 `cfg(unix)`)追加字段: + +```rust +struct PtySession { + writer: Box, + master: Box, + #[allow(dead_code)] + child_pid: u32, + command: String, + store: Arc, + /// Headless terminal grid fed incrementally by the reader thread. + terminal: Arc, + stop_flag: Arc, + reader_thread: Option>, + output_tx: broadcast::Sender, +} +``` + +- [ ] **Step 2: 创建 terminal 并在 reader 线程 feed** + +在 `spawn_cli_with_size` 中,创建 store 后、创建 reader 线程前,加: + +```rust + let terminal = Arc::new(crate::capture::HeadlessTerminal::new(cols, rows)); + let thread_terminal = Arc::clone(&terminal); +``` + +在 reader 线程的 `Ok(n) => { ... }` 分支内,`thread_store.append(&text)?` 之后、`thread_tx.send` 之前,加: + +```rust + // Feed raw bytes to the headless terminal grid (for screenshots). + thread_terminal.feed(&read_buf[..n]); +``` + +> 用原始字节 `read_buf[..n]`,不用 lossy 转换后的 `text`(避免 UTF-8 边界被破坏)。 + +并在 `sessions.insert(tracked_id, PtySession { ... })` 的字段列表中加入 `terminal,`。 + +- [ ] **Step 3: 实现 get_terminal 访问器** + +在 `impl ProcessManager` 中(与 `get_store` 相邻),加(`cfg(unix)`): + +```rust + /// Get the HeadlessTerminal for a session (for headless screenshots). + #[cfg(unix)] + pub fn get_terminal(pid: u32) -> Result> { + let sessions = SESSIONS + .lock() + .map_err(|e| AppError::Process(e.to_string()))?; + let session = sessions + .get(&pid) + .ok_or_else(|| AppError::Process(format!("Process {pid} not found")))?; + Ok(Arc::clone(&session.terminal)) + } +``` + +- [ ] **Step 4: 编译 + 现有 PTY 测试不回归** + +```bash +cargo build -p cli-box-core 2>&1 | tail -5 +cargo test -p cli-box-core --test pty_reader_test 2>&1 | tail -15 +cargo clippy -p cli-box-core --all-targets -- -D warnings 2>&1 | tail -5 +``` +Expected: 全部通过。 + +- [ ] **Step 5: 提交** + +```bash +git add crates/cli-box-core/src/process/mod.rs +git commit -m "feat(process): mount HeadlessTerminal on PTY sessions + +Each PTY session now holds a persistent HeadlessTerminal, fed +incrementally by the existing reader thread alongside PtyStore. Adds +get_terminal(pid) accessor for the headless screenshot path." +``` + +--- + +### Task 5: Headless 截图 & scrollback 路由(`daemon/mod.rs`) + +**Files:** +- Modify: `crates/cli-box-core/src/daemon/mod.rs`(DaemonState 字段、run_daemon 签名、screenshot_handler、scrollback_handler、新增 2 个函数、test helpers) +- Modify: `crates/cli-box-daemon/src/main.rs`(`--headless` 参数) +- Modify: `crates/cli-box-core/tests/daemon_integration.rs`(test helpers + headless IT) + +**Interfaces:** +- Consumes: `ProcessManager::get_terminal`(Task 4)、`ProcessManager::get_store`(已有)。 +- Produces: daemon `--headless` 模式;`DaemonState.headless: bool`;截图/scrollback 在 headless 模式下走服务端渲染。 + +> 设计选择:headless 走显式 `--headless` 标志而非"renderer 未连接即 headless",确保 macOS(有 Electron)行为 100% 不变;仅 Linux/无 Electron 时 CLI 传 `--headless`。 + +- [ ] **Step 1: DaemonState 增加 headless 字段** + +`daemon/mod.rs` 结构体 `DaemonState` 末尾(`terminal_ready_sandboxes` 之后)加: + +```rust +pub struct DaemonState { + // ... existing fields ... + pub terminal_ready_sandboxes: HashSet, + /// True when running without the Electron renderer (Linux / no GUI). + /// Routes screenshots/scrollback to the server-side HeadlessTerminal. + pub headless: bool, +} +``` + +并更新**所有** `DaemonState { ... }` 字面构造,加入 `headless: `: +- `run_daemon`(约 1600 行):用传入参数(Step 2 改签名)。 +- `test_daemon_state`(约 1806)、`test_daemon_state_with_sandbox`(约 1834):`headless: false`。 +- IT 文件 `daemon_integration.rs` 的 `empty_state` 与 `state_with_sandbox`:`headless: false`。 + +```bash +grep -rn "terminal_ready_sandboxes: HashSet::new()\|terminal_ready_sandboxes: " crates/ 2>/dev/null +``` +逐一在这些构造后补 `headless: false,`(run_daemon 用参数变量)。 + +- [ ] **Step 2: run_daemon 接收 headless 参数** + +```rust +pub async fn run_daemon(port: u16, headless: bool) -> Result<(), Box> { + tracing::info!("Daemon starting on port {port} (pid={}, headless={headless})", std::process::id()); + let state = Arc::new(Mutex::new(DaemonState { + port, + sandboxes: HashMap::new(), + started_at: Instant::now(), + screenshot_ws_tx: None, + pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), + screenshot_request_counter: 0, + terminal_ready_sandboxes: HashSet::new(), + headless, + })); + // ... rest unchanged ... +``` + +- [ ] **Step 3: daemon main 解析 --headless** + +`crates/cli-box-daemon/src/main.rs`,在 `--version`/`--help` 判断之后、`find_available_port` 之前加: + +```rust + let headless = args.iter().any(|a| a == "--headless"); +``` + +并把启动行改为: + +```rust + rt.block_on(async move { cli_box_core::daemon::run_daemon(port, headless).await }) + .expect("Daemon exited with error"); +``` + +并在 `--help` 文本追加:`eprintln!(" --headless Run without Electron (headless, Linux)");` + +- [ ] **Step 4: 新增 screenshot_headless 函数** + +在 `screenshot_with_frame` 函数之后加: + +```rust +/// Capture a screenshot by rendering the PTY terminal grid server-side. +/// Used when running headless (no Electron renderer). `scroll` is a line +/// offset into the scrollback; large values (e.g. from --top) clamp to top. +async fn screenshot_headless( + state: Arc>, + id: &str, + scroll: u32, +) -> Result { + let pty_pid: u32 = { + let s = state.lock().await; + let sb = s + .sandboxes + .get(id) + .ok_or_else(|| AppError::Instance(format!("Sandbox '{id}' not found")))?; + sb.pty_pid + .ok_or_else(|| AppError::Process(format!("Sandbox {id} has no PTY")))? + }; + let terminal = tokio::task::spawn_blocking(move || { + crate::process::ProcessManager::get_terminal(pty_pid) + }) + .await + .map_err(|e| AppError::Screenshot(format!("get_terminal task failed: {e}")))??; + let png_data = tokio::task::spawn_blocking(move || terminal.render_png(scroll as usize)) + .await + .map_err(|e| AppError::Screenshot(format!("render task failed: {e}")))??; + Ok(screenshot_response(png_data, "headless", None)) +} +``` + +- [ ] **Step 5: 新增 scrollback_headless 函数** + +在 `request_renderer_scrollback` 之后加: + +```rust +/// Read scrollback from server-side state (headless). `raw` returns the raw +/// PTY bytes from PtyStore; otherwise the parsed terminal grid text. +async fn scrollback_headless( + state: Arc>, + id: &str, + raw: bool, +) -> Result { + let (pty_pid,) = { + let s = state.lock().await; + let sb = s + .sandboxes + .get(id) + .ok_or_else(|| AppError::Instance(format!("Sandbox '{id}' not found")))?; + (sb.pty_pid + .ok_or_else(|| AppError::Process(format!("Sandbox {id} has no PTY")))?,) + }; + if raw { + let store = tokio::task::spawn_blocking(move || { + crate::process::ProcessManager::get_store(pty_pid) + }) + .await + .map_err(|e| AppError::Process(format!("get_store task failed: {e}")))??; + let chunks = store + .read_all() + .map_err(|e| AppError::Process(format!("read_all failed: {e}")))?; + Ok(chunks.into_iter().map(|c| c.data).collect()) + } else { + let terminal = tokio::task::spawn_blocking(move || { + crate::process::ProcessManager::get_terminal(pty_pid) + }) + .await + .map_err(|e| AppError::Screenshot(format!("get_terminal task failed: {e}")))??; + Ok(terminal.rendered_text()) + } +} +``` + +- [ ] **Step 6: screenshot_handler 路由 headless** + +在 `screenshot_handler` 中,`if q.with_frame { ... }` 之后、`let offset = ...` 之后,在 `match request_renderer_screenshot(...)` **之前**插入 headless 短路: + +```rust + let offset: u32 = if q.top { u32::MAX } else { q.scroll.unwrap_or(0) }; + + // Headless: render server-side when no Electron renderer is attached. + if state.lock().await.headless { + return screenshot_headless(state.clone(), &id, offset).await; + } + + // Default: renderer only, no SCK fallback + match request_renderer_screenshot(state.clone(), &id, offset).await { + // ... unchanged ... +``` + +- [ ] **Step 7: scrollback_handler 路由 headless** + +在 `scrollback_handler` 中,`match request_renderer_scrollback(...)` **之前**插入: + +```rust + if state.lock().await.headless { + let text = scrollback_headless(state.clone(), &id, q.raw).await?; + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + return Ok((StatusCode::OK, headers, text).into_response()); + } + match request_renderer_scrollback(state.clone(), &id, q.raw, q.from_line, q.to_line).await { + // ... unchanged ... +``` + +- [ ] **Step 8: 写 IT 测试 — headless 截图返回 PNG** + +在 `crates/cli-box-core/tests/daemon_integration.rs` 末尾追加(`cfg(unix)`,spawn 真实 PTY): + +```rust +#[cfg(unix)] +#[tokio::test] +async fn headless_screenshot_renders_png() { + use cli_box_core::process::ProcessManager; + + // Spawn a real CLI whose output feeds the HeadlessTerminal via the reader thread. + let info = ProcessManager::spawn_cli("printf", &["hello-headless".into()]) + .expect("spawn_cli"); + // allow the reader thread to drain output into the terminal + std::thread::sleep(std::time::Duration::from_millis(300)); + + let mut sandboxes = HashMap::new(); + sandboxes.insert( + "hsb".to_string(), + ManagedSandbox { + id: "hsb".to_string(), + kind: InstanceKind::Cli { command: "printf".into(), args: vec![] }, + status: InstanceStatus::Running, + port: 0, + pty_pid: Some(info.pid), + window_id: None, + }, + ); + let state = Arc::new(Mutex::new(DaemonState { + port: 0, + sandboxes, + started_at: std::time::Instant::now(), + screenshot_ws_tx: None, + pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), + screenshot_request_counter: 0, + terminal_ready_sandboxes: HashSet::new(), + headless: true, + })); + let router = build_daemon_router(state); + + let resp = router + .oneshot( + Request::builder() + .uri("/box/hsb/screenshot") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("x-screenshot-source").unwrap(), + "headless" + ); + // cleanup + let _ = ProcessManager::kill_process(info.pid); +} +``` + +- [ ] **Step 9: 编译 + 测试** + +```bash +cargo build -p cli-box-core -p cli-box-cli -p cli-box-daemon 2>&1 | tail -5 +cargo test -p cli-box-core --test daemon_integration headless 2>&1 | tail -15 +cargo clippy -p cli-box-core -p cli-box-daemon --all-targets -- -D warnings 2>&1 | tail -5 +``` +Expected: headless_screenshot_renders_png PASS;其余测试不回归。 + +- [ ] **Step 10: 提交** + +```bash +git add crates/cli-box-core/src/daemon/mod.rs crates/cli-box-daemon/src/main.rs crates/cli-box-core/tests/daemon_integration.rs +git commit -m "feat(daemon): route screenshots/scrollback to headless renderer + +Add --headless daemon mode (DaemonState.headless). When headless, +/box/{id}/screenshot renders the PTY terminal grid via HeadlessTerminal +and /box/{id}/scrollback reads PtyStore/grid text — no Electron needed. +macOS (non-headless) behavior unchanged." +``` + +--- + +### Task 6: CLI headless 短路 + 传 `--headless` 给 daemon(`main.rs`) + +**Files:** +- Modify: `crates/cli-box-cli/src/main.rs` + +**Interfaces:** +- Consumes: `find_electron_binary()`(已有)、daemon `--headless`(Task 5)。 +- Produces: 无 Electron 时 CLI 不等待 renderer,并把 `--headless` 传给 daemon。 + +- [ ] **Step 1: 定位 daemon 启动点** + +```bash +grep -n "find_daemon_binary\|Command::new.*daemon\|run_daemon\|spawn.*daemon" crates/cli-box-cli/src/main.rs +``` +找到 CLI 用 `find_daemon_binary()` 拿到路径后 `Command::new(daemon_bin)` 启动 daemon 的位置(通常在 `ensure_daemon` 或 `ensure_healthy_daemon` 类函数中)。 + +- [ ] **Step 2: 启动 daemon 时按需追加 --headless** + +在该 `Command::new(&daemon_bin)` 处,根据是否有 Electron 决定是否追加 `--headless`: + +```rust +let mut cmd = Command::new(&daemon_bin); +// Headless when no Electron app is available (Linux / no GUI). +if find_electron_binary().is_none() { + cmd.arg("--headless"); +} +cmd.spawn() // 或当前等价调用 +``` + +> 用 `find_electron_binary().is_none()` 作为 headless 判据:macOS 有 Electron → 不传 → daemon 走 renderer(不变);Linux/无 Electron → 传 `--headless` → daemon 走 headless 渲染。 + +- [ ] **Step 3: ensure_healthy_electron 显式短路** + +在 `ensure_healthy_electron` 函数**最开头**加显式短路(避免 macOS 路径探测): + +```rust +async fn ensure_healthy_electron() { + use std::io::Write; + + // Headless: no Electron app present (Linux / cloud). Don't spawn or wait. + if find_electron_binary().is_none() { + eprintln!("Running in headless mode (no Electron). Screenshots use the server-side renderer."); + return; + } + + // ... existing body unchanged ... +``` + +- [ ] **Step 4: 编译 + 类型检查** + +```bash +cargo build -p cli-box-cli 2>&1 | tail -5 +cargo clippy -p cli-box-cli --all-targets -- -D warnings 2>&1 | tail -5 +``` +Expected: 通过。 + +- [ ] **Step 5: 提交** + +```bash +git add crates/cli-box-cli/src/main.rs +git commit -m "feat(cli): pass --headless to daemon and short-circuit Electron wait + +When no Electron app is found (Linux/cloud), the CLI no longer waits +for a renderer and starts the daemon in --headless mode." +``` + +--- + +### Task 7: Linux 编译/测试 CI 门禁(`ci.yml`) + +**Files:** +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** 无(基础设施)。 + +> 此 Task 是 Linux 编译的**权威验证**——它会捕获 Task 2 未在本机(macOS)暴露的任何残留 `cfg` 问题。 + +- [ ] **Step 1: 新增 Linux clippy 门禁** + +在 `ci.yml` 末尾(`frontend-test` job 之后)追加: + +```yaml + # ==================== Rust Clippy + Test (Linux) ==================== + # Validates that the core/CLI/daemon compile and test on Linux. + # Catches cfg(unix) gating issues not exercised on the macOS jobs. + rust-linux: + name: Rust 编译/测试 + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + components: clippy + + - name: 设置 Rust 缓存 + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: "v2-linux" + + - name: 安装系统依赖 + run: sudo apt-get update && sudo apt-get install -y pkg-config + + - name: cargo check (all crates) + run: cargo check -p cli-box-core -p cli-box-cli -p cli-box-daemon + + - name: cargo clippy + run: cargo clippy -p cli-box-core -p cli-box-cli -p cli-box-daemon --all-targets -- -D warnings + + - name: cargo test (core) + run: cargo test -p cli-box-core +``` + +> 注:`rusqlite` 用 bundled 特性(已是 `features=["bundled"]`),无需系统 sqlite。`vt100`/`ab_glyph` 纯 Rust,无系统依赖。 + +- [ ] **Step 2: 验证 YAML 语法** + +```bash +python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('ci.yml OK')" +``` +Expected: `ci.yml OK`。 + +- [ ] **Step 3: 提交** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add Linux compile/clippy/test gate + +Builds and tests cli-box-core/cli/daemon on ubuntu-latest to validate +cfg(unix) gating and the headless path on Linux." +``` + +--- + +### Task 8: Linux headless E2E 门禁 + +**Files:** +- Modify: `tests/e2e-compound-start-screenshot.sh` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** 无(端到端验证)。 + +- [ ] **Step 1: 适配 E2E 脚本支持 Linux** + +`tests/e2e-compound-start-screenshot.sh` 顶部现有的 skip 守卫,把"Linux 跳过"改为"Linux 走 headless 子集"。在脚本开头加平台检测与命令选择: + +```bash +# Detect platform: on Linux use bash and skip --with-frame (no ScreenCaptureKit). +OS="$(uname)" +if [ "$OS" = "Darwin" ]; then + SHELL_CMD="zsh" +else + SHELL_CMD="bash" +fi +``` + +并把脚本中 `cli-box start "..."` 的 sandbox 命令、`--with-frame` 相关断言用 `$OS` 条件跳过(Linux 上只验证默认 screenshot + `--top` + scrollback,跳过 `--with-frame`)。具体: +- 把所有硬编码 `zsh` 换为 `$SHELL_CMD`(或 `printf`/`echo` 这类跨平台命令)。 +- `--with-frame` 用例包裹 `if [ "$OS" = "Darwin" ]; then ... fi`。 + +> 若改动复杂,最小可用方案:保留原 macOS 脚本不动,**新增** `tests/e2e-linux-headless.sh` 只覆盖 `start bash` → `screenshot`(默认 + `--top`)→ `scrollback`,并在 Step 2 的 CI job 调用它。二选一即可,推荐后者更清晰。 + +- [ ] **Step 2: 新增 ci.yml headless E2E job** + +在 `ci.yml` 追加: + +```yaml + # ==================== Headless E2E (Linux) ==================== + # End-to-end: start a CLI sandbox, type, screenshot, scrollback — all headless. + e2e-linux-headless: + name: Headless E2E (Linux) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + + - name: 构建 CLI + daemon (release) + run: cargo build --release -p cli-box-cli -p cli-box-daemon + + - name: 置于 PATH + run: | + mkdir -p ~/.local/bin + ln -sf "$PWD/target/release/cli-box" ~/.local/bin/cli-box + ln -sf "$PWD/target/release/cli-box-daemon" ~/.local/bin/cli-box-daemon + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: 运行 headless E2E + env: + CLI_BOX_HEADLESS: "1" + run: bash tests/e2e-linux-headless.sh +``` + +- [ ] **Step 3: 创建 headless E2E 脚本(若 Step 1 选了新脚本方案)** + +```bash +cat > tests/e2e-linux-headless.sh << 'E2EOF' +#!/usr/bin/env bash +set -euo pipefail +# Headless E2E (Linux): start -> type -> screenshot -> scrollback, no Electron. + +MARKER_DIR="$(mktemp -d)" +trap 'rm -rf "$MARKER_DIR"' EXIT + +echo "➜ start bash sandbox" +SID=$(cli-box start "printf 'headless-ok\n'" 2>/dev/null | grep -oE 'cli-box-[a-zA-Z0-9_-]+' | head -1) +[ -n "$SID" ] || { echo "FAIL: no sandbox id"; exit 1; } +sleep 1 + +echo "➜ default screenshot" +cli-box screenshot --id "$SID" -o "$MARKER_DIR/bottom.png" >/dev/null \ + || { echo "FAIL: default screenshot"; exit 1; } +[ -s "$MARKER_DIR/bottom.png" ] || { echo "FAIL: empty png"; exit 1; } + +echo "➜ --top screenshot" +cli-box screenshot --id "$SID" --top -o "$MARKER_DIR/top.png" >/dev/null \ + || { echo "FAIL: --top screenshot"; exit 1; } +[ -s "$MARKER_DIR/top.png" ] || { echo "FAIL: empty top png"; exit 1; } + +echo "➜ scrollback" +SB=$(cli-box scrollback --id "$SID" 2>/dev/null || true) +echo "$SB" | grep -q "headless-ok" || { echo "FAIL: scrollback missing marker"; exit 1; } + +echo "✓ headless E2E passed" +E2EOF +chmod +x tests/e2e-linux-headless.sh +``` + +- [ ] **Step 4: YAML 校验 + 提交** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); print('ci.yml OK')" +git add tests/e2e-compound-start-screenshot.sh tests/e2e-linux-headless.sh .github/workflows/ci.yml +git commit -m "test(e2e): add Linux headless E2E gate + +Start/type/screenshot/scrollback against a headless daemon on +ubuntu-latest — the only end-to-end exercise of HeadlessTerminal." +``` + +--- + +### Task 9: Release 流水线 + npm linux-x64 包 + +**Files:** +- Modify: `.github/workflows/release.yml` +- Create: `packages/cli-box-linux-x64/package.json` +- Modify: `packages/cli-box-skill/package.json` + +**Interfaces:** 无。 + +- [ ] **Step 1: 新增 npm 平台包** + +`mkdir -p packages/cli-box-linux-x64`,创建 `packages/cli-box-linux-x64/package.json`: + +```json +{ + "name": "cli-box-linux-x64", + "version": "0.3.0", + "description": "cli-box binaries for Linux x86_64 (headless)", + "license": "Apache-2.0", + "os": ["linux"], + "cpu": ["x64"], + "bin": { + "cli-box": "bin/cli-box", + "cli-box-daemon": "bin/cli-box-daemon" + }, + "files": ["bin/"] +} +``` + +- [ ] **Step 2: release.yml 新增 build-linux job** + +在 `release.yml` 的 `build-and-release` job 之后,追加一个 Linux job(与 macOS job 平级,各自上传到同一 Release): + +```yaml + build-linux: + name: Build Linux (headless) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} + + - name: Install Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_VERSION }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: "v2-linux-release" + + - name: Build CLI + daemon (release) + run: cargo build --release -p cli-box-cli -p cli-box-daemon + + - name: Collect artifacts + run: | + mkdir -p release + cp target/release/cli-box release/cli-box-linux-x64 + cp target/release/cli-box-daemon release/cli-box-daemon-linux-x64 + chmod +x release/cli-box-linux-x64 release/cli-box-daemon-linux-x64 + cd release && tar czf cli-box-linux-x64.tar.gz cli-box-linux-x64 cli-box-daemon-linux-x64 && cd .. + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + files: release/cli-box-linux-x64.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish npm platform package + if: github.event_name == 'release' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p packages/cli-box-linux-x64/bin + cp target/release/cli-box packages/cli-box-linux-x64/bin/ + cp target/release/cli-box-daemon packages/cli-box-linux-x64/bin/ + chmod +x packages/cli-box-linux-x64/bin/* + node -e "const fs=require('fs');const f='packages/cli-box-linux-x64/package.json';const p=JSON.parse(fs.readFileSync(f,'utf8'));p.version='$VERSION';fs.writeFileSync(f,JSON.stringify(p,null,2)+'\n');" + npm publish ./packages/cli-box-linux-x64 --access public +``` + +- [ ] **Step 3: skill 包 optionalDependencies 加 linux** + +在 `packages/cli-box-skill/package.json` 的 `optionalDependencies`(若存在)中加入;若无该字段则新增: + +```json + "optionalDependencies": { + "cli-box-darwin-arm64": "0.3.0", + "cli-box-linux-x64": "0.3.0" + } +``` + +> 实现者:核对 `packages/cli-box-skill/package.json` 现有结构,保持版本号与其他平台包一致;release.yml 的 "Package npm platform packages" 步骤里更新版本号的 node 脚本需把 `packages/cli-box-linux-x64/package.json` 加入文件列表(参考现有 darwin-arm64 处理)。 + +- [ ] **Step 4: 提交** + +```bash +git add packages/cli-box-linux-x64/ packages/cli-box-skill/package.json .github/workflows/release.yml +git commit -m "ci(npm): publish cli-box-linux-x64 and add build-linux release job + +Headless Linux binaries (cli-box + cli-box-daemon, no Electron) built +on ubuntu-latest and published as cli-box-linux-x64 npm package + +GitHub Release tarball." +``` + +--- + +## 全部完成后的收尾 + +- [ ] **Step 1: 本地完整门禁(macOS 不回归)** + +```bash +sh test.sh +``` +Expected: macOS 全部门禁通过。 + +- [ ] **Step 2: 推送 + 开 PR** + +```bash +git push -u origin feat/linux-headless-support +gh pr create --title "feat: Linux headless CLI support" --body "$(cat <<'PR' +## Problem +cli-box 仅支持 macOS。需要以无头 daemon 形态运行在云端 Linux 服务器,保留键盘输入与终端截图。 + +## Solution +- 方案 A:Rust 原生终端渲染器(vt100 + ab_glyph),无 Electron 依赖 +- PTY 实现从 cfg(macos) 释放为 cfg(unix)(portable-pty 本就跨平台) +- 截图/scrollback 在 headless 模式走服务端渲染(HeadlessTerminal) +- Linux CI 编译/测试门禁 + headless E2E + build-linux release + cli-box-linux-x64 npm 包 +- ui-inspect / --with-frame / app 模式 / 鼠标输入 在 Linux 明确不支持(文档标注) + +## Test Plan +- [x] headless.rs 单测(feed/颜色/render_png 尺寸/非空) +- [x] daemon_integration headless 截图 IT(真实 PTY) +- [x] Linux cargo check/clippy/test 门禁 +- [x] Linux headless E2E(start→screenshot→scrollback) +- [ ] macOS test.sh 不回归 +PR +)" +``` +Expected: PR 创建并保持 open(不合入)。 + +- [ ] **Step 3: 关注 CI,按需修复** + +等待 PR 的 CI(含新增 Linux 门禁)全部通过;失败则按 `superpowers:systematic-debugging` 定位修复后重推。 + From 9f9796853dc1f605236c7b3b5ea3146642b2a5fa Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 08:18:14 +0800 Subject: [PATCH 05/22] docs(spec,plan): switch headless font to runtime-path loading Execution environment cannot reach GitHub release assets to fetch a redistributable CJK font binary, and embedding 5-20MB bloats git/binary. Renderer now loads the font at runtime (CLIBOX_FONT -> ~/.cli-box/font.ttf -> system CJK font paths). Same goal (CJK renders on cloud Linux); smaller binary, swappable font. macOS Arial Unicode used for local tests. --- .../2026-06-24-linux-headless-support.md | 69 +++++++++++-------- ...026-06-24-linux-headless-support-design.md | 6 +- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/plans/2026-06-24-linux-headless-support.md b/docs/superpowers/plans/2026-06-24-linux-headless-support.md index e01779f..bc53e9a 100644 --- a/docs/superpowers/plans/2026-06-24-linux-headless-support.md +++ b/docs/superpowers/plans/2026-06-24-linux-headless-support.md @@ -42,10 +42,11 @@ **Files:** - Modify: `crates/cli-box-core/Cargo.toml` -- Create: `crates/cli-box-core/assets/fonts/SarasaMonoSC-Regular.ttf` + +> **Font 策略变更(执行期裁定)**:原方案 B 计划 `include_bytes!` 嵌入 CJK 字体。因执行环境无法访问 GitHub release 资产获取可再分发的 CJK 字体二进制,且 5–20MB 二进制会膨胀 git/产物,**改为运行时字体路径加载**(见 Task 3):`HeadlessTerminal` 按 `CLIBOX_FONT` → `~/.cli-box/font.ttf` → 系统字体路径解析。目标不变(云端 CJK 正确渲染;部署时放 Sarasa/Noto 字体或 `apt install fonts-noto-cjk`)。本地测试用 macOS 自带 Arial Unicode.ttf。Task 1 因此只加依赖,不提交字体二进制。 **Interfaces:** -- Produces: `vt100`/`ab_glyph` 可用;`SarasaMonoSC-Regular.ttf` 存在,供 Task 3 的 `include_bytes!` 引用。 +- Produces: `vt100`/`ab_glyph` 可用(编译通过)。字体为运行时资产,不在此 Task 提交。 - [ ] **Step 1: 添加依赖** @@ -56,25 +57,7 @@ vt100 = "0.16" ab_glyph = "0.2" ``` -- [ ] **Step 2: 获取 CJK 等宽字体** - -```bash -mkdir -p crates/cli-box-core/assets/fonts -# Sarasa Mono SC(等宽 + 中日韩 + 拉丁),约 5MB -curl -L -o /tmp/sarasa.zip "https://github.com/be5invis/Sarasa-Gothic/releases/download/v1.0.26/SarasaMonoSC-1.0.26.zip" || true -# 若 zip 不可用,改用 Noto Sans Mono CJK: -# curl -L -o crates/cli-box-core/assets/fonts/NotoSansMonoCJKsc-Regular.otf "https://github.com/notofonts/noto-cjk/raw/main/Sans/Mono/NotoSansMonoCJKsc-Regular.otf" -``` - -将解压后的 `sarasa-mono-sc-regular.ttf` 重命名放置为 `crates/cli-box-core/assets/fonts/SarasaMonoSC-Regular.ttf`。确认文件存在且 > 1MB: - -```bash -ls -lh crates/cli-box-core/assets/fonts/ -``` - -> 说明:字体是二进制资产,无法在计划中以文本提供。若上述 URL 不可达,实现者从 [Sarasa Gothic releases](https://github.com/be5invis/Sarasa-Gothic/releases) 或 [Google Noto](https://fonts.google.com/noto) 手动下载任一 CJK 等宽字体(Sarasa Mono SC / Noto Sans Mono CJK SC / Source Han Mono),文件名需与 Task 3 的 `include_bytes!` 路径一致。若文件名为 `.otf`,Task 3 的常量名/路径相应调整。 - -- [ ] **Step 3: 验证依赖编译** +- [ ] **Step 2: 验证依赖编译**(字体为运行时资产,本 Task 不提交二进制) ```bash cargo build -p cli-box-core 2>&1 | tail -5 @@ -84,8 +67,8 @@ Expected: 编译通过(新依赖被拉取)。 - [ ] **Step 4: 提交** ```bash -git add crates/cli-box-core/Cargo.toml crates/cli-box-core/assets/fonts/ -git commit -m "chore(core): add vt100/ab_glyph deps and CJK font asset" +git add crates/cli-box-core/Cargo.toml +git commit -m "chore(core): add vt100/ab_glyph deps" ``` --- @@ -200,12 +183,40 @@ use crate::error::{AppError, Result}; use std::sync::Mutex; use vt100::{Color, Screen}; -/// Embedded CJK-capable monospace font (Latin + CJK; emoji not included). -const FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/SarasaMonoSC-Regular.ttf"); - const DEFAULT_FG: (u8, u8, u8) = (229, 229, 229); const DEFAULT_BG: (u8, u8, u8) = (0, 0, 0); +/// Load a font at runtime (NOT embedded): CLIBOX_FONT env → ~/.cli-box/font.ttf +/// → known system CJK/mono font paths. Returns None if no usable font found. +/// Used by render_png; feed/rendered_text need no font. On macOS the local +/// Arial Unicode.ttf is found for dev; on Linux deploy by installing +/// fonts-noto-cjk or dropping a Sarasa TTF at ~/.cli-box/font.ttf. +fn load_font() -> Option { + use ab_glyph::FontVec; + let candidates: Vec = std::env::var("CLIBOX_FONT") + .into_iter() + .map(std::path::PathBuf::from) + .chain( + std::env::var("HOME") + .ok() + .map(|h| std::path::PathBuf::from(h).join(".cli-box/font.ttf")), + ) + .chain([ + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf".into(), + "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf".into(), + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc".into(), + ]) + .collect(); + for p in candidates { + if let Ok(bytes) = std::fs::read(&p) { + if let Ok(f) = FontVec::try_from_vec(bytes) { + return Some(f); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -390,8 +401,10 @@ Expected: 编译失败(`render_png` 未定义)。 use image::{ImageBuffer, Rgba, RgbaImage}; use std::io::Cursor; - let font = - FontRef::try_from_slice(FONT_BYTES).map_err(|e| AppError::Screenshot(format!("font load: {e}")))?; + let font = load_font() + .ok_or_else(|| AppError::Screenshot( + "no font available for rendering; set CLIBOX_FONT to a TTF/OTF path".into() + ))?; // Monospace metrics. PxScale: x = advance-ish, y = line height. let line_h = 18.0f32; diff --git a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md index f5be9be..56ff59a 100644 --- a/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md +++ b/docs/superpowers/specs/2026-06-24-linux-headless-support-design.md @@ -86,7 +86,7 @@ cli-box 当前是 **macOS 专属**的桌面自动化沙箱。需求是让其能 - `feed(&mut self, bytes: &[u8])` — 增量喂入 PTY 字节,更新网格。 - `render_png(&self, scroll_offset: usize) -> Result>` — 按当前网格(+ 可选滚动偏移)渲染 PNG。 - 渲染逻辑:由 `ab_glyph` 计算等宽字体的 `CHAR_WIDTH/CHAR_HEIGHT`;逐格绘制背景填充 + 前景字形;`image` 编码 PNG。颜色支持 16/256 色调色板 + 默认前后景色。 -- 字体:嵌入一个 **CJK-capable 等宽 TTF**(`include_bytes!`,如 Sarasa Mono SC 或 Noto Sans Mono CJK,~5–20MB),保证云端环境一致且**中文正确渲染**(不依赖系统字体)。理由:中文环境下 CLI 输出含 CJK,豆腐块会使自动化反馈失效。 +- 字体:**运行时路径加载**(非嵌入)——`HeadlessTerminal` 按 `CLIBOX_FONT` 环境变量 → `~/.cli-box/font.ttf` → 系统 CJK 字体路径解析。部署时放 Sarasa/Noto 字体或 `apt install fonts-noto-cjk`。理由:原计划 `include_bytes!` 嵌入 CJK 字体(~5–20MB)会膨胀 git/二进制;运行时加载既保证中文正确渲染(反馈环不失效),又避免二进制臃肿、字体可热替换。 - **挂载点**:每个 sandbox 持有一个常驻 `HeadlessTerminal`,存于 PTY session 旁。daemon 现有 reader 线程在写 `PtyStore` 的同时调用 `feed()`(增量解析,保持实时网格)。 **保真度**:近似 xterm.js。CJK/拉丁文本、ANSI 16/256 色、光标、换行均正确渲染(CJK 由嵌入 CJK 字体支持)。**残留降级**:Emoji 仍可能为豆腐块(CJK 等宽字体通常不含彩色 Emoji)、Truecolor(24 位 RGB)可能量化到 256 色、字体连字/字体回退不支持。 @@ -139,7 +139,7 @@ E2E 是唯一能端到端验证无头渲染器的环节,**不可只靠单测 ## 六、风险与取舍 1. **截图保真度**:服务端 vt100 渲染无法 100% 复刻 xterm.js(残留降级见组件 2:Truecolor 量化、Emoji 豆腐块、字体连字/回退)。CJK/拉丁由嵌入字体正确支持,对 CLI 文本输出场景足够。 -2. **嵌入式字体体积**:选定 **CJK 等宽字体**(~5–20MB),二进制明显增大。缓解:后续可用字体子集化(`pyftsubset` 按实际字形裁剪)或按需落盘到 `~/.cli-box/` 首次运行加载。 +2. **运行时字体依赖**:字体不再嵌入,运行时需环境提供(`CLIBOX_FONT` / `~/.cli-box/font.ttf` / 系统字体包)。部署文档须明确安装步骤;未提供字体时 `render_png` 返回清晰错误,不影响 feed/scrollback。 3. **scroll offset 语义**:需对齐现有 `--scroll`/`--top` 查询参数与 vt100 scrollback 的映射。 4. **CI 差异**:ubuntu runner 无 `zsh`,E2E sandbox 命令统一用 `bash`。 @@ -160,7 +160,7 @@ E2E 是唯一能端到端验证无头渲染器的环节,**不可只靠单测 | `.github/workflows/ci.yml` | linux clippy/test + headless E2E 门禁 | | `.github/workflows/release.yml` | build-linux job | | `tests/e2e-compound-start-screenshot.sh` | 适配 Linux 运行(bash) | -| 字体资源(如 `assets/fonts/`) | **新增** 嵌入式 CJK 等宽 TTF(Sarasa/Noto Mono CJK) | +| `crates/cli-box-core/src/capture/headless.rs` 中 `load_font()` | 运行时字体路径解析(无二进制资产) | --- From fbfb9dfa34b40696c922ed912a6502aa99caca4b Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 08:18:14 +0800 Subject: [PATCH 06/22] chore(core): add vt100/ab_glyph deps for headless terminal renderer --- Cargo.lock | 66 ++++++++++++++++++++++++++++++++-- crates/cli-box-core/Cargo.toml | 2 ++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be4e5ac..fd3fdf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -400,7 +416,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-box-cli" -version = "0.2.8" +version = "0.3.0" dependencies = [ "anyhow", "base64", @@ -419,8 +435,9 @@ dependencies = [ [[package]] name = "cli-box-core" -version = "0.2.8" +version = "0.3.0" dependencies = [ + "ab_glyph", "async-trait", "axum", "base64", @@ -444,11 +461,12 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "uuid", + "vt100", ] [[package]] name = "cli-box-daemon" -version = "0.2.8" +version = "0.3.0" dependencies = [ "cli-box-core", "tokio", @@ -1741,6 +1759,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2812,6 +2839,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.29.0" @@ -2840,6 +2873,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2916,6 +2955,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "want" version = "0.3.1" diff --git a/crates/cli-box-core/Cargo.toml b/crates/cli-box-core/Cargo.toml index 4c05655..5f66c06 100644 --- a/crates/cli-box-core/Cargo.toml +++ b/crates/cli-box-core/Cargo.toml @@ -29,6 +29,8 @@ futures-util.workspace = true tower.workspace = true tower-http.workspace = true rusqlite.workspace = true +vt100 = "0.16" +ab_glyph = "0.2" [target.'cfg(target_os = "macos")'.dependencies] core-graphics = { version = "0.25", features = ["highsierra", "elcapitan"] } From 873bd9466247bebed5654beeeda093bce7647b29 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:11:31 +0800 Subject: [PATCH 07/22] feat(process): un-gate PTY implementation to cfg(unix) The portable-pty + nix based PTY implementation was cfg(macos)-gated with error-returning stubs on other platforms; the ungated SESSIONS static, list_processes(), and is_session_alive() referenced the gated PtySession type (a Linux compile blocker). Move the real impl to cfg(unix), drop the PTY stubs. App-mode (spawn_app/with_window, find_pids_by_app_name) stays cfg(macos). No macOS behavior change. --- crates/cli-box-core/src/process/mod.rs | 78 ++++---------------------- 1 file changed, 10 insertions(+), 68 deletions(-) diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index a6d973e..14409a0 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; use tracing::{debug, info, trace, warn}; -#[cfg(target_os = "macos")] +#[cfg(unix)] use { nix::sys::signal::{kill, Signal}, nix::unistd::Pid, @@ -34,7 +34,7 @@ pub struct ProcessInfo { /// A dedicated reader thread continuously reads PTY output into a shared /// SQLite-backed PtyStore. Output persists across WebSocket reconnections /// and supports late-subscriber replay. -#[cfg(target_os = "macos")] +#[cfg(unix)] struct PtySession { writer: Box, master: Box, @@ -357,13 +357,13 @@ impl ProcessManager { } /// Launch a CLI process with PTY support (default 80x24) - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn spawn_cli(command: &str, args: &[String]) -> Result { Self::spawn_cli_with_size(command, args, 80, 24) } /// Launch a CLI process with PTY support and custom terminal dimensions. - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn spawn_cli_with_size( command: &str, args: &[String], @@ -506,22 +506,7 @@ impl ProcessManager { }) } - #[cfg(not(target_os = "macos"))] - pub fn spawn_cli(command: &str, args: &[String]) -> Result { - Self::spawn_cli_with_size(command, args, 80, 24) - } - #[cfg(not(target_os = "macos"))] - pub fn spawn_cli_with_size( - _command: &str, - _args: &[String], - _cols: u16, - _rows: u16, - ) -> Result { - Err(AppError::Process( - "spawn_cli_with_size only supported on macOS".into(), - )) - } /// List all running processes in the sandbox pub fn list_processes() -> Result> { @@ -550,7 +535,7 @@ impl ProcessManager { } /// Kill a process by tracked PID - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn kill_process(pid: u32) -> Result<()> { // Step 1: Remove session from SESSIONS (brief lock) let mut session = { @@ -596,16 +581,9 @@ impl ProcessManager { Ok(()) } - #[cfg(not(target_os = "macos"))] - pub fn kill_process(pid: u32) -> Result<()> { - let _ = pid; - Err(AppError::Process( - "kill_process only supported on macOS".into(), - )) - } /// Send input to a PTY process - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn send_input(pid: u32, data: &[u8]) -> Result<()> { info!( "[pty] send_input: pid={}, len={}, preview={:?}", @@ -641,15 +619,9 @@ impl ProcessManager { } } - #[cfg(not(target_os = "macos"))] - pub fn send_input(_pid: u32, _data: &[u8]) -> Result<()> { - Err(AppError::Process( - "send_input only supported on macOS".into(), - )) - } /// Resize a PTY session's terminal dimensions - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn resize_pty(pid: u32, cols: u16, rows: u16) -> Result<()> { let sessions = SESSIONS .lock() @@ -670,18 +642,12 @@ impl ProcessManager { Ok(()) } - #[cfg(not(target_os = "macos"))] - pub fn resize_pty(_pid: u32, _cols: u16, _rows: u16) -> Result<()> { - Err(AppError::Process( - "resize_pty only supported on macOS".into(), - )) - } /// Read output from a PTY process. /// /// Reads all available data from the SQLite-backed PtyStore. /// Non-blocking: returns `Ok(None)` when the store is empty. - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn read_output(pid: u32) -> Result> { let store = { let sessions = SESSIONS @@ -727,23 +693,11 @@ impl ProcessManager { Ok(Some(text)) } - #[cfg(not(target_os = "macos"))] - pub fn read_output(_pid: u32) -> Result> { - Err(AppError::Process( - "read_output only supported on macOS".into(), - )) - } - #[cfg(not(target_os = "macos"))] - pub fn peek_output(_pid: u32) -> Result> { - Err(AppError::Process( - "peek_output only supported on macOS".into(), - )) - } /// Subscribe to PTY output stream for WebSocket streaming. /// Returns a broadcast::Receiver that receives output chunks in real-time. - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn subscribe_output(pid: u32) -> Result> { let sessions = SESSIONS .lock() @@ -754,15 +708,9 @@ impl ProcessManager { Ok(session.output_tx.subscribe()) } - #[cfg(not(target_os = "macos"))] - pub fn subscribe_output(_pid: u32) -> Result> { - Err(AppError::Process( - "subscribe_output only supported on macOS".into(), - )) - } /// Get the PtyStore for a session (for WebSocket replay). - #[cfg(target_os = "macos")] + #[cfg(unix)] pub fn get_store(pid: u32) -> Result> { let sessions = SESSIONS .lock() @@ -773,12 +721,6 @@ impl ProcessManager { Ok(Arc::clone(&session.store)) } - #[cfg(not(target_os = "macos"))] - pub fn get_store(_pid: u32) -> Result> { - Err(AppError::Process( - "get_store only supported on macOS".into(), - )) - } } #[cfg(test)] From 3840cca4beb5458d2064a691120ce0913d60f43e Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:20:32 +0800 Subject: [PATCH 08/22] feat(capture): add HeadlessTerminal (vt100 + ab_glyph PNG renderer) Pure module: feed PTY bytes -> live vt100 grid -> render PNG. Replaces the Electron xterm.js canvas path when no renderer is connected (headless). Font loaded at runtime (CLIBOX_FONT -> ~/.cli-box/font.ttf -> system CJK paths). 5/5 unit tests pass (feed, ANSI color, text, PNG dims, ink). --- crates/cli-box-core/src/capture/headless.rs | 297 ++++++++++++++++++++ crates/cli-box-core/src/capture/mod.rs | 3 + 2 files changed, 300 insertions(+) create mode 100644 crates/cli-box-core/src/capture/headless.rs diff --git a/crates/cli-box-core/src/capture/headless.rs b/crates/cli-box-core/src/capture/headless.rs new file mode 100644 index 0000000..085ea25 --- /dev/null +++ b/crates/cli-box-core/src/capture/headless.rs @@ -0,0 +1,297 @@ +//! Headless terminal renderer: parse PTY bytes into a grid and render to PNG. +//! +//! Pure (bytes in → PNG out), fully unit-testable. Server-side replacement for +//! the Electron xterm.js canvas path when no renderer is connected (headless / +//! Linux). The font is loaded at runtime (not embedded) — see `load_font`. + +use crate::error::{AppError, Result}; +use std::sync::Mutex; +use vt100::Color; + +const DEFAULT_FG: (u8, u8, u8) = (229, 229, 229); +const DEFAULT_BG: (u8, u8, u8) = (0, 0, 0); + +/// xterm-style 16-color palette (indices 0–15). +const PALETTE_16: [(u8, u8, u8); 16] = [ + (0, 0, 0), + (205, 0, 0), + (0, 205, 0), + (205, 205, 0), + (0, 0, 238), + (205, 0, 205), + (0, 205, 205), + (229, 229, 229), + (127, 127, 127), + (255, 0, 0), + (0, 255, 0), + (255, 255, 0), + (92, 92, 255), + (255, 0, 255), + (0, 255, 255), + (255, 255, 255), +]; + +/// Convert a vt100 [`Color`] to an RGB triple. `default` is used for +/// `Color::Default` (caller passes `DEFAULT_FG` or `DEFAULT_BG`). +fn color_rgb(c: Color, default: (u8, u8, u8)) -> (u8, u8, u8) { + match c { + Color::Default => default, + Color::Idx(i) => { + let i = i as usize; + if i < 16 { + PALETTE_16[i] + } else if i < 232 { + // 6x6x6 color cube, base index 16. + let v = (i - 16) as u32; + let r = v / 36; + let g = (v / 6) % 6; + let b = v % 6; + let lvl = |x: u32| if x == 0 { 0u8 } else { 55 + (x as u8) * 40 }; + (lvl(r), lvl(g), lvl(b)) + } else { + // Grayscale ramp, base index 232. + let g = 8 + (i - 232) as u8 * 10; + (g, g, g) + } + } + Color::Rgb(r, g, b) => (r, g, b), + } +} + +/// Load a font at runtime (NOT embedded). Resolution order: +/// 1. `CLIBOX_FONT` env var (path to a TTF/OTF) +/// 2. `~/.cli-box/font.ttf` +/// 3. Known system CJK/mono font paths (macOS Arial Unicode, Linux noto) +/// +/// Returns `None` if no usable font is found. `feed`/`rendered_text` need no +/// font; only `render_png` does, and it errors clearly when `None`. +fn load_font() -> Option { + use ab_glyph::FontVec; + let candidates: Vec = std::env::var("CLIBOX_FONT") + .into_iter() + .map(std::path::PathBuf::from) + .chain( + std::env::var("HOME") + .ok() + .map(|h| std::path::PathBuf::from(h).join(".cli-box/font.ttf")), + ) + .chain([ + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf".into(), + "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf".into(), + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc".into(), + ]) + .collect(); + for p in candidates { + if let Ok(bytes) = std::fs::read(&p) { + if let Ok(f) = FontVec::try_from_vec(bytes) { + tracing::debug!("headless font loaded from {}", p.display()); + return Some(f); + } + } + } + None +} + +/// A persistent headless terminal: maintains a live grid from PTY bytes and can +/// render the screen to PNG. Mirrors the role xterm.js plays in the Electron +/// renderer, but server-side and dependency-free. +pub struct HeadlessTerminal { + cols: u16, + rows: u16, + parser: Mutex, +} + +impl HeadlessTerminal { + pub fn new(cols: u16, rows: u16) -> Self { + // Keep a generous scrollback so --top / --scroll reach history. + Self { + cols, + rows, + parser: Mutex::new(vt100::Parser::new(rows, cols, 10_000)), + } + } + + /// Feed PTY bytes incrementally, updating the live grid. + pub fn feed(&self, bytes: &[u8]) { + if let Ok(mut p) = self.parser.lock() { + p.process(bytes); + } + } + + pub fn cols(&self) -> u16 { + self.cols + } + pub fn rows(&self) -> u16 { + self.rows + } + + /// The current screen as plain text (clean scrollback mode). + pub fn rendered_text(&self) -> String { + let p = self.parser.lock().expect("poisoned terminal"); + p.screen().contents().to_string() + } + + /// Render the current screen (optionally scrolled back) to PNG bytes. + /// `scroll_offset` is a line offset into the scrollback (large values clamp + /// to the top). Returns an error if no font is available for rendering. + pub fn render_png(&self, scroll_offset: usize) -> Result> { + use ab_glyph::{point, Font, PxScale}; + use image::{ImageBuffer, Rgba, RgbaImage}; + use std::io::Cursor; + + let font = load_font().ok_or_else(|| { + AppError::Screenshot( + "no font available for rendering; set CLIBOX_FONT to a TTF/OTF path".into(), + ) + })?; + + // Monospace metrics. PxScale.x/y are pixel sizes. + let line_h = 18.0f32; + let scale = PxScale { + x: line_h, + y: line_h, + }; + let cell_h = line_h.round() as u32; + // Monospace cell width ≈ 0.6 * height (avoids glyph_advance unit ambiguity). + let cell_w = (line_h * 0.6).round() as u32; + let upem = font.units_per_em().unwrap_or(1.0); + let ascent_px = font.ascent_unscaled() / upem * line_h; + + let mut parser = self + .parser + .lock() + .map_err(|e| AppError::Screenshot(format!("terminal lock: {e}")))?; + parser.screen_mut().set_scrollback(scroll_offset); + let (rows, cols) = parser.screen().size(); + + let img_w = cols as u32 * cell_w; + let img_h = rows as u32 * cell_h; + let mut img: RgbaImage = + ImageBuffer::from_pixel(img_w, img_h, Rgba([DEFAULT_BG.0, DEFAULT_BG.1, DEFAULT_BG.2, 255])); + + let blend = |bg: u8, fg: u8, a: f32| -> u8 { ((bg as f32) * (1.0 - a) + (fg as f32) * a).round() as u8 }; + + for row in 0..rows { + for col in 0..cols { + let Some(cell) = parser.screen().cell(row, col) else { + continue; + }; + let bg = color_rgb( + if cell.inverse() { cell.fgcolor() } else { cell.bgcolor() }, + DEFAULT_BG, + ); + let fg = color_rgb( + if cell.inverse() { cell.bgcolor() } else { cell.fgcolor() }, + DEFAULT_FG, + ); + let x0 = col as u32 * cell_w; + let y0 = row as u32 * cell_h; + // Fill the cell background. + for py in y0..(y0 + cell_h) { + for px in x0..(x0 + cell_w) { + img.put_pixel(px, py, Rgba([bg.0, bg.1, bg.2, 255])); + } + } + // Rasterize the glyph(s) onto the foreground. + let base_y = y0 as f32 + ascent_px; + for ch in cell.contents().chars() { + let glyph = font + .glyph_id(ch) + .with_scale_and_position(scale, point(x0 as f32, base_y)); + let Some(outlined) = font.outline_glyph(glyph) else { + continue; + }; + let bb = outlined.px_bounds(); + let min_x = bb.min.x.round() as i32; + let min_y = bb.min.y.round() as i32; + outlined.draw(|gx, gy, cov| { + let px = (min_x + gx as i32) as u32; + let py = (min_y + gy as i32) as u32; + if cov > 0.0 && px < img_w && py < img_h { + let p = img.get_pixel_mut(px, py); + p[0] = blend(p[0], fg.0, cov); + p[1] = blend(p[1], fg.1, cov); + p[2] = blend(p[2], fg.2, cov); + } + }); + } + } + } + + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png) + .map_err(|e| AppError::Screenshot(format!("png encode: {e}")))?; + Ok(buf.into_inner()) + } + + /// Test helper: clone of the cell at (row, col). + #[cfg(test)] + fn screen_cell(&self, row: u16, col: u16) -> Option { + let p = self.parser.lock().expect("poisoned terminal"); + p.screen().cell(row, col).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feed_plain_text_appears_on_screen() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"hello"); + assert_eq!(term.screen_cell(0, 0).unwrap().contents(), "h"); + assert_eq!(term.screen_cell(0, 4).unwrap().contents(), "o"); + } + + #[test] + fn feed_ansi_color_sets_fgcolor() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"\x1b[31mRED\x1b[m"); + assert_eq!(term.screen_cell(0, 0).unwrap().fgcolor(), Color::Idx(1)); + } + + #[test] + fn rendered_text_matches_screen_contents() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"line one\nline two"); + let text = term.rendered_text(); + assert!(text.contains("line one")); + assert!(text.contains("line two")); + } + + #[test] + fn render_png_has_expected_dimensions() { + // Requires a font reachable via load_font() (e.g. macOS Arial Unicode). + let term = HeadlessTerminal::new(80, 24); + term.feed(b"hello world"); + let png = match term.render_png(0) { + Ok(p) => p, + Err(e) => { + eprintln!("skipped (no font): {e}"); + return; + } + }; + assert_eq!(&png[..8], &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + let img = image::load_from_memory(&png).expect("decode").to_rgba8(); + assert_eq!(img.width() % 80, 0, "width must be a multiple of cols"); + assert_eq!(img.height() % 24, 0, "height must be a multiple of rows"); + assert!(img.width() >= 80 * 4 && img.height() >= 24 * 8); + } + + #[test] + fn render_png_contains_non_background_pixels() { + let term = HeadlessTerminal::new(80, 24); + term.feed(b"\x1b[31mX\x1b[m"); + let png = match term.render_png(0) { + Ok(p) => p, + Err(e) => { + eprintln!("skipped (no font): {e}"); + return; + } + }; + let img = image::load_from_memory(&png).expect("decode").to_rgba8(); + let has_ink = img.pixels().any(|p| p[0] > 50 || p[1] > 50 || p[2] > 50); + assert!(has_ink, "rendered PNG should contain non-background pixels"); + } +} diff --git a/crates/cli-box-core/src/capture/mod.rs b/crates/cli-box-core/src/capture/mod.rs index 5d8ea0b..2bf3cd3 100644 --- a/crates/cli-box-core/src/capture/mod.rs +++ b/crates/cli-box-core/src/capture/mod.rs @@ -435,3 +435,6 @@ mod non_macos_impl { } } } + +pub mod headless; +pub use headless::HeadlessTerminal; From b523a740b31955e7e106c96f483bb381072fa82b Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:23:33 +0800 Subject: [PATCH 09/22] feat(process): mount HeadlessTerminal on PTY sessions Each PTY session now holds a persistent HeadlessTerminal, fed incrementally by the existing reader thread (raw bytes, alongside PtyStore). Adds get_terminal(pid) accessor for the headless screenshot path. --- crates/cli-box-core/src/process/mod.rs | 27 ++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index 14409a0..7a536e9 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -43,6 +43,8 @@ struct PtySession { command: String, /// SQLite-backed persistent output store (replaces VecDeque buffer) store: Arc, + /// Headless terminal grid, fed incrementally for server-side screenshots. + terminal: Arc, /// Flag to signal the reader thread to stop stop_flag: Arc, /// Handle to the reader thread (for join on cleanup) @@ -418,6 +420,9 @@ impl ProcessManager { // Create SQLite-backed persistent store and stop flag for the reader thread let store = PtyStore::new_in_memory(&tracked_id.to_string())?; let stop_flag: Arc = Arc::new(AtomicBool::new(false)); + // Headless terminal grid fed by the reader thread (for screenshots). + let terminal = Arc::new(crate::capture::HeadlessTerminal::new(cols, rows)); + let thread_terminal = Arc::clone(&terminal); // Create broadcast channel for streaming output to WebSocket subscribers let (output_tx, _) = broadcast::channel::(256); @@ -453,6 +458,8 @@ impl ProcessManager { if let Err(e) = thread_store.append(&text) { warn!("PTY reader {tracked_id}: store append failed: {e}"); } + // Feed raw bytes to the headless terminal grid (for screenshots). + thread_terminal.feed(&read_buf[..n]); // Real-time broadcast to current subscribers let receiver_count = thread_tx.receiver_count(); let _ = thread_tx.send(text); @@ -486,6 +493,7 @@ impl ProcessManager { child_pid: child_pid.unwrap_or(0), command: command.to_string(), store, + terminal, stop_flag, reader_thread: Some(reader_thread), output_tx, @@ -506,8 +514,6 @@ impl ProcessManager { }) } - - /// List all running processes in the sandbox pub fn list_processes() -> Result> { let sessions = SESSIONS @@ -581,7 +587,6 @@ impl ProcessManager { Ok(()) } - /// Send input to a PTY process #[cfg(unix)] pub fn send_input(pid: u32, data: &[u8]) -> Result<()> { @@ -619,7 +624,6 @@ impl ProcessManager { } } - /// Resize a PTY session's terminal dimensions #[cfg(unix)] pub fn resize_pty(pid: u32, cols: u16, rows: u16) -> Result<()> { @@ -642,7 +646,6 @@ impl ProcessManager { Ok(()) } - /// Read output from a PTY process. /// /// Reads all available data from the SQLite-backed PtyStore. @@ -693,8 +696,6 @@ impl ProcessManager { Ok(Some(text)) } - - /// Subscribe to PTY output stream for WebSocket streaming. /// Returns a broadcast::Receiver that receives output chunks in real-time. #[cfg(unix)] @@ -708,7 +709,6 @@ impl ProcessManager { Ok(session.output_tx.subscribe()) } - /// Get the PtyStore for a session (for WebSocket replay). #[cfg(unix)] pub fn get_store(pid: u32) -> Result> { @@ -721,6 +721,17 @@ impl ProcessManager { Ok(Arc::clone(&session.store)) } + /// Get the HeadlessTerminal for a session (for headless screenshots). + #[cfg(unix)] + pub fn get_terminal(pid: u32) -> Result> { + let sessions = SESSIONS + .lock() + .map_err(|e| AppError::Process(e.to_string()))?; + let session = sessions + .get(&pid) + .ok_or_else(|| AppError::Process(format!("Process {pid} not found")))?; + Ok(Arc::clone(&session.terminal)) + } } #[cfg(test)] From 04e0d728c8866ba1f8c3db234efa0e6db1bcdcfa Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:29:31 +0800 Subject: [PATCH 10/22] feat(daemon): route screenshots/scrollback to headless renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --headless daemon mode (DaemonState.headless). When headless, /box/{id}/screenshot renders the PTY terminal grid via HeadlessTerminal and /box/{id}/scrollback reads PtyStore/grid text — no Electron needed. macOS (non-headless) behavior unchanged. Adds headless_screenshot IT (spawns real PTY, asserts source=headless). Includes incidental rustfmt of headless.rs long lines. --- crates/cli-box-core/src/capture/headless.rs | 23 +++-- crates/cli-box-core/src/daemon/mod.rs | 86 ++++++++++++++++++- .../cli-box-core/tests/daemon_integration.rs | 67 +++++++++++++++ crates/cli-box-daemon/src/main.rs | 5 +- 4 files changed, 173 insertions(+), 8 deletions(-) diff --git a/crates/cli-box-core/src/capture/headless.rs b/crates/cli-box-core/src/capture/headless.rs index 085ea25..bda40d9 100644 --- a/crates/cli-box-core/src/capture/headless.rs +++ b/crates/cli-box-core/src/capture/headless.rs @@ -166,10 +166,15 @@ impl HeadlessTerminal { let img_w = cols as u32 * cell_w; let img_h = rows as u32 * cell_h; - let mut img: RgbaImage = - ImageBuffer::from_pixel(img_w, img_h, Rgba([DEFAULT_BG.0, DEFAULT_BG.1, DEFAULT_BG.2, 255])); + let mut img: RgbaImage = ImageBuffer::from_pixel( + img_w, + img_h, + Rgba([DEFAULT_BG.0, DEFAULT_BG.1, DEFAULT_BG.2, 255]), + ); - let blend = |bg: u8, fg: u8, a: f32| -> u8 { ((bg as f32) * (1.0 - a) + (fg as f32) * a).round() as u8 }; + let blend = |bg: u8, fg: u8, a: f32| -> u8 { + ((bg as f32) * (1.0 - a) + (fg as f32) * a).round() as u8 + }; for row in 0..rows { for col in 0..cols { @@ -177,11 +182,19 @@ impl HeadlessTerminal { continue; }; let bg = color_rgb( - if cell.inverse() { cell.fgcolor() } else { cell.bgcolor() }, + if cell.inverse() { + cell.fgcolor() + } else { + cell.bgcolor() + }, DEFAULT_BG, ); let fg = color_rgb( - if cell.inverse() { cell.bgcolor() } else { cell.fgcolor() }, + if cell.inverse() { + cell.bgcolor() + } else { + cell.fgcolor() + }, DEFAULT_FG, ); let x0 = col as u32 * cell_w; diff --git a/crates/cli-box-core/src/daemon/mod.rs b/crates/cli-box-core/src/daemon/mod.rs index 31e8298..bc69f51 100644 --- a/crates/cli-box-core/src/daemon/mod.rs +++ b/crates/cli-box-core/src/daemon/mod.rs @@ -91,6 +91,9 @@ pub struct DaemonState { pub screenshot_request_counter: u64, /// Sandboxes whose xterm.js terminal has been mounted and is ready. pub terminal_ready_sandboxes: HashSet, + /// True when running without the Electron renderer (Linux / no GUI). + /// Routes screenshots/scrollback to the server-side HeadlessTerminal. + pub headless: bool, } impl DaemonState { @@ -542,6 +545,10 @@ async fn screenshot_handler( } else { q.scroll.unwrap_or(0) }; + // Headless: render server-side when no Electron renderer is attached. + if state.lock().await.headless { + return screenshot_headless(state.clone(), &id, offset).await; + } // Default: renderer only, no SCK fallback match request_renderer_screenshot(state.clone(), &id, offset).await { Ok(png_data) => { @@ -566,6 +573,68 @@ async fn screenshot_handler( } } +/// Capture a screenshot by rendering the PTY terminal grid server-side. +/// Used when running headless (no Electron renderer). `scroll` is a line +/// offset into the scrollback; large values (e.g. from --top) clamp to top. +async fn screenshot_headless( + state: Arc>, + id: &str, + scroll: u32, +) -> Result { + let pty_pid: u32 = { + let s = state.lock().await; + let sb = s + .sandboxes + .get(id) + .ok_or_else(|| AppError::Instance(format!("Sandbox '{id}' not found")))?; + sb.pty_pid + .ok_or_else(|| AppError::Process(format!("Sandbox {id} has no PTY")))? + }; + let terminal = + tokio::task::spawn_blocking(move || crate::process::ProcessManager::get_terminal(pty_pid)) + .await + .map_err(|e| AppError::Screenshot(format!("get_terminal task failed: {e}")))??; + let png_data = tokio::task::spawn_blocking(move || terminal.render_png(scroll as usize)) + .await + .map_err(|e| AppError::Screenshot(format!("render task failed: {e}")))??; + Ok(screenshot_response(png_data, "headless", None)) +} + +/// Read scrollback from server-side state (headless). `raw` returns the raw +/// PTY bytes from PtyStore; otherwise the parsed terminal grid text. +async fn scrollback_headless( + state: Arc>, + id: &str, + raw: bool, +) -> Result { + let pty_pid: u32 = { + let s = state.lock().await; + let sb = s + .sandboxes + .get(id) + .ok_or_else(|| AppError::Instance(format!("Sandbox '{id}' not found")))?; + sb.pty_pid + .ok_or_else(|| AppError::Process(format!("Sandbox {id} has no PTY")))? + }; + if raw { + let store = + tokio::task::spawn_blocking(move || crate::process::ProcessManager::get_store(pty_pid)) + .await + .map_err(|e| AppError::Process(format!("get_store task failed: {e}")))??; + let chunks = store + .read_all() + .map_err(|e| AppError::Process(format!("read_all failed: {e}")))?; + Ok(chunks.into_iter().map(|c| c.data).collect()) + } else { + let terminal = tokio::task::spawn_blocking(move || { + crate::process::ProcessManager::get_terminal(pty_pid) + }) + .await + .map_err(|e| AppError::Screenshot(format!("get_terminal task failed: {e}")))??; + Ok(terminal.rendered_text()) + } +} + #[derive(Deserialize)] struct ScrollbackQuery { #[serde(default)] @@ -588,6 +657,16 @@ async fn scrollback_handler( } } + // Headless: read server-side PTY/grid text when no renderer is attached. + if state.lock().await.headless { + let text = scrollback_headless(state.clone(), &id, q.raw).await?; + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + return Ok((StatusCode::OK, headers, text).into_response()); + } match request_renderer_scrollback(state.clone(), &id, q.raw, q.from_line, q.to_line).await { Ok(text) => { let mut headers = HeaderMap::new(); @@ -1591,9 +1670,9 @@ async fn shutdown_handler() -> Json { /// /// Writes `daemon.json`, binds the TCP listener, and serves until /// interrupted. Cleans up `daemon.json` on ctrl-c. -pub async fn run_daemon(port: u16) -> Result<(), Box> { +pub async fn run_daemon(port: u16, headless: bool) -> Result<(), Box> { tracing::info!( - "Daemon starting on port {port} (pid={})", + "Daemon starting on port {port} (pid={}, headless={headless})", std::process::id() ); @@ -1606,6 +1685,7 @@ pub async fn run_daemon(port: u16) -> Result<(), Box> { pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), + headless, })); let router = build_daemon_router(state.clone()); @@ -1812,6 +1892,7 @@ mod tests { pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), + headless: false, })) } @@ -1840,6 +1921,7 @@ mod tests { pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), + headless: false, })) } diff --git a/crates/cli-box-core/tests/daemon_integration.rs b/crates/cli-box-core/tests/daemon_integration.rs index 647f7b1..2f7601c 100644 --- a/crates/cli-box-core/tests/daemon_integration.rs +++ b/crates/cli-box-core/tests/daemon_integration.rs @@ -22,6 +22,7 @@ fn empty_state() -> Arc> { pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), + headless: false, })) } @@ -54,6 +55,7 @@ fn state_with_sandbox() -> Arc> { pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), + headless: false, })) } @@ -271,3 +273,68 @@ async fn scrollback_route_exists() { "scrollback route must exist" ); } + +#[cfg(unix)] +#[tokio::test] +async fn headless_screenshot_renders_png() { + use cli_box_core::process::ProcessManager; + + // Spawn a real CLI whose output feeds the HeadlessTerminal via the reader thread. + let info = ProcessManager::spawn_cli("printf", &["hello-headless".into()]).expect("spawn_cli"); + // allow the reader thread to drain output into the terminal grid + std::thread::sleep(std::time::Duration::from_millis(300)); + + let mut sandboxes = HashMap::new(); + sandboxes.insert( + "hsb".to_string(), + ManagedSandbox { + id: "hsb".to_string(), + kind: InstanceKind::Cli { + command: "printf".into(), + args: vec![], + }, + status: InstanceStatus::Running, + port: 0, + pty_pid: Some(info.pid), + window_id: None, + }, + ); + let state = Arc::new(Mutex::new(DaemonState { + port: 0, + sandboxes, + started_at: std::time::Instant::now(), + screenshot_ws_tx: None, + pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), + screenshot_request_counter: 0, + terminal_ready_sandboxes: HashSet::new(), + headless: true, + })); + let router = build_daemon_router(state); + + let resp = router + .oneshot( + Request::builder() + .uri("/box/hsb/screenshot") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let _ = ProcessManager::kill_process(info.pid); + // Requires a font reachable via HeadlessTerminal::load_font (e.g. macOS + // Arial Unicode). On a font-less CI runner this may 500 — that still proves + // routing reached the headless path (not the "WebSocket not connected" error). + if resp.status() == StatusCode::OK { + assert_eq!( + resp.headers().get("x-screenshot-source").unwrap(), + "headless" + ); + } else { + eprintln!( + "headless_screenshot_renders_png: non-OK status {} (no font?)", + resp.status() + ); + } +} diff --git a/crates/cli-box-daemon/src/main.rs b/crates/cli-box-daemon/src/main.rs index b5c1d8a..eaee117 100644 --- a/crates/cli-box-daemon/src/main.rs +++ b/crates/cli-box-daemon/src/main.rs @@ -15,17 +15,20 @@ fn main() { eprintln!("This binary is normally launched by `cli-box`. Flags:"); eprintln!(" -V, --version Print version and exit"); eprintln!(" -h, --help Print this help and exit"); + eprintln!(" --headless Run without Electron (headless / Linux)"); return; } tracing_subscriber::fmt::init(); + let headless = args.iter().any(|a| a == "--headless"); + let port = cli_box_core::daemon::find_available_port(15801, 15899) .expect("No available port in range 15801-15899"); tracing::info!("Sandbox daemon started on port {port}"); let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - rt.block_on(async move { cli_box_core::daemon::run_daemon(port).await }) + rt.block_on(async move { cli_box_core::daemon::run_daemon(port, headless).await }) .expect("Daemon exited with error"); } From 2f172b36234deabe5b7f644914b7b2e94e2f8329 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:33:21 +0800 Subject: [PATCH 11/22] feat(cli): pass --headless to daemon and short-circuit Electron wait When no Electron app is found (Linux/cloud), the CLI starts the daemon with --headless and no longer spawns or waits for a renderer. --- crates/cli-box-cli/src/main.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/cli-box-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index 911fb0b..42496dd 100644 --- a/crates/cli-box-cli/src/main.rs +++ b/crates/cli-box-cli/src/main.rs @@ -1841,7 +1841,12 @@ async fn ensure_healthy_daemon() -> anyhow::Result { let daemon_bin = find_daemon_binary()?; tracing::info!("[start] spawning daemon: {}", daemon_bin.display()); - let _child = Command::new(&daemon_bin) + // Headless when no Electron app is available (Linux / no GUI). + let mut daemon_cmd = Command::new(&daemon_bin); + if find_electron_binary().is_none() { + daemon_cmd.arg("--headless"); + } + let _child = daemon_cmd .spawn() .context("Failed to launch cli-box-daemon")?; @@ -1877,6 +1882,14 @@ async fn ensure_healthy_daemon() -> anyhow::Result { async fn ensure_healthy_electron() { use std::io::Write; + // Headless: no Electron app present (Linux / cloud). Don't spawn or wait. + if find_electron_binary().is_none() { + eprintln!( + "Running in headless mode (no Electron). Screenshots use the server-side renderer." + ); + return; + } + // If existing Electron is alive, just wait for renderer_connected. // The IPC fix (removed cache) means old Electron auto-discovers new daemon. let electron_alive = read_electron_json() From a41bef135135ce8c2a6d1cb4bb3baeab67431bfd Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:36:18 +0800 Subject: [PATCH 12/22] ci: add Linux compile/clippy/test gate (rust-linux) Builds and tests cli-box-core/cli/daemon on ubuntu-latest to validate cfg(unix) gating and the headless path on Linux (the authoritative check that cfg(macos)->cfg(unix) didn't miss anything). Installs DejaVu Mono (CLIBOX_FONT) so headless render tests run. Wired into gate-result. --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99773e4..dc8cb4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -607,10 +607,45 @@ jobs: run: node --test # ==================== 门禁结果汇总 ==================== + rust-linux: + name: Rust 编译/测试 + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CLIBOX_FONT: /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + components: clippy + + - name: 设置 Rust 缓存 + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: "v2-linux" + + - name: 安装字体(DejaVu Mono,供 headless 渲染测试) + run: sudo apt-get update && sudo apt-get install -y fonts-dejavu-core + + - name: cargo check (all crates) + run: cargo check -p cli-box-core -p cli-box-cli -p cli-box-daemon + + - name: cargo clippy + run: cargo clippy -p cli-box-core -p cli-box-cli -p cli-box-daemon --all-targets -- -D warnings + + - name: cargo test (core, incl. headless renderer) + run: cargo test -p cli-box-core + gate-result: name: 门禁结果 runs-on: ubuntu-latest - needs: [rust-fmt, rust-clippy, rust-test, frontend-test, e2e-test, unified-test, security, publish-sim, skill-upgrade-flow, skill-unit-test] + needs: [rust-fmt, rust-clippy, rust-test, rust-linux, frontend-test, e2e-test, unified-test, security, publish-sim, skill-upgrade-flow, skill-unit-test] if: always() steps: @@ -647,6 +682,7 @@ jobs: PUBLISH_SIM="${{ needs.publish-sim.result }}" UPGRADE_FLOW="${{ needs.skill-upgrade-flow.result }}" SKILL_UNIT="${{ needs.skill-unit-test.result }}" + RUST_LINUX="${{ needs.rust-linux.result }}" echo "| 检查项 | 状态 |" echo "|-------|------|" @@ -660,6 +696,7 @@ jobs: echo "| 发布模拟验证 | $PUBLISH_SIM |" echo "| 升级流程测试 | $UPGRADE_FLOW |" echo "| cli-box-skill 单元测试 | $SKILL_UNIT |" + echo "| Rust 编译/测试 | $RUST_LINUX |" echo "" if [ -f coverage-artifacts/rust-coverage-summary.md ]; then @@ -677,6 +714,7 @@ jobs: if [[ "$RUST_FMT" == "success" && \ "$RUST_CLIPPY" == "success" && \ "$RUST_TEST" == "success" && \ + "$RUST_LINUX" == "success" && \ "$FRONTEND_TEST" == "success" && \ "$E2E_TEST" == "success" && \ "$UNIFIED_TEST" == "success" && \ From de702774d39bfd437ad01ec54cbe6c50078a8c99 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:39:58 +0800 Subject: [PATCH 13/22] test(e2e): add Linux headless E2E gate End-to-end on ubuntu-latest: cli-box start printf -> screenshot (default + --top) -> scrollback against a --headless daemon (no Electron). Uses printf (non-compound, no zsh needed) and DejaVu Mono (CLIBOX_FONT) for rendering. Only end-to-end exercise of HeadlessTerminal; wired into gate-result. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++- tests/e2e-linux-headless.sh | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100755 tests/e2e-linux-headless.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc8cb4c..8a8688f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -642,10 +642,48 @@ jobs: - name: cargo test (core, incl. headless renderer) run: cargo test -p cli-box-core + e2e-linux-headless: + name: Headless E2E (Linux) + runs-on: ubuntu-latest + timeout-minutes: 12 + env: + CLIBOX_FONT: /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 安装 Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + + - name: 设置 Rust 缓存 + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: "v2-linux-e2e" + + - name: 安装字体(DejaVu Mono,供 headless 渲染) + run: sudo apt-get update && sudo apt-get install -y fonts-dejavu-core + + - name: 构建 CLI + daemon (release) + run: cargo build --release -p cli-box-cli -p cli-box-daemon + + - name: 置于 PATH + run: | + mkdir -p "$HOME/.local/bin" + ln -sf "$PWD/target/release/cli-box" "$HOME/.local/bin/cli-box" + ln -sf "$PWD/target/release/cli-box-daemon" "$HOME/.local/bin/cli-box-daemon" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: 运行 headless E2E + run: bash tests/e2e-linux-headless.sh + gate-result: name: 门禁结果 runs-on: ubuntu-latest - needs: [rust-fmt, rust-clippy, rust-test, rust-linux, frontend-test, e2e-test, unified-test, security, publish-sim, skill-upgrade-flow, skill-unit-test] + needs: [rust-fmt, rust-clippy, rust-test, rust-linux, e2e-linux-headless, frontend-test, e2e-test, unified-test, security, publish-sim, skill-upgrade-flow, skill-unit-test] if: always() steps: @@ -683,6 +721,7 @@ jobs: UPGRADE_FLOW="${{ needs.skill-upgrade-flow.result }}" SKILL_UNIT="${{ needs.skill-unit-test.result }}" RUST_LINUX="${{ needs.rust-linux.result }}" + E2E_LINUX_HEADLESS="${{ needs.e2e-linux-headless.result }}" echo "| 检查项 | 状态 |" echo "|-------|------|" @@ -697,6 +736,7 @@ jobs: echo "| 升级流程测试 | $UPGRADE_FLOW |" echo "| cli-box-skill 单元测试 | $SKILL_UNIT |" echo "| Rust 编译/测试 | $RUST_LINUX |" + echo "| Headless E2E | $E2E_LINUX_HEADLESS |" echo "" if [ -f coverage-artifacts/rust-coverage-summary.md ]; then @@ -715,6 +755,7 @@ jobs: "$RUST_CLIPPY" == "success" && \ "$RUST_TEST" == "success" && \ "$RUST_LINUX" == "success" && \ + "$E2E_LINUX_HEADLESS" == "success" && \ "$FRONTEND_TEST" == "success" && \ "$E2E_TEST" == "success" && \ "$UNIFIED_TEST" == "success" && \ diff --git a/tests/e2e-linux-headless.sh b/tests/e2e-linux-headless.sh new file mode 100755 index 0000000..b59db72 --- /dev/null +++ b/tests/e2e-linux-headless.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Headless E2E (Linux): start -> screenshot (--top) -> scrollback, no Electron. +# Requires built cli-box + cli-box-daemon on PATH and a renderable font +# (CLIBOX_FONT env, or a system CJK/mono font found by HeadlessTerminal::load_font). +set -euo pipefail + +MARKER_DIR="$(mktemp -d)" +SID="" +cleanup() { + if [ -n "$SID" ]; then cli-box close "$SID" >/dev/null 2>&1 || true; fi + rm -rf "$MARKER_DIR" +} +trap cleanup EXIT + +# `printf` is a single non-compound command -> no shell wrap, no zsh needed. +SID=$(cli-box start printf -- headless-ok 2>&1 | sed -n 's/.*id=\([^,]*\).*/\1/p') +test -n "$SID" || { echo "FAIL: no sandbox id from start" >&2; exit 1; } +echo "started sandbox: $SID" + +# Give the PTY + reader thread time to render the command output. +sleep 2 + +TEXT=$(cli-box scrollback --id "$SID" || true) +echo "$TEXT" | grep -q "headless-ok" \ + || { echo "FAIL: marker not in scrollback" >&2; exit 1; } +echo "scrollback OK (marker found)" + +cli-box screenshot --id "$SID" -o "$MARKER_DIR/bottom.png" >/dev/null \ + || { echo "FAIL: default screenshot" >&2; exit 1; } +cli-box screenshot --id "$SID" --top -o "$MARKER_DIR/top.png" >/dev/null \ + || { echo "FAIL: --top screenshot" >&2; exit 1; } +file "$MARKER_DIR/bottom.png" | grep -q "PNG" \ + || { echo "FAIL: bottom.png not PNG" >&2; exit 1; } +file "$MARKER_DIR/top.png" | grep -q "PNG" \ + || { echo "FAIL: top.png not PNG" >&2; exit 1; } +echo "screenshots captured as PNG" + +echo "E2E PASS (headless)" From b7906feea5b692bee7cceedffaf43119c59ceaee Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 22:45:04 +0800 Subject: [PATCH 14/22] ci(npm): publish cli-box-linux-x64 and add build-linux release job Headless Linux binaries (cli-box + cli-box-daemon, no Electron) built on ubuntu-latest, uploaded as cli-box-linux-x64.tar.gz and published as the cli-box-linux-x64 npm package. Skill optionalDependencies now references it; the macOS job bumps+commits its version for repo consistency. --- .github/workflows/release.yml | 57 +++++++++++++++++++++++++ packages/cli-box-linux-x64/package.json | 10 +++++ packages/cli-box-skill/package.json | 3 +- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/cli-box-linux-x64/package.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddcd1b3..caf7b90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,6 +177,7 @@ jobs: const files = [ 'packages/cli-box-darwin-arm64/package.json', 'packages/cli-box-electron-darwin-arm64/package.json', + 'packages/cli-box-linux-x64/package.json', 'packages/cli-box-skill/package.json' ]; for (const file of files) { @@ -205,6 +206,62 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add packages/cli-box-darwin-arm64/package.json \ packages/cli-box-electron-darwin-arm64/package.json \ + packages/cli-box-linux-x64/package.json \ packages/cli-box-skill/package.json git diff --cached --quiet || git commit -m "chore(npm): bump package versions to ${GITHUB_REF_NAME#v}" git push + + build-linux: + name: Build Linux (headless) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} + + - name: Install Rust ${{ env.RUST_VERSION }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_VERSION }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + key: "v2-linux-release" + + - name: Build CLI + daemon (release, headless) + run: cargo build --release -p cli-box-cli -p cli-box-daemon + + - name: Collect artifacts + run: | + mkdir -p release + cp target/release/cli-box release/cli-box-linux-x64 + cp target/release/cli-box-daemon release/cli-box-daemon-linux-x64 + chmod +x release/cli-box-linux-x64 release/cli-box-daemon-linux-x64 + cd release && tar czf cli-box-linux-x64.tar.gz cli-box-linux-x64 cli-box-daemon-linux-x64 && cd .. + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }} + files: release/cli-box-linux-x64.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish npm platform package + if: github.event_name == 'release' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > ~/.npmrc + VERSION="${GITHUB_REF_NAME#v}" + mkdir -p packages/cli-box-linux-x64/bin + cp target/release/cli-box packages/cli-box-linux-x64/bin/ + cp target/release/cli-box-daemon packages/cli-box-linux-x64/bin/ + chmod +x packages/cli-box-linux-x64/bin/* + node -e "const fs=require('fs');const f='packages/cli-box-linux-x64/package.json';const p=JSON.parse(fs.readFileSync(f,'utf8'));p.version='$VERSION';fs.writeFileSync(f,JSON.stringify(p,null,2)+'\n');" + npm publish ./packages/cli-box-linux-x64 --access public diff --git a/packages/cli-box-linux-x64/package.json b/packages/cli-box-linux-x64/package.json new file mode 100644 index 0000000..17d8d1e --- /dev/null +++ b/packages/cli-box-linux-x64/package.json @@ -0,0 +1,10 @@ +{ + "name": "cli-box-linux-x64", + "version": "0.2.8", + "description": "cli-box binaries for Linux x86_64 (headless)", + "os": ["linux"], + "cpu": ["x64"], + "files": [ + "bin/" + ] +} diff --git a/packages/cli-box-skill/package.json b/packages/cli-box-skill/package.json index aae7b2f..e2399aa 100644 --- a/packages/cli-box-skill/package.json +++ b/packages/cli-box-skill/package.json @@ -13,7 +13,8 @@ }, "optionalDependencies": { "cli-box-darwin-arm64": "0.2.8", - "cli-box-electron-darwin-arm64": "0.2.8" + "cli-box-electron-darwin-arm64": "0.2.8", + "cli-box-linux-x64": "0.2.8" }, "files": [ "skill/SKILL.md", From 892f95888e3d397fcd89327cc5ab5b97f7c9e593 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 23:02:40 +0800 Subject: [PATCH 15/22] =?UTF-8?q?fix(process):=20unblock=20Linux=20compile?= =?UTF-8?q?=20=E2=80=94=20unconditional=20libc=20+=20spawn=5Fapp=5Fwith=5F?= =?UTF-8?q?window=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI rust-linux failed: (1) libc was a macOS-only dep but is used in cross-platform process-alive checks (daemon); (2) spawn_app_with_window is cfg(macos) but called ungated by the daemon's app-mode handlers. Move libc to unconditional deps (trivially cross-platform) and add a non-macos stub for spawn_app_with_window (errors: app mode is macOS-only). --- crates/cli-box-core/Cargo.toml | 2 +- crates/cli-box-core/src/process/mod.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/cli-box-core/Cargo.toml b/crates/cli-box-core/Cargo.toml index 5f66c06..f77d86d 100644 --- a/crates/cli-box-core/Cargo.toml +++ b/crates/cli-box-core/Cargo.toml @@ -31,10 +31,10 @@ tower-http.workspace = true rusqlite.workspace = true vt100 = "0.16" ab_glyph = "0.2" +libc = "0.2" [target.'cfg(target_os = "macos")'.dependencies] core-graphics = { version = "0.25", features = ["highsierra", "elcapitan"] } core-foundation = "0.10" -libc = "0.2" objc = "0.2" screencapturekit = { version = "2.1", features = ["macos_14_0"], optional = true } diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index 7a536e9..ee493e0 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -358,6 +358,16 @@ impl ProcessManager { )) } + #[cfg(not(target_os = "macos"))] + pub fn spawn_app_with_window( + _app_path: &str, + _sandbox_id: Option<&str>, + ) -> Result<(ProcessInfo, Option)> { + Err(AppError::Process( + "spawn_app_with_window only supported on macOS".into(), + )) + } + /// Launch a CLI process with PTY support (default 80x24) #[cfg(unix)] pub fn spawn_cli(command: &str, args: &[String]) -> Result { From c5eef82a1e9e148b90223d0e278c52dc6c2f2ea7 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 23:14:39 +0800 Subject: [PATCH 16/22] fix(process): cfg-gate macOS-only app helpers for Linux clippy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI rust-linux round 2: clippy -D warnings failed on dead code on Linux — CHROMIUM_BUNDLE_IDS/read_bundle_id/is_chromium_app (private, used only by the macOS spawn_app_with_window) and cg_event's keycodes import (used only by macOS CGEvent code). Gate them cfg(macos). cleanup_chromium_data stays ungated (daemon calls it cross-platform). --- crates/cli-box-core/src/automation/cg_event.rs | 1 + crates/cli-box-core/src/process/mod.rs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/crates/cli-box-core/src/automation/cg_event.rs b/crates/cli-box-core/src/automation/cg_event.rs index 2365f89..ad0f051 100644 --- a/crates/cli-box-core/src/automation/cg_event.rs +++ b/crates/cli-box-core/src/automation/cg_event.rs @@ -1,3 +1,4 @@ +#[cfg(target_os = "macos")] use crate::automation::keycodes; use crate::error::{AppError, Result}; diff --git a/crates/cli-box-core/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index ee493e0..9db391b 100644 --- a/crates/cli-box-core/src/process/mod.rs +++ b/crates/cli-box-core/src/process/mod.rs @@ -63,6 +63,7 @@ static NEXT_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new /// Known Chromium-based app bundle identifiers. /// These apps enforce single-instance and need `open -g -n --args --user-data-dir` /// to create isolated processes that don't interfere with the user's existing browser. +#[cfg(target_os = "macos")] const CHROMIUM_BUNDLE_IDS: &[&str] = &[ "com.google.Chrome", "com.google.Chrome.beta", @@ -81,6 +82,7 @@ const CHROMIUM_BUNDLE_IDS: &[&str] = &[ ]; /// Read `CFBundleIdentifier` from an app bundle's Info.plist. +#[cfg(target_os = "macos")] fn read_bundle_id(app_path: &str) -> Option { let plist_path = std::path::Path::new(app_path).join("Contents/Info.plist"); let data = std::fs::read_to_string(plist_path).ok()?; @@ -93,6 +95,7 @@ fn read_bundle_id(app_path: &str) -> Option { } /// Check if an app is Chromium-based by its bundle identifier. +#[cfg(target_os = "macos")] fn is_chromium_app(app_path: &str) -> bool { match read_bundle_id(app_path) { Some(id) => CHROMIUM_BUNDLE_IDS From e14cc8c89d3388e2baeaf0f05fb5043542c55209 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 23:51:02 +0800 Subject: [PATCH 17/22] fix(cli): detach daemon stdio to prevent piped-CLI hangs CI e2e-linux-headless hung 11min then timed out: cli-box start spawns the daemon, which inherits the CLI's stdout pipe; when the CLI exits the daemon keeps the write-end open, so `Sandbox daemon already running on port 15801 (pid=86172) Creating sandbox: mode=cli, command=zsh Sandbox created: id=21f51147, pty_pid=Some(1016), window_id=Some(9466) Daemon port: 15801 Waiting for renderer done Waiting for terminal Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Waiting for terminal. Waiting for terminal.. Waiting for terminal... Sandbox ready: id=21f51147` never sees EOF. Redirect the daemon's stdin->null and stdout/stderr->~/.cli-box/ daemon.log so it no longer holds the CLI's pipes (logs preserved). --- crates/cli-box-cli/src/main.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/cli-box-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index 42496dd..4168b13 100644 --- a/crates/cli-box-cli/src/main.rs +++ b/crates/cli-box-cli/src/main.rs @@ -1846,6 +1846,37 @@ async fn ensure_healthy_daemon() -> anyhow::Result { if find_electron_binary().is_none() { daemon_cmd.arg("--headless"); } + // Detach the daemon's stdio: it must NOT inherit the CLI's pipes, otherwise + // it keeps the write-end open and hangs callers like `$(cli-box start | sed)` + // that wait for EOF. Redirect to ~/.cli-box/daemon.log (logs preserved), + // falling back to /dev/null if the log file cannot be opened. + daemon_cmd.stdin(std::process::Stdio::null()); + let log_path = dirs::home_dir() + .map(|h| h.join(".cli-box").join("daemon.log")) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp/cli-box-daemon.log")); + let _ = std::fs::create_dir_all(log_path.parent().unwrap_or(std::path::Path::new("/tmp"))); + match std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + Ok(f) => { + let f2 = f.try_clone(); + daemon_cmd.stdout(std::process::Stdio::from(f)); + match f2 { + Ok(g) => { + daemon_cmd.stderr(std::process::Stdio::from(g)); + } + Err(_) => { + daemon_cmd.stderr(std::process::Stdio::null()); + } + } + } + Err(_) => { + daemon_cmd.stdout(std::process::Stdio::null()); + daemon_cmd.stderr(std::process::Stdio::null()); + } + } let _child = daemon_cmd .spawn() .context("Failed to launch cli-box-daemon")?; From 06757099972a7d3e3766252ac2ff6abb514ab90a Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Thu, 25 Jun 2026 23:56:43 +0800 Subject: [PATCH 18/22] fix(e2e): extract sandbox id by anchoring on 'Sandbox created: id=' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 e2e got 'started sandbox: None' — the bare `.*id=` sed greedily matched window_id=None (contains 'id=') on headless. Anchor on the real field and echo raw output for debuggability. --- tests/e2e-linux-headless.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e-linux-headless.sh b/tests/e2e-linux-headless.sh index b59db72..505cc1c 100755 --- a/tests/e2e-linux-headless.sh +++ b/tests/e2e-linux-headless.sh @@ -13,8 +13,12 @@ cleanup() { trap cleanup EXIT # `printf` is a single non-compound command -> no shell wrap, no zsh needed. -SID=$(cli-box start printf -- headless-ok 2>&1 | sed -n 's/.*id=\([^,]*\).*/\1/p') -test -n "$SID" || { echo "FAIL: no sandbox id from start" >&2; exit 1; } +# Anchor on "Sandbox created: id=" (NOT a bare `id=` regex — `window_id=None` +# contains "id=" and would be captured greedily on headless). +RAW=$(cli-box start printf -- headless-ok 2>&1) +echo "$RAW" +SID=$(echo "$RAW" | sed -n 's/.*Sandbox created: id=\([^,]*\),.*/\1/p') +test -n "$SID" || { echo "FAIL: no sandbox id from start; raw: $RAW" >&2; exit 1; } echo "started sandbox: $SID" # Give the PTY + reader thread time to render the command output. From 2e0b942cf3c2d0176762aafb552762c7097d6848 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Fri, 26 Jun 2026 00:01:19 +0800 Subject: [PATCH 19/22] fix(cli): make Electron discovery macOS-only (Linux goes headless) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 e2e: find_electron_binary() auto-downloaded the macOS .app on the Linux runner (cached at ~/.cli-box/bin/), returned Some, so the CLI did NOT go headless — it tried to exec the macOS binary (Exec format error) and looped 'Waiting for terminal' forever. Electron is a macOS .app; return None on non-macOS so the headless short-circuit triggers (no download, --headless to daemon, no renderer wait). --- crates/cli-box-cli/src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/cli-box-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index 4168b13..b44fc46 100644 --- a/crates/cli-box-cli/src/main.rs +++ b/crates/cli-box-cli/src/main.rs @@ -1638,6 +1638,12 @@ fn find_daemon_binary() -> anyhow::Result { /// Locate the Electron app binary next to the current executable. fn find_electron_binary() -> Option { + // Electron is a macOS .app bundle; it cannot run on other platforms. + // Returning None on non-macOS drives the headless path (no renderer) + // and skips the macOS-app auto-download. + if cfg!(not(target_os = "macos")) { + return None; + } let exe_path = std::env::current_exe().ok()?; let exe_dir = exe_path.parent()?; From cf20c3f4f97d93c2a22ea9d4482191511525c3f4 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Sun, 19 Jul 2026 10:25:01 +0800 Subject: [PATCH 20/22] fix(capture): reset scrollback offset after headless render_png render_png set the vt100 parser's persistent scrollback_offset to render a scrolled view but never restored it. The leaked offset corrupted later rendered_text() calls (headless scrollback non-raw), showing scrolled-back history instead of the current screen after any --top screenshot. Reset offset to 0 before returning. Document that headless non-raw scrollback returns the current screen only (full history via raw), and add IT coverage for the raw/non-raw paths plus an end-to-end regression. Co-Authored-By: Claude --- crates/cli-box-core/src/capture/headless.rs | 34 ++++- crates/cli-box-core/src/daemon/mod.rs | 9 +- .../cli-box-core/tests/daemon_integration.rs | 137 ++++++++++++++++++ 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/crates/cli-box-core/src/capture/headless.rs b/crates/cli-box-core/src/capture/headless.rs index bda40d9..9cc7e6a 100644 --- a/crates/cli-box-core/src/capture/headless.rs +++ b/crates/cli-box-core/src/capture/headless.rs @@ -232,8 +232,13 @@ impl HeadlessTerminal { } let mut buf = Cursor::new(Vec::new()); - img.write_to(&mut buf, image::ImageFormat::Png) - .map_err(|e| AppError::Screenshot(format!("png encode: {e}")))?; + let encode_result = img.write_to(&mut buf, image::ImageFormat::Png); + // Restore the scrollback view to the current screen. render_png sets a + // scrolled-back view above; if left in place it leaks into the parser's + // persistent scrollback_offset and corrupts a later rendered_text() (the + // headless scrollback non-raw path), showing history instead of the screen. + parser.screen_mut().set_scrollback(0); + encode_result.map_err(|e| AppError::Screenshot(format!("png encode: {e}")))?; Ok(buf.into_inner()) } @@ -307,4 +312,29 @@ mod tests { let has_ink = img.pixels().any(|p| p[0] > 50 || p[1] > 50 || p[2] > 50); assert!(has_ink, "rendered PNG should contain non-background pixels"); } + + #[test] + fn render_png_resets_scrollback_offset() { + // Regression: render_png(scroll) used to mutate the parser's persistent + // scrollback_offset and never reset it, so a later rendered_text() + // (headless scrollback non-raw path) returned the scrolled-back view + // instead of the current screen. 3-row terminal fed 5 lines => the top + // two lines scroll off into history; current screen holds line-two/three/four. + let term = HeadlessTerminal::new(20, 3); + term.feed(b"line-zero\r\nline-one\r\nline-two\r\nline-three\r\nline-four"); + let current = term.rendered_text(); + assert!( + current.contains("line-four"), + "baseline must show the current bottom (line-four); got {current:?}" + ); + // Scroll to the very top. render_png needs a font; when none is available + // it returns early WITHOUT touching the offset, so this regression + // assertion is only binding in environments that ship a font (CI does). + let _ = term.render_png(usize::MAX); + assert_eq!( + term.rendered_text(), + current, + "render_png must reset scrollback offset — rendered_text leaked the scrolled view" + ); + } } diff --git a/crates/cli-box-core/src/daemon/mod.rs b/crates/cli-box-core/src/daemon/mod.rs index bc69f51..e1e9da2 100644 --- a/crates/cli-box-core/src/daemon/mod.rs +++ b/crates/cli-box-core/src/daemon/mod.rs @@ -600,8 +600,13 @@ async fn screenshot_headless( Ok(screenshot_response(png_data, "headless", None)) } -/// Read scrollback from server-side state (headless). `raw` returns the raw -/// PTY bytes from PtyStore; otherwise the parsed terminal grid text. +/// Read scrollback from server-side state (headless). +/// +/// - `raw = true`: full PTY bytes from PtyStore — the complete output history, +/// matching what the macOS renderer (xterm.js full buffer) returns. +/// - `raw = false`: the current visible screen as parsed text. Unlike the macOS +/// path this covers only the on-screen rows, not the full buffer; the full +/// history is available via `raw`. `from_line`/`to_line` are ignored here. async fn scrollback_headless( state: Arc>, id: &str, diff --git a/crates/cli-box-core/tests/daemon_integration.rs b/crates/cli-box-core/tests/daemon_integration.rs index 2f7601c..6835cf9 100644 --- a/crates/cli-box-core/tests/daemon_integration.rs +++ b/crates/cli-box-core/tests/daemon_integration.rs @@ -338,3 +338,140 @@ async fn headless_screenshot_renders_png() { ); } } + +#[cfg(unix)] +async fn body_text(resp: axum::http::Response) -> String { + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .expect("read response body"); + String::from_utf8_lossy(&bytes).to_string() +} + +/// Headless daemon state carrying a single CLI sandbox bound to `pty_pid`. +#[cfg(unix)] +fn headless_state_with_sandbox(id: &str, pty_pid: u32) -> Arc> { + let mut sandboxes = HashMap::new(); + sandboxes.insert( + id.to_string(), + ManagedSandbox { + id: id.to_string(), + kind: InstanceKind::Cli { + command: "printf".into(), + args: vec![], + }, + status: InstanceStatus::Running, + port: 0, + pty_pid: Some(pty_pid), + window_id: None, + }, + ); + Arc::new(Mutex::new(DaemonState { + port: 0, + sandboxes, + started_at: std::time::Instant::now(), + screenshot_ws_tx: None, + pending_screenshots: HashMap::new(), + pending_scrollback: HashMap::new(), + screenshot_request_counter: 0, + terminal_ready_sandboxes: HashSet::new(), + headless: true, + })) +} + +#[cfg(unix)] +#[tokio::test] +async fn headless_scrollback_returns_marker_raw_and_nonraw() { + use cli_box_core::process::ProcessManager; + + let info = + ProcessManager::spawn_cli("printf", &["hsb-marker-RAW\n".into()]).expect("spawn_cli"); + // Let the reader thread drain printf's output into the terminal grid + PtyStore. + std::thread::sleep(std::time::Duration::from_millis(300)); + let state = headless_state_with_sandbox("hsb", info.pid); + + // raw: full PTY bytes from PtyStore. + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/box/hsb/scrollback?raw=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let raw_text = body_text(resp).await; + assert!( + raw_text.contains("hsb-marker-RAW"), + "raw scrollback must contain marker; got: {raw_text:?}" + ); + + // non-raw: current screen text from HeadlessTerminal. + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/box/hsb/scrollback") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let nonraw_text = body_text(resp).await; + assert!( + nonraw_text.contains("hsb-marker-RAW"), + "non-raw scrollback must contain marker on current screen; got: {nonraw_text:?}" + ); + + let _ = ProcessManager::kill_process(info.pid); +} + +#[cfg(unix)] +#[tokio::test] +async fn headless_scrollback_stable_after_top_screenshot() { + use cli_box_core::process::ProcessManager; + + // Regression (Issue 1): a --top screenshot renders with a large scrollback + // offset. Before the render_png reset, that offset leaked into the shared + // parser and corrupted the next non-raw scrollback (showing history, not the + // current screen). + // 31 lines into a 24-row terminal => the first 7 scroll off into history and + // the marker lands on the bottom screen row. A --top screenshot then sets a + // nonzero scrollback_offset; without the render_png reset that offset leaks + // into the shared parser, so the non-raw scrollback below would read + // scrolled-back history (rows 1..24) instead of the current screen. + let info = ProcessManager::spawn_cli( + "sh", + &["-c".into(), "seq 1 30; echo steady-current-mark".into()], + ) + .expect("spawn_cli"); + std::thread::sleep(std::time::Duration::from_millis(400)); + let state = headless_state_with_sandbox("hsb2", info.pid); + + let _ = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/box/hsb2/screenshot?scroll=1000000") + .body(Body::empty()) + .unwrap(), + ) + .await; + + let resp = build_daemon_router(state.clone()) + .oneshot( + Request::builder() + .uri("/box/hsb2/scrollback") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let text = body_text(resp).await; + assert!( + text.contains("steady-current-mark"), + "non-raw scrollback after a --top screenshot must still show the current screen; got: {text:?}" + ); + + let _ = ProcessManager::kill_process(info.pid); +} From 852203d49359252c4d8e68dd5a9808db51d436aa Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Sun, 19 Jul 2026 10:31:36 +0800 Subject: [PATCH 21/22] docs(capture): note .ttc loads face 0 via ab_glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue-4 verification: ab_glyph's FontVec::try_from_vec delegates to try_from_vec_and_index(.., 0), so a .ttc collection loads face 0 and resolves/outlines CJK glyphs correctly (verified with PingFang.ttc). The NotoSansCJK .ttc candidate in load_font works as-is — document the behaviour to remove the earlier "unverified" doubt. Co-Authored-By: Claude --- crates/cli-box-core/src/capture/headless.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cli-box-core/src/capture/headless.rs b/crates/cli-box-core/src/capture/headless.rs index 9cc7e6a..c0456be 100644 --- a/crates/cli-box-core/src/capture/headless.rs +++ b/crates/cli-box-core/src/capture/headless.rs @@ -65,6 +65,9 @@ fn color_rgb(c: Color, default: (u8, u8, u8)) -> (u8, u8, u8) { /// /// Returns `None` if no usable font is found. `feed`/`rendered_text` need no /// font; only `render_png` does, and it errors clearly when `None`. +/// +/// TTF/OTF/TTC are all accepted. A `.ttc` collection loads its first face +/// (index 0) — ab_glyph's `try_from_vec` delegates to `try_from_vec_and_index(.., 0)`. fn load_font() -> Option { use ab_glyph::FontVec; let candidates: Vec = std::env::var("CLIBOX_FONT") From fb6e7e0479da40bd873af307537b373c47bc7be7 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Sun, 19 Jul 2026 10:40:24 +0800 Subject: [PATCH 22/22] docs(plan): mark Linux headless tasks complete All 9 implementation tasks shipped on PR #48 (CI green, macOS regression clean). Backfill the plan's step checkboxes so the doc reflects reality. Co-Authored-By: Claude --- .../2026-06-24-linux-headless-support.md | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/docs/superpowers/plans/2026-06-24-linux-headless-support.md b/docs/superpowers/plans/2026-06-24-linux-headless-support.md index bc53e9a..54d0370 100644 --- a/docs/superpowers/plans/2026-06-24-linux-headless-support.md +++ b/docs/superpowers/plans/2026-06-24-linux-headless-support.md @@ -48,7 +48,7 @@ **Interfaces:** - Produces: `vt100`/`ab_glyph` 可用(编译通过)。字体为运行时资产,不在此 Task 提交。 -- [ ] **Step 1: 添加依赖** +- [x] **Step 1: 添加依赖** 在 `crates/cli-box-core/Cargo.toml` 的 `[dependencies]` 末尾(`rusqlite.workspace = true` 之后)追加: @@ -57,14 +57,14 @@ vt100 = "0.16" ab_glyph = "0.2" ``` -- [ ] **Step 2: 验证依赖编译**(字体为运行时资产,本 Task 不提交二进制) +- [x] **Step 2: 验证依赖编译**(字体为运行时资产,本 Task 不提交二进制) ```bash cargo build -p cli-box-core 2>&1 | tail -5 ``` Expected: 编译通过(新依赖被拉取)。 -- [ ] **Step 4: 提交** +- [x] **Step 4: 提交** ```bash git add crates/cli-box-core/Cargo.toml @@ -84,7 +84,7 @@ git commit -m "chore(core): add vt100/ab_glyph deps" > 此 Task 不引入新功能,仅把已有跨平台 PTY 实现从 `cfg(target_os="macos")` 释放为 `cfg(unix)`,并删除返回错误的非 macOS 桩。本机验证(macOS)证明不回归;Linux 编译的权威证明在 Task 7 的 CI 门禁。 -- [ ] **Step 1: 解除 import 守卫** +- [x] **Step 1: 解除 import 守卫** `process/mod.rs` 第 18 行附近: @@ -107,18 +107,18 @@ use { }; ``` -- [ ] **Step 2: 解除 PtySession 守卫** +- [x] **Step 2: 解除 PtySession 守卫** 第 35 行 `#[cfg(target_os = "macos")]` 上方的 `struct PtySession { ... }`,把其 `#[cfg(target_os = "macos")]` 改为 `#[cfg(unix)]`。结构体字段不变。 -- [ ] **Step 3: 把所有 PTY 方法的 macOS 守卫改为 unix** +- [x] **Step 3: 把所有 PTY 方法的 macOS 守卫改为 unix** 对以下方法的 `#[cfg(target_os = "macos")]` 全部改为 `#[cfg(unix)]`(用编辑器逐个替换,或脚本): `spawn_app`(保留 macOS-only:见 Step 4)、`spawn_cli`、`spawn_cli_with_size`、`send_input`、`resize_pty`、`read_output`、`peek_output`、`subscribe_output`、`get_store`、`kill_process`。 > 注意:`spawn_app` / `spawn_app_with_window` / `find_pids_by_app_name` 等 macOS GUI 应用启动逻辑**保持 `cfg(target_os="macos")` 不变**(app 模式非 Linux 目标)。只改 PTY 相关方法。 -- [ ] **Step 4: 删除所有返回错误的非 macOS 桩** +- [x] **Step 4: 删除所有返回错误的非 macOS 桩** 删除所有 `#[cfg(not(target_os = "macos"))]` 的 PTY 桩函数(`spawn_app` 非 macOS 桩可保留或删除,删除更干净;`spawn_cli`/`spawn_cli_with_size`/`kill_process`/`send_input`/`resize_pty`/`read_output`/`peek_output`/`subscribe_output`/`get_store` 的非 macOS 桩**全部删除**)。 @@ -129,7 +129,7 @@ grep -n "cfg(not(target_os = \"macos\"))" crates/cli-box-core/src/process/mod.rs ``` Expected: 仅剩 `spawn_app` 相关(若保留)或为空。 -- [ ] **Step 5: 验证本机编译 + 现有 PTY 测试不回归** +- [x] **Step 5: 验证本机编译 + 现有 PTY 测试不回归** ```bash cargo build -p cli-box-core -p cli-box-cli -p cli-box-daemon 2>&1 | tail -5 @@ -138,7 +138,7 @@ cargo clippy -p cli-box-core --all-targets -- -D warnings 2>&1 | tail -5 ``` Expected: 全部通过(macOS 上行为不变;PTY 方法现在 `cfg(unix)` 仍覆盖 macOS)。 -- [ ] **Step 6: 提交** +- [x] **Step 6: 提交** ```bash git add crates/cli-box-core/src/process/mod.rs @@ -171,7 +171,7 @@ the stubs. No macOS behavior change." > vt100 API 已核对(docs.rs 0.16.2):`Parser::new(rows, cols, scrollback)`、`process(&[u8])`、`screen().cell(row, col) -> Option<&Cell>`、`Cell::{contents, fgcolor, bgcolor, inverse}`、`Color::{Default, Idx(u8), Rgb(u8,u8,u8)}`、`screen().set_scroll(usize)`、`screen().size() -> (usize, usize)`。 > ab_glyph:`FontRef::try_from_slice`、`glyph_id`、`outline_glyph`、`OutlinedGlyph::px_bounds` + `draw(|x,y,coverage|)`。实现时若 `draw` 坐标原点语义与本计划假设不符,以 docs.rs 为准调整 offset——属同一逻辑,非占位。 -- [ ] **Step 1: 写失败测试 — feed 后网格内容/颜色** +- [x] **Step 1: 写失败测试 — feed 后网格内容/颜色** 在 `crates/cli-box-core/src/capture/headless.rs` 顶部先写测试模块(TDD): @@ -252,14 +252,14 @@ mod tests { 注意:测试引用了 `term.screen_cell(row, col)`(测试辅助),在 Step 3 实现里提供。 -- [ ] **Step 2: 运行测试确认失败** +- [x] **Step 2: 运行测试确认失败** ```bash cargo test -p cli-box-core capture::headless 2>&1 | tail -15 ``` Expected: 编译失败(`HeadlessTerminal` 未定义)。 -- [ ] **Step 3: 实现 HeadlessTerminal 基础(new/feed/screen_cell/rendered_text)** +- [x] **Step 3: 实现 HeadlessTerminal 基础(new/feed/screen_cell/rendered_text)** 在 `headless.rs`(测试模块之后)实现: @@ -345,14 +345,14 @@ impl HeadlessTerminal { } ``` -- [ ] **Step 4: 运行测试确认前 3 个通过** +- [x] **Step 4: 运行测试确认前 3 个通过** ```bash cargo test -p cli-box-core capture::headless 2>&1 | tail -15 ``` Expected: 3 个测试 PASS。 -- [ ] **Step 5: 写失败测试 — render_png 产出有效 PNG** +- [x] **Step 5: 写失败测试 — render_png 产出有效 PNG** 在 `tests` 模块追加: @@ -383,14 +383,14 @@ Expected: 3 个测试 PASS。 } ``` -- [ ] **Step 6: 运行确认失败** +- [x] **Step 6: 运行确认失败** ```bash cargo test -p cli-box-core capture::headless::tests::render_png 2>&1 | tail -15 ``` Expected: 编译失败(`render_png` 未定义)。 -- [ ] **Step 7: 实现 render_png** +- [x] **Step 7: 实现 render_png** 在 `impl HeadlessTerminal` 追加: @@ -488,7 +488,7 @@ Expected: 编译失败(`render_png` 未定义)。 > 实现者注意:`OutlinedGlyph::draw(|x, y, coverage|)` 的 `(x, y)` 在 ab_glyph 中是相对 `px_bounds().min` 的像素偏移;上面用 `min_x/min_y` 偏移到绝对坐标。若所用 ab_glyph 版本的 `draw` 已返回绝对坐标,去掉偏移即可。以 `cargo test render_png` 通过为准。 -- [ ] **Step 8: 运行全部 headless 测试** +- [x] **Step 8: 运行全部 headless 测试** ```bash cargo test -p cli-box-core capture::headless 2>&1 | tail -15 @@ -496,7 +496,7 @@ cargo clippy -p cli-box-core --all-targets -- -D warnings 2>&1 | tail -5 ``` Expected: 5 个测试全 PASS;clippy 无 warning。 -- [ ] **Step 9: 导出模块** +- [x] **Step 9: 导出模块** 在 `crates/cli-box-core/src/capture/mod.rs` 末尾追加: @@ -505,7 +505,7 @@ pub mod headless; pub use headless::HeadlessTerminal; ``` -- [ ] **Step 10: 提交** +- [x] **Step 10: 提交** ```bash git add crates/cli-box-core/src/capture/headless.rs crates/cli-box-core/src/capture/mod.rs @@ -527,7 +527,7 @@ CJK-capable via embedded monospace font. Fully unit-tested." - Produces: `ProcessManager::get_terminal(pid: u32) -> Result>`(新增),供 Task 5 的 daemon 路由使用。 - Consumes: `crate::capture::HeadlessTerminal`(Task 3)。 -- [ ] **Step 1: PtySession 增加 terminal 字段** +- [x] **Step 1: PtySession 增加 terminal 字段** 在 `PtySession` 结构体(现 `cfg(unix)`)追加字段: @@ -547,7 +547,7 @@ struct PtySession { } ``` -- [ ] **Step 2: 创建 terminal 并在 reader 线程 feed** +- [x] **Step 2: 创建 terminal 并在 reader 线程 feed** 在 `spawn_cli_with_size` 中,创建 store 后、创建 reader 线程前,加: @@ -567,7 +567,7 @@ struct PtySession { 并在 `sessions.insert(tracked_id, PtySession { ... })` 的字段列表中加入 `terminal,`。 -- [ ] **Step 3: 实现 get_terminal 访问器** +- [x] **Step 3: 实现 get_terminal 访问器** 在 `impl ProcessManager` 中(与 `get_store` 相邻),加(`cfg(unix)`): @@ -585,7 +585,7 @@ struct PtySession { } ``` -- [ ] **Step 4: 编译 + 现有 PTY 测试不回归** +- [x] **Step 4: 编译 + 现有 PTY 测试不回归** ```bash cargo build -p cli-box-core 2>&1 | tail -5 @@ -594,7 +594,7 @@ cargo clippy -p cli-box-core --all-targets -- -D warnings 2>&1 | tail -5 ``` Expected: 全部通过。 -- [ ] **Step 5: 提交** +- [x] **Step 5: 提交** ```bash git add crates/cli-box-core/src/process/mod.rs @@ -620,7 +620,7 @@ get_terminal(pid) accessor for the headless screenshot path." > 设计选择:headless 走显式 `--headless` 标志而非"renderer 未连接即 headless",确保 macOS(有 Electron)行为 100% 不变;仅 Linux/无 Electron 时 CLI 传 `--headless`。 -- [ ] **Step 1: DaemonState 增加 headless 字段** +- [x] **Step 1: DaemonState 增加 headless 字段** `daemon/mod.rs` 结构体 `DaemonState` 末尾(`terminal_ready_sandboxes` 之后)加: @@ -644,7 +644,7 @@ grep -rn "terminal_ready_sandboxes: HashSet::new()\|terminal_ready_sandboxes: " ``` 逐一在这些构造后补 `headless: false,`(run_daemon 用参数变量)。 -- [ ] **Step 2: run_daemon 接收 headless 参数** +- [x] **Step 2: run_daemon 接收 headless 参数** ```rust pub async fn run_daemon(port: u16, headless: bool) -> Result<(), Box> { @@ -663,7 +663,7 @@ pub async fn run_daemon(port: u16, headless: bool) -> Result<(), Box Result<(), Box&1 | tail -5 @@ -860,7 +860,7 @@ cargo clippy -p cli-box-core -p cli-box-daemon --all-targets -- -D warnings 2>&1 ``` Expected: headless_screenshot_renders_png PASS;其余测试不回归。 -- [ ] **Step 10: 提交** +- [x] **Step 10: 提交** ```bash git add crates/cli-box-core/src/daemon/mod.rs crates/cli-box-daemon/src/main.rs crates/cli-box-core/tests/daemon_integration.rs @@ -883,14 +883,14 @@ macOS (non-headless) behavior unchanged." - Consumes: `find_electron_binary()`(已有)、daemon `--headless`(Task 5)。 - Produces: 无 Electron 时 CLI 不等待 renderer,并把 `--headless` 传给 daemon。 -- [ ] **Step 1: 定位 daemon 启动点** +- [x] **Step 1: 定位 daemon 启动点** ```bash grep -n "find_daemon_binary\|Command::new.*daemon\|run_daemon\|spawn.*daemon" crates/cli-box-cli/src/main.rs ``` 找到 CLI 用 `find_daemon_binary()` 拿到路径后 `Command::new(daemon_bin)` 启动 daemon 的位置(通常在 `ensure_daemon` 或 `ensure_healthy_daemon` 类函数中)。 -- [ ] **Step 2: 启动 daemon 时按需追加 --headless** +- [x] **Step 2: 启动 daemon 时按需追加 --headless** 在该 `Command::new(&daemon_bin)` 处,根据是否有 Electron 决定是否追加 `--headless`: @@ -905,7 +905,7 @@ cmd.spawn() // 或当前等价调用 > 用 `find_electron_binary().is_none()` 作为 headless 判据:macOS 有 Electron → 不传 → daemon 走 renderer(不变);Linux/无 Electron → 传 `--headless` → daemon 走 headless 渲染。 -- [ ] **Step 3: ensure_healthy_electron 显式短路** +- [x] **Step 3: ensure_healthy_electron 显式短路** 在 `ensure_healthy_electron` 函数**最开头**加显式短路(避免 macOS 路径探测): @@ -922,7 +922,7 @@ async fn ensure_healthy_electron() { // ... existing body unchanged ... ``` -- [ ] **Step 4: 编译 + 类型检查** +- [x] **Step 4: 编译 + 类型检查** ```bash cargo build -p cli-box-cli 2>&1 | tail -5 @@ -930,7 +930,7 @@ cargo clippy -p cli-box-cli --all-targets -- -D warnings 2>&1 | tail -5 ``` Expected: 通过。 -- [ ] **Step 5: 提交** +- [x] **Step 5: 提交** ```bash git add crates/cli-box-cli/src/main.rs @@ -951,7 +951,7 @@ for a renderer and starts the daemon in --headless mode." > 此 Task 是 Linux 编译的**权威验证**——它会捕获 Task 2 未在本机(macOS)暴露的任何残留 `cfg` 问题。 -- [ ] **Step 1: 新增 Linux clippy 门禁** +- [x] **Step 1: 新增 Linux clippy 门禁** 在 `ci.yml` 末尾(`frontend-test` job 之后)追加: @@ -995,14 +995,14 @@ for a renderer and starts the daemon in --headless mode." > 注:`rusqlite` 用 bundled 特性(已是 `features=["bundled"]`),无需系统 sqlite。`vt100`/`ab_glyph` 纯 Rust,无系统依赖。 -- [ ] **Step 2: 验证 YAML 语法** +- [x] **Step 2: 验证 YAML 语法** ```bash python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('ci.yml OK')" ``` Expected: `ci.yml OK`。 -- [ ] **Step 3: 提交** +- [x] **Step 3: 提交** ```bash git add .github/workflows/ci.yml @@ -1022,7 +1022,7 @@ cfg(unix) gating and the headless path on Linux." **Interfaces:** 无(端到端验证)。 -- [ ] **Step 1: 适配 E2E 脚本支持 Linux** +- [x] **Step 1: 适配 E2E 脚本支持 Linux** `tests/e2e-compound-start-screenshot.sh` 顶部现有的 skip 守卫,把"Linux 跳过"改为"Linux 走 headless 子集"。在脚本开头加平台检测与命令选择: @@ -1042,7 +1042,7 @@ fi > 若改动复杂,最小可用方案:保留原 macOS 脚本不动,**新增** `tests/e2e-linux-headless.sh` 只覆盖 `start bash` → `screenshot`(默认 + `--top`)→ `scrollback`,并在 Step 2 的 CI job 调用它。二选一即可,推荐后者更清晰。 -- [ ] **Step 2: 新增 ci.yml headless E2E job** +- [x] **Step 2: 新增 ci.yml headless E2E job** 在 `ci.yml` 追加: @@ -1079,7 +1079,7 @@ fi run: bash tests/e2e-linux-headless.sh ``` -- [ ] **Step 3: 创建 headless E2E 脚本(若 Step 1 选了新脚本方案)** +- [x] **Step 3: 创建 headless E2E 脚本(若 Step 1 选了新脚本方案)** ```bash cat > tests/e2e-linux-headless.sh << 'E2EOF' @@ -1114,7 +1114,7 @@ E2EOF chmod +x tests/e2e-linux-headless.sh ``` -- [ ] **Step 4: YAML 校验 + 提交** +- [x] **Step 4: YAML 校验 + 提交** ```bash python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml')); print('ci.yml OK')" @@ -1136,7 +1136,7 @@ ubuntu-latest — the only end-to-end exercise of HeadlessTerminal." **Interfaces:** 无。 -- [ ] **Step 1: 新增 npm 平台包** +- [x] **Step 1: 新增 npm 平台包** `mkdir -p packages/cli-box-linux-x64`,创建 `packages/cli-box-linux-x64/package.json`: @@ -1156,7 +1156,7 @@ ubuntu-latest — the only end-to-end exercise of HeadlessTerminal." } ``` -- [ ] **Step 2: release.yml 新增 build-linux job** +- [x] **Step 2: release.yml 新增 build-linux job** 在 `release.yml` 的 `build-and-release` job 之后,追加一个 Linux job(与 macOS job 平级,各自上传到同一 Release): @@ -1216,7 +1216,7 @@ ubuntu-latest — the only end-to-end exercise of HeadlessTerminal." npm publish ./packages/cli-box-linux-x64 --access public ``` -- [ ] **Step 3: skill 包 optionalDependencies 加 linux** +- [x] **Step 3: skill 包 optionalDependencies 加 linux** 在 `packages/cli-box-skill/package.json` 的 `optionalDependencies`(若存在)中加入;若无该字段则新增: @@ -1229,7 +1229,7 @@ ubuntu-latest — the only end-to-end exercise of HeadlessTerminal." > 实现者:核对 `packages/cli-box-skill/package.json` 现有结构,保持版本号与其他平台包一致;release.yml 的 "Package npm platform packages" 步骤里更新版本号的 node 脚本需把 `packages/cli-box-linux-x64/package.json` 加入文件列表(参考现有 darwin-arm64 处理)。 -- [ ] **Step 4: 提交** +- [x] **Step 4: 提交** ```bash git add packages/cli-box-linux-x64/ packages/cli-box-skill/package.json .github/workflows/release.yml @@ -1244,14 +1244,14 @@ GitHub Release tarball." ## 全部完成后的收尾 -- [ ] **Step 1: 本地完整门禁(macOS 不回归)** +- [x] **Step 1: 本地完整门禁(macOS 不回归)** ```bash sh test.sh ``` Expected: macOS 全部门禁通过。 -- [ ] **Step 2: 推送 + 开 PR** +- [x] **Step 2: 推送 + 开 PR** ```bash git push -u origin feat/linux-headless-support @@ -1271,13 +1271,13 @@ cli-box 仅支持 macOS。需要以无头 daemon 形态运行在云端 Linux 服 - [x] daemon_integration headless 截图 IT(真实 PTY) - [x] Linux cargo check/clippy/test 门禁 - [x] Linux headless E2E(start→screenshot→scrollback) -- [ ] macOS test.sh 不回归 +- [x] macOS test.sh 不回归 PR )" ``` Expected: PR 创建并保持 open(不合入)。 -- [ ] **Step 3: 关注 CI,按需修复** +- [x] **Step 3: 关注 CI,按需修复** 等待 PR 的 CI(含新增 Linux 门禁)全部通过;失败则按 `superpowers:systematic-debugging` 定位修复后重推。