diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99773e4..8a8688f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -607,10 +607,83 @@ 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 + + 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, 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: @@ -647,6 +720,8 @@ 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 }}" + E2E_LINUX_HEADLESS="${{ needs.e2e-linux-headless.result }}" echo "| 检查项 | 状态 |" echo "|-------|------|" @@ -660,6 +735,8 @@ jobs: echo "| 发布模拟验证 | $PUBLISH_SIM |" 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 @@ -677,6 +754,8 @@ jobs: if [[ "$RUST_FMT" == "success" && \ "$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/.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/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-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index 911fb0b..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()?; @@ -1841,7 +1847,43 @@ 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"); + } + // 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")?; @@ -1877,6 +1919,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() diff --git a/crates/cli-box-core/Cargo.toml b/crates/cli-box-core/Cargo.toml index 4c05655..f77d86d 100644 --- a/crates/cli-box-core/Cargo.toml +++ b/crates/cli-box-core/Cargo.toml @@ -29,10 +29,12 @@ futures-util.workspace = true tower.workspace = true 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/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/capture/headless.rs b/crates/cli-box-core/src/capture/headless.rs new file mode 100644 index 0000000..c0456be --- /dev/null +++ b/crates/cli-box-core/src/capture/headless.rs @@ -0,0 +1,343 @@ +//! 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`. +/// +/// 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") + .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()); + 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()) + } + + /// 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"); + } + + #[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/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; diff --git a/crates/cli-box-core/src/daemon/mod.rs b/crates/cli-box-core/src/daemon/mod.rs index 31e8298..e1e9da2 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,73 @@ 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 = 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, + 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 +662,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 +1675,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 +1690,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 +1897,7 @@ mod tests { pending_scrollback: HashMap::new(), screenshot_request_counter: 0, terminal_ready_sandboxes: HashSet::new(), + headless: false, })) } @@ -1840,6 +1926,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/src/process/mod.rs b/crates/cli-box-core/src/process/mod.rs index a6d973e..9db391b 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, @@ -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) @@ -61,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", @@ -79,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()?; @@ -91,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 @@ -356,14 +361,24 @@ 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(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], @@ -418,6 +433,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 +471,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 +506,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,23 +527,6 @@ 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> { let sessions = SESSIONS @@ -550,7 +554,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 +600,8 @@ 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 +637,8 @@ 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 +659,11 @@ 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 +709,9 @@ 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 +722,8 @@ 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,11 +734,16 @@ 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(), - )) + /// 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)) } } diff --git a/crates/cli-box-core/tests/daemon_integration.rs b/crates/cli-box-core/tests/daemon_integration.rs index 647f7b1..6835cf9 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,205 @@ 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() + ); + } +} + +#[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); +} 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"); } 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..54d0370 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-linux-headless-support.md @@ -0,0 +1,1283 @@ +# 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` + +> **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` 可用(编译通过)。字体为运行时资产,不在此 Task 提交。 + +- [x] **Step 1: 添加依赖** + +在 `crates/cli-box-core/Cargo.toml` 的 `[dependencies]` 末尾(`rusqlite.workspace = true` 之后)追加: + +```toml +vt100 = "0.16" +ab_glyph = "0.2" +``` + +- [x] **Step 2: 验证依赖编译**(字体为运行时资产,本 Task 不提交二进制) + +```bash +cargo build -p cli-box-core 2>&1 | tail -5 +``` +Expected: 编译通过(新依赖被拉取)。 + +- [x] **Step 4: 提交** + +```bash +git add crates/cli-box-core/Cargo.toml +git commit -m "chore(core): add vt100/ab_glyph deps" +``` + +--- + +### 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 门禁。 + +- [x] **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}, +}; +``` + +- [x] **Step 2: 解除 PtySession 守卫** + +第 35 行 `#[cfg(target_os = "macos")]` 上方的 `struct PtySession { ... }`,把其 `#[cfg(target_os = "macos")]` 改为 `#[cfg(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 相关方法。 + +- [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 桩**全部删除**)。 + +验证删除后无残留 `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` 相关(若保留)或为空。 + +- [x] **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)。 + +- [x] **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——属同一逻辑,非占位。 + +- [x] **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}; + +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::*; + + #[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 实现里提供。 + +- [x] **Step 2: 运行测试确认失败** + +```bash +cargo test -p cli-box-core capture::headless 2>&1 | tail -15 +``` +Expected: 编译失败(`HeadlessTerminal` 未定义)。 + +- [x] **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() + } +} +``` + +- [x] **Step 4: 运行测试确认前 3 个通过** + +```bash +cargo test -p cli-box-core capture::headless 2>&1 | tail -15 +``` +Expected: 3 个测试 PASS。 + +- [x] **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"); + } +``` + +- [x] **Step 6: 运行确认失败** + +```bash +cargo test -p cli-box-core capture::headless::tests::render_png 2>&1 | tail -15 +``` +Expected: 编译失败(`render_png` 未定义)。 + +- [x] **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 = 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; + 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` 通过为准。 + +- [x] **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。 + +- [x] **Step 9: 导出模块** + +在 `crates/cli-box-core/src/capture/mod.rs` 末尾追加: + +```rust +pub mod headless; +pub use headless::HeadlessTerminal; +``` + +- [x] **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)。 + +- [x] **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, +} +``` + +- [x] **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,`。 + +- [x] **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)) + } +``` + +- [x] **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: 全部通过。 + +- [x] **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`。 + +- [x] **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 用参数变量)。 + +- [x] **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 ... +``` + +- [x] **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)");` + +- [x] **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)) +} +``` + +- [x] **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()) + } +} +``` + +- [x] **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 ... +``` + +- [x] **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 ... +``` + +- [x] **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); +} +``` + +- [x] **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;其余测试不回归。 + +- [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 +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。 + +- [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` 类函数中)。 + +- [x] **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 渲染。 + +- [x] **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 ... +``` + +- [x] **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: 通过。 + +- [x] **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` 问题。 + +- [x] **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,无系统依赖。 + +- [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`。 + +- [x] **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:** 无(端到端验证)。 + +- [x] **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 调用它。二选一即可,推荐后者更清晰。 + +- [x] **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 +``` + +- [x] **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 +``` + +- [x] **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:** 无。 + +- [x] **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/"] +} +``` + +- [x] **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 +``` + +- [x] **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 处理)。 + +- [x] **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." +``` + +--- + +## 全部完成后的收尾 + +- [x] **Step 1: 本地完整门禁(macOS 不回归)** + +```bash +sh test.sh +``` +Expected: macOS 全部门禁通过。 + +- [x] **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) +- [x] macOS test.sh 不回归 +PR +)" +``` +Expected: PR 创建并保持 open(不合入)。 + +- [x] **Step 3: 关注 CI,按需修复** + +等待 PR 的 CI(含新增 Linux 门禁)全部通过;失败则按 `superpowers:systematic-debugging` 定位修复后重推。 + 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..56ff59a --- /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 分支全是返回错误的桩函数(这些桩本身能编译)。真正的 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%)。 + +--- + +## 二、目标与非目标 + +### 目标 + +- 在 **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 桩函数;修复**未门控**的 `SESSIONS` 静态与 `list_processes()`/`is_session_alive()` 对 macOS 专属 `PtySession` 的引用(将其与真实 PTY 实现一并移到 `cfg(unix)`)。 +- `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 色调色板 + 默认前后景色。 +- 字体:**运行时路径加载**(非嵌入)——`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 色、字体连字/字体回退不支持。 + +### 组件 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(残留降级见组件 2:Truecolor 量化、Emoji 豆腐块、字体连字/回退)。CJK/拉丁由嵌入字体正确支持,对 CLI 文本输出场景足够。 +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`。 + +--- + +## 七、改动文件清单(预估) + +| 文件 | 改动 | +|------|------| +| `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 | +| `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) | +| `crates/cli-box-core/src/capture/headless.rs` 中 `load_font()` | 运行时字体路径解析(无二进制资产) | + +--- + +**版本**:v1.0 | **创建**:2026-06-24 | **方案**:方案 A(Rust 原生终端渲染器) 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", diff --git a/tests/e2e-linux-headless.sh b/tests/e2e-linux-headless.sh new file mode 100755 index 0000000..505cc1c --- /dev/null +++ b/tests/e2e-linux-headless.sh @@ -0,0 +1,42 @@ +#!/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. +# 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. +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)"