diff --git a/.github/assets/hero.png b/.github/assets/hero.png index 21849f9..8921c3c 100644 Binary files a/.github/assets/hero.png and b/.github/assets/hero.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 2e3ff11..ace2a51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,365 +1,264 @@ -# cli-box — macOS 桌面自动化沙箱 +# cli-box — 开发工作流指南 -> **核心理念**:一个可复用的 macOS 桌面自动化沙箱,支持多实例管理——通过 CLI 命令启动独立沙箱窗口,在其中运行任意 CLI 或 macOS 应用,并通过模拟鼠标/键盘操作与截图反馈进行自动化控制。 +> **使用方式**:用户发送需求后,Claude 读取本文档,按照预设工作流自动执行完整开发周期。 > -> 对目标应用**零侵入**,所有操作在 OS 层面完成(CGEvent + AXUIElement + ScreenCaptureKit)。 -> -> **Daemon + Electron 架构**:一个长驻 Rust daemon 管理所有沙箱实例(PTY 进程 + macOS 应用),通过单一 HTTP API 对外服务。Electron 应用作为 GUI 前端,提供 tab 管理、xterm.js 终端和截图预览。CLI 直接与 daemon 通信。 +> **核心原则**:Superpowers 驱动 · 测试先行 · 代码检视 · 不自动合入主分支 + +--- + +## 一、项目概览 -## 一、架构总览 +**cli-box** 是一个 macOS 桌面自动化沙箱,支持多实例管理。通过 CLI 命令启动独立沙箱窗口,在其中运行任意 CLI 或 macOS 应用,并通过模拟鼠标/键盘操作与截图反馈进行自动化控制。 + +**架构**:Rust daemon(PTY + 自动化引擎)+ Electron GUI(xterm.js + React)+ CLI 工具 ``` -┌──────────────────────────────────────────────────────────────┐ -│ Agent / 用户 (CLI / MCP / HTTP) │ -│ cli-box start / list / screenshot / click / type / key │ -└───────────────────────────────┬───────────────────────────────┘ - │ HTTP (localhost:15801) - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ cli-box-daemon (Rust, 单实例) │ -│ PTY Manager + App Manager + Automation Engine │ -│ Instance Registry (~/.cli-box/instances/) │ -└───────────────────────────────┬───────────────────────────────┘ - │ WebSocket (PTY 流) - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ Electron App (单实例, Chromium) │ -│ Tab 管理 + xterm.js + 控制面板 + 截图预览 │ -└──────────────────────────────────────────────────────────────┘ +Agent / 用户 (CLI / MCP / HTTP) + │ HTTP (localhost:15801) + ▼ +cli-box-daemon (Rust, 单实例) + │ WebSocket (PTY 流 + 截图) + ▼ +Electron App (单实例, Chromium) ``` -**设计原则**: -1. **零侵入**:目标应用不需要任何适配,所有操作在 OS 层面完成 -2. **单 daemon 多沙箱**:一个 daemon 进程管理所有沙箱实例,通过 CLI 管理生命周期 -3. **窗口级截图**:ScreenCaptureKit 按窗口 ID 截图,不需要窗口在前台 -4. **双协议**:MCP (Agent CLI 原生) + HTTP (通用调用) -5. **文件系统注册中心**:沙箱实例通过 `~/.cli-box/instances/.json` 注册和发现 +**技术栈**:Rust (tokio + axum) · Electron (React + xterm.js) · macOS API (CGEvent + AXUIElement + ScreenCaptureKit) -**PTY Reader Thread**: Each PTY session spawns a dedicated background thread that continuously reads output into a shared buffer. The HTTP `/pty/output/:pid` endpoint drains from this buffer non-blocking. This replaces the earlier take/read/put-back pattern that blocked on idle TUI apps (opencode, vim, etc.). +--- -## 二、技术栈 +## 二、开发工作流(完整周期) -| 项目属性 | 规范值 | -|---------|--------| -| 核心库 | Rust (Edition 2021, >=1.88), `cli-box-core` library crate | -| CLI | Rust, `cli-box-cli` binary crate | -| 桌面框架 | Electron (Chromium) | -| 桌面前端 | React 18 + TS + Vite + xterm.js | -| 异步运行时 | tokio | -| macOS API | CoreGraphics (CGEvent), ApplicationServices (AXUIElement), ScreenCaptureKit | -| 包管理 | Cargo Workspace + pnpm | -| 测试 | cargo test (Rust) + vitest (TS) | -| 目标平台 | macOS (Apple Silicon 优先) | -| License | Apache 2.0 | +当用户发送新需求时,按以下阶段顺序执行: -## 三、目录结构 +### 阶段 0:分支准备 -``` -cli-box/ -├── Cargo.toml # Workspace 根 -├── crates/ -│ ├── cli-box-core/ # 🔑 自动化核心 (library) -│ │ └── src/ -│ │ ├── lib.rs, error.rs -│ │ ├── automation/ # CGEvent 输入模拟 + AXUIElement UI 检查 -│ │ │ ├── cg_event.rs -│ │ │ └── ax_ui.rs -│ │ ├── capture/ # ScreenCaptureKit 截图引擎 -│ │ │ └── mod.rs -│ │ ├── process/ # PTY + NSWorkspace 进程管理 -│ │ │ └── mod.rs -│ │ ├── sandbox/ # 沙箱窗口管理 (含多实例支持) -│ │ │ └── mod.rs -│ │ ├── instance/ # NEW: 沙箱实例注册中心 -│ │ │ └── mod.rs -│ │ └── server/ # NEW: HTTP API 服务器 (library) -│ │ └── mod.rs -│ └── cli-box-cli/ # 🖥️ CLI (binary) -│ └── src/ -│ ├── main.rs # start/list/close + 所有子命令 -│ ├── client.rs # NEW: HTTP 客户端 (与沙箱实例通信) -│ └── mcp_server.rs # MCP stdio 服务器 -├── electron-app/ # 🌐 沙箱窗口前端 (xterm.js + React) -│ └── src/ -│ ├── main.tsx, api.ts -│ └── components/ # Terminal, ControlPanel, StatusBar, RecordControls -├── src-tauri/ # 🖥️ macOS 宿主应用 (Tauri) -│ └── src/main.rs # 多实例支持:CLI 参数解析 + 内嵌 HTTP API -├── docs/ -│ ├── design/ # 设计文档 -│ └── task/ # 任务管理 (README.md + phase-*.md + task_records.json) -├── tests/ -│ └── fixtures/ # 集成测试场景 (YAML/JSON) -└── config.example.json +```bash +git checkout main && git pull +git checkout -b feat/- ``` -## 四、核心接口 +- 从 main 新切特性分支 +- 分支命名:`feat/`、`fix/`、`test/`、`docs/` 前缀 -### CLI 命令 (cli-box-cli) +### 阶段 1:需求分析与方案设计 -```bash -# 多实例管理 -cli-box start # 启动沙箱,打开 zsh 终端(默认) -cli-box start claude # 启动沙箱,直接运行 Claude Code -cli-box start --shell # 等同于 cli-box start(快捷方式) -cli-box start /path/to/App.app # 启动沙箱,运行 macOS 应用 -cli-box list # 列出所有活跃沙箱及其状态 -cli-box close # 关闭指定沙箱 -cli-box inspect # 查看沙箱详情 - -# 沙箱作用域操作 (通过 --id 指定目标沙箱) -cli-box screenshot --id # 截取沙箱截图 -cli-box screenshot --id -o result.png # 截图并指定输出路径 -cli-box click --id 100 200 # 在沙箱内模拟点击 -cli-box type --id "hello world" # 在沙箱内模拟输入 -cli-box key --id Return --modifiers cmd # 在沙箱内模拟按键 - -# 进程管理 (沙箱内) -cli-box windows --id # 列出沙箱内窗口 -cli-box processes --id # 列出沙箱内进程 - -# 独立模式 (无多实例,向后兼容) -cli-box serve --port 5801 # 启动独立 HTTP + MCP 服务器 -cli-box mcp-serve # MCP stdio 模式 -``` +**使用技能**:`superpowers:brainstorming` -### 实例注册中心 (文件系统) +1. **探索项目上下文** — 读取相关代码、文档、最近提交 +2. **需求澄清** — 一次一个问题,与用户对齐目标和约束 +3. **方案探讨** — 提出 2-3 个方案,分析 trade-offs,给出推荐 +4. **设计呈现** — 分段呈现架构、组件、数据流、错误处理、测试策略 +5. **设计文档** — 写入 `docs/superpowers/specs/YYYY-MM-DD--design.md` +6. **自检** — 检查占位符、内部一致性、范围、歧义 +7. **用户确认** — 等用户 review spec 后再进入下一阶段 -``` -~/.cli-box/instances/ -├── abc123.json # {id, port, pid, kind, title, status, created_at, window_id} -├── def456.json -└── ... -``` +### 阶段 2:测试设计 -### MCP Tools (Agent 调用) - -```yaml -# 沙箱实例管理 (NEW) -list_sandboxes: # 列出所有活跃沙箱 -start_sandbox: # 启动新沙箱 (--cli/--app) -close_sandbox: # 关闭指定沙箱 - -# 窗口管理 -list_windows: # 列出沙箱内所有窗口 -find_window: # 按 app 名/标题查找 -focus_window: # 聚焦指定窗口 - -# 进程管理 -spawn_app: # 启动 .app (如 Hi Boss.app) -spawn_cli: # 启动 CLI 进程 (如 hiboss) -kill_process: # 终止进程 -list_processes: # 列出沙箱内进程 - -# 输入模拟 -click: # 鼠标点击 (x, y, button) -double_click: # 双击 -type_text: # 输入文本 -press_key: # 按键 (Return, Tab, etc.) -scroll: # 滚动 -drag: # 拖拽 - -# 截图 (核心) -screenshot: # 截取沙箱窗口 (base64 PNG) -screenshot_window: # 截取沙箱内指定子窗口 -screenshot_region: # 截取沙箱内指定区域 - -# UI 检查 (高级) -inspect_ui: # 读取 AX 树 -find_element: # 按 role/title 查找 UI 元素 -``` +在写实现计划之前,先设计测试策略: -### HTTP API (每实例独立端口 `:5801`/`:15802`/etc.) +| 测试层级 | 工具 | 覆盖范围 | +|---------|------|---------| +| **UT (单元测试)** | `cargo test` (Rust) · `vitest` (TS) | 单个函数/模块,mock 外部依赖 | +| **IT (集成测试)** | `cargo test --test daemon_integration` | Daemon HTTP API 端点,tower::ServiceExt::oneshot | +| **E2E (端到端)** | `test.sh` · `tests/e2e-*.sh` | 完整用户场景,CLI 命令驱动 | -``` -GET /health 健康检查 -GET /sandbox/info 沙箱信息 (id, mode, running process) -POST /shutdown 关闭沙箱 -GET /windows 列出窗口 -GET /processes 列出进程 -POST /app/spawn 启动 .app -POST /cli/spawn 启动 CLI -POST /input/click 鼠标点击 -POST /input/type 键盘输入 -POST /input/key 按键 -POST /input/scroll 滚动 -POST /input/drag 拖拽 -GET /screenshot 截取沙箱窗口 (PNG) -GET /screenshot/:window_id 截取指定窗口 -GET /screenshot/region 截取指定区域 -GET /ui/inspect/:window_id 读取 UI 树 -POST /ui/find 查找 UI 元素 -POST /record/start 开始录制 -POST /record/stop 停止录制 -POST /playback/actions 回放操作 -POST /scenario/run 运行测试场景 -POST /diff 截图差异对比 -WS /pty/ws/{pid} PTY WebSocket (文本=输入, JSON=resize) -``` +测试设计原则: +- **TDD**:先写失败测试 → 实现 → 通过 → 重构 +- **场景覆盖**:正常路径 + 边界条件 + 错误处理 +- **回归看护**:发现 bug 时,先补充能复现问题的测试,再修复 -### Rust API (cli-box-core) - -```rust -// 实例管理 -use cli_box_core::instance::{InstanceRegistry, SandboxInstance, generate_instance_id}; -let registry = InstanceRegistry::default(); -let instance = SandboxInstance::new(id, port, kind); -registry.register(&instance)?; -let all_instances = registry.list()?; -registry.unregister("abc123")?; - -// 输入模拟 -use cli_box_core::automation::cg_event::InputSimulator; -InputSimulator::click(100.0, 200.0, MouseButton::Left)?; -InputSimulator::type_text("Hello")?; -InputSimulator::press_key("Return", &["cmd"])?; - -// 截图 -use cli_box_core::capture::ScreenCapture; -let png_bytes = ScreenCapture::capture_window(window_id)?; - -// UI 检查 -use cli_box_core::automation::ax_ui::UiInspector; -let tree = UiInspector::inspect_window(window_id)?; - -// 进程管理 -use cli_box_core::process::ProcessManager; -ProcessManager::spawn_app("/path/to/App.app")?; -ProcessManager::spawn_cli("claude", &["--help".into()])?; - -// 沙箱管理 (多实例) -use cli_box_core::sandbox::{Sandbox, SandboxConfig}; -let config = SandboxConfig { - id: Some("abc123".into()), - port: Some(15801), - mode: Some("cli".into()), - command: Some("claude".into()), - ..SandboxConfig::default() -}; -let mut sandbox = Sandbox::new(config); -sandbox.init(window_id)?; -let screenshot = sandbox.screenshot()?; -``` +### 阶段 3:实现计划 -## 五、macOS 权限要求 +**使用技能**:`superpowers:writing-plans` -| 权限 | 用途 | 授予方式 | -|------|------|---------| -| **Accessibility** | CGEvent 输入模拟、AXUIElement 读取 | 系统设置 → 隐私与安全 → 辅助功能 | -| **Screen Recording** | ScreenCaptureKit 截图 | 系统设置 → 隐私与安全 → 屏幕录制 | +- 写入 `docs/superpowers/plans/YYYY-MM-DD-.md` +- 每个 Task 包含:Files(精确路径)、Steps(完整代码)、Verification(命令 + 预期输出)、Commit +- 遵循 DRY · YAGNI · TDD · 频繁提交 -**注意**:这两个权限必须用户手动授予,无法通过代码自动获取。建议不经过 App Store,直接分发 .dmg。 +### 阶段 4:计划执行 -## 六、Git 规范 +**使用技能**:`superpowers:subagent-driven-development` -``` -格式:(): +每个 Task: +1. 派发实现子代理(提供完整上下文,不共享会话历史) +2. 实现 → 测试 → 提交 +3. **Spec 合规检视** — 实现是否匹配 spec +4. **代码质量检视** — Rust 惯用法、一致性、测试质量 +5. 修复检视问题 → 重新检视 → 通过后进入下一 Task -scope: sandbox | automation | capture | process | server | cli | ui +### 阶段 5:代码检视 -示例: -feat(sandbox): 实现沙箱窗口管理 -feat(automation): CGEvent 鼠标点击模拟 -feat(capture): ScreenCaptureKit 窗口截图 -fix(server): 修复 HTTP API 端口冲突 -``` +**使用技能**:`superpowers:requesting-code-review` -## 七、核心工作流程 +完成所有 Task 后,派发最终代码检视: +- Base SHA → HEAD SHA 完整 diff +- 关注:架构一致性、测试覆盖、安全问题、命名规范 +- Critical 立即修复,Important 合入前修复,Minor 记录 -### 7.1 任务执行流程 +### 阶段 6:测试执行与回归 +#### 6.1 本地质量门禁 + +```bash +sh test.sh ``` -[1]创建任务记录(待执行) → [2]创建特性分支 → [3]编写设计文档(写到docs/design) - → [4]编写测试(RED) → [5]编码实现(GREEN) → [6]重构优化(REFACTOR) - → [7]本地检查(fmt+clippy+check+typecheck) → [8]验证测试覆盖率 - → [9]更新任务状态(已完成,必须在push前) → [10]git commit → [11]git push - → [12]创建 PR → [13]等待 CI 门禁通过 -``` -### 7.2 沙箱使用流程 +执行内容: +- `cargo test -p cli-box-core -p cli-box-cli` — Rust 测试 +- `cargo clippy --all-targets -- -D warnings` — 静态分析 +- `cargo fmt --all -- --check` — 格式检查 +- `pnpm typecheck` — TypeScript 类型检查 +- `pnpm vitest run` — 前端单元测试 +- Playwright E2E 测试 +- E2E skill 安装测试 +- "sandbox" 残留检查 + +**如果失败**:使用 `superpowers:systematic-debugging` 分析根因,修复后回归。 + +#### 6.2 Release 构建 ```bash -# 1. 启动沙箱(默认打开 zsh 终端) -cli-box start -# → 自动打开沙箱窗口,xterm.js 中运行 zsh -# → 可在终端中输入 claude、opencode 等命令 - -# 2. 启动沙箱(直接运行指定命令) -cli-box start claude -# → 自动打开沙箱窗口,直接运行 claude - -# 3. 启动沙箱(运行 macOS 应用) -cli-box start /Applications/cc-switch.app -# → 打开沙箱窗口,启动 cc-switch,关联其窗口 - -# 4. 查看所有沙箱 -cli-box list -# → ID TITLE KIND STATUS PORT CREATED -# → abc123 "claude" CLI Running 15801 2026-05-16 10:30 -# → def456 "cc-switch" APP Running 15802 2026-05-16 10:31 - -# 5. 操作指定沙箱 -cli-box screenshot --id abc123 -o sandbox.png # 截图 -cli-box click --id abc123 100 200 # 点击 -cli-box type --id abc123 --pty "帮我写一个函数" # PTY 输入 -cli-box key --id abc123 --pty Return # PTY 按键 - -# 6. 关闭沙箱 -cli-box close abc123 -# → 关闭沙箱窗口,清理注册信息,终止关联进程 +sh release.sh ``` -### 7.3 命令序列 +产出:`release/cli-box` + `release/cli-box-daemon` + `release/CLI Box.app` + +#### 6.3 Release 测试 + +按照 `tests/release_test.md` 执行手动场景测试: +- 只使用 CLI 命令测试,不直接调用 REST API +- 每步操作都截图保存到 `release_test/YYYY-MM-DD-HH-MM-SS/` +- 检查截图是否符合预期 +- 生成 markdown 测试报告 + +**如果测试出问题**: +1. 分析错误根因 +2. 判断能否通过 UT/IT/E2E 复现 +3. 如果可以,补充测试用例覆盖问题场景 +4. 修复问题后回归全部测试 + +### 阶段 7:提交与 CI ```bash -# 本地检查 -cargo fmt --all -- --check && cargo clippy --all-targets \ - && cargo check --all-targets && cargo test --all \ - && pnpm typecheck && pnpm format:check && pnpm test:unit - -# 构建 Tauri 应用 -cd electron-app && pnpm install && pnpm build && cd .. -cargo build --release -p cli-box-cli - -# 使用 CLI 启动沙箱(默认 zsh) -cargo run -p cli-box-cli -- start - -# 通过 HTTP 直接调用 (已知端口) -curl http://127.0.0.1:5801/screenshot -o screenshot.png -curl -X POST http://127.0.0.1:5801/input/click \ - -H "Content-Type: application/json" \ - -d '{"x": 100, "y": 200, "button": "left"}' - -# Agent 通过 MCP 调用 (Claude Code 配置) -# .claude/settings.json 中添加 MCP server 配置 +git push -u origin +gh pr create --title "" --body "<body>" ``` -## 八、安全约束 +- **提交远端**,等待 CI 执行结果 +- **不合入主分支** — PR 保持 open 状态 +- 分析 CI 结果,如有失败则修复后重新推送 -- ✅ 沙箱窗口是独立 NSWindow,截图范围可控 -- ✅ ScreenCaptureKit 按窗口 ID 截图,不截全屏 -- ✅ 目标应用不需要任何适配 -- ✅ Accessibility 和 Screen Recording 权限需用户手动授权 -- ✅ 每实例 HTTP API 仅监听 `127.0.0.1`,不暴露外部网络 -- ✅ 实例注册中心仅存储在本地文件系统 `~/.cli-box/instances/` -- ✅ 沙箱关闭时自动清理注册信息并终止关联进程 +--- + +## 三、Git 规范 -## 目录速查 +### Commit 格式 + +``` +<type>(<scope>): <description> + +[optional body] +[optional footer] +``` + +| type | 用途 | +|------|------| +| `feat` | 新功能 | +| `fix` | Bug 修复 | +| `test` | 测试相关 | +| `docs` | 文档更新 | +| `refactor` | 重构(不改变行为) | +| `chore` | 构建/工具链变更 | + +### Scope + +`sandbox` · `automation` · `capture` · `process` · `server` · `cli` · `ui` · `daemon` · `client` · `electron` + +### 提交策略 + +- 小粒度提交,每个提交可独立编译通过 +- 实现和测试在同一个或相邻提交中 +- `cargo fmt` 和 `clippy` 问题在提交前修复 + +--- + +## 四、目录速查 | 内容 | 路径 | |------|------| -| 核心库 | `/crates/cli-box-core/src/` | -| 实例管理 | `/crates/cli-box-core/src/instance/` | -| HTTP 服务器 | `/crates/cli-box-core/src/server/` | -| CLI 入口 | `/crates/cli-box-cli/src/main.rs` | -| HTTP 客户端 | `/crates/cli-box-cli/src/client.rs` | -| Tauri 宿主 | `/src-tauri/src/main.rs` | -| 沙箱前端 | `/electron-app/src/` | -| 前端 API 层 | `/electron-app/src/api.ts` | -| 设计文档 | `/docs/design/` | -| 任务管理 | `/docs/task/` | -| 本文件 | `/CLAUDE.md` | +| 核心库 | `crates/cli-box-core/src/` | +| Daemon | `crates/cli-box-core/src/daemon/mod.rs` | +| 实例管理 | `crates/cli-box-core/src/instance/` | +| HTTP 服务器 | `crates/cli-box-core/src/server/` | +| CLI 入口 | `crates/cli-box-cli/src/main.rs` | +| HTTP 客户端 | `crates/cli-box-cli/src/client.rs` | +| Electron 前端 | `electron-app/src/` | +| 前端 API 层 | `electron-app/src/api.ts` | +| 设计文档 | `docs/superpowers/specs/` | +| 实现计划 | `docs/superpowers/plans/` | +| Release 测试脚本 | `test.sh` · `release.sh` | +| Release 测试场景 | `tests/release_test.md` | +| Release 测试报告 | `release_test/YYYY-MM-DD-HH-MM-SS/` | +| 本文件 | `CLAUDE.md` | + +--- + +## 五、测试层级说明 + +### UT (单元测试) + +- **Rust**:`#[cfg(test)] mod tests` 在每个文件内,`cargo test` 运行 +- **TypeScript**:`*.test.ts` 文件,`vitest` 运行 +- 测试单个函数/模块,mock 外部依赖(HTTP、文件系统、macOS API) + +### IT (集成测试) + +- **位置**:`crates/cli-box-core/tests/daemon_integration.rs` +- **方式**:`tower::ServiceExt::oneshot` 测试 daemon 路由,不绑定真实 TCP 端口 +- **覆盖**:每个 HTTP 端点的正常/错误路径 + +### E2E (端到端测试) + +- **脚本**:`test.sh` 编排所有测试,`tests/e2e-*.sh` 执行具体场景 +- **场景**:CLI 命令 → daemon 响应 → 验证结果 +- **覆盖**:sandbox 生命周期、截图、输入模拟、skill 安装 + +### Release 测试 + +- **触发**:`sh release.sh` 构建后手动执行 +- **场景**:按照 `tests/release_test.md` 中的完整用户流程 +- **产出**:截图 + markdown 测试报告 + +--- + +## 六、Superpowers 技能使用 + +| 阶段 | 技能 | 触发时机 | +|------|------|---------| +| 需求分析 | `superpowers:brainstorming` | 新需求开始时 | +| 计划编写 | `superpowers:writing-plans` | 设计确认后 | +| 计划执行 | `superpowers:subagent-driven-development` | 实现阶段 | +| 代码检视 | `superpowers:requesting-code-review` | 每个 Task 完成后 + 最终检视 | +| Bug 调试 | `superpowers:systematic-debugging` | 测试失败或异常行为时 | +| 分支收尾 | `superpowers:finishing-a-development-branch` | 所有任务完成时 | + +**调试原则**(systematic-debugging): +- **NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST** +- Phase 1: 根因调查 → Phase 2: 模式分析 → Phase 3: 假设验证 → Phase 4: 修复实现 +- 3 次修复失败 → 质疑架构,与用户讨论 + +--- + +## 七、关键约束 + +- **语言**:用户使用中文交流,代码和注释使用英文 +- **不自动合入**:PR 创建后保持 open,不执行 merge +- **测试驱动**:先写测试,再写实现 +- **CLI 优先**:Release 测试只使用 CLI 命令,不直接调用 REST API +- **截图验证**:Release 测试每步操作都截图,检查图片是否符合预期 +- **Superpowers 驱动**:使用 Superpowers 技能完成各阶段工作,不跳过 +- **零侵入**:目标应用不需要任何适配,所有操作在 OS 层面完成 --- -**版本**:v0.2.0 | **创建**:2026-05-13 | **更新**:2026-05-16 | **维护者**:cli-box 项目 +**版本**:v0.2.0 | **创建**:2026-05-13 | **更新**:2026-06-06 | **维护者**:cli-box 项目 diff --git a/crates/cli-box-cli/src/client.rs b/crates/cli-box-cli/src/client.rs index cf3002b..07dcafe 100644 --- a/crates/cli-box-cli/src/client.rs +++ b/crates/cli-box-cli/src/client.rs @@ -48,6 +48,27 @@ pub struct DaemonHealthResponse { pub sandboxes: usize, } +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct DaemonReadinessResponse { + pub status: String, + pub renderer_connected: bool, +} + +/// Check daemon readiness (renderer WebSocket connection status). +pub async fn daemon_readiness() -> Result<DaemonReadinessResponse> { + let base = daemon_base_url()?; + let client = reqwest_client(); + let resp = client + .get(format!("{base}/readyz")) + .timeout(std::time::Duration::from_secs(3)) + .send() + .await + .with_context(|| "Failed to connect to daemon readyz endpoint")?; + let readiness: DaemonReadinessResponse = resp.json().await?; + Ok(readiness) +} + #[derive(Debug, Deserialize, Serialize)] pub struct DaemonSandbox { pub id: String, @@ -114,11 +135,16 @@ pub async fn daemon_list_sandboxes() -> Result<Vec<DaemonSandbox>> { /// Take a screenshot of a sandbox via the daemon HTTP API. /// Returns PNG data along with fallback info from response headers. -pub async fn daemon_screenshot(sandbox_id: &str) -> Result<ScreenshotResult> { +pub async fn daemon_screenshot(sandbox_id: &str, with_frame: bool) -> Result<ScreenshotResult> { let base = daemon_base_url()?; let client = reqwest_client(); + let url = if with_frame { + format!("{base}/box/{sandbox_id}/screenshot?with_frame=true") + } else { + format!("{base}/box/{sandbox_id}/screenshot") + }; let resp = client - .get(format!("{base}/box/{sandbox_id}/screenshot")) + .get(url) .send() .await .with_context(|| "screenshot request to daemon failed")?; @@ -1036,6 +1062,24 @@ mod tests { assert_eq!(resp.status, "ready"); assert!(resp.pending_cli); } + + // ── DaemonReadinessResponse deserialization ───────────── + + #[test] + fn test_deserialize_daemon_readiness_connected() { + let json = r#"{"status":"ready","renderer_connected":true}"#; + let resp: DaemonReadinessResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, "ready"); + assert!(resp.renderer_connected); + } + + #[test] + fn test_deserialize_daemon_readiness_not_connected() { + let json = r#"{"status":"not_ready","renderer_connected":false}"#; + let resp: DaemonReadinessResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.status, "not_ready"); + assert!(!resp.renderer_connected); + } } #[cfg(test)] diff --git a/crates/cli-box-cli/src/main.rs b/crates/cli-box-cli/src/main.rs index a443086..f0bd7a4 100644 --- a/crates/cli-box-cli/src/main.rs +++ b/crates/cli-box-cli/src/main.rs @@ -109,6 +109,10 @@ enum Commands { /// Window ID to capture (overrides auto-detection) #[arg(long)] window_id: Option<u32>, + + /// Use ScreenCaptureKit to capture the full window frame (requires Screen Recording permission) + #[arg(long)] + with_frame: bool, }, /// List all visible windows on the system @@ -248,8 +252,9 @@ async fn main() -> anyhow::Result<()> { output, id, window_id: _window_id, + with_frame, } => { - cmd_screenshot_daemon(&output, id.as_deref()).await?; + cmd_screenshot_daemon(&output, id.as_deref(), with_frame).await?; } Commands::Windows => { cmd_windows()?; @@ -477,16 +482,59 @@ async fn cmd_start_daemon(command: &str, args: &[String]) -> anyhow::Result<()> // Spawn Electron only if not already running. // If already running, the renderer polls /box/list and will pick up the new sandbox. - if find_running_electron() { + let electron_newly_spawned = if find_running_electron() { tracing::info!("[start] Electron already running, skipping spawn"); + false } else if let Some(electron_bin) = find_electron_binary() { tracing::info!("[start] spawning Electron: {}", electron_bin.display()); let _child = Command::new(&electron_bin) .spawn() .context("Failed to launch Electron app")?; tracing::info!("[start] Electron launched"); + true } else { tracing::warn!("[start] Electron app not found, running in headless daemon mode"); + false + }; + + // Wait for renderer WebSocket to connect if Electron was newly spawned. + if electron_newly_spawned { + print!("正在启动"); + use std::io::Write; + let _ = std::io::stdout().flush(); + + let timeout = std::time::Duration::from_secs(60); + let start = std::time::Instant::now(); + let poll_interval = std::time::Duration::from_secs(1); + let mut dot_count: u8 = 0; + + loop { + if start.elapsed() > timeout { + println!(); + tracing::warn!( + "[start] Renderer WebSocket did not connect within {}s, continuing anyway", + timeout.as_secs() + ); + break; + } + + match client::daemon_readiness().await { + Ok(resp) if resp.renderer_connected => { + println!(" 完成"); + break; + } + Err(e) => { + tracing::trace!("[start] readyz check failed (will retry): {e}"); + } + _ => {} + } + + dot_count = (dot_count % 3) + 1; + print!("\r正在启动{:<3}", ".".repeat(dot_count as usize)); + let _ = std::io::stdout().flush(); + + tokio::time::sleep(poll_interval).await; + } } Ok(()) @@ -638,23 +686,29 @@ async fn cmd_click_daemon(x: f64, y: f64, id: &str, button: &str) -> anyhow::Res } /// Take a screenshot via the daemon API. -async fn cmd_screenshot_daemon(output: &std::path::Path, id: Option<&str>) -> anyhow::Result<()> { +async fn cmd_screenshot_daemon( + output: &std::path::Path, + id: Option<&str>, + with_frame: bool, +) -> anyhow::Result<()> { let sandbox_id = id.ok_or_else(|| { anyhow::anyhow!( "--id is required for screenshots. Use: cli-box screenshot --id <sandbox-id>" ) })?; - let result = client::daemon_screenshot(sandbox_id).await?; + let result = client::daemon_screenshot(sandbox_id, with_frame).await?; if result.source.as_deref() == Some("screencapturekit") { - eprintln!( - "Warning: screenshot used ScreenCaptureKit fallback (captured entire window).\n Reason: {}", - result.fallback_reason.as_deref().unwrap_or("unknown") - ); - eprintln!(" For terminal-only screenshots, ensure the Electron app is connected."); + eprintln!("Screenshot captured with ScreenCaptureKit (full window frame)."); } + if let Some(parent) = output.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {:?}", parent))?; + } + } std::fs::write(output, &result.png_data) .with_context(|| format!("Failed to write screenshot to {:?}", output))?; println!( @@ -1193,11 +1247,12 @@ fn mcp_tools() -> serde_json::Value { }, { "name": "screenshot_sandbox", - "description": "Take a screenshot of a sandbox (returns base64 PNG)", + "description": "Take a screenshot of a sandbox (returns base64 PNG). Default: renderer capture (no permission needed). Use with_frame=true for full window capture (requires Screen Recording permission).", "inputSchema": { "type": "object", "properties": { - "sandbox_id": { "type": "string" } + "sandbox_id": { "type": "string" }, + "with_frame": { "type": "boolean", "description": "Use ScreenCaptureKit for full window frame capture (requires Screen Recording permission)", "default": false } }, "required": ["sandbox_id"] } @@ -1318,7 +1373,8 @@ async fn handle_mcp_tool(name: &str, args: &serde_json::Value) -> serde_json::Va } "screenshot_sandbox" => { let id = args["sandbox_id"].as_str().unwrap_or(""); - let result = client::daemon_screenshot(id).await?; + let with_frame = args["with_frame"].as_bool().unwrap_or(false); + let result = client::daemon_screenshot(id, with_frame).await?; let b64 = base64_encode(&result.png_data); let mut response = serde_json::json!({ "sandbox_id": id, "image_base64": b64 }); if let Some(ref source) = result.source { @@ -1710,4 +1766,22 @@ mod tests { let _ = std::fs::remove_file(&path); } } + + #[test] + fn screenshot_output_creates_parent_directories() { + let tmp = std::env::temp_dir().join(format!("cli_box_test_{}", std::process::id())); + let nested = tmp.join("a").join("b").join("c").join("shot.png"); + + // Simulate the logic from cmd_screenshot_daemon + let output = nested.as_path(); + if let Some(parent) = output.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).unwrap(); + } + } + std::fs::write(output, b"\x89PNG").unwrap(); + + assert!(nested.exists(), "File should exist at nested path"); + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/crates/cli-box-core/src/daemon/mod.rs b/crates/cli-box-core/src/daemon/mod.rs index c8b7865..6e73f81 100644 --- a/crates/cli-box-core/src/daemon/mod.rs +++ b/crates/cli-box-core/src/daemon/mod.rs @@ -123,6 +123,14 @@ struct HealthResponse { sandboxes: usize, } +#[derive(Debug, Serialize)] +pub struct DaemonReadinessResponse { + /// "ready" if renderer WebSocket is connected, "not_ready" otherwise. + pub status: String, + /// Whether the Electron renderer's screenshot WebSocket is connected. + pub renderer_connected: bool, +} + #[derive(Deserialize)] pub struct UiFindRequest { pub role: String, @@ -241,6 +249,7 @@ pub fn build_daemon_router(state: Arc<Mutex<DaemonState>>) -> Router { Router::new() .route("/health", get(health_handler)) + .route("/readyz", get(readyz_handler)) .route("/box/list", get(list_sandboxes_handler)) .route("/box/create", post(create_sandbox_handler)) .route("/box/{id}/close", post(close_sandbox_handler)) @@ -281,6 +290,25 @@ async fn health_handler(State(state): State<Arc<Mutex<DaemonState>>>) -> Json<He }) } +/// Daemon-level readiness: always returns 200 with readiness in JSON body. +/// Unlike the sandbox-level /readyz (server/mod.rs) which returns 503/200, +/// this is a polling endpoint — callers check `renderer_connected` in the response. +async fn readyz_handler( + State(state): State<Arc<Mutex<DaemonState>>>, +) -> Json<DaemonReadinessResponse> { + let s = state.lock().await; + let renderer_connected = s.screenshot_ws_tx.is_some(); + Json(DaemonReadinessResponse { + status: if renderer_connected { + "ready" + } else { + "not_ready" + } + .to_string(), + renderer_connected, + }) +} + async fn list_sandboxes_handler( State(state): State<Arc<Mutex<DaemonState>>>, ) -> Json<Vec<ManagedSandbox>> { @@ -428,9 +456,16 @@ async fn close_sandbox_handler( Ok(Json(serde_json::json!({"closed": id}))) } +#[derive(Deserialize)] +struct ScreenshotQuery { + #[serde(default)] + with_frame: bool, +} + async fn screenshot_handler( State(state): State<Arc<Mutex<DaemonState>>>, Path(id): Path<String>, + axum::extract::Query(q): axum::extract::Query<ScreenshotQuery>, ) -> Result<Response, AppError> { // Verify sandbox exists { @@ -440,7 +475,12 @@ async fn screenshot_handler( } } - // Attempt 1: Ask the Electron renderer to capture via WebSocket + if q.with_frame { + // --with-frame: use ScreenCaptureKit directly, skip renderer + return screenshot_with_frame(state, &id).await; + } + + // Default: renderer only, no SCK fallback match request_renderer_screenshot(state.clone(), &id).await { Ok(png_data) => { tracing::info!( @@ -452,11 +492,14 @@ async fn screenshot_handler( } Err(reason) => { tracing::warn!( - "[screenshot] renderer capture failed for sandbox {}: {}; falling back to ScreenCaptureKit", + "[screenshot] renderer capture failed for sandbox {}: {}", id, reason ); - return screenshot_fallback(state, &id).await; + Err(AppError::Screenshot(format!( + "Screenshot failed: {}. Use --with-frame to capture via ScreenCaptureKit (requires Screen Recording permission).", + reason + ))) } } } @@ -481,34 +524,29 @@ fn screenshot_response(png_data: Vec<u8>, source: &str, fallback_reason: Option< (StatusCode::OK, headers, png_data).into_response() } -/// Capture a screenshot via ScreenCaptureKit as a fallback, returning headers -/// that indicate the fallback source and reason. -async fn screenshot_fallback( +/// Capture a screenshot using ScreenCaptureKit (requires Screen Recording permission). +/// Handles stale window IDs by re-discovering the window by title. +async fn screenshot_with_frame( state: Arc<Mutex<DaemonState>>, id: &str, ) -> Result<Response, AppError> { - tracing::info!( - "[screenshot] using ScreenCaptureKit fallback for sandbox {} (captures entire window)", - id - ); + // Switch to the target tab before capturing so SCK sees the correct content. + if let Err(e) = request_switch_tab(state.clone(), id).await { + tracing::warn!("Failed to switch tab for sandbox {}: {}", id, e); + } let window_id = { let s = state.lock().await; s.sandboxes.get(id).and_then(|sb| sb.window_id) }; + // Try stored window_id first, handle stale IDs if let Some(wid) = window_id { let result = tokio::task::spawn_blocking(move || ScreenCapture::capture_window(wid)) .await .map_err(|e| AppError::Screenshot(format!("screenshot task failed: {e}")))?; match result { - Ok(png_data) => { - return Ok(screenshot_response( - png_data, - "screencapturekit", - Some("renderer_unavailable"), - )); - } + Ok(png_data) => return Ok(screenshot_response(png_data, "screencapturekit", None)), Err(AppError::WindowNotFound(_)) => { tracing::warn!( "Stored window_id={} for sandbox {} is stale, re-discovering", @@ -516,11 +554,19 @@ async fn screenshot_fallback( id ); } + Err(AppError::Screenshot(msg)) + if msg.contains("permission") || msg.contains("denied") => + { + return Err(AppError::Screenshot(format!( + "{}. Grant Screen Recording in System Settings → Privacy & Security → Screen Recording.", + msg + ))); + } Err(e) => return Err(e), } } - // Re-discover the Electron window by title + // Re-discover window by title let new_wid = tokio::task::spawn_blocking(|| ScreenCapture::find_window_by_title("CLI Box")) .await .map_err(|e| AppError::Screenshot(format!("window discovery task failed: {e}")))??; @@ -538,11 +584,7 @@ async fn screenshot_fallback( let png_data = tokio::task::spawn_blocking(move || ScreenCapture::capture_window(new_wid)) .await .map_err(|e| AppError::Screenshot(format!("screenshot task failed: {e}")))??; - Ok(screenshot_response( - png_data, - "screencapturekit", - Some("renderer_unavailable"), - )) + Ok(screenshot_response(png_data, "screencapturekit", None)) } // ── Screenshot WebSocket ──────────────────────────────────────── @@ -604,6 +646,62 @@ async fn request_renderer_screenshot( } } +/// Request the renderer to switch to a specific tab via WebSocket. +/// Waits for the renderer to acknowledge the tab switch. +async fn request_switch_tab( + state: Arc<Mutex<DaemonState>>, + sandbox_id: &str, +) -> Result<(), String> { + let (request_id, response_rx, mut ws_tx) = { + let mut s = state.lock().await; + let ws_tx = s + .screenshot_ws_tx + .take() + .ok_or("WebSocket not connected (renderer may be closed or not yet connected)")?; + + s.screenshot_request_counter += 1; + let request_id = s.screenshot_request_counter; + + let (response_tx, response_rx) = oneshot::channel(); + s.pending_screenshots.insert(request_id, response_tx); + + (request_id, response_rx, ws_tx) + }; + + let msg = serde_json::json!({ + "type": "switch_tab_request", + "request_id": request_id, + "sandbox_id": sandbox_id, + }); + + if ws_tx + .send(Message::Text(msg.to_string().into())) + .await + .is_err() + { + let mut s = state.lock().await; + s.pending_screenshots.remove(&request_id); + s.screenshot_ws_tx = Some(ws_tx); + return Err("Failed to send switch_tab request over WebSocket".to_string()); + } + + { + let mut s = state.lock().await; + s.screenshot_ws_tx = Some(ws_tx); + } + + match tokio::time::timeout(std::time::Duration::from_secs(2), response_rx).await { + Ok(Ok(Ok(_))) => Ok(()), + Ok(Ok(Err(e))) => Err(format!("Renderer returned error on switch_tab: {e}")), + Ok(Err(_)) => Err("Response channel dropped during switch_tab".to_string()), + Err(_) => { + let mut s = state.lock().await; + s.pending_screenshots.remove(&request_id); + Err("Renderer did not respond to switch_tab within 2s timeout".to_string()) + } + } +} + /// WebSocket endpoint for renderer-based screenshot capture. async fn screenshot_ws_handler( State(state): State<Arc<Mutex<DaemonState>>>, @@ -662,6 +760,14 @@ async fn handle_screenshot_ws(state: Arc<Mutex<DaemonState>>, socket: WebSocket) } } } + Some("switch_tab_response") => { + if let Some(req_id) = request_id { + let mut s = state.lock().await; + if let Some(tx) = s.pending_screenshots.remove(&req_id) { + let _ = tx.send(Ok(vec![])); + } + } + } _ => { tracing::warn!( "[screenshot_ws] unknown message type: {:?}", @@ -1441,6 +1547,46 @@ mod tests { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn screenshot_with_frame_attempts_sck() { + let app = test_daemon_router_with_sandbox(); + let resp = app + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?with_frame=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // with_frame=true routes to SCK path. Without a real window, it fails + // with either 404 (WindowNotFound with SCK) or 500 (Screenshot error). + // Either way, it must NOT be 400 (Bad Request) — proves query param is parsed. + let status = resp.status(); + assert_ne!( + status, + StatusCode::BAD_REQUEST, + "with_frame=true should be parsed, not rejected as bad request" + ); + } + + #[tokio::test] + async fn screenshot_without_frame_fails_when_renderer_unavailable() { + let app = test_daemon_router_with_sandbox(); + let resp = app + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Default path (no with_frame) tries renderer only, should fail with 500 + // since no renderer WebSocket is connected in tests + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[tokio::test] async fn click_valid_request() { let app = test_daemon_router_with_sandbox(); @@ -1698,4 +1844,40 @@ mod tests { "renderer_unavailable" ); } + + #[tokio::test] + async fn request_switch_tab_returns_error_when_ws_not_connected() { + let state = test_daemon_state_with_sandbox(); + let result = request_switch_tab(state, "test-sb").await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("WebSocket not connected"), + "Expected 'WebSocket not connected', got: {err}" + ); + } + + #[tokio::test] + async fn screenshot_with_frame_logs_warning_on_switch_tab_failure() { + // screenshot_with_frame calls request_switch_tab first, which will fail + // (no WebSocket), then proceeds to SCK capture. The function should not + // return 400 (Bad Request) — the tab switch failure should be a warning. + let app = test_daemon_router_with_sandbox(); + let resp = app + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?with_frame=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // SCK capture also fails (no real window) → 404 or 500, but NOT 400. + let status = resp.status(); + assert_ne!( + status, + StatusCode::BAD_REQUEST, + "switch_tab failure should not cause bad request, got {status}" + ); + } } diff --git a/crates/cli-box-core/src/server/mod.rs b/crates/cli-box-core/src/server/mod.rs index b110972..2b9fda2 100644 --- a/crates/cli-box-core/src/server/mod.rs +++ b/crates/cli-box-core/src/server/mod.rs @@ -136,6 +136,8 @@ struct RegionQuery { struct ScreenshotQuery { #[serde(default)] window_id: Option<u32>, + #[serde(default)] + with_frame: bool, } #[derive(Deserialize)] @@ -406,6 +408,11 @@ async fn screenshot_handler( let window_id = q.window_id.or(state.lock().await.window_id); match window_id { Some(id) => { + if !q.with_frame { + return Err(AppError::Screenshot( + "Default screenshot is not supported in legacy mode. Use ?with_frame=true (requires Screen Recording permission).".to_string() + )); + } let png_data = ScreenCapture::capture_window(id)?; Ok((StatusCode::OK, [("content-type", "image/png")], png_data).into_response()) } @@ -1343,7 +1350,7 @@ mod tests { } #[tokio::test] - async fn standalone_rejects_screenshot() { + async fn standalone_rejects_screenshot_without_with_frame() { let app = test_router(); let resp = app .oneshot( @@ -1354,12 +1361,31 @@ mod tests { ) .await .unwrap(); - // When window_id is Some(42) in test state, screenshot tries to capture - // which fails on non-macOS or without permissions, returning 404 or 500 + // Without ?with_frame=true, legacy server returns 500 with guidance message + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"].as_str().unwrap().contains("with_frame")); + } + + #[tokio::test] + async fn screenshot_with_frame_attempts_capture() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/screenshot?with_frame=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // with_frame=true attempts SCK capture, may succeed (200), fail with + // permission error (500), or window-not-found (404) in test env let status = resp.status(); assert!( matches!(status.as_u16(), 200 | 404 | 500), - "unexpected status: {status}" + "with_frame=true should attempt capture, got {status}" ); } @@ -1395,12 +1421,12 @@ mod tests { #[tokio::test] async fn screenshot_with_query_window_id() { - // Test that window_id from query parameter is used + // Test that window_id from query parameter is used when with_frame=true let app = test_router(); let resp = app .oneshot( Request::builder() - .uri("/screenshot?window_id=999") + .uri("/screenshot?window_id=999&with_frame=true") .body(Body::empty()) .unwrap(), ) diff --git a/crates/cli-box-core/tests/daemon_integration.rs b/crates/cli-box-core/tests/daemon_integration.rs index ef13cb9..f1f77e3 100644 --- a/crates/cli-box-core/tests/daemon_integration.rs +++ b/crates/cli-box-core/tests/daemon_integration.rs @@ -5,7 +5,8 @@ use axum::body::Body; use axum::http::{self, Request, StatusCode}; -use cli_box_core::daemon::{build_daemon_router, DaemonState}; +use cli_box_core::daemon::{build_daemon_router, DaemonState, ManagedSandbox}; +use cli_box_core::instance::{InstanceKind, InstanceStatus}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; @@ -26,6 +27,36 @@ fn router() -> axum::Router { build_daemon_router(empty_state()) } +fn state_with_sandbox() -> Arc<Mutex<DaemonState>> { + let mut sandboxes = HashMap::new(); + sandboxes.insert( + "test-sb".to_string(), + ManagedSandbox { + id: "test-sb".to_string(), + kind: InstanceKind::Cli { + command: "zsh".to_string(), + args: vec![], + }, + status: InstanceStatus::Running, + port: 0, + pty_pid: None, + 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(), + screenshot_request_counter: 0, + })) +} + +fn router_with_sandbox() -> axum::Router { + build_daemon_router(state_with_sandbox()) +} + #[tokio::test] async fn health_endpoint_returns_ok() { let resp = router() @@ -111,6 +142,21 @@ async fn screenshot_nonexistent_returns_404() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn screenshot_with_frame_nonexistent_returns_404() { + let resp = router() + .oneshot( + Request::builder() + .uri("/box/no-such-id/screenshot?with_frame=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn unknown_route_returns_404() { let resp = router() @@ -125,3 +171,48 @@ async fn unknown_route_returns_404() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn screenshot_with_frame_attempts_tab_switch() { + // with_frame=true should attempt a tab switch before SCK capture. + // Without a WebSocket connection, the switch fails gracefully and + // the handler continues to the SCK path (which also fails — no real window). + // The key assertion: it does NOT return a client error. + let resp = router_with_sandbox() + .oneshot( + Request::builder() + .uri("/box/test-sb/screenshot?with_frame=true") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let status = resp.status(); + // SCK path fails with 404 (WindowNotFound) or 500 (Screenshot error), + // but must NOT be 400 (Bad Request) — proves query param is parsed. + assert_ne!( + status, + StatusCode::BAD_REQUEST, + "with_frame=true should be parsed, not rejected as bad request" + ); +} + +#[tokio::test] +async fn readyz_returns_not_ready_without_renderer() { + let resp = router() + .oneshot( + Request::builder() + .uri("/readyz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "not_ready"); + assert_eq!(json["renderer_connected"], false); +} diff --git a/docs/superpowers/plans/2026-06-05-screenshot-with-frame.md b/docs/superpowers/plans/2026-06-05-screenshot-with-frame.md new file mode 100644 index 0000000..a3710e0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-screenshot-with-frame.md @@ -0,0 +1,479 @@ +# Screenshot --with-frame Implementation Plan + +> **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:** Make ScreenCaptureKit opt-in — default screenshots never trigger Screen Recording permission; `--with-frame` flag explicitly enables SCK capture. + +**Architecture:** The daemon's `screenshot_handler` gains a `with_frame` query param. Default path uses renderer WebSocket only (no SCK fallback). `--with-frame` skips renderer and uses SCK directly, with permission error guidance on failure. The entitlements plist removes the screen-capture declaration. + +**Tech Stack:** Rust (axum HTTP handlers, clap CLI args), serde query params + +--- + +### Task 1: Add `--with-frame` flag to CLI screenshot command + +**Files:** +- Modify: `crates/cli-box-cli/src/main.rs:100-112` (Screenshot enum variant) +- Modify: `crates/cli-box-cli/src/main.rs:247-253` (match arm) +- Modify: `crates/cli-box-cli/src/main.rs:641-665` (cmd_screenshot_daemon) + +- [ ] **Step 1: Add `--with-frame` flag to Screenshot command** + +In `crates/cli-box-cli/src/main.rs`, add the flag to the `Screenshot` variant (after line 111): + +```rust + /// Take a screenshot of a sandbox window + Screenshot { + /// Output file path + #[arg(short, long, default_value = "screenshot.png")] + output: PathBuf, + + /// Sandbox instance ID + #[arg(long)] + id: Option<String>, + + /// Window ID to capture (overrides auto-detection) + #[arg(long)] + window_id: Option<u32>, + + /// Use ScreenCaptureKit to capture the full window frame (requires Screen Recording permission) + #[arg(long)] + with_frame: bool, + }, +``` + +- [ ] **Step 2: Pass `with_frame` through the match arm** + +Update the match arm (around line 247) to destructure and pass `with_frame`: + +```rust + Commands::Screenshot { + output, + id, + window_id: _window_id, + with_frame, + } => { + cmd_screenshot_daemon(&output, id.as_deref(), with_frame).await?; + } +``` + +- [ ] **Step 3: Update `cmd_screenshot_daemon` to accept and use `with_frame`** + +Change the function signature and logic (line 641): + +```rust +async fn cmd_screenshot_daemon( + output: &std::path::Path, + id: Option<&str>, + with_frame: bool, +) -> anyhow::Result<()> { + let sandbox_id = id.ok_or_else(|| { + anyhow::anyhow!( + "--id is required for screenshots. Use: cli-box screenshot --id <sandbox-id>" + ) + })?; + + let result = client::daemon_screenshot(sandbox_id, with_frame).await?; + + if result.source.as_deref() == Some("screencapturekit") { + eprintln!( + "Screenshot captured with ScreenCaptureKit (full window frame)." + ); + } + + std::fs::write(output, &result.png_data) + .with_context(|| format!("Failed to write screenshot to {:?}", output))?; + println!( + "Screenshot saved to {:?} ({} bytes)", + output, + result.png_data.len() + ); + Ok(()) +} +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo check -p cli-box-cli` +Expected: compiles without errors (will have warnings about unused `with_frame` in client until Task 2) + +- [ ] **Step 5: Commit** + +```bash +git add crates/cli-box-cli/src/main.rs +git commit -m "feat(cli): add --with-frame flag to screenshot command" +``` + +--- + +### Task 2: Update client to pass `with_frame` query param + +**Files:** +- Modify: `crates/cli-box-cli/src/client.rs:117-146` (daemon_screenshot) + +- [ ] **Step 1: Add `with_frame` parameter to `daemon_screenshot`** + +In `crates/cli-box-cli/src/client.rs`, change the function signature and URL (line 117): + +```rust +pub async fn daemon_screenshot(sandbox_id: &str, with_frame: bool) -> Result<ScreenshotResult> { + let base = daemon_base_url()?; + let client = reqwest_client(); + let url = if with_frame { + format!("{base}/box/{sandbox_id}/screenshot?with_frame=true") + } else { + format!("{base}/box/{sandbox_id}/screenshot") + }; + let resp = client + .get(url) + .send() + .await + .with_context(|| "screenshot request to daemon failed")?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("screenshot failed (HTTP {status}): {text}"); + } + let source = resp + .headers() + .get("x-screenshot-source") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let fallback_reason = resp + .headers() + .get("x-screenshot-fallback-reason") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let png_data = resp.bytes().await?.to_vec(); + Ok(ScreenshotResult { + png_data, + source, + fallback_reason, + }) +} +``` + +- [ ] **Step 2: Update MCP handler call site** + +In `crates/cli-box-cli/src/main.rs`, update the MCP `screenshot_sandbox` handler (around line 1319) to pass `with_frame`: + +```rust + "screenshot_sandbox" => { + let id = args["sandbox_id"].as_str().unwrap_or(""); + let with_frame = args["with_frame"].as_bool().unwrap_or(false); + let result = client::daemon_screenshot(id, with_frame).await?; + let b64 = base64_encode(&result.png_data); + let mut response = serde_json::json!({ "sandbox_id": id, "image_base64": b64 }); + if let Some(ref source) = result.source { + response["screenshot_source"] = serde_json::json!(source); + } + if let Some(ref reason) = result.fallback_reason { + response["fallback_reason"] = serde_json::json!(reason); + } + Ok(response) + } +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo check -p cli-box-cli` +Expected: compiles without errors + +- [ ] **Step 4: Commit** + +```bash +git add crates/cli-box-cli/src/client.rs crates/cli-box-cli/src/main.rs +git commit -m "feat(client): pass with_frame query param to daemon screenshot API" +``` + +--- + +### Task 3: Update daemon screenshot handler to respect `with_frame` + +**Files:** +- Modify: `crates/cli-box-core/src/daemon/mod.rs:431-462` (screenshot_handler) +- Modify: `crates/cli-box-core/src/daemon/mod.rs:486-546` (screenshot_fallback) + +- [ ] **Step 1: Add query param struct and update handler signature** + +In `crates/cli-box-core/src/daemon/mod.rs`, add the query struct before `screenshot_handler` (around line 430): + +```rust +#[derive(Deserialize)] +struct ScreenshotQuery { + #[serde(default)] + with_frame: bool, +} +``` + +Update the handler signature to accept the query param: + +```rust +async fn screenshot_handler( + State(state): State<Arc<Mutex<DaemonState>>>, + Path(id): Path<String>, + axum::extract::Query(q): axum::extract::Query<ScreenshotQuery>, +) -> Result<Response, AppError> { + // Verify sandbox exists + { + let s = state.lock().await; + if !s.sandboxes.contains_key(&id) { + return Err(AppError::Instance(format!("Sandbox '{id}' not found"))); + } + } + + if q.with_frame { + // --with-frame: use ScreenCaptureKit directly, skip renderer + return screenshot_with_frame(state, &id).await; + } + + // Default: renderer only, no SCK fallback + match request_renderer_screenshot(state.clone(), &id).await { + Ok(png_data) => { + tracing::info!( + "[screenshot] sandbox {} captured via renderer ({} bytes)", + id, + png_data.len() + ); + Ok(screenshot_response(png_data, "renderer", None)) + } + Err(reason) => { + tracing::warn!( + "[screenshot] renderer capture failed for sandbox {}: {}", + id, + reason + ); + Err(AppError::Screenshot(format!( + "Screenshot failed: {}. Use --with-frame to capture via ScreenCaptureKit (requires Screen Recording permission).", + reason + ))) + } + } +} +``` + +- [ ] **Step 2: Add `screenshot_with_frame` function** + +Add a new function after `screenshot_fallback` (around line 546): + +```rust +/// Capture a screenshot using ScreenCaptureKit (requires Screen Recording permission). +async fn screenshot_with_frame( + state: Arc<Mutex<DaemonState>>, + id: &str, +) -> Result<Response, AppError> { + let window_id = { + let s = state.lock().await; + s.sandboxes.get(id).and_then(|sb| sb.window_id) + }; + + let wid = match window_id { + Some(wid) => wid, + None => { + // Re-discover window + let new_wid = + tokio::task::spawn_blocking(|| ScreenCapture::find_window_by_title("CLI Box")) + .await + .map_err(|e| { + AppError::Screenshot(format!("window discovery task failed: {e}")) + })??; + let mut s = state.lock().await; + if let Some(sb) = s.sandboxes.get_mut(id) { + sb.window_id = Some(new_wid); + } + let registry = InstanceRegistry::default(); + let _ = registry.update_window_id(id, new_wid); + new_wid + } + }; + + let result = tokio::task::spawn_blocking(move || ScreenCapture::capture_window(wid)) + .await + .map_err(|e| AppError::Screenshot(format!("screenshot task failed: {e}")))?; + + match result { + Ok(png_data) => Ok(screenshot_response(png_data, "screencapturekit", None)), + Err(e) => Err(AppError::Screenshot(format!( + "{}. If permission was denied, grant Screen Recording in System Settings → Privacy & Security → Screen Recording.", + e + ))), + } +} +``` + +- [ ] **Step 3: Keep `screenshot_fallback` as-is** + +The existing `screenshot_fallback` function stays unchanged — it's still used by other internal code paths if needed. + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo check -p cli-box-core` +Expected: compiles without errors + +- [ ] **Step 5: Commit** + +```bash +git add crates/cli-box-core/src/daemon/mod.rs +git commit -m "feat(daemon): add with_frame query param to screenshot handler" +``` + +--- + +### Task 4: Update MCP tool schema for `screenshot_sandbox` + +**Files:** +- Modify: `crates/cli-box-cli/src/main.rs:1194-1203` (MCP tool schema) + +- [ ] **Step 1: Add `with_frame` to tool schema** + +In the `mcp_tools()` function, update the `screenshot_sandbox` tool definition: + +```rust + { + "name": "screenshot_sandbox", + "description": "Take a screenshot of a sandbox (returns base64 PNG). Default: renderer capture (no permission needed). Use with_frame=true for full window capture (requires Screen Recording permission).", + "inputSchema": { + "type": "object", + "properties": { + "sandbox_id": { "type": "string" }, + "with_frame": { "type": "boolean", "description": "Use ScreenCaptureKit for full window frame capture (requires Screen Recording permission)", "default": false } + }, + "required": ["sandbox_id"] + } + }, +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p cli-box-cli` +Expected: compiles without errors + +- [ ] **Step 3: Commit** + +```bash +git add crates/cli-box-cli/src/main.rs +git commit -m "feat(mcp): add with_frame parameter to screenshot_sandbox tool" +``` + +--- + +### Task 5: Remove screen-capture entitlement + +**Files:** +- Modify: `entitlements.plist` + +- [ ] **Step 1: Remove screen-capture entitlement** + +Remove lines 9-10 from `entitlements.plist`: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + <key>com.apple.security.cs.disable-library-validation</key> + <true/> +</dict> +</plist> +``` + +- [ ] **Step 2: Commit** + +```bash +git add entitlements.plist +git commit -m "fix(entitlements): remove screen-capture entitlement" +``` + +--- + +### Task 6: Update legacy server screenshot handler + +**Files:** +- Modify: `crates/cli-box-core/src/server/mod.rs:402-414` (screenshot_handler) + +- [ ] **Step 1: Add `with_frame` query param to legacy server** + +In `crates/cli-box-core/src/server/mod.rs`, the existing `ScreenshotQuery` already exists (used for `window_id`). Add `with_frame` to it and update the handler: + +First, find the existing `ScreenshotQuery` struct (search for it in the file) and add the field: + +```rust +#[derive(Deserialize)] +struct ScreenshotQuery { + window_id: Option<u32>, + #[serde(default)] + with_frame: bool, +} +``` + +Update the handler to respect `with_frame`: + +```rust +async fn screenshot_handler( + State(state): State<Arc<Mutex<AppState>>>, + Query(q): Query<ScreenshotQuery>, +) -> Result<impl IntoResponse, AppError> { + let window_id = q.window_id.or(state.lock().await.window_id); + match window_id { + Some(id) => { + if !q.with_frame { + return Err(AppError::Screenshot( + "Default screenshot is not supported in legacy mode. Use ?with_frame=true (requires Screen Recording permission).".to_string() + )); + } + let png_data = ScreenCapture::capture_window(id)?; + Ok((StatusCode::OK, [("content-type", "image/png")], png_data).into_response()) + } + None => Err(AppError::BadRequest(SANDBOX_WINDOW_REQUIRED.to_string())), + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p cli-box-core` +Expected: compiles without errors + +- [ ] **Step 3: Commit** + +```bash +git add crates/cli-box-core/src/server/mod.rs +git commit -m "feat(server): add with_frame query param to legacy screenshot handler" +``` + +--- + +### Task 7: Verify full build and tests + +**Files:** None (verification only) + +- [ ] **Step 1: Run full cargo check** + +Run: `cargo check --all-targets` +Expected: compiles without errors + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy --all-targets` +Expected: no errors (warnings acceptable) + +- [ ] **Step 3: Run existing tests** + +Run: `cargo test --all` +Expected: all tests pass + +- [ ] **Step 4: Run frontend checks** + +Run: `pnpm typecheck && pnpm format:check && pnpm test:unit` +Expected: all pass + +- [ ] **Step 5: Commit any fixes** + +If any fixes were needed, commit them: + +```bash +git add -A +git commit -m "fix: address review feedback from build verification" +``` diff --git a/docs/superpowers/plans/2026-06-05-start-readiness-wait.md b/docs/superpowers/plans/2026-06-05-start-readiness-wait.md new file mode 100644 index 0000000..b28e65e --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-start-readiness-wait.md @@ -0,0 +1,263 @@ +# cli-box start Readiness Wait Implementation Plan + +> **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:** When `cli-box start` spawns a new Electron app, wait for the renderer WebSocket to connect before returning, showing animated dots ("正在启动...") to the user. + +**Architecture:** Add a `/readyz` endpoint to the daemon that reports whether the renderer WebSocket is connected (`screenshot_ws_tx.is_some()`). Add a `daemon_readiness()` client function. Modify `cmd_start_daemon` to poll this endpoint with animated output when Electron was newly spawned, with a 60-second timeout. + +**Tech Stack:** Rust (axum HTTP handler, reqwest client, tokio async), CLI terminal output + +--- + +### Task 1: Add `/readyz` endpoint to daemon + +**Files:** +- Modify: `crates/cli-box-core/src/daemon/mod.rs` — add `DaemonReadinessResponse` struct + `readyz_handler` + route +- Test: `crates/cli-box-core/tests/daemon_integration.rs` — add readyz integration test + +- [ ] **Step 1: Add `DaemonReadinessResponse` struct** + +In `crates/cli-box-core/src/daemon/mod.rs`, after the existing `HealthResponse` struct (around line 196), add: + +```rust +#[derive(Debug, Serialize)] +pub struct DaemonReadinessResponse { + /// "ready" if renderer WebSocket is connected, "not_ready" otherwise. + pub status: String, + /// Whether the Electron renderer's screenshot WebSocket is connected. + pub renderer_connected: bool, +} +``` + +- [ ] **Step 2: Add `readyz_handler`** + +After the `health_handler` function (around line 282), add: + +```rust +async fn readyz_handler(State(state): State<Arc<Mutex<DaemonState>>>) -> Json<DaemonReadinessResponse> { + let s = state.lock().await; + let renderer_connected = s.screenshot_ws_tx.is_some(); + Json(DaemonReadinessResponse { + status: if renderer_connected { "ready" } else { "not_ready" }.to_string(), + renderer_connected, + }) +} +``` + +- [ ] **Step 3: Register `/readyz` route** + +In `build_daemon_router` (around line 243), add the route after the `/health` line: + +```rust + .route("/readyz", get(readyz_handler)) +``` + +- [ ] **Step 4: Add integration test for `/readyz`** + +In `crates/cli-box-core/tests/daemon_integration.rs`, add: + +```rust +#[tokio::test] +async fn readyz_returns_not_ready_without_renderer() { + let resp = router() + .oneshot( + Request::builder() + .uri("/readyz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["status"], "not_ready"); + assert_eq!(json["renderer_connected"], false); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p cli-box-core --test daemon_integration -- readyz` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add crates/cli-box-core/src/daemon/mod.rs crates/cli-box-core/tests/daemon_integration.rs +git commit -m "feat(daemon): add /readyz endpoint for renderer WebSocket status" +``` + +--- + +### Task 2: Add `daemon_readiness()` client function + +**Files:** +- Modify: `crates/cli-box-cli/src/client.rs` — add `DaemonReadinessResponse` struct + `daemon_readiness()` function + +- [ ] **Step 1: Add `DaemonReadinessResponse` and `daemon_readiness()`** + +In `crates/cli-box-cli/src/client.rs`, after the existing `DaemonHealthResponse` struct (around line 49), add: + +```rust +#[derive(Debug, Deserialize)] +pub struct DaemonReadinessResponse { + pub status: String, + pub renderer_connected: bool, +} + +/// Check daemon readiness (renderer WebSocket connection status). +pub async fn daemon_readiness() -> Result<DaemonReadinessResponse> { + let base = daemon_base_url()?; + let client = reqwest_client(); + let resp = client + .get(format!("{base}/readyz")) + .send() + .await + .with_context(|| "Failed to connect to daemon readyz endpoint")?; + let readiness: DaemonReadinessResponse = resp.json().await?; + Ok(readiness) +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test -p cli-box-cli` +Expected: PASS (existing tests still pass, new function compiles) + +- [ ] **Step 3: Commit** + +```bash +git add crates/cli-box-cli/src/client.rs +git commit -m "feat(client): add daemon_readiness() for renderer WebSocket status" +``` + +--- + +### Task 3: Modify `cmd_start_daemon` to poll with animated dots + +**Files:** +- Modify: `crates/cli-box-cli/src/main.rs:425-498` — `cmd_start_daemon` function + +- [ ] **Step 1: Add readiness polling after Electron spawn** + +In `cmd_start_daemon`, after the Electron spawn block (after line 495, before `Ok(())`), add the readiness wait logic. The full modified function ending should be: + +```rust + // Spawn Electron only if not already running. + // If already running, the renderer polls /box/list and will pick up the new sandbox. + let electron_newly_spawned = if find_running_electron() { + tracing::info!("[start] Electron already running, skipping spawn"); + false + } else if let Some(electron_bin) = find_electron_binary() { + tracing::info!("[start] spawning Electron: {}", electron_bin.display()); + let _child = Command::new(&electron_bin) + .spawn() + .context("Failed to launch Electron app")?; + tracing::info!("[start] Electron launched"); + true + } else { + tracing::warn!("[start] Electron app not found, running in headless daemon mode"); + false + }; + + // Wait for renderer WebSocket to connect if Electron was newly spawned. + if electron_newly_spawned { + print!("正在启动"); + use std::io::Write; + let _ = std::io::stdout().flush(); + + let timeout = std::time::Duration::from_secs(60); + let start = std::time::Instant::now(); + let poll_interval = std::time::Duration::from_secs(1); + let mut dot_count: u8 = 0; + + loop { + if start.elapsed() > timeout { + println!(); + tracing::warn!( + "[start] Renderer WebSocket did not connect within {}s, continuing anyway", + timeout.as_secs() + ); + break; + } + + match client::daemon_readiness().await { + Ok(resp) if resp.renderer_connected => { + println!(" 完成"); + break; + } + _ => {} + } + + dot_count = (dot_count % 3) + 1; + print!("\r正在启动{}", ".".repeat(dot_count as usize)); + print!("{}", " ".repeat(3 - dot_count as usize)); // clear leftover dots + let _ = std::io::stdout().flush(); + + tokio::time::sleep(poll_interval).await; + } + } + + Ok(()) +``` + +- [ ] **Step 2: Build and verify compilation** + +Run: `cargo build -p cli-box-cli` +Expected: Compiles without errors + +- [ ] **Step 3: Commit** + +```bash +git add crates/cli-box-cli/src/main.rs +git commit -m "feat(cli): poll daemon /readyz with animated dots when Electron is newly spawned" +``` + +--- + +### Task 4: Manual smoke test + +- [ ] **Step 1: Run `sh test.sh` to verify all existing tests pass** + +Run: `sh test.sh` +Expected: All tests pass + +- [ ] **Step 2: Run `sh release.sh` to build release binary** + +Run: `sh release.sh` +Expected: Release binary built successfully + +- [ ] **Step 3: Test the animated dots manually** + +```bash +# Kill any existing daemon and electron +release/cli-box daemon stop 2>/dev/null; pkill -f "CLI Box" 2>/dev/null + +# Run start — should show "正在启动... 完成" +release/cli-box start zsh +``` + +Expected: Terminal shows "正在启动" with dots cycling (. .. ... . .. ...) then " 完成" when renderer connects. + +- [ ] **Step 4: Test with Electron already running** + +```bash +# With Electron already open, start another sandbox +release/cli-box start claude +``` + +Expected: No animated dots (Electron already running, renderer already connected). + +- [ ] **Step 5: Verify screenshots work immediately after start** + +```bash +# Start fresh, take screenshot right after +release/cli-box start zsh +sleep 2 +release/cli-box screenshot --id $(release/cli-box list --json | jq -r '.[0].id') -o /tmp/test-after-start.png +``` + +Expected: Screenshot succeeds (renderer WebSocket was connected before command returned). diff --git a/docs/superpowers/specs/2026-06-05-screenshot-e2e-tests-design.md b/docs/superpowers/specs/2026-06-05-screenshot-e2e-tests-design.md new file mode 100644 index 0000000..a23b389 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-screenshot-e2e-tests-design.md @@ -0,0 +1,37 @@ +# Screenshot --with-frame E2E Tests Design + +## Goal + +Add E2E test coverage for the `--with-frame` screenshot feature, covering both daemon routing logic (CI-runnable) and full CLI flow (local macOS only). + +## Approach + +Two test layers: + +1. **Rust integration tests** — `oneshot` HTTP tests against daemon router, verifying `with_frame` query param routing. CI-runnable, no macOS dependencies. +2. **Shell E2E tests** — Build CLI binary, start daemon, run `cli-box screenshot` commands, verify output and exit codes. Local macOS only (skip CI). + +## Rust Integration Tests + +File: `crates/cli-box-core/tests/daemon_integration.rs` + +| Test | Request | Assertion | +|------|---------|-----------| +| `screenshot_with_frame_nonexistent` | `GET /box/no-such-id/screenshot?with_frame=true` | 404 | +| `screenshot_default_nonexistent` | `GET /box/no-such-id/screenshot` | 404 | +| `screenshot_with_frame_query_parsed` | `GET /box/test-sb/screenshot?with_frame=true` | Not 400 (param parsed correctly) | + +## Shell E2E Tests + +File: `tests/e2e-screenshot-with-frame.sh` + +Flow: +1. Build CLI binary (reuse `ensure_platform_binaries` pattern) +2. Start daemon: `cli-box start zsh` +3. Wait for sandbox ready +4. Test: `cli-box screenshot --id <id>` — verify exit code, file output +5. Test: `cli-box screenshot --id <id> --with-frame` — verify exit code, error message guidance +6. Test: `cli-box screenshot --id <id> --with-frame -o /tmp/test.png` — verify file +7. Cleanup: `cli-box close <id>` + +CI skip: `uname == Darwin && CI` → skip (same pattern as `e2e-skill-install.sh`) diff --git a/electron-app/src/renderer/main.tsx b/electron-app/src/renderer/main.tsx index cfe8302..fb29634 100644 --- a/electron-app/src/renderer/main.tsx +++ b/electron-app/src/renderer/main.tsx @@ -136,7 +136,23 @@ function App() { ws.onmessage = async (event) => { try { const msg = JSON.parse(event.data); - if (msg.type === "capture_request") { + if (msg.type === "switch_tab_request") { + const { sandbox_id, request_id } = msg; + // Switch tab via IPC so main process repositions WebContentsView + try { + await window.sandbox.switchTab(sandbox_id); + } catch { + // fallback: update React state only + setActiveTabId(sandbox_id); + } + // Small delay for tab to render + await new Promise((r) => setTimeout(r, 200)); + ws?.send(JSON.stringify({ + type: "switch_tab_response", + request_id, + sandbox_id, + })); + } else if (msg.type === "capture_request") { const { sandbox_id, request_id } = msg; const tabRef = terminalRefs.current.get(sandbox_id); if (tabRef?.current) { diff --git a/entitlements.plist b/entitlements.plist index 0f05cde..6883e4a 100644 --- a/entitlements.plist +++ b/entitlements.plist @@ -6,7 +6,5 @@ <true/> <key>com.apple.security.cs.disable-library-validation</key> <true/> - <key>com.apple.developer.screen-capture</key> - <true/> </dict> </plist> diff --git a/release/README.md b/release/README.md index ce31c83..b89e2b6 100644 --- a/release/README.md +++ b/release/README.md @@ -137,4 +137,4 @@ A: 等待几秒让 CLI 工具启动,终端会自动连接 PTY 输出。 --- -**版本**: v${VERSION} | **构建时间**: 2026-06-04 13:50 +**版本**: v${VERSION} | **构建时间**: 2026-06-05 19:15 diff --git a/test.sh b/test.sh index 1eff5cc..beed860 100755 --- a/test.sh +++ b/test.sh @@ -113,7 +113,7 @@ info "Checking for 'sandbox' remnants in user-facing strings..." # Check specific files that were renamed for leftover "sandbox" references # in user-visible strings (help text, error messages, log prefixes) SANDBOX_REMNANTS=$(grep -rn '"sandbox' crates/cli-box-cli/src/ crates/cli-box-core/src/daemon/mod.rs electron-app/src/renderer/ --include='*.rs' --include='*.ts' --include='*.tsx' 2>/dev/null \ - | grep -v '//\|/\*\|\*\|#\|test\|Test\|TEST\|sandbox_state\|sandbox_config\|SandboxConfig\|SandboxState\|ManagedSandbox\|SandboxInstance\|sandbox_id\|sandbox/\|/sandbox\|sandbox-daemon\|sandbox-cli\|sandbox-core\|sandbox-electron\|SANDBOX_\|sandbox_daemon\|sandbox_cli\|sandbox_core\|Sandbox\|\.sandbox\|sandbox\.json\|instances/\|pty_store\|SandboxRegion\|SandboxInfo\|SandboxKind\|SandboxStatus' || true) + | grep -v '//\|/\*\|\*\|#\|test\|Test\|TEST\|sandbox_state\|sandbox_config\|SandboxConfig\|SandboxState\|ManagedSandbox\|SandboxInstance\|sandbox_id\|sandbox/\|/sandbox\|sandbox-daemon\|sandbox-cli\|sandbox-core\|sandbox-electron\|SANDBOX_\|sandbox_daemon\|sandbox_cli\|sandbox_core\|Sandbox\|\.sandbox\|sandbox\.json\|instances/\|pty_store\|SandboxRegion\|SandboxInfo\|SandboxKind\|SandboxStatus\|\["sandboxes"\]' || true) if [ -n "$SANDBOX_REMNANTS" ]; then echo "$SANDBOX_REMNANTS" warn "Found potential 'sandbox' remnants in user-facing strings (review above)" diff --git a/tests/e2e-screenshot-with-frame.sh b/tests/e2e-screenshot-with-frame.sh new file mode 100755 index 0000000..feca9d5 --- /dev/null +++ b/tests/e2e-screenshot-with-frame.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================ +# E2E Screenshot --with-frame Test +# ============================================================ +# Verifies that cli-box screenshot works with and without +# the --with-frame flag. Tests the full CLI → daemon flow. +# +# Usage: bash tests/e2e-screenshot-with-frame.sh +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +info() { echo -e "${GREEN}➜${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +err() { echo -e "${RED}✗${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } + +FAILED=0 + +# ==================== Skip on Linux CI ==================== +if [ "$(uname)" = "Linux" ] && [ -n "${CI:-}" ]; then + warn "Skipping E2E screenshot tests on Linux CI (macOS required)" + exit 0 +fi + +# ==================== Skip on CI (requires macOS permissions) ==================== +if [ -n "${CI:-}" ]; then + warn "Skipping E2E screenshot tests on CI (Screen Recording permission required)" + exit 0 +fi + +# ==================== Setup: ensure platform package has binaries ==================== +ensure_platform_binaries() { + local PKG_BIN="$REPO_ROOT/packages/cli-box-darwin-arm64/bin" + if [ -f "$PKG_BIN/cli-box" ] && [ -f "$PKG_BIN/cli-box-daemon" ]; then + return + fi + + info "Building binaries..." + if ! cargo build -p cli-box-cli -p cli-box-daemon 2>&1; then + err "cargo build failed" + exit 1 + fi + + mkdir -p "$PKG_BIN" + if [ -f "$REPO_ROOT/target/release/cli-box" ]; then + ln -sf "$REPO_ROOT/target/release/cli-box" "$PKG_BIN/cli-box" + ln -sf "$REPO_ROOT/target/release/cli-box-daemon" "$PKG_BIN/cli-box-daemon" + elif [ -f "$REPO_ROOT/target/debug/cli-box" ]; then + ln -sf "$REPO_ROOT/target/debug/cli-box" "$PKG_BIN/cli-box" + ln -sf "$REPO_ROOT/target/debug/cli-box-daemon" "$PKG_BIN/cli-box-daemon" + else + err "No built binaries found" + exit 1 + fi + + ok "Binaries ready" +} + +# ==================== Find CLI binary ==================== +find_cli() { + if [ -f "$REPO_ROOT/target/release/cli-box" ]; then + echo "$REPO_ROOT/target/release/cli-box" + elif [ -f "$REPO_ROOT/target/debug/cli-box" ]; then + echo "$REPO_ROOT/target/debug/cli-box" + else + err "cli-box binary not found" + exit 1 + fi +} + +# ==================== Test 1: Default screenshot (no --with-frame) ==================== +test_default_screenshot() { + info "Test 1: Default screenshot (renderer path)" + + local CLI="$1" + local SANDBOX_ID="$2" + + local OUT_FILE + OUT_FILE=$(mktemp /tmp/cli-box-screenshot-XXXXXX.png) + + # Default screenshot uses renderer path, should work without Screen Recording permission + if "$CLI" screenshot --id "$SANDBOX_ID" -o "$OUT_FILE" 2>&1; then + if [ -f "$OUT_FILE" ] && [ -s "$OUT_FILE" ]; then + ok " Default screenshot saved ($(wc -c < "$OUT_FILE") bytes)" + else + err " Screenshot file is empty or missing" + FAILED=1 + fi + else + # May fail if renderer is not connected — that's acceptable + warn " Default screenshot failed (renderer may not be connected)" + fi + + rm -f "$OUT_FILE" +} + +# ==================== Test 2: --with-frame screenshot ==================== +test_with_frame_screenshot() { + info "Test 2: --with-frame screenshot (ScreenCaptureKit path)" + + local CLI="$1" + local SANDBOX_ID="$2" + + local OUT_FILE + OUT_FILE=$(mktemp /tmp/cli-box-screenshot-XXXXXX.png) + + local OUTPUT + OUTPUT=$("$CLI" screenshot --id "$SANDBOX_ID" --with-frame -o "$OUT_FILE" 2>&1) && local EXIT_CODE=0 || local EXIT_CODE=$? + + if [ $EXIT_CODE -eq 0 ]; then + # Succeeded — Screen Recording permission granted + if [ -f "$OUT_FILE" ] && [ -s "$OUT_FILE" ]; then + ok " --with-frame screenshot saved ($(wc -c < "$OUT_FILE") bytes)" + # Verify the output mentions ScreenCaptureKit + if echo "$OUTPUT" | grep -qi "screencapturekit\|ScreenCaptureKit"; then + ok " Output confirms ScreenCaptureKit was used" + else + warn " Output does not mention ScreenCaptureKit (may be using renderer)" + fi + else + err " Screenshot file is empty or missing" + FAILED=1 + fi + else + # Failed — likely no Screen Recording permission + if echo "$OUTPUT" | grep -qi "screen recording\|permission\|with_frame"; then + ok " --with-frame correctly reports permission error" + else + err " --with-frame failed with unexpected error: $OUTPUT" + FAILED=1 + fi + fi + + rm -f "$OUT_FILE" +} + +# ==================== Test 3: --with-frame error message guidance ==================== +test_with_frame_error_guidance() { + info "Test 3: --with-frame error message guidance" + + local CLI="$1" + local SANDBOX_ID="$2" + + # Try --with-frame and check that error messages are helpful + local OUTPUT + OUTPUT=$("$CLI" screenshot --id "$SANDBOX_ID" --with-frame 2>&1) && local EXIT_CODE=0 || local EXIT_CODE=$? + + if [ $EXIT_CODE -ne 0 ]; then + # Check error message contains helpful guidance + if echo "$OUTPUT" | grep -qi "screen recording\|permission\|system settings"; then + ok " Error message provides permission guidance" + else + warn " Error message could be more helpful: $OUTPUT" + fi + else + ok " --with-frame succeeded (permission already granted)" + fi +} + +# ==================== Main ==================== +echo "" +echo "==============================================" +echo " E2E Screenshot --with-frame Tests" +echo "==============================================" +echo "" + +ensure_platform_binaries + +CLI=$(find_cli) +info "Using CLI: $CLI" + +# Verify CLI works +if ! "$CLI" --help >/dev/null 2>&1; then + err "cli-box --help failed" + exit 1 +fi +ok "CLI is functional" + +# Kill any existing daemon to start fresh +"$CLI" close-all 2>/dev/null || true +sleep 1 + +# Start a sandbox +info "Starting sandbox..." +START_OUTPUT=$("$CLI" start zsh 2>&1) || true +SANDBOX_ID=$(echo "$START_OUTPUT" | grep -oE '[a-f0-9]{6}' | head -1 || true) + +if [ -z "$SANDBOX_ID" ]; then + err "Failed to start sandbox" + err "Output: $START_OUTPUT" + exit 1 +fi + +ok "Sandbox started: $SANDBOX_ID" + +# Wait for sandbox to be ready +sleep 3 + +# Cleanup on exit +cleanup() { + info "Cleaning up sandbox $SANDBOX_ID..." + "$CLI" close "$SANDBOX_ID" 2>/dev/null || true +} +trap cleanup EXIT + +echo "" + +# Run tests +test_default_screenshot "$CLI" "$SANDBOX_ID" +echo "" +test_with_frame_screenshot "$CLI" "$SANDBOX_ID" +echo "" +test_with_frame_error_guidance "$CLI" "$SANDBOX_ID" + +# ==================== Summary ==================== +echo "" +echo "==============================================" +if [ "$FAILED" -eq 0 ]; then + echo -e "${GREEN}All E2E screenshot tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some E2E screenshot tests failed.${NC}" + exit 1 +fi diff --git a/tests/release_test.md b/tests/release_test.md index 44c2bf3..36a3798 100644 --- a/tests/release_test.md +++ b/tests/release_test.md @@ -3,6 +3,7 @@ 2. /superpowers:systematic-debugging 使用 @release.sh 打包编译release,然后你进行一个简单场景的测试,场景一:在沙箱启动claude以后,回车确认,然后输入你是谁?然后出发回车发送。场景二:在沙箱启动zsh,然后输入echo "hello world",然后回车发送。每一步操作后都截图保存到release_test/${{时间戳,yyyy-mm-dd-hh-mm-ss}}文件夹下,然后检查截图结果,查看是否符合预期,注意读取图片前先判断图片是否存在问题 3. /superpowers:systematic-debugging 使用 @release.sh 打包编译release,然后先打开opencode,再打开zsh,分别CLI命令行截图两个窗口,获取到的是各自的界面 4. 当前打开的claude,在回车确认后,判断界面上,不会有选项`Yes, I trust this folder`残留 +5. 用`start opencode`命令,打开opencode,使用screenshot --with-frame进行截图,查看是否有边框 5. 新增测试点 - pnpm dev 后 Electron 窗口正常打开 - 终端日志显示 "Daemon started on port XXXX"