diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec61f93..9be59b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,11 @@ # system-test-sandbox - CI门禁 # 在PR创建/更新及push到main时触发 # -# 门禁检查项 (Rust + TypeScript): +# 门禁检查项 (Rust): # 1. Rust 格式化检查 - cargo fmt # 2. Rust Clippy - 代码规范 + 编译检查 (macOS, 需要系统框架) # 3. Rust 单元测试 - cargo test (macOS) -# 4. 前端构建 - pnpm build -# 5. 前端 Lint - prettier -# 6. 前端类型检查 - pnpm typecheck -# 7. 前端单元测试 - vitest -# 8. 安全检查 - 硬编码密钥检测 + 依赖漏洞扫描 +# 4. 安全检查 - 硬编码密钥检测 + 依赖漏洞扫描 # # @see CLAUDE.md 七、核心工作流程 @@ -32,9 +28,7 @@ concurrency: cancel-in-progress: true env: - NODE_VERSION: '22' - PNPM_VERSION: '9' - RUST_VERSION: '1.88' + RUST_VERSION: '1.91' jobs: # ==================== Rust 格式化检查 ==================== @@ -113,18 +107,15 @@ jobs: - name: 运行 cargo test run: cargo test -p sandbox-core - - name: 安装 cargo-tarpaulin - continue-on-error: true + - name: 安装 cargo-llvm-cov run: | - if ! command -v cargo-tarpaulin &> /dev/null; then - cargo install cargo-tarpaulin --locked - fi + rustup component add llvm-tools-preview + cargo install cargo-llvm-cov --locked - name: 运行测试覆盖率 - if: success() - continue-on-error: true run: | - cargo tarpaulin -p sandbox-core -p sandbox-cli --out Xml --out Html --output-dir coverage --skip-clean + mkdir -p coverage + cargo llvm-cov -p sandbox-core --cobertura --output-path coverage/cobertura.xml - name: 生成覆盖率摘要 if: always() @@ -157,6 +148,9 @@ jobs: echo "> 详细报告见 Rust 覆盖率 artifact" >> "$SUMMARY_FILE" else echo "> ⚠️ 未生成覆盖率报告" >> "$SUMMARY_FILE" + echo "❌ 覆盖率生成失败:cobertura.xml 未生成" >> "$SUMMARY_FILE" + echo "::error::覆盖率报告生成失败,检查 llvm-cov 输出的测试失败信息" + exit 1 fi echo "" >> "$SUMMARY_FILE" cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY" @@ -177,158 +171,6 @@ jobs: path: coverage/ retention-days: 14 - # ==================== 前端构建 ==================== - frontend-build: - name: 前端构建 - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: 检出代码 - uses: actions/checkout@v4 - - - name: 设置 Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: 安装 pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - - - name: 设置依赖缓存 - uses: actions/cache@v4 - with: - path: | - ~/.pnpm-store - sandbox-web/node_modules - key: ${{ runner.os }}-pnpm-${{ hashFiles('sandbox-web/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: 安装依赖 - working-directory: sandbox-web - run: pnpm install --frozen-lockfile - - - name: 构建 - working-directory: sandbox-web - run: pnpm build - - # ==================== 前端 Lint 检查 ==================== - frontend-lint: - name: 前端 Lint - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: 检出代码 - uses: actions/checkout@v4 - - - name: 设置 Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: 安装 pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - - - name: 设置依赖缓存 - uses: actions/cache@v4 - with: - path: | - ~/.pnpm-store - sandbox-web/node_modules - key: ${{ runner.os }}-pnpm-${{ hashFiles('sandbox-web/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: 安装依赖 - working-directory: sandbox-web - run: pnpm install --frozen-lockfile - - - name: 运行格式检查 - working-directory: sandbox-web - run: pnpm format:check - - # ==================== 前端类型检查 ==================== - frontend-typecheck: - name: 前端类型检查 - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: 检出代码 - uses: actions/checkout@v4 - - - name: 设置 Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: 安装 pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - - - name: 设置依赖缓存 - uses: actions/cache@v4 - with: - path: | - ~/.pnpm-store - sandbox-web/node_modules - key: ${{ runner.os }}-pnpm-${{ hashFiles('sandbox-web/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: 安装依赖 - working-directory: sandbox-web - run: pnpm install --frozen-lockfile - - - name: TypeScript 类型检查 - working-directory: sandbox-web - run: pnpm typecheck - - # ==================== 前端单元测试 ==================== - frontend-test: - name: 前端单元测试 - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: 检出代码 - uses: actions/checkout@v4 - - - name: 设置 Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: 安装 pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - - - name: 设置依赖缓存 - uses: actions/cache@v4 - with: - path: | - ~/.pnpm-store - sandbox-web/node_modules - key: ${{ runner.os }}-pnpm-${{ hashFiles('sandbox-web/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: 安装依赖 - working-directory: sandbox-web - run: pnpm install --frozen-lockfile - - - name: 运行单元测试 - working-directory: sandbox-web - run: pnpm test:unit - # ==================== 安全检查 ==================== security: name: 安全检查 @@ -347,11 +189,6 @@ jobs: - name: 安装系统依赖 run: sudo apt-get update && sudo apt-get install -y cmake clang - - name: 设置 pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - - name: Rust 依赖审计 if: always() continue-on-error: false @@ -363,15 +200,6 @@ jobs: echo "::notice::未找到 Cargo.lock,跳过 Rust 依赖审计" fi - - name: 前端依赖审计 - working-directory: sandbox-web - run: | - if [ -f pnpm-lock.yaml ]; then - pnpm audit --audit-level=high || echo "::warning::前端依赖存在高危漏洞" - else - echo "::notice::未找到 pnpm-lock.yaml,跳过前端依赖审计" - fi - - name: 检查硬编码密钥 run: | echo "检查硬编码密钥..." @@ -411,7 +239,7 @@ jobs: gate-result: name: 门禁结果 runs-on: ubuntu-latest - needs: [rust-fmt, rust-clippy, rust-test, frontend-build, frontend-lint, frontend-typecheck, frontend-test, security] + needs: [rust-fmt, rust-clippy, rust-test, security] if: always() steps: @@ -423,14 +251,6 @@ jobs: path: coverage-artifacts continue-on-error: true - - name: 下载前端覆盖率摘要 - uses: actions/download-artifact@v4 - if: always() - with: - name: frontend-coverage-summary - path: coverage-artifacts - continue-on-error: true - - name: 检查门禁结果 run: | echo "============================================" @@ -438,13 +258,9 @@ jobs: echo "============================================" echo "" - BUILD_RESULT="${{ needs.frontend-build.result }}" RUST_FMT="${{ needs.rust-fmt.result }}" RUST_CLIPPY="${{ needs.rust-clippy.result }}" RUST_TEST="${{ needs.rust-test.result }}" - LINT_RESULT="${{ needs.frontend-lint.result }}" - TYPECHECK_RESULT="${{ needs.frontend-typecheck.result }}" - TEST_RESULT="${{ needs.frontend-test.result }}" SECURITY_RESULT="${{ needs.security.result }}" echo "| 检查项 | 状态 |" @@ -452,10 +268,6 @@ jobs: echo "| Rust 格式化 | $RUST_FMT |" echo "| Rust Clippy | $RUST_CLIPPY |" echo "| Rust 测试 | $RUST_TEST |" - echo "| 前端构建 | $BUILD_RESULT |" - echo "| 前端 Lint | $LINT_RESULT |" - echo "| 前端类型检查 | $TYPECHECK_RESULT |" - echo "| 前端测试 | $TEST_RESULT |" echo "| 安全检查 | $SECURITY_RESULT |" echo "" @@ -464,18 +276,10 @@ jobs: cat coverage-artifacts/rust-coverage-summary.md echo "" fi - if [ -f coverage-artifacts/frontend-coverage-summary.md ]; then - cat coverage-artifacts/frontend-coverage-summary.md - echo "" - fi - if [[ "$BUILD_RESULT" == "success" && \ - "$RUST_FMT" == "success" && \ + if [[ "$RUST_FMT" == "success" && \ "$RUST_CLIPPY" == "success" && \ "$RUST_TEST" == "success" && \ - "$LINT_RESULT" == "success" && \ - "$TYPECHECK_RESULT" == "success" && \ - "$TEST_RESULT" == "success" && \ "$SECURITY_RESULT" == "success" ]]; then echo "✅ 所有门禁检查通过!" exit 0 @@ -495,10 +299,6 @@ jobs: 'Rust 格式化': '${{ needs.rust-fmt.result }}', 'Rust Clippy': '${{ needs.rust-clippy.result }}', 'Rust 测试': '${{ needs.rust-test.result }}', - '前端构建': '${{ needs.frontend-build.result }}', - '前端 Lint': '${{ needs.frontend-lint.result }}', - '前端类型检查': '${{ needs.frontend-typecheck.result }}', - '前端测试': '${{ needs.frontend-test.result }}', '安全检查': '${{ needs.security.result }}', }; @@ -515,10 +315,6 @@ jobs: const rustCov = fs.readFileSync('coverage-artifacts/rust-coverage-summary.md', 'utf8'); coverageSection += '\n' + rustCov + '\n'; } catch (e) {} - try { - const feCov = fs.readFileSync('coverage-artifacts/frontend-coverage-summary.md', 'utf8'); - coverageSection += '\n' + feCov + '\n'; - } catch (e) {} const body = `## 🔒 门禁检查结果 @@ -570,16 +366,6 @@ jobs: - name: 检出代码 uses: actions/checkout@v4 - - name: 设置 Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: 安装 pnpm - uses: pnpm/action-setup@v4 - with: - version: ${{ env.PNPM_VERSION }} - - name: 安装 Rust ${{ env.RUST_VERSION }} uses: dtolnay/rust-toolchain@stable with: @@ -590,36 +376,13 @@ jobs: with: cache-on-failure: true - - name: 设置前端依赖缓存 - uses: actions/cache@v4 - with: - path: | - ~/.pnpm-store - sandbox-web/node_modules - key: ${{ runner.os }}-pnpm-${{ hashFiles('sandbox-web/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: 安装前端依赖 - working-directory: sandbox-web - run: pnpm install --frozen-lockfile - - name: 构建 CLI 二进制 (release) run: cargo build -p sandbox-cli --release - - name: 构建前端 + Tauri 桌面应用 - run: | - if ! cargo tauri --version &>/dev/null; then - cargo install tauri-cli --version "^2" --locked - fi - cargo tauri build - - name: 整理构建产物 run: | mkdir -p release cp target/release/sandbox release/ - find target/release/bundle/macos -name "*.app" -maxdepth 1 -exec cp -R {} release/ \; 2>/dev/null || true - find target/release/bundle/dmg -name "*.dmg" -maxdepth 1 -exec cp {} release/ \; 2>/dev/null || true echo "## 构建产物" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" ls -lh release/ | tail -n +2 | awk '{printf "| %s | %s |\n", $NF, $5}' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 1d61d63..e771bd4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ target/ # Build output dist/ out/ -release/ +release/* +!release/README.md # Rust **/*.rs.bk diff --git a/CLAUDE.md b/CLAUDE.md index 2c547b2..3177246 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,24 +127,24 @@ system-test-sandbox/ ```bash # 多实例管理 sandbox-cli start --cli "claude" # 启动沙箱,运行 Claude Code,返回 sandbox-id -sandbox-cli start --cli "echo" --args "hello" # 带参数启动 CLI +sandbox-cli start --cli "echo" -- "hello" # 带参数启动 CLI sandbox-cli start --app "/path/to/App.app" # 启动沙箱,运行 macOS 应用 sandbox-cli list # 列出所有活跃沙箱及其状态 sandbox-cli close # 关闭指定沙箱 sandbox-cli inspect # 查看沙箱详情 -# 沙箱作用域操作 (通过 --id 或 指定目标沙箱) -sandbox-cli screenshot # 截取沙箱截图 -sandbox-cli screenshot -o result.png # 截图并指定输出路径 -sandbox-cli click 100 200 # 在沙箱内模拟点击 -sandbox-cli type "hello world" # 在沙箱内模拟输入 -sandbox-cli key Return --modifiers cmd # 在沙箱内模拟按键 +# 沙箱作用域操作 (通过 --id 指定目标沙箱) +sandbox-cli screenshot --id # 截取沙箱截图 +sandbox-cli screenshot --id -o result.png # 截图并指定输出路径 +sandbox-cli click --id 100 200 # 在沙箱内模拟点击 +sandbox-cli type --id "hello world" # 在沙箱内模拟输入 +sandbox-cli key --id Return --modifiers cmd # 在沙箱内模拟按键 # 进程管理 (沙箱内) -sandbox-cli windows # 列出沙箱内窗口 -sandbox-cli processes # 列出沙箱内进程 -sandbox-cli spawn-cli "npm" --args "test" # 在沙箱内启动新的 CLI -sandbox-cli kill # 终止沙箱内进程 +sandbox-cli windows --id # 列出沙箱内窗口 +sandbox-cli processes --id # 列出沙箱内进程 +sandbox-cli spawn-cli --id "npm" -- "test" # 在沙箱内启动新的 CLI +sandbox-cli kill --id # 终止沙箱内进程 # 独立模式 (无多实例,向后兼容) sandbox-cli serve --port 5801 # 启动独立 HTTP + MCP 服务器 @@ -298,7 +298,7 @@ fix(server): 修复 HTTP API 端口冲突 ### 7.1 任务执行流程 ``` -[1]创建任务记录(待执行) → [2]创建特性分支 → [3]编写设计文档 +[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 @@ -325,10 +325,10 @@ sandbox-cli list # → def456 "cc-switch" APP Running 15802 2026-05-16 10:31 # 4. 操作指定沙箱 -sandbox-cli screenshot abc123 -o sandbox.png # 截图 -sandbox-cli click abc123 100 200 # 点击 -sandbox-cli type abc123 "帮我写一个函数" # 输入文本 -sandbox-cli key abc123 Return # 按键 +sandbox-cli screenshot --id abc123 -o sandbox.png # 截图 +sandbox-cli click --id abc123 100 200 # 点击 +sandbox-cli type --id abc123 "帮我写一个函数" # 输入文本 +sandbox-cli key --id abc123 Return # 按键 # 5. 关闭沙箱 sandbox-cli close abc123 diff --git a/Cargo.lock b/Cargo.lock index 0171bca..578111f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,7 +206,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey 0.1.1", + "pastey", "rayon", "thiserror 2.0.18", "v_frame", @@ -640,6 +640,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -663,9 +673,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -676,7 +686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1185,6 +1195,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1192,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1206,6 +1225,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1221,21 +1246,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1243,7 +1253,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1298,7 +1307,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1612,6 +1620,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1727,6 +1754,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1738,6 +1766,37 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1756,9 +1815,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2247,6 +2308,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2395,6 +2462,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2788,6 +2872,49 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2864,12 +2991,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pastey" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3355,77 +3476,96 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", - "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-util", + "tokio-native-tls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] [[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "rmcp" -version = "1.7.0" +name = "reqwest" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "async-trait", "base64 0.22.1", - "chrono", - "futures", - "pastey 0.2.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", "pin-project-lite", - "rmcp-macros", - "schemars 1.2.1", "serde", "serde_json", - "thiserror 2.0.18", + "sync_wrapper", "tokio", "tokio-util", - "tracing", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] -name = "rmcp-macros" -version = "1.7.0" +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "darling", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.117", + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] @@ -3443,6 +3583,52 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3469,17 +3655,10 @@ name = "sandbox-cli" version = "0.1.0" dependencies = [ "anyhow", - "axum", - "base64 0.22.1", "clap", - "rmcp", + "reqwest 0.12.28", "sandbox-core", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_yaml", "tokio", - "tower", "tracing", "tracing-subscriber", ] @@ -3489,20 +3668,33 @@ name = "sandbox-core" version = "0.1.0" dependencies = [ "async-trait", + "axum", "base64 0.22.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "image", + "libc", "nix 0.29.0", "objc", "portable-pty", "screencapturekit", "serde", "serde_json", - "serde_yaml", "thiserror 2.0.18", "tokio", + "tower", + "tower-http", "tracing", + "uuid", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -3513,7 +3705,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive 0.8.22", + "schemars_derive", "serde", "serde_json", "url", @@ -3538,10 +3730,8 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ - "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -3558,18 +3748,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3582,6 +3760,29 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c26aa93dbf2f4edfc3646a9b9c4ab0d7acfb1b0e5938fe2d8d1d9a4e66b3502" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3761,19 +3962,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serial2" version = "0.2.37" @@ -4027,6 +4215,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -4079,6 +4273,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4096,6 +4311,7 @@ dependencies = [ name = "system-test-sandbox" version = "0.1.0" dependencies = [ + "axum", "sandbox-core", "serde", "serde_json", @@ -4103,6 +4319,8 @@ dependencies = [ "tauri-build", "tauri-plugin-shell", "tokio", + "tracing", + "uuid", ] [[package]] @@ -4113,7 +4331,7 @@ checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -4164,9 +4382,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ "anyhow", "bytes", @@ -4192,7 +4410,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -4215,9 +4433,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", @@ -4236,9 +4454,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", "brotli", @@ -4263,9 +4481,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4277,9 +4495,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.1" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" dependencies = [ "anyhow", "glob", @@ -4314,9 +4532,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ "cookie", "dpi", @@ -4339,9 +4557,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", @@ -4365,9 +4583,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", "brotli", @@ -4412,6 +4630,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -4569,6 +4800,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4621,7 +4872,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -4684,7 +4935,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -4693,7 +4944,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -4720,9 +4971,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -4910,10 +5161,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" @@ -4987,6 +5238,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5421,6 +5678,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5466,6 +5734,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5714,9 +5991,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -5975,6 +6252,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 5db9083..61f415b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,10 @@ image = "0.25" nix = { version = "0.29", features = ["signal", "process"] } portable-pty = "0.9" axum = "0.8" -rmcp = { version = "1.7", features = ["server", "macros", "transport-io", "schemars"] } tower = "0.5" -serde_yaml = "0.9" +tower-http = { version = "0.6", features = ["cors"] } +uuid = { version = "1", features = ["v4"] } +clap = { version = "4", features = ["derive"] } +anyhow = "1" +reqwest = { version = "0.12", features = ["json"] } sandbox-core = { path = "crates/sandbox-core" } diff --git a/crates/sandbox-cli/Cargo.toml b/crates/sandbox-cli/Cargo.toml index b19ac20..89629e3 100644 --- a/crates/sandbox-cli/Cargo.toml +++ b/crates/sandbox-cli/Cargo.toml @@ -13,15 +13,8 @@ path = "src/main.rs" [dependencies] sandbox-core = { workspace = true, features = ["screencapturekit"] } tokio.workspace = true -clap = { version = "4", features = ["derive"] } -anyhow = "1" +clap.workspace = true +anyhow.workspace = true +reqwest.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -axum.workspace = true -rmcp.workspace = true -tower.workspace = true -serde_yaml.workspace = true -serde.workspace = true -serde_json.workspace = true -base64.workspace = true -schemars = "0.8" diff --git a/crates/sandbox-cli/build.rs b/crates/sandbox-cli/build.rs index 9bb3e1c..5f2883f 100644 --- a/crates/sandbox-cli/build.rs +++ b/crates/sandbox-cli/build.rs @@ -1,12 +1,7 @@ fn main() { - // Only apply on macOS - if cfg!(target_os = "macos") { - // Add Swift runtime rpath so screencapturekit can find - // libswift_Concurrency.dylib at runtime - println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); - - // Also add the Xcode Toolchain path as fallback - // This covers both Xcode and Command Line Tools installations - println!("cargo:rustc-link-arg=-Wl,-rpath,/Library/Developer/Toolchains/swift-latest.xctoolchain/usr/lib/swift/macosx"); + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-arg=-rpath"); + println!("cargo:rustc-link-arg=/usr/lib/swift"); } } diff --git a/crates/sandbox-cli/src/main.rs b/crates/sandbox-cli/src/main.rs index 4c1fec6..9588fe8 100644 --- a/crates/sandbox-cli/src/main.rs +++ b/crates/sandbox-cli/src/main.rs @@ -1,18 +1,15 @@ +use anyhow::Context; use clap::{Parser, Subcommand}; -use sandbox_core::automation::cg_event::{InputSimulator, MouseButton}; use sandbox_core::capture::ScreenCapture; -use sandbox_core::process::ProcessManager; -use sandbox_core::recorder::ActionRecorder; use std::path::PathBuf; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::Mutex; - -mod mcp_server; -mod server; +use std::process::Command; +/// macOS Desktop Automation Sandbox CLI +/// +/// Run CLI commands or macOS apps in isolated sandbox windows, +/// take screenshots, and simulate mouse/keyboard input. #[derive(Parser)] -#[command(name = "sandbox-cli", about = "macOS Desktop Automation Sandbox CLI")] +#[command(name = "sandbox", version, about)] struct Cli { #[command(subcommand)] command: Commands, @@ -20,190 +17,284 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Start the sandbox server (HTTP + MCP) - Serve { - /// HTTP port - #[arg(long, default_value = "5801")] - port: u16, - - /// Sandbox window width - #[arg(long, default_value = "1280")] - width: u32, - - /// Sandbox window height - #[arg(long, default_value = "800")] - height: u32, - }, - - /// Take a screenshot of the sandbox - Screenshot { - /// Output file path - #[arg(short, long)] - output: Option, - /// Window ID to capture (uses sandbox window if not specified) - #[arg(long)] - window_id: Option, - }, - - /// List windows in the sandbox - Windows, - - /// List processes in the sandbox - Processes, - - /// Spawn an app in the sandbox - SpawnApp { - /// Path to the .app bundle - path: String, - }, - - /// Spawn a CLI in the sandbox - SpawnCli { - /// Command to run + /// Start a sandbox with a CLI command in a Terminal window + /// + /// Opens Terminal.app and runs the specified command. + /// Use 'sandbox screenshot' to capture the sandbox window. + Start { + /// Command to run (e.g., "claude", "node", "echo") command: String, - /// Additional arguments + /// Additional arguments passed to the command #[arg(trailing_var_arg = true)] args: Vec, }, - /// Simulate mouse click - Click { - x: f64, - y: f64, - #[arg(short, long, default_value = "left")] - button: String, - }, - - /// Simulate typing text - Type { - /// Text to type - text: String, - }, - - /// Simulate key press - Key { - /// Key name (e.g., Return, Tab, Space) - key: String, + /// Take a screenshot of the sandbox window + Screenshot { + /// Output file path + #[arg(short, long, default_value = "screenshot.png")] + output: PathBuf, - /// Modifier keys (e.g., cmd, shift, alt) - #[arg(short, long)] - modifiers: Vec, + /// Window ID to capture (auto-detected if not specified) + #[arg(long)] + window_id: Option, }, - /// Kill a process by PID - Kill { - /// Process ID - pid: u32, - }, + /// List all visible windows on the system + Windows, - /// Start MCP server via stdio (for Claude Code / OpenCode integration) - McpServe, + /// Shutdown the sandbox (close the Terminal window) + Shutdown, } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) .init(); let cli = Cli::parse(); match cli.command { - Commands::Serve { - port, - width, - height, - } => { - tracing::info!("Starting sandbox server on port {port} ({width}x{height})"); - - let state = Arc::new(Mutex::new(server::AppState { - start_time: Instant::now(), - sandbox_title: "System Test Sandbox".to_string(), - recorder: ActionRecorder::new(), - })); - - let app = server::build_router(state); - let addr = format!("127.0.0.1:{port}"); - let listener = tokio::net::TcpListener::bind(&addr).await?; - - tracing::info!("HTTP API server listening on http://{addr}"); - println!("Sandbox HTTP API server started on http://{addr}"); - println!(" GET http://{addr}/health"); - println!(" GET http://{addr}/screenshot"); - println!(" POST http://{addr}/input/click"); - println!(" POST http://{addr}/cli/spawn"); - - axum::serve(listener, app).await?; + Commands::Start { command, args } => { + cmd_start(&command, &args)?; } Commands::Screenshot { output, window_id } => { - let path = output.unwrap_or_else(|| PathBuf::from("sandbox_screenshot.png")); - tracing::info!("Taking screenshot -> {path:?} (window_id={window_id:?})"); - - let png_data = ScreenCapture::capture_sandbox_by_id(window_id)?; - std::fs::write(&path, &png_data)?; - println!("Screenshot saved to {path:?} ({} bytes)", png_data.len()); + cmd_screenshot(&output, window_id)?; } Commands::Windows => { - tracing::info!("Listing windows..."); - let windows = ScreenCapture::list_windows()?; - for (id, title) in &windows { - println!(" Window ID={id}: {title}"); - } - println!("Total: {} windows", windows.len()); + cmd_windows()?; } - Commands::Processes => { - tracing::info!("Listing processes..."); - let processes = ProcessManager::list_processes()?; - for p in &processes { - println!(" PID={}: {} (running={})", p.pid, p.name, p.is_running); - } - println!("Total: {} processes", processes.len()); - } - Commands::SpawnApp { path } => { - tracing::info!("Spawning app: {path}"); - let info = ProcessManager::spawn_app(&path)?; - println!("App spawned: PID={}, name={}", info.pid, info.name); - } - Commands::SpawnCli { command, args } => { - tracing::info!("Spawning CLI: {command} {args:?}"); - let args_refs: Vec = args.iter().map(|s| s.to_string()).collect(); - let info = ProcessManager::spawn_cli(&command, &args_refs)?; - println!("CLI spawned: PID={}, name={}", info.pid, info.name); - println!("Use 'sandbox-cli kill {}' to terminate", info.pid); + Commands::Shutdown => { + cmd_shutdown()?; } - Commands::Click { x, y, button } => { - let button = match button.to_lowercase().as_str() { - "left" => MouseButton::Left, - "right" => MouseButton::Right, - "middle" => MouseButton::Middle, - other => anyhow::bail!("Unknown button: {other}. Use left, right, or middle."), - }; - tracing::info!("Clicking at ({x}, {y}) button={button:?}"); - InputSimulator::click(x, y, button)?; - println!("Clicked at ({x}, {y})"); - } - Commands::Type { text } => { - tracing::info!("Typing: {text}"); - InputSimulator::type_text(&text)?; - println!("Typed: {text}"); + } + + Ok(()) +} + +/// Launch the Tauri sandbox app with the given CLI command inside it. +/// +/// The sandbox app embeds an xterm.js terminal where the command runs. +fn cmd_start(command: &str, args: &[String]) -> anyhow::Result<()> { + let bundle_path = find_tauri_bundle()?; + let app_binary = bundle_path.join("Contents/MacOS/system-test-sandbox"); + + // Build Tauri args: --mode=cli --cmd= [-- ] + let mut tauri_args = vec!["--mode=cli".to_string(), format!("--cmd={}", command)]; + if !args.is_empty() { + tauri_args.push("--".to_string()); + tauri_args.extend(args.iter().cloned()); + } + + // Run the binary directly (not via open -a) so arguments are passed correctly + Command::new(&app_binary) + .args(&tauri_args) + .spawn() + .context("Failed to launch Tauri sandbox app")?; + + let full_cmd = if args.is_empty() { + command.to_string() + } else { + format!("{} {}", command, args.join(" ")) + }; + println!("Sandbox started: {}", full_cmd); + println!("Use 'sandbox screenshot' to capture the sandbox window"); + Ok(()) +} + +/// Find the Tauri app bundle path. +/// +/// Search order: +/// 1. /System Test Sandbox.app (release layout: side-by-side) +/// 2. /bundle/macos/System Test Sandbox.app (cargo tauri build layout) +/// 3. /target/release/bundle/macos/... (dev build layout) +fn find_tauri_bundle() -> anyhow::Result { + let app_name = "System Test Sandbox.app"; + let exe_path = std::env::current_exe().context("Failed to get current exe path")?; + let exe_dir = exe_path.parent().context("No parent dir for exe")?; + + // Try 1: side-by-side with CLI (release layout) + let path1 = exe_dir.join(app_name); + if path1.exists() { + return Ok(path1); + } + + // Try 2: /bundle/macos/ (cargo tauri build output) + let path2 = exe_dir.join("bundle/macos").join(app_name); + if path2.exists() { + return Ok(path2); + } + + // Try 3: project root layout (exe in /release/, bundle in /target/release/bundle/macos/) + if let Some(project_root) = exe_dir.parent() { + let path3 = project_root + .join("target/release/bundle/macos") + .join(app_name); + if path3.exists() { + return Ok(path3); } - Commands::Key { key, modifiers } => { - tracing::info!("Pressing key: {key} modifiers={modifiers:?}"); - let mod_refs: Vec<&str> = modifiers.iter().map(|s| s.as_str()).collect(); - InputSimulator::press_key(&key, &mod_refs)?; - println!("Pressed key: {key} {modifiers:?}"); + } + + anyhow::bail!( + "Tauri sandbox app not found.\n\ + Searched:\n {}\n {}\n {}\n\ + Build it first with: cargo tauri build", + path1.display(), + path2.display(), + exe_dir + .join("../target/release/bundle/macos") + .join(app_name) + .display() + ) +} + +/// Take a screenshot of the sandbox window. +/// +/// Tries to auto-discover the Terminal window by title, +/// or uses --window-id if explicitly provided. +fn cmd_screenshot(output: &PathBuf, window_id: Option) -> anyhow::Result<()> { + let id = if let Some(id) = window_id { + id + } else { + // Auto-discover: look for a Terminal window + discover_sandbox_window()? + }; + + let png = ScreenCapture::capture_window(id).context(format!( + "Failed to capture window {id}. Is Screen Recording permission granted?" + ))?; + + std::fs::write(output, &png).context(format!("Failed to write screenshot to {:?}", output))?; + + println!("Screenshot saved to {:?} ({} bytes)", output, png.len()); + Ok(()) +} + +/// List all visible windows via ScreenCaptureKit. +fn cmd_windows() -> anyhow::Result<()> { + let windows = ScreenCapture::list_windows() + .context("Failed to list windows. Is Screen Recording permission granted?")?; + + if windows.is_empty() { + println!("No windows found."); + return Ok(()); + } + + println!("{:<12} Title", "Window ID"); + println!("{}", "-".repeat(80)); + for (id, title) in &windows { + // Truncate long titles, respecting UTF-8 character boundaries + let title_display = if title.len() > 64 { + let mut end = 61; + while end > 0 && !title.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &title[..end]) + } else { + title.clone() + }; + println!("{:<12} {}", id, title_display); + } + println!("Total: {} windows", windows.len()); + Ok(()) +} + +/// Close the sandbox window (Tauri app or Terminal.app). +fn cmd_shutdown() -> anyhow::Result<()> { + // Try closing the Tauri sandbox app window first + let windows = ScreenCapture::list_windows() + .context("Failed to list windows. Is Screen Recording permission granted?")?; + + let tauri_window = windows + .iter() + .find(|(_, title)| title.starts_with("System Test Sandbox")); + + if let Some((id, title)) = tauri_window { + // Close the Tauri app window — this also terminates the process + println!("Closing sandbox window: {} (ID: {})", title, id); + // Use osascript to close the window via its process + let script = r#"tell application "System Events" + set procList to every process whose name is "system-test-sandbox" + repeat with proc in procList + set winList to every window of proc + repeat with win in winList + close win + end repeat + end repeat +end tell"# + .to_string(); + let _ = Command::new("osascript").arg("-e").arg(&script).output(); + } else { + // Fallback: close Terminal.app first window + let script = r#"tell application "Terminal" + close first window +end tell"#; + let _ = Command::new("osascript").arg("-e").arg(script).output(); + } + + println!("Sandbox shutdown complete."); + Ok(()) +} + +/// Auto-discover the sandbox window. +/// +/// Priority order: +/// 1. Tauri sandbox app window (title = "System Test Sandbox") +/// 2. Terminal.app window (title pattern: "user — command — W×H") +/// 3. Any window containing the command name (e.g., "claude") +fn discover_sandbox_window() -> anyhow::Result { + let windows = ScreenCapture::list_windows() + .context("Failed to list windows. Is Screen Recording permission granted?")?; + + // Priority 1: Tauri sandbox app window + // Titles may be "System Test Sandbox" or "System Test Sandbox [claude]" + for (id, title) in &windows { + if title.starts_with("System Test Sandbox") { + return Ok(*id); } - Commands::Kill { pid } => { - tracing::info!("Killing process: {pid}"); - ProcessManager::kill_process(pid)?; - println!("Process {pid} terminated"); + } + + // Priority 2: Terminal.app windows have titles like "user — command — 120×30" + for (id, title) in &windows { + if is_terminal_title(title) { + return Ok(*id); } - Commands::McpServe => { - tracing::info!("Starting MCP server on stdio"); - mcp_server::run_stdio_server().await?; + } + + // Priority 3: Fallback — match any window containing "claude" + for (id, title) in &windows { + if title.to_lowercase().contains("claude") { + return Ok(*id); } } - Ok(()) + anyhow::bail!( + "No sandbox window found automatically.\n\ + Use 'sandbox windows' to list all windows, then 'sandbox screenshot --window-id '." + ) +} + +/// Returns true if the title matches the Terminal.app window title pattern. +/// +/// Terminal.app titles look like: "username — command — 120×30" +/// The trailing dimension suffix " — W×H" is the reliable marker. +fn is_terminal_title(title: &str) -> bool { + // Find the last " — " separator (space + em dash + space, 5 bytes in UTF-8) + let sep = " — "; + let last_sep = match title.rfind(sep) { + Some(pos) => pos + sep.len(), + None => return false, + }; + + let suffix = &title[last_sep..]; + // suffix should be "W×H" like "120×30" + let parts: Vec<&str> = suffix.split('×').collect(); + if parts.len() != 2 { + return false; + } + parts[0].trim().parse::().is_ok() && parts[1].trim().parse::().is_ok() } diff --git a/crates/sandbox-cli/src/mcp_server.rs b/crates/sandbox-cli/src/mcp_server.rs deleted file mode 100644 index f350f60..0000000 --- a/crates/sandbox-cli/src/mcp_server.rs +++ /dev/null @@ -1,693 +0,0 @@ -use rmcp::model::{ - CallToolRequestParams, CallToolResult, Content, ErrorCode, Implementation, InitializeResult, - ListToolsResult, ServerCapabilities, Tool, -}; -use rmcp::service::{RequestContext, RoleServer}; -use rmcp::ServerHandler; -use sandbox_core::automation::ax_ui::UiInspector; -use sandbox_core::automation::cg_event::{InputSimulator, MouseButton}; -use sandbox_core::capture::ScreenCapture; -use sandbox_core::diff::{diff_images, DiffOptions}; -use sandbox_core::player::ActionPlayer; -use sandbox_core::process::ProcessManager; -use sandbox_core::recorder::{Action, ActionRecorder}; -use sandbox_core::scenario::ScenarioRunner; -use serde::Deserialize; - -#[derive(Clone)] -pub struct SandboxMcpServer { - recorder: std::sync::Arc, -} - -impl SandboxMcpServer { - pub fn new() -> Self { - Self { - recorder: std::sync::Arc::new(ActionRecorder::new()), - } - } -} - -// ── Tool implementations (called by MCP handler) ──────── - -impl SandboxMcpServer { - async fn do_screenshot(&self) -> Result { - let png_data = ScreenCapture::capture_sandbox() - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - - let b64 = base64_encode(&png_data); - Ok(CallToolResult::success(vec![Content::text(format!( - "Screenshot captured ({} bytes base64)", - b64.len() - ))])) - } - - async fn do_click(&self, params: ClickParams) -> Result { - let button = match params.button.to_lowercase().as_str() { - "left" => MouseButton::Left, - "right" => MouseButton::Right, - "middle" => MouseButton::Middle, - _ => MouseButton::Left, - }; - InputSimulator::click(params.x, params.y, button) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "ok".to_string(), - )])) - } - - async fn do_type_text( - &self, - params: TypeTextParams, - ) -> Result { - InputSimulator::type_text(¶ms.text) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "ok".to_string(), - )])) - } - - async fn do_press_key( - &self, - params: PressKeyParams, - ) -> Result { - let mod_refs: Vec<&str> = params.modifiers.iter().map(|s| s.as_str()).collect(); - InputSimulator::press_key(¶ms.key, &mod_refs) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "ok".to_string(), - )])) - } - - async fn do_spawn_cli( - &self, - params: SpawnCliParams, - ) -> Result { - let info = ProcessManager::spawn_cli(¶ms.command, ¶ms.args) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text(format!( - "Spawned PID={}", - info.pid - ))])) - } - - async fn do_kill_process( - &self, - params: KillProcessParams, - ) -> Result { - ProcessManager::kill_process(params.pid) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "ok".to_string(), - )])) - } - - async fn do_list_processes(&self) -> Result { - let processes = ProcessManager::list_processes() - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let out: Vec = processes - .iter() - .map(|p| format!("PID={}: {}", p.pid, p.name)) - .collect(); - Ok(CallToolResult::success(vec![Content::text(out.join("\n"))])) - } - - async fn do_list_windows(&self) -> Result { - let windows = ScreenCapture::list_windows() - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let out: Vec = windows - .iter() - .map(|(id, title)| format!("ID={id}: {title}")) - .collect(); - Ok(CallToolResult::success(vec![Content::text(out.join("\n"))])) - } - - async fn do_double_click( - &self, - params: ClickParams, - ) -> Result { - InputSimulator::double_click(params.x, params.y) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "ok".to_string(), - )])) - } - - async fn do_inspect_ui( - &self, - params: InspectUiParams, - ) -> Result { - let element = UiInspector::inspect_window(params.window_id) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let json = serde_json::to_string_pretty(&element) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text(json)])) - } - - async fn do_find_element( - &self, - params: FindElementParams, - ) -> Result { - let elements = UiInspector::find_elements( - params.window_id, - params.role.as_deref(), - params.title.as_deref(), - ) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let json = serde_json::to_string_pretty(&elements) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text(json)])) - } - - async fn do_get_element_value( - &self, - params: GetElementValueParams, - ) -> Result { - let value = UiInspector::get_element_value(¶ms.element_id) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text(format!( - "{value:?}" - ))])) - } - - async fn do_record_action( - &self, - params: RecordActionParams, - ) -> Result { - let action = params.to_action(); - self.recorder - .record(action) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "recorded".to_string(), - )])) - } - - async fn do_record_start(&self) -> Result { - self.recorder - .start(None) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text( - "recording_started".to_string(), - )])) - } - - async fn do_record_stop(&self) -> Result { - let actions = self - .recorder - .stop() - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let json = serde_json::to_string_pretty(&actions) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text(json)])) - } - - async fn do_play_actions( - &self, - params: PlayActionsParams, - ) -> Result { - let mut player = ActionPlayer::new(params.speed); - let results = player.play(¶ms.actions).await; - Ok(CallToolResult::success(vec![Content::text(format!( - "{results:?}" - ))])) - } - - async fn do_run_scenario( - &self, - params: RunScenarioParams, - ) -> Result { - let scenario = ScenarioRunner::load_from_str(¶ms.yaml) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let report = ScenarioRunner::run(&scenario, params.speed).await; - Ok(CallToolResult::success(vec![Content::text( - report.to_markdown(), - )])) - } - - async fn do_diff_screenshot( - &self, - params: DiffScreenshotParams, - ) -> Result { - use base64::Engine; - let expected = base64::engine::general_purpose::STANDARD - .decode(¶ms.expected) - .map_err(|e| rmcp::ErrorData::invalid_params(format!("Invalid base64: {e}"), None))?; - let actual = base64::engine::general_purpose::STANDARD - .decode(¶ms.actual) - .map_err(|e| rmcp::ErrorData::invalid_params(format!("Invalid base64: {e}"), None))?; - - let mut options = DiffOptions::default(); - if let Some(t) = params.threshold { - options.threshold = t; - } - if let Some(m) = params.max_diff_percentage { - options.max_diff_percentage = m; - } - - let result = diff_images(&expected, &actual, &options) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - let json = serde_json::to_string_pretty(&result) - .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; - Ok(CallToolResult::success(vec![Content::text(json)])) - } - - #[allow(clippy::type_complexity)] - fn dispatch_tool( - &self, - name: &str, - args: Option, - ) -> Result< - std::pin::Pin< - Box< - dyn std::future::Future> - + Send - + '_, - >, - >, - rmcp::ErrorData, - > { - let args = args.unwrap_or_default(); - match name { - "screenshot" => Ok(Box::pin(self.do_screenshot())), - "click" => { - let p: ClickParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_click(p))) - } - "type_text" => { - let p: TypeTextParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_type_text(p))) - } - "press_key" => { - let p: PressKeyParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_press_key(p))) - } - "spawn_cli" => { - let p: SpawnCliParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_spawn_cli(p))) - } - "kill_process" => { - let p: KillProcessParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_kill_process(p))) - } - "list_processes" => Ok(Box::pin(self.do_list_processes())), - "list_windows" => Ok(Box::pin(self.do_list_windows())), - "double_click" => { - let p: ClickParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_double_click(p))) - } - "inspect_ui" => { - let p: InspectUiParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_inspect_ui(p))) - } - "find_element" => { - let p: FindElementParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_find_element(p))) - } - "get_element_value" => { - let p: GetElementValueParams = - serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_get_element_value(p))) - } - "record_action" => { - let p: RecordActionParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_record_action(p))) - } - "record_start" => Ok(Box::pin(self.do_record_start())), - "record_stop" => Ok(Box::pin(self.do_record_stop())), - "play_actions" => { - let p: PlayActionsParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_play_actions(p))) - } - "run_scenario" => { - let p: RunScenarioParams = serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_run_scenario(p))) - } - "diff_screenshot" => { - let p: DiffScreenshotParams = - serde_json::from_value(serde_json::Value::Object(args)) - .map_err(|e| rmcp::ErrorData::invalid_params(e.to_string(), None))?; - Ok(Box::pin(self.do_diff_screenshot(p))) - } - _ => Err(rmcp::ErrorData::new( - ErrorCode::METHOD_NOT_FOUND, - format!("Tool not found: {name}"), - None, - )), - } - } -} - -// ── Parameter types ────────────────────────────────────── - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct ClickParams { - #[schemars(description = "X coordinate")] - pub x: f64, - #[schemars(description = "Y coordinate")] - pub y: f64, - #[schemars(description = "Mouse button")] - #[serde(default = "default_button")] - pub button: String, -} - -fn default_button() -> String { - "left".to_string() -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct TypeTextParams { - #[schemars(description = "Text to type")] - pub text: String, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct PressKeyParams { - #[schemars(description = "Key name")] - pub key: String, - #[schemars(description = "Modifier keys")] - #[serde(default)] - pub modifiers: Vec, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct SpawnCliParams { - #[schemars(description = "Command to execute")] - pub command: String, - #[schemars(description = "Command arguments")] - #[serde(default)] - pub args: Vec, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct KillProcessParams { - #[schemars(description = "Process ID to kill")] - pub pid: u32, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct InspectUiParams { - #[schemars(description = "Window ID to inspect")] - pub window_id: u32, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct FindElementParams { - #[schemars(description = "Window ID to search in")] - pub window_id: u32, - #[schemars(description = "AXRole filter (e.g., AXButton, AXTextField)")] - #[serde(default)] - pub role: Option, - #[schemars(description = "Title substring filter")] - #[serde(default)] - pub title: Option, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct GetElementValueParams { - #[schemars(description = "Element ID path (format: pid:window_idx:child_idx...)")] - pub element_id: String, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct RecordActionParams { - #[schemars( - description = "Action type: click, type_text, press_key, scroll, drag, screenshot, wait" - )] - pub action_type: String, - pub x: Option, - pub y: Option, - pub button: Option, - pub text: Option, - pub key: Option, - pub modifiers: Option>, - pub direction: Option, - pub amount: Option, - pub from_x: Option, - pub from_y: Option, - pub to_x: Option, - pub to_y: Option, - pub label: Option, - pub duration_ms: Option, -} - -impl RecordActionParams { - fn to_action(&self) -> Action { - match self.action_type.as_str() { - "click" => Action::Click { - x: self.x.unwrap_or(0.0), - y: self.y.unwrap_or(0.0), - button: self.button.clone().unwrap_or_else(|| "left".to_string()), - timestamp_ms: None, - }, - "double_click" => Action::DoubleClick { - x: self.x.unwrap_or(0.0), - y: self.y.unwrap_or(0.0), - timestamp_ms: None, - }, - "type_text" => Action::TypeText { - text: self.text.clone().unwrap_or_default(), - timestamp_ms: None, - }, - "press_key" => Action::PressKey { - key: self.key.clone().unwrap_or_default(), - modifiers: self.modifiers.clone().unwrap_or_default(), - timestamp_ms: None, - }, - "scroll" => Action::Scroll { - x: self.x.unwrap_or(0.0), - y: self.y.unwrap_or(0.0), - direction: self.direction.clone().unwrap_or_else(|| "down".to_string()), - amount: self.amount.unwrap_or(1), - timestamp_ms: None, - }, - "drag" => Action::Drag { - from_x: self.from_x.unwrap_or(0.0), - from_y: self.from_y.unwrap_or(0.0), - to_x: self.to_x.unwrap_or(0.0), - to_y: self.to_y.unwrap_or(0.0), - timestamp_ms: None, - }, - "screenshot" => Action::Screenshot { - label: self.label.clone(), - timestamp_ms: None, - }, - "wait" => Action::Wait { - duration_ms: self.duration_ms.unwrap_or(1000), - timestamp_ms: None, - }, - _ => Action::Wait { - duration_ms: 0, - timestamp_ms: None, - }, - } - } -} - -#[derive(Deserialize)] -pub struct PlayActionsParams { - pub actions: Vec, - #[serde(default = "default_speed")] - pub speed: f64, -} - -fn default_speed() -> f64 { - 1.0 -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct RunScenarioParams { - #[schemars(description = "YAML scenario content")] - pub yaml: String, - #[schemars(description = "Speed multiplier (1.0 = original speed)")] - #[serde(default = "default_speed")] - pub speed: f64, -} - -#[derive(Deserialize, schemars::JsonSchema)] -pub struct DiffScreenshotParams { - #[schemars(description = "Base64-encoded expected PNG image")] - pub expected: String, - #[schemars(description = "Base64-encoded actual PNG image")] - pub actual: String, - #[schemars(description = "Pixel difference threshold (0-255)")] - #[serde(default)] - pub threshold: Option, - #[schemars(description = "Maximum diff percentage for identical (0.0-100.0)")] - #[serde(default)] - pub max_diff_percentage: Option, -} - -// ── Tool definitions (for list_tools) ──────────────────── - -fn tool_definitions() -> Vec { - let empty_schema = serde_json::Map::new(); - vec![ - Tool::new( - "screenshot", - "Take a screenshot of the sandbox window. Returns base64-encoded PNG image data.", - empty_schema.clone(), - ), - Tool::new( - "click", - "Simulate a mouse click at coordinates (x, y) with button (left/right/middle).", - empty_schema.clone(), - ), - Tool::new( - "type_text", - "Type text into the currently focused element.", - empty_schema.clone(), - ), - Tool::new( - "press_key", - "Press a key (Return, Tab, Escape, etc.) with optional modifiers.", - empty_schema.clone(), - ), - Tool::new( - "spawn_cli", - "Spawn a CLI process in the sandbox.", - empty_schema.clone(), - ), - Tool::new( - "kill_process", - "Kill a running process by its PID.", - empty_schema.clone(), - ), - Tool::new( - "list_processes", - "List all processes running in the sandbox.", - empty_schema.clone(), - ), - Tool::new( - "list_windows", - "List all available windows with their IDs and titles.", - empty_schema.clone(), - ), - Tool::new( - "double_click", - "Simulate a double click at the specified coordinates.", - empty_schema.clone(), - ), - Tool::new( - "inspect_ui", - "Read the AXUIElement tree for a window. Returns the full UI element hierarchy.", - empty_schema.clone(), - ), - Tool::new( - "find_element", - "Find UI elements by role and/or title within a window.", - empty_schema.clone(), - ), - Tool::new( - "get_element_value", - "Get the value of a specific UI element by its path ID.", - empty_schema.clone(), - ), - Tool::new( - "record_start", - "Start recording user actions.", - empty_schema.clone(), - ), - Tool::new( - "record_stop", - "Stop recording and return recorded actions.", - empty_schema.clone(), - ), - Tool::new( - "record_action", - "Record a single action manually.", - empty_schema.clone(), - ), - Tool::new( - "play_actions", - "Play back a list of recorded actions.", - empty_schema.clone(), - ), - Tool::new( - "run_scenario", - "Run a YAML test scenario and return a report.", - empty_schema.clone(), - ), - Tool::new( - "diff_screenshot", - "Compare two base64-encoded screenshots and return diff results.", - empty_schema.clone(), - ), - ] -} - -// ── ServerHandler implementation ───────────────────────── - -impl ServerHandler for SandboxMcpServer { - fn get_info(&self) -> InitializeResult { - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new( - "system-test-sandbox", - env!("CARGO_PKG_VERSION"), - )) - .with_instructions( - "macOS Desktop Automation Sandbox MCP Server.\n\ - Tools: screenshot, click, type_text, press_key, spawn_cli, \ - kill_process, list_processes, list_windows, double_click.", - ) - } - - async fn call_tool( - &self, - request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - self.dispatch_tool(&request.name, request.arguments)?.await - } - - async fn list_tools( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(ListToolsResult::with_all_items(tool_definitions())) - } - - fn get_tool(&self, name: &str) -> Option { - tool_definitions().into_iter().find(|t| t.name == name) - } -} - -// ── MCP stdio server runner ────────────────────────────── - -pub async fn run_stdio_server() -> Result<(), anyhow::Error> { - use rmcp::ServiceExt; - - tracing::info!("Starting MCP server on stdio transport"); - - let (stdin, stdout) = rmcp::transport::io::stdio(); - - let service = SandboxMcpServer::new() - .serve((stdin, stdout)) - .await - .inspect_err(|e| tracing::error!("MCP server init error: {e:?}")) - .map_err(|e| anyhow::anyhow!("MCP serve error: {e}"))?; - - tracing::info!("MCP server running, waiting for requests..."); - service - .waiting() - .await - .map_err(|e| anyhow::anyhow!("MCP runtime error: {e}"))?; - Ok(()) -} - -fn base64_encode(data: &[u8]) -> String { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(data) -} diff --git a/crates/sandbox-cli/src/server.rs b/crates/sandbox-cli/src/server.rs deleted file mode 100644 index d272e4f..0000000 --- a/crates/sandbox-cli/src/server.rs +++ /dev/null @@ -1,437 +0,0 @@ -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - routing::{get, post}, - Json, Router, -}; -use sandbox_core::automation::ax_ui::{UiElement, UiInspector}; -use sandbox_core::automation::cg_event::{InputSimulator, MouseButton}; -use sandbox_core::capture::ScreenCapture; -use sandbox_core::diff::{diff_images, DiffOptions, DiffResult}; -use sandbox_core::player::ActionPlayer; -use sandbox_core::process::ProcessManager; -use sandbox_core::recorder::{Action, ActionRecorder}; -use sandbox_core::scenario::ScenarioRunner; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::Mutex; - -/// Shared application state for the HTTP server -pub struct AppState { - pub start_time: Instant, - #[allow(dead_code)] - pub sandbox_title: String, - pub recorder: ActionRecorder, -} - -/// API response wrapper -#[derive(Serialize)] -#[allow(dead_code)] -struct ApiResponse { - success: bool, - data: T, -} - -/// Health check response -#[derive(Serialize)] -struct HealthResponse { - status: String, - version: String, - uptime_secs: u64, -} - -/// Click request body -#[derive(Deserialize)] -struct ClickRequest { - x: f64, - y: f64, - #[serde(default = "default_button")] - button: String, -} - -fn default_button() -> String { - "left".to_string() -} - -/// Type text request body -#[derive(Deserialize)] -struct TypeRequest { - text: String, -} - -/// Key press request body -#[derive(Deserialize)] -struct KeyRequest { - key: String, - #[serde(default)] - modifiers: Vec, -} - -/// Scroll request body -#[derive(Deserialize)] -struct ScrollRequest { - x: f64, - y: f64, - direction: String, - amount: i32, -} - -/// Drag request body -#[derive(Deserialize)] -struct DragRequest { - from_x: f64, - from_y: f64, - to_x: f64, - to_y: f64, -} - -/// Spawn app request body -#[derive(Deserialize)] -struct SpawnAppRequest { - path: String, -} - -/// Spawn CLI request body -#[derive(Deserialize)] -struct SpawnCliRequest { - command: String, - #[serde(default)] - args: Vec, -} - -/// Kill process request body -#[derive(Deserialize)] -struct KillRequest { - pid: u32, -} - -/// Region screenshot query params -#[derive(Deserialize)] -struct RegionQuery { - x: i32, - y: i32, - width: u32, - height: u32, -} - -/// Screenshot query params -#[derive(Deserialize)] -struct ScreenshotQuery { - #[serde(default)] - window_id: Option, -} - -/// Build the HTTP API router -pub fn build_router(state: Arc>) -> Router { - Router::new() - // Health - .route("/health", get(health_handler)) - // Windows - .route("/windows", get(list_windows_handler)) - // Processes - .route("/processes", get(list_processes_handler)) - // App spawn - .route("/app/spawn", post(spawn_app_handler)) - // CLI spawn - .route("/cli/spawn", post(spawn_cli_handler)) - // Process kill - .route("/process/kill", post(kill_process_handler)) - // Input - .route("/input/click", post(click_handler)) - .route("/input/type", post(type_handler)) - .route("/input/key", post(key_handler)) - .route("/input/scroll", post(scroll_handler)) - .route("/input/drag", post(drag_handler)) - // Screenshots - .route("/screenshot", get(screenshot_handler)) - .route("/screenshot/region", get(screenshot_region_handler)) - // UI inspect (Phase 3) - .route("/ui/inspect/{window_id}", get(ui_inspect_handler)) - .route("/ui/find", post(ui_find_handler)) - .route("/ui/value", get(ui_value_handler)) - // Recording & Playback (Phase 4) - .route("/record/start", post(record_start_handler)) - .route("/record/stop", post(record_stop_handler)) - .route("/record/actions", get(record_actions_handler)) - .route("/playback/actions", post(playback_actions_handler)) - .route("/scenario/run", post(scenario_run_handler)) - .route("/diff", post(diff_handler)) - .with_state(state) -} - -// ── Handlers ────────────────────────────────────────────── - -async fn health_handler(State(state): State>>) -> Json { - let s = state.lock().await; - Json(HealthResponse { - status: "ok".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - uptime_secs: s.start_time.elapsed().as_secs(), - }) -} - -async fn list_windows_handler() -> Result>, AppError> { - let windows = ScreenCapture::list_windows()?; - Ok(Json(windows)) -} - -async fn list_processes_handler() -> Result>, AppError> -{ - let processes = ProcessManager::list_processes()?; - Ok(Json(processes)) -} - -async fn spawn_app_handler( - Json(req): Json, -) -> Result, AppError> { - let info = ProcessManager::spawn_app(&req.path)?; - Ok(Json(info)) -} - -async fn spawn_cli_handler( - Json(req): Json, -) -> Result, AppError> { - let info = ProcessManager::spawn_cli(&req.command, &req.args)?; - Ok(Json(info)) -} - -async fn kill_process_handler( - Json(req): Json, -) -> Result, AppError> { - ProcessManager::kill_process(req.pid)?; - Ok(Json(serde_json::json!({"killed": req.pid}))) -} - -async fn click_handler(Json(req): Json) -> Result, AppError> { - let button = match req.button.to_lowercase().as_str() { - "left" => MouseButton::Left, - "right" => MouseButton::Right, - "middle" => MouseButton::Middle, - other => return Err(AppError::BadRequest(format!("Unknown button: {other}"))), - }; - InputSimulator::click(req.x, req.y, button)?; - Ok(Json( - serde_json::json!({"clicked": {"x": req.x, "y": req.y, "button": req.button}}), - )) -} - -async fn type_handler(Json(req): Json) -> Result, AppError> { - InputSimulator::type_text(&req.text)?; - Ok(Json(serde_json::json!({"typed": req.text}))) -} - -async fn key_handler(Json(req): Json) -> Result, AppError> { - let mod_refs: Vec<&str> = req.modifiers.iter().map(|s| s.as_str()).collect(); - InputSimulator::press_key(&req.key, &mod_refs)?; - Ok(Json( - serde_json::json!({"pressed": {"key": req.key, "modifiers": req.modifiers}}), - )) -} - -async fn scroll_handler( - Json(req): Json, -) -> Result, AppError> { - InputSimulator::scroll(req.x, req.y, &req.direction, req.amount)?; - Ok(Json(serde_json::json!({"scrolled": true}))) -} - -async fn drag_handler(Json(req): Json) -> Result, AppError> { - InputSimulator::drag(req.from_x, req.from_y, req.to_x, req.to_y)?; - Ok(Json(serde_json::json!({"dragged": true}))) -} - -async fn screenshot_handler( - Query(q): Query, -) -> Result { - let png_data = ScreenCapture::capture_sandbox_by_id(q.window_id)?; - Ok((StatusCode::OK, [("content-type", "image/png")], png_data)) -} - -async fn screenshot_region_handler( - Query(q): Query, -) -> Result { - let png_data = ScreenCapture::capture_region(q.x, q.y, q.width, q.height)?; - Ok((StatusCode::OK, [("content-type", "image/png")], png_data)) -} - -async fn ui_inspect_handler(Path(window_id): Path) -> Result, AppError> { - let result = tokio::task::spawn_blocking(move || UiInspector::inspect_window(window_id)) - .await - .map_err(|e| AppError::Accessibility(format!("UI inspect task failed: {e}")))?; - Ok(Json(result?)) -} - -#[derive(Deserialize)] -struct UiFindRequest { - window_id: u32, - #[serde(default)] - role: Option, - #[serde(default)] - title: Option, -} - -async fn ui_find_handler(Json(req): Json) -> Result>, AppError> { - let window_id = req.window_id; - let role = req.role; - let title = req.title; - let result = tokio::task::spawn_blocking(move || { - UiInspector::find_elements(window_id, role.as_deref(), title.as_deref()) - }) - .await - .map_err(|e| AppError::Accessibility(format!("UI find task failed: {e}")))?; - Ok(Json(result?)) -} - -#[derive(Deserialize)] -struct UiValueQuery { - element_id: String, -} - -async fn ui_value_handler( - Query(q): Query, -) -> Result, AppError> { - let value = UiInspector::get_element_value(&q.element_id)?; - Ok(Json(serde_json::json!({ "value": value }))) -} - -// ── Recording & Playback (Phase 4) ────────────────────── - -#[derive(Deserialize)] -struct RecordStartRequest { - #[serde(default)] - output_path: Option, -} - -async fn record_start_handler( - State(state): State>>, - Json(req): Json, -) -> Result, AppError> { - let app = state.lock().await; - let path = req.output_path.map(std::path::PathBuf::from); - app.recorder.start(path)?; - Ok(Json(serde_json::json!({"recording": true}))) -} - -async fn record_stop_handler( - State(state): State>>, -) -> Result, AppError> { - let app = state.lock().await; - let actions = app.recorder.stop()?; - Ok(Json(serde_json::json!({ - "recording": false, - "actions_count": actions.len(), - "actions": actions, - }))) -} - -async fn record_actions_handler( - State(state): State>>, -) -> Result>, AppError> { - let app = state.lock().await; - Ok(Json(app.recorder.actions())) -} - -#[derive(Deserialize)] -struct PlaybackRequest { - actions: Vec, - #[serde(default = "default_speed")] - speed: f64, -} - -fn default_speed() -> f64 { - 1.0 -} - -async fn playback_actions_handler( - Json(req): Json, -) -> Result, AppError> { - let mut player = ActionPlayer::new(req.speed); - let results = player.play(&req.actions).await; - Ok(Json(serde_json::json!({ - "results_count": results.len(), - "results": format!("{results:?}"), - }))) -} - -#[derive(Deserialize)] -struct ScenarioRequest { - /// YAML scenario content as a string - yaml: String, - #[serde(default = "default_speed")] - speed: f64, -} - -async fn scenario_run_handler( - Json(req): Json, -) -> Result, AppError> { - let scenario = ScenarioRunner::load_from_str(&req.yaml)?; - let report = ScenarioRunner::run(&scenario, req.speed).await; - Ok(Json(serde_json::json!({ - "name": report.name, - "status": format!("{:?}", report.status), - "passed": report.passed_steps, - "failed": report.failed_steps, - "total": report.total_steps, - "duration_ms": report.duration_ms, - "report_markdown": report.to_markdown(), - "report_json": serde_json::to_value(&report).unwrap_or_default(), - }))) -} - -#[derive(Deserialize)] -struct DiffRequest { - expected: String, // base64 encoded PNG - actual: String, // base64 encoded PNG - #[serde(default)] - threshold: Option, - #[serde(default)] - max_diff_percentage: Option, -} - -async fn diff_handler(Json(req): Json) -> Result, AppError> { - use base64::Engine; - let expected = base64::engine::general_purpose::STANDARD - .decode(&req.expected) - .map_err(|e| AppError::BadRequest(format!("Invalid base64 (expected): {e}")))?; - let actual = base64::engine::general_purpose::STANDARD - .decode(&req.actual) - .map_err(|e| AppError::BadRequest(format!("Invalid base64 (actual): {e}")))?; - - let mut options = DiffOptions::default(); - if let Some(t) = req.threshold { - options.threshold = t; - } - if let Some(m) = req.max_diff_percentage { - options.max_diff_percentage = m; - } - - let result = diff_images(&expected, &actual, &options)?; - Ok(Json(result)) -} - -// ── Error handling ─────────────────────────────────────── - -enum AppError { - Core(sandbox_core::AppError), - BadRequest(String), - Accessibility(String), -} - -impl From for AppError { - fn from(e: sandbox_core::AppError) -> Self { - AppError::Core(e) - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> axum::response::Response { - let (status, message) = match self { - AppError::Core(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), - AppError::Accessibility(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), - }; - (status, Json(serde_json::json!({"error": message}))).into_response() - } -} diff --git a/crates/sandbox-core/Cargo.toml b/crates/sandbox-core/Cargo.toml index 6012299..c4f2407 100644 --- a/crates/sandbox-core/Cargo.toml +++ b/crates/sandbox-core/Cargo.toml @@ -21,10 +21,14 @@ base64.workspace = true image.workspace = true nix.workspace = true portable-pty.workspace = true -serde_yaml.workspace = true +uuid.workspace = true +axum.workspace = true +tower.workspace = true +tower-http.workspace = true [target.'cfg(target_os = "macos")'.dependencies] -core-graphics = { version = "0.25", features = ["highsierra"] } +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/sandbox-core/src/automation/ax_ui.rs b/crates/sandbox-core/src/automation/ax_ui.rs index 615ebd9..d97a49e 100644 --- a/crates/sandbox-core/src/automation/ax_ui.rs +++ b/crates/sandbox-core/src/automation/ax_ui.rs @@ -40,6 +40,9 @@ mod macos_impl { use core_foundation::number::CFNumberRef; type AXUIElementRef = *const c_void; + type AXError = i32; + + const K_AX_ERROR_SUCCESS: AXError = 0; #[link(name = "ApplicationServices", kind = "framework")] extern "C" { @@ -48,8 +51,8 @@ mod macos_impl { element: AXUIElementRef, attribute: CFStringRef, ) -> CFTypeRef; - #[allow(dead_code)] - fn AXUIElementGetPid(element: AXUIElementRef, pid: *mut i32) -> i32; + fn AXUIElementGetPid(element: AXUIElementRef, pid: *mut i32) -> AXError; + fn AXIsProcessTrusted() -> bool; } #[link(name = "CoreGraphics", kind = "framework")] @@ -70,6 +73,28 @@ mod macos_impl { CFString::new(s) } + /// Validate that an AXUIElementRef is usable by calling AXUIElementGetPid. + /// Returns true if the element appears valid. + unsafe fn ax_element_is_valid(element: AXUIElementRef) -> bool { + if element.is_null() { + return false; + } + let mut pid: i32 = 0; + let result = AXUIElementGetPid(element, &mut pid as *mut i32); + result == K_AX_ERROR_SUCCESS + } + + /// Check Accessibility permission and return an error if not granted. + fn check_accessibility_permission() -> Result<()> { + if unsafe { AXIsProcessTrusted() } { + Ok(()) + } else { + Err(AppError::Accessibility( + "Accessibility permission not granted. Grant it in System Settings → Privacy & Security → Accessibility.".to_string(), + )) + } + } + /// CFTypeRef → String conversion unsafe fn cf_to_string(raw: CFTypeRef) -> Option { if raw.is_null() { @@ -92,12 +117,18 @@ mod macos_impl { } unsafe fn ax_get_string(element: AXUIElementRef, attr_name: &str) -> Option { + if !ax_element_is_valid(element) { + return None; + } let attr = ax_attr(attr_name); let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); cf_to_string(raw) } unsafe fn ax_get_children(element: AXUIElementRef) -> Vec { + if !ax_element_is_valid(element) { + return vec![]; + } let attr = ax_attr("AXChildren"); let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); if raw.is_null() { @@ -118,6 +149,9 @@ mod macos_impl { } unsafe fn ax_get_attr_array(element: AXUIElementRef, attr_name: &str) -> Vec { + if !ax_element_is_valid(element) { + return vec![]; + } let attr = ax_attr(attr_name); let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); if raw.is_null() { @@ -272,6 +306,8 @@ mod macos_impl { impl UiInspector { pub fn inspect_window(window_id: u32) -> Result { + check_accessibility_permission()?; + let pid = get_pid_for_window(window_id) .ok_or_else(|| AppError::WindowNotFound(format!("Window {window_id} not found")))?; @@ -303,6 +339,8 @@ mod macos_impl { role: Option<&str>, title: Option<&str>, ) -> Result> { + check_accessibility_permission()?; + let pid = get_pid_for_window(window_id) .ok_or_else(|| AppError::WindowNotFound(format!("Window {window_id} not found")))?; @@ -320,6 +358,8 @@ mod macos_impl { } pub fn get_element_value(element_id: &str) -> Result> { + check_accessibility_permission()?; + let parts: Vec<&str> = element_id.split(':').collect(); if parts.len() < 2 { return Err(AppError::Accessibility("Invalid element ID".to_string())); diff --git a/crates/sandbox-core/src/automation/cg_event.rs b/crates/sandbox-core/src/automation/cg_event.rs index ed07c0c..edcaff7 100644 --- a/crates/sandbox-core/src/automation/cg_event.rs +++ b/crates/sandbox-core/src/automation/cg_event.rs @@ -12,10 +12,21 @@ pub enum MouseButton { /// Input simulator using CGEvents (macOS Core Graphics) pub struct InputSimulator; +/// Post a CGEvent to either a specific process or globally. +/// When `target_pid` is `Some`, uses `post_to_pid` for targeted delivery. +/// When `None`, uses `post(HID)` for global delivery (legacy behavior). +#[cfg(target_os = "macos")] +fn post_event(event: &core_graphics::event::CGEvent, target_pid: Option) { + match target_pid { + Some(pid) => event.post_to_pid(pid as libc::pid_t), + None => event.post(core_graphics::event::CGEventTapLocation::HID), + } +} + impl InputSimulator { /// Simulate a mouse click at the given coordinates #[cfg(target_os = "macos")] - pub fn click(x: f64, y: f64, button: MouseButton) -> Result<()> { + pub fn click(x: f64, y: f64, button: MouseButton, target_pid: Option) -> Result<()> { use core_graphics::event::CGEventType; use core_graphics::event_source::CGEventSource; @@ -31,57 +42,59 @@ impl InputSimulator { MouseButton::Middle => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp), }; - mouse_event(&source, down_type, position, button)?; - mouse_event(&source, up_type, position, button)?; + mouse_event(&source, down_type, position, button, target_pid)?; + mouse_event(&source, up_type, position, button, target_pid)?; - tracing::debug!("Click at ({}, {}), button={:?}", x, y, button); + tracing::debug!( + "Click at ({}, {}), button={:?}, target_pid={:?}", + x, + y, + button, + target_pid + ); Ok(()) } #[cfg(not(target_os = "macos"))] - pub fn click(_x: f64, _y: f64, _button: MouseButton) -> Result<()> { + pub fn click(_x: f64, _y: f64, _button: MouseButton, _target_pid: Option) -> Result<()> { Err(AppError::Input("click only supported on macOS".into())) } /// Simulate a double click at the given coordinates - pub fn double_click(x: f64, y: f64) -> Result<()> { - // Set double-click interval via CGEvent - #[cfg(target_os = "macos")] - { - // CGEventSetIntegerValueField for click count - Self::click(x, y, MouseButton::Left)?; - std::thread::sleep(std::time::Duration::from_millis(50)); - Self::click(x, y, MouseButton::Left)?; - } - #[cfg(not(target_os = "macos"))] - { - let _ = (x, y); - return Err(AppError::Input( - "double_click only supported on macOS".into(), - )); - } + #[cfg(target_os = "macos")] + pub fn double_click(x: f64, y: f64, target_pid: Option) -> Result<()> { + Self::click(x, y, MouseButton::Left, target_pid)?; + std::thread::sleep(std::time::Duration::from_millis(50)); + Self::click(x, y, MouseButton::Left, target_pid)?; Ok(()) } + #[cfg(not(target_os = "macos"))] + pub fn double_click(_x: f64, _y: f64, _target_pid: Option) -> Result<()> { + Err(AppError::Input( + "double_click only supported on macOS".into(), + )) + } + /// Simulate typing text character by character #[cfg(target_os = "macos")] - pub fn type_text(text: &str) -> Result<()> { + pub fn type_text(text: &str, target_pid: Option) -> Result<()> { for c in text.chars() { - type_character(c)?; + type_character(c, target_pid)?; } Ok(()) } #[cfg(not(target_os = "macos"))] - pub fn type_text(_text: &str) -> Result<()> { + pub fn type_text(_text: &str, _target_pid: Option) -> Result<()> { Err(AppError::Input("type_text only supported on macOS".into())) } /// Simulate pressing a key with optional modifiers #[cfg(target_os = "macos")] - pub fn press_key(key: &str, modifiers: &[&str]) -> Result<()> { + pub fn press_key(key: &str, modifiers: &[&str], target_pid: Option) -> Result<()> { + use core_graphics::event::CGEvent; use core_graphics::event::CGEventFlags; - use core_graphics::event::{CGEvent, CGEventTapLocation}; use core_graphics::event_source::CGEventSource; let key_code = keycodes::key_name_to_code(key) @@ -105,28 +118,40 @@ impl InputSimulator { if flags > 0 { key_down.set_flags(CGEventFlags::from_bits_truncate(flags)); } - key_down.post(CGEventTapLocation::HID); + post_event(&key_down, target_pid); let key_up = CGEvent::new_keyboard_event(source, key_code, false) .map_err(|e| AppError::Input(format!("Failed to create key-up event: {e:?}")))?; if flags > 0 { key_up.set_flags(CGEventFlags::from_bits_truncate(flags)); } - key_up.post(CGEventTapLocation::HID); - - tracing::debug!("Press key={}, modifiers={:?}", key, modifiers); + post_event(&key_up, target_pid); + + tracing::debug!( + "Press key={}, modifiers={:?}, target_pid={:?}", + key, + modifiers, + target_pid + ); Ok(()) } #[cfg(not(target_os = "macos"))] - pub fn press_key(_key: &str, _modifiers: &[&str]) -> Result<()> { + pub fn press_key(_key: &str, _modifiers: &[&str], _target_pid: Option) -> Result<()> { Err(AppError::Input("press_key only supported on macOS".into())) } /// Simulate scrolling #[cfg(target_os = "macos")] - pub fn scroll(x: f64, y: f64, direction: &str, amount: i32) -> Result<()> { - use core_graphics::event::{CGEvent, CGEventTapLocation, ScrollEventUnit}; + pub fn scroll( + x: f64, + y: f64, + direction: &str, + amount: i32, + target_pid: Option, + ) -> Result<()> { + use core_graphics::event::CGEvent; + use core_graphics::event::ScrollEventUnit; use core_graphics::event_source::CGEventSource; let _ = (x, y); @@ -151,20 +176,38 @@ impl InputSimulator { CGEvent::new_scroll_event(source, ScrollEventUnit::LINE, 2, delta_y, delta_x, 0) .map_err(|e| AppError::Input(format!("Failed to create scroll event: {e:?}")))?; - scroll.post(CGEventTapLocation::HID); - tracing::debug!("Scroll dir={}, amount={}", direction, amount); + post_event(&scroll, target_pid); + tracing::debug!( + "Scroll dir={}, amount={}, target_pid={:?}", + direction, + amount, + target_pid + ); Ok(()) } #[cfg(not(target_os = "macos"))] - pub fn scroll(_x: f64, _y: f64, _direction: &str, _amount: i32) -> Result<()> { + pub fn scroll( + _x: f64, + _y: f64, + _direction: &str, + _amount: i32, + _target_pid: Option, + ) -> Result<()> { Err(AppError::Input("scroll only supported on macOS".into())) } /// Simulate a drag from one point to another #[cfg(target_os = "macos")] - pub fn drag(from_x: f64, from_y: f64, to_x: f64, to_y: f64) -> Result<()> { - use core_graphics::event::{CGEvent, CGEventTapLocation, CGEventType}; + pub fn drag( + from_x: f64, + from_y: f64, + to_x: f64, + to_y: f64, + target_pid: Option, + ) -> Result<()> { + use core_graphics::event::CGEvent; + use core_graphics::event::CGEventType; use core_graphics::event_source::CGEventSource; use core_graphics::geometry::CGPoint; @@ -184,7 +227,7 @@ impl InputSimulator { core_graphics::event::CGMouseButton::Left, ) .map_err(|e| AppError::Input(format!("Failed to create mouse-down event: {e:?}")))?; - down.post(CGEventTapLocation::HID); + post_event(&down, target_pid); // Drag to end (small steps for smoothness) let steps = 20; @@ -201,7 +244,7 @@ impl InputSimulator { core_graphics::event::CGMouseButton::Left, ) .map_err(|e| AppError::Input(format!("Failed to create drag event: {e:?}")))?; - drag.post(CGEventTapLocation::HID); + post_event(&drag, target_pid); std::thread::sleep(std::time::Duration::from_millis(5)); } @@ -213,14 +256,27 @@ impl InputSimulator { core_graphics::event::CGMouseButton::Left, ) .map_err(|e| AppError::Input(format!("Failed to create mouse-up event: {e:?}")))?; - up.post(CGEventTapLocation::HID); - - tracing::debug!("Drag from ({},{}) to ({},{})", from_x, from_y, to_x, to_y); + post_event(&up, target_pid); + + tracing::debug!( + "Drag from ({},{}) to ({},{}), target_pid={:?}", + from_x, + from_y, + to_x, + to_y, + target_pid + ); Ok(()) } #[cfg(not(target_os = "macos"))] - pub fn drag(_from_x: f64, _from_y: f64, _to_x: f64, _to_y: f64) -> Result<()> { + pub fn drag( + _from_x: f64, + _from_y: f64, + _to_x: f64, + _to_y: f64, + _target_pid: Option, + ) -> Result<()> { Err(AppError::Input("drag only supported on macOS".into())) } } @@ -232,8 +288,9 @@ fn mouse_event( event_type: core_graphics::event::CGEventType, position: core_graphics::geometry::CGPoint, button: MouseButton, + target_pid: Option, ) -> Result<()> { - use core_graphics::event::{CGEvent, CGEventTapLocation}; + use core_graphics::event::CGEvent; let cg_button = match button { MouseButton::Left => core_graphics::event::CGMouseButton::Left, @@ -244,14 +301,14 @@ fn mouse_event( let event = CGEvent::new_mouse_event(source.clone(), event_type, position, cg_button) .map_err(|e| AppError::Input(format!("Failed to create mouse event: {e:?}")))?; - event.post(CGEventTapLocation::HID); + post_event(&event, target_pid); Ok(()) } /// Type a single character using CGEvent keyboard simulation #[cfg(target_os = "macos")] -fn type_character(c: char) -> Result<()> { - use core_graphics::event::{CGEvent, CGEventTapLocation}; +fn type_character(c: char, target_pid: Option) -> Result<()> { + use core_graphics::event::CGEvent; use core_graphics::event_source::CGEventSource; let needs_shift = keycodes::char_needs_shift(c); @@ -268,7 +325,7 @@ fn type_character(c: char) -> Result<()> { if needs_shift { let shift_down = CGEvent::new_keyboard_event(source.clone(), 0x38, true) .map_err(|e| AppError::Input(format!("Failed to create shift-down event: {e:?}")))?; - shift_down.post(CGEventTapLocation::HID); + post_event(&shift_down, target_pid); } // Key down @@ -278,18 +335,18 @@ fn type_character(c: char) -> Result<()> { use core_graphics::event::CGEventFlags; key_down.set_flags(CGEventFlags::CGEventFlagShift); } - key_down.post(CGEventTapLocation::HID); + post_event(&key_down, target_pid); // Key up let key_up = CGEvent::new_keyboard_event(source.clone(), key_code, false) .map_err(|e| AppError::Input(format!("Failed to create key-up for '{c}': {e:?}")))?; - key_up.post(CGEventTapLocation::HID); + post_event(&key_up, target_pid); // Release shift if needed if needs_shift { let shift_up = CGEvent::new_keyboard_event(source, 0x38, false) .map_err(|e| AppError::Input(format!("Failed to create shift-up event: {e:?}")))?; - shift_up.post(CGEventTapLocation::HID); + post_event(&shift_up, target_pid); } Ok(()) diff --git a/crates/sandbox-core/src/automation/keycodes.rs b/crates/sandbox-core/src/automation/keycodes.rs index 927ae68..f29ea0c 100644 --- a/crates/sandbox-core/src/automation/keycodes.rs +++ b/crates/sandbox-core/src/automation/keycodes.rs @@ -235,4 +235,160 @@ mod tests { assert_eq!(modifier_to_flag("ctrl"), Some(flags::CONTROL)); assert_eq!(modifier_to_flag("unknown"), None); } + + #[test] + fn test_key_name_modifier_keys() { + assert_eq!(key_name_to_code("command"), Some(0x37)); + assert_eq!(key_name_to_code("cmd"), Some(0x37)); + assert_eq!(key_name_to_code("shift"), Some(0x38)); + assert_eq!(key_name_to_code("caps_lock"), Some(0x39)); + assert_eq!(key_name_to_code("option"), Some(0x3A)); + assert_eq!(key_name_to_code("alt"), Some(0x3A)); + assert_eq!(key_name_to_code("control"), Some(0x3B)); + assert_eq!(key_name_to_code("ctrl"), Some(0x3B)); + assert_eq!(key_name_to_code("right_shift"), Some(0x3C)); + assert_eq!(key_name_to_code("right_option"), Some(0x3D)); + assert_eq!(key_name_to_code("right_alt"), Some(0x3D)); + assert_eq!(key_name_to_code("right_control"), Some(0x3E)); + assert_eq!(key_name_to_code("right_ctrl"), Some(0x3E)); + assert_eq!(key_name_to_code("function"), Some(0x3F)); + assert_eq!(key_name_to_code("fn"), Some(0x3F)); + } + + #[test] + fn test_key_name_function_keys() { + assert_eq!(key_name_to_code("f1"), Some(0x7A)); + assert_eq!(key_name_to_code("f2"), Some(0x7B)); + assert_eq!(key_name_to_code("f3"), Some(0x7C)); + assert_eq!(key_name_to_code("f4"), Some(0x7D)); + assert_eq!(key_name_to_code("f5"), Some(0x7E)); + assert_eq!(key_name_to_code("f6"), Some(0x7F)); + assert_eq!(key_name_to_code("f7"), Some(0x80)); + assert_eq!(key_name_to_code("f8"), Some(0x81)); + assert_eq!(key_name_to_code("f9"), Some(0x82)); + assert_eq!(key_name_to_code("f10"), Some(0x83)); + assert_eq!(key_name_to_code("f11"), Some(0x84)); + assert_eq!(key_name_to_code("f12"), Some(0x85)); + } + + #[test] + fn test_key_name_navigation() { + assert_eq!(key_name_to_code("home"), Some(0x73)); + assert_eq!(key_name_to_code("end"), Some(0x77)); + assert_eq!(key_name_to_code("page_up"), Some(0x74)); + assert_eq!(key_name_to_code("page_down"), Some(0x79)); + assert_eq!(key_name_to_code("left"), Some(0x7B)); + assert_eq!(key_name_to_code("right"), Some(0x7C)); + assert_eq!(key_name_to_code("down"), Some(0x7D)); + assert_eq!(key_name_to_code("up"), Some(0x7E)); + assert_eq!(key_name_to_code("left_arrow"), Some(0x7B)); + assert_eq!(key_name_to_code("right_arrow"), Some(0x7C)); + assert_eq!(key_name_to_code("down_arrow"), Some(0x7D)); + assert_eq!(key_name_to_code("up_arrow"), Some(0x7E)); + } + + #[test] + fn test_key_name_media() { + assert_eq!(key_name_to_code("volume_up"), Some(0x48)); + assert_eq!(key_name_to_code("volume_down"), Some(0x49)); + assert_eq!(key_name_to_code("mute"), Some(0x4A)); + } + + #[test] + fn test_key_name_special() { + assert_eq!(key_name_to_code("delete"), Some(0x33)); + assert_eq!(key_name_to_code("backspace"), Some(0x33)); + assert_eq!(key_name_to_code("backtick"), Some(0x32)); + } + + #[test] + fn test_key_name_unknown() { + assert_eq!(key_name_to_code("nonexistent"), None); + assert_eq!(key_name_to_code(""), None); + } + + #[test] + fn test_char_to_key_name_letters() { + assert_eq!(char_to_key_name('a'), Some("a")); + assert_eq!(char_to_key_name('z'), Some("z")); + assert_eq!(char_to_key_name('A'), Some("a")); + assert_eq!(char_to_key_name('Z'), Some("z")); + } + + #[test] + fn test_char_to_key_name_digits() { + for c in '0'..='9' { + assert!(char_to_key_name(c).is_some(), "char {c}"); + } + assert_eq!(char_to_key_name('5'), Some("5")); + } + + #[test] + fn test_char_to_key_name_special() { + assert_eq!(char_to_key_name(' '), Some("space")); + assert_eq!(char_to_key_name('\n'), Some("return")); + assert_eq!(char_to_key_name('\r'), Some("return")); + assert_eq!(char_to_key_name('\t'), Some("tab")); + assert_eq!(char_to_key_name('-'), Some("minus")); + assert_eq!(char_to_key_name('='), Some("equal")); + assert_eq!(char_to_key_name('['), Some("left_bracket")); + assert_eq!(char_to_key_name(']'), Some("right_bracket")); + assert_eq!(char_to_key_name('\\'), Some("backslash")); + assert_eq!(char_to_key_name(';'), Some("semicolon")); + assert_eq!(char_to_key_name('\''), Some("quote")); + assert_eq!(char_to_key_name(','), Some("comma")); + assert_eq!(char_to_key_name('.'), Some("period")); + assert_eq!(char_to_key_name('/'), Some("slash")); + assert_eq!(char_to_key_name('`'), Some("backtick")); + } + + #[test] + fn test_char_to_key_name_unknown() { + assert_eq!(char_to_key_name('!'), None); + assert_eq!(char_to_key_name('@'), None); + assert_eq!(char_to_key_name('~'), None); + } + + #[test] + fn test_char_needs_shift_comprehensive() { + let shift_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+{}|:\"<>?"; + for c in shift_chars.chars() { + assert!(char_needs_shift(c), "char '{c}' should need shift"); + } + let no_shift = "abcdefghijklmnopqrstuvwxyz0123456789-=[]\\;',./`"; + for c in no_shift.chars() { + assert!(!char_needs_shift(c), "char '{c}' should NOT need shift"); + } + } + + #[test] + fn test_char_to_key_name_all_letters() { + for c in 'a'..='z' { + let got = char_to_key_name(c); + assert!(got.is_some(), "char '{c}' should return Some"); + let ch = got.unwrap(); + assert_eq!(ch.len(), 1, "key name for '{c}' should be single char"); + } + // Uppercase should also work + for c in 'A'..='Z' { + let got = char_to_key_name(c); + assert!(got.is_some(), "char '{c}' (uppercase) should return Some"); + } + } + + #[test] + fn test_char_to_key_name_all_digits_explicit() { + for c in '0'..='9' { + let got = char_to_key_name(c); + assert!(got.is_some(), "digit '{c}' should return Some"); + // Key name should match the digit + assert_eq!(got.unwrap().len(), 1); + } + } + + #[test] + fn test_modifier_to_flag_aliases() { + assert_eq!(modifier_to_flag("command"), Some(flags::COMMAND)); + assert_eq!(modifier_to_flag("alt"), Some(flags::ALTERNATE)); + } } diff --git a/crates/sandbox-core/src/capture/mod.rs b/crates/sandbox-core/src/capture/mod.rs index 6c541ff..95e52c4 100644 --- a/crates/sandbox-core/src/capture/mod.rs +++ b/crates/sandbox-core/src/capture/mod.rs @@ -10,12 +10,25 @@ mod macos_impl { use screencapturekit::shareable_content::SCShareableContent; use screencapturekit::stream::configuration::SCStreamConfiguration; use screencapturekit::stream::content_filter::SCContentFilter; + use std::sync::Once; + + static CG_INIT: Once = Once::new(); + + /// Ensure CoreGraphics is initialized before ScreenCaptureKit calls. + /// Without this, `SCShareableContent::get()` (async path) triggers + /// `CGS_REQUIRE_INIT` assertion when run from non-GUI context. + fn ensure_cg_initialized() { + CG_INIT.call_once(|| unsafe { + screencapturekit::ffi::sc_initialize_core_graphics(); + }); + } impl ScreenCapture { /// Capture a specific window by its SCWindow ID. /// Returns PNG-encoded image bytes. /// Works even when the window is behind other windows. pub fn capture_window(window_id: u32) -> Result> { + ensure_cg_initialized(); let content = SCShareableContent::get().map_err(|e| { AppError::Screenshot(format!("Failed to get shareable content: {e:?}")) })?; @@ -44,8 +57,10 @@ mod macos_impl { rgba_to_png(&rgba, image.width(), image.height()) } - /// Capture a region of a display - pub fn capture_region(_x: i32, _y: i32, width: u32, height: u32) -> Result> { + /// Capture a region of a display at the given screen coordinates. + /// Captures the full display and crops to (x, y, width, height) using the image crate. + pub fn capture_region(x: i32, y: i32, width: u32, height: u32) -> Result> { + ensure_cg_initialized(); let content = SCShareableContent::get().map_err(|e| { AppError::Screenshot(format!("Failed to get shareable content: {e:?}")) })?; @@ -55,14 +70,18 @@ mod macos_impl { .first() .ok_or_else(|| AppError::Screenshot("No display found".into()))?; + let frame = display.frame(); + let display_w = frame.width as u32; + let display_h = frame.height as u32; + let filter = SCContentFilter::create() .with_display(display) .with_excluding_windows(&[]) .build(); let config = SCStreamConfiguration::new() - .with_width(width) - .with_height(height); + .with_width(display_w) + .with_height(display_h); let image = SCScreenshotManager::capture_image(&filter, &config) .map_err(|e| AppError::Screenshot(format!("Failed to capture region: {e:?}")))?; @@ -71,17 +90,20 @@ mod macos_impl { .rgba_data() .map_err(|e| AppError::Screenshot(format!("Failed to get RGBA data: {e:?}")))?; - rgba_to_png(&rgba, image.width(), image.height()) + // Crop to the requested region using the image crate + crop_rgba(&rgba, image.width(), image.height(), x, y, width, height) } /// Capture the sandbox window by searching for it by title pub fn capture_sandbox() -> Result> { + ensure_cg_initialized(); Self::capture_sandbox_by_id(None) } /// Capture the sandbox window, optionally by a specific window ID. /// If window_id is None, searches for a window titled "System Test Sandbox". pub fn capture_sandbox_by_id(window_id: Option) -> Result> { + ensure_cg_initialized(); let content = SCShareableContent::get().map_err(|e| { AppError::Screenshot(format!("Failed to get shareable content: {e:?}")) })?; @@ -130,6 +152,7 @@ mod macos_impl { /// Find a window by title substring pub fn find_window_by_title(title: &str) -> Result { + ensure_cg_initialized(); let content = SCShareableContent::get().map_err(|e| { AppError::Screenshot(format!("Failed to get shareable content: {e:?}")) })?; @@ -144,6 +167,7 @@ mod macos_impl { /// List all available windows with their IDs and titles pub fn list_windows() -> Result> { + ensure_cg_initialized(); let content = SCShareableContent::get().map_err(|e| { AppError::Screenshot(format!("Failed to get shareable content: {e:?}")) })?; @@ -173,6 +197,48 @@ mod macos_impl { Ok(cursor.into_inner()) } + + /// Crop RGBA pixel data to the specified region, then encode as PNG. + fn crop_rgba( + rgba: &[u8], + full_width: usize, + full_height: usize, + x: i32, + y: i32, + width: u32, + height: u32, + ) -> Result> { + use image::imageops; + use image::{ImageBuffer, RgbaImage}; + use std::io::Cursor; + + let mut img: RgbaImage = + ImageBuffer::from_raw(full_width as u32, full_height as u32, rgba.to_vec()) + .ok_or_else(|| { + AppError::Screenshot("Failed to create image buffer from RGBA data".into()) + })?; + + let crop_x = x.max(0) as u32; + let crop_y = y.max(0) as u32; + let crop_w = width.min(full_width as u32 - crop_x); + let crop_h = height.min(full_height as u32 - crop_y); + + if crop_w == 0 || crop_h == 0 { + return Err(AppError::Screenshot(format!( + "Crop region ({x}, {y}, {width}x{height}) is outside display bounds ({full_width}x{full_height})" + ))); + } + + let cropped = imageops::crop(&mut img, crop_x, crop_y, crop_w, crop_h); + let cropped_img = cropped.to_image(); + + let mut cursor = Cursor::new(Vec::new()); + cropped_img + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| AppError::Screenshot(format!("Failed to encode cropped PNG: {e}")))?; + + Ok(cursor.into_inner()) + } } #[cfg(any(not(target_os = "macos"), not(feature = "screencapturekit")))] diff --git a/crates/sandbox-core/src/diff.rs b/crates/sandbox-core/src/diff.rs deleted file mode 100644 index 1383281..0000000 --- a/crates/sandbox-core/src/diff.rs +++ /dev/null @@ -1,236 +0,0 @@ -use crate::error::{AppError, Result}; -use image::{GenericImageView, Pixel, RgbaImage}; -use serde::{Deserialize, Serialize}; - -/// Result of comparing two screenshots -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiffResult { - /// Whether the images are considered identical - pub identical: bool, - /// Percentage of pixels that differ (0.0 - 100.0) - pub diff_percentage: f64, - /// Total number of pixels compared - pub total_pixels: u64, - /// Number of pixels that differ - pub changed_pixels: u64, -} - -/// Options for screenshot comparison -#[derive(Debug, Clone)] -pub struct DiffOptions { - /// Pixel difference threshold (0-255). Pixels with channel differences - /// below this are considered identical. Default: 10. - pub threshold: u8, - /// Maximum diff percentage to consider images identical. Default: 0.0 (any diff = not identical). - pub max_diff_percentage: f64, - /// Ignore pixels within this border (in pixels). Default: 0. - pub ignore_border: u32, -} - -impl Default for DiffOptions { - fn default() -> Self { - Self { - threshold: 10, - max_diff_percentage: 0.0, - ignore_border: 0, - } - } -} - -/// Compare two PNG images and return a diff result -pub fn diff_images(expected: &[u8], actual: &[u8], options: &DiffOptions) -> Result { - let expected_img = image::load_from_memory(expected) - .map_err(|e| AppError::Screenshot(format!("Failed to load expected image: {e}")))?; - let actual_img = image::load_from_memory(actual) - .map_err(|e| AppError::Screenshot(format!("Failed to load actual image: {e}")))?; - - let (ew, eh) = expected_img.dimensions(); - let (aw, ah) = actual_img.dimensions(); - - if ew != aw || eh != ah { - return Ok(DiffResult { - identical: false, - diff_percentage: 100.0, - total_pixels: (ew as u64) * (eh as u64), - changed_pixels: (ew as u64) * (eh as u64), - }); - } - - let total_pixels = (ew as u64) * (eh as u64); - let mut changed_pixels: u64 = 0; - let threshold = options.threshold; - - let ib = options.ignore_border; - for y in ib..(eh - ib) { - for x in ib..(ew - ib) { - let ep = expected_img.get_pixel(x, y); - let ap = actual_img.get_pixel(x, y); - let channels = ep.channels(); - let ach = ap.channels(); - - let mut diff = false; - for c in 0..channels.len() { - let d = channels[c].abs_diff(ach[c]); - if d > threshold { - diff = true; - break; - } - } - if diff { - changed_pixels += 1; - } - } - } - - let diff_percentage = if total_pixels > 0 { - (changed_pixels as f64) / (total_pixels as f64) * 100.0 - } else { - 0.0 - }; - - Ok(DiffResult { - identical: diff_percentage <= options.max_diff_percentage, - diff_percentage, - total_pixels, - changed_pixels, - }) -} - -/// Generate a diff image highlighting changed pixels in red -pub fn diff_image(expected: &[u8], actual: &[u8], options: &DiffOptions) -> Result> { - let expected_img = image::load_from_memory(expected) - .map_err(|e| AppError::Screenshot(format!("Failed to load expected image: {e}")))?; - let actual_img = image::load_from_memory(actual) - .map_err(|e| AppError::Screenshot(format!("Failed to load actual image: {e}")))?; - - let (ew, eh) = expected_img.dimensions(); - let (aw, ah) = actual_img.dimensions(); - - let width = ew.min(aw); - let height = eh.min(ah); - - let mut result = RgbaImage::new(width, height); - - for y in 0..height { - for x in 0..width { - let ep = expected_img.get_pixel(x, y); - let ap = actual_img.get_pixel(x, y); - let channels = ep.channels(); - let ach = ap.channels(); - - let mut is_diff = false; - for c in 0..channels.len().min(ach.len()) { - let d = channels[c].abs_diff(ach[c]); - if d > options.threshold { - is_diff = true; - break; - } - } - - if is_diff { - // Highlight changed pixels in semi-transparent red - let alpha = 180u8; - result.put_pixel(x, y, image::Rgba([255, 0, 0, alpha])); - } else { - // Show actual image with reduced opacity for context - let p = ap; - let data = p.channels(); - result.put_pixel( - x, - y, - image::Rgba([ - (data[0] as u16 * 3 / 4) as u8, - (data[1] as u16 * 3 / 4) as u8, - (data[2] as u16 * 3 / 4) as u8, - 255, - ]), - ); - } - } - } - - let mut buf = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(result) - .write_to(&mut buf, image::ImageFormat::Png) - .map_err(|e| AppError::Screenshot(format!("Failed to encode diff image: {e}")))?; - Ok(buf.into_inner()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_identical_images() { - // Create two identical 10x10 red images - let mut img = RgbaImage::new(10, 10); - for y in 0..10 { - for x in 0..10 { - img.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); - } - } - let mut buf1 = std::io::Cursor::new(Vec::new()); - img.write_to(&mut buf1, image::ImageFormat::Png).unwrap(); - let png1 = buf1.into_inner(); - - let result = diff_images(&png1, &png1, &DiffOptions::default()).unwrap(); - assert!(result.identical); - assert_eq!(result.diff_percentage, 0.0); - assert_eq!(result.changed_pixels, 0); - } - - #[test] - fn test_different_images() { - let mut img1 = RgbaImage::new(10, 10); - let mut img2 = RgbaImage::new(10, 10); - for y in 0..10 { - for x in 0..10 { - img1.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); - img2.put_pixel(x, y, image::Rgba([0, 255, 0, 255])); - } - } - let mut buf1 = std::io::Cursor::new(Vec::new()); - let mut buf2 = std::io::Cursor::new(Vec::new()); - img1.write_to(&mut buf1, image::ImageFormat::Png).unwrap(); - img2.write_to(&mut buf2, image::ImageFormat::Png).unwrap(); - - let result = diff_images( - &buf1.into_inner(), - &buf2.into_inner(), - &DiffOptions::default(), - ) - .unwrap(); - assert!(!result.identical); - assert_eq!(result.changed_pixels, 100); - assert_eq!(result.diff_percentage, 100.0); - } - - #[test] - fn test_size_mismatch() { - let mut img1 = RgbaImage::new(10, 10); - let mut img2 = RgbaImage::new(20, 20); - for y in 0..10 { - for x in 0..10 { - img1.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); - } - } - for y in 0..20 { - for x in 0..20 { - img2.put_pixel(x, y, image::Rgba([0, 255, 0, 255])); - } - } - let mut buf1 = std::io::Cursor::new(Vec::new()); - let mut buf2 = std::io::Cursor::new(Vec::new()); - img1.write_to(&mut buf1, image::ImageFormat::Png).unwrap(); - img2.write_to(&mut buf2, image::ImageFormat::Png).unwrap(); - - let result = diff_images( - &buf1.into_inner(), - &buf2.into_inner(), - &DiffOptions::default(), - ) - .unwrap(); - assert!(!result.identical); - assert_eq!(result.diff_percentage, 100.0); - } -} diff --git a/crates/sandbox-core/src/instance/mod.rs b/crates/sandbox-core/src/instance/mod.rs new file mode 100644 index 0000000..0ab8746 --- /dev/null +++ b/crates/sandbox-core/src/instance/mod.rs @@ -0,0 +1,343 @@ +use crate::error::{AppError, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Generate a random 8-character hex instance ID +pub fn generate_instance_id() -> String { + let id = uuid::Uuid::new_v4(); + let bytes = id.as_bytes(); + format!( + "{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3] + ) +} + +/// What kind of process a sandbox is running +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "detail")] +pub enum InstanceKind { + #[serde(rename = "cli")] + Cli { + command: String, + #[serde(default)] + args: Vec, + }, + #[serde(rename = "app")] + App { path: String }, +} + +/// Status of a sandbox instance +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "detail")] +pub enum InstanceStatus { + Starting, + Running, + Stopped, + Error(String), +} + +/// A registered sandbox instance +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxInstance { + pub id: String, + pub port: u16, + pub pid: u32, + pub kind: InstanceKind, + pub title: String, + pub status: InstanceStatus, + pub created_at: String, + pub window_id: Option, +} + +impl SandboxInstance { + pub fn new(id: String, port: u16, pid: u32, kind: InstanceKind) -> Self { + let title = match &kind { + InstanceKind::Cli { command, .. } => command.clone(), + InstanceKind::App { path } => std::path::Path::new(path) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + }; + Self { + id, + port, + pid, + kind, + title, + status: InstanceStatus::Starting, + created_at: chrono_now(), + window_id: None, + } + } +} + +fn chrono_now() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + // Simple ISO-ish timestamp from epoch + let secs = now.as_secs(); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + 1970 + secs / 31536000, + (secs % 31536000) / 2592000, + (secs % 2592000) / 86400, + (secs % 86400) / 3600, + (secs % 3600) / 60, + secs % 60, + ) +} + +/// File-system based instance registry +pub struct InstanceRegistry { + base_dir: PathBuf, +} + +impl Default for InstanceRegistry { + fn default() -> Self { + Self::new(dirs_home().join(".sandbox").join("instances")) + } +} + +fn dirs_home() -> PathBuf { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) +} + +impl InstanceRegistry { + pub fn new(base_dir: PathBuf) -> Self { + Self { base_dir } + } + + fn instance_path(&self, id: &str) -> PathBuf { + self.base_dir.join(format!("{id}.json")) + } + + /// Register a new sandbox instance + pub fn register(&self, instance: &SandboxInstance) -> Result<()> { + std::fs::create_dir_all(&self.base_dir) + .map_err(|e| AppError::Instance(format!("Failed to create registry dir: {e}")))?; + let path = self.instance_path(&instance.id); + let json = serde_json::to_string_pretty(instance) + .map_err(|e| AppError::Instance(format!("Failed to serialize instance: {e}")))?; + std::fs::write(&path, json) + .map_err(|e| AppError::Instance(format!("Failed to write registry file: {e}")))?; + tracing::info!("Registered instance: {}", instance.id); + Ok(()) + } + + /// Get a specific instance by ID + pub fn get(&self, id: &str) -> Result { + let path = self.instance_path(id); + if !path.exists() { + return Err(AppError::Instance(format!("Instance '{id}' not found"))); + } + let json = std::fs::read_to_string(&path) + .map_err(|e| AppError::Instance(format!("Failed to read instance '{id}': {e}")))?; + serde_json::from_str(&json) + .map_err(|e| AppError::Instance(format!("Failed to parse instance '{id}': {e}"))) + } + + /// List all registered instances, sorted by created_at descending + pub fn list(&self) -> Result> { + if !self.base_dir.exists() { + return Ok(Vec::new()); + } + let mut instances = Vec::new(); + let entries = std::fs::read_dir(&self.base_dir) + .map_err(|e| AppError::Instance(format!("Failed to read registry dir: {e}")))?; + for entry in entries { + let entry = + entry.map_err(|e| AppError::Instance(format!("Failed to read dir entry: {e}")))?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let json = std::fs::read_to_string(&path).unwrap_or_default(); + if let Ok(instance) = serde_json::from_str::(&json) { + instances.push(instance); + } + } + } + instances.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(instances) + } + + /// Remove an instance from the registry + pub fn unregister(&self, id: &str) -> Result<()> { + let path = self.instance_path(id); + if path.exists() { + std::fs::remove_file(&path).map_err(|e| { + AppError::Instance(format!("Failed to remove instance '{id}': {e}")) + })?; + tracing::info!("Unregistered instance: {id}"); + } + Ok(()) + } + + /// Update the status of an instance + pub fn update_status(&self, id: &str, status: InstanceStatus) -> Result<()> { + let mut instance = self.get(id)?; + instance.status = status; + self.register(&instance) + } + + /// Update the window ID of an instance + pub fn update_window_id(&self, id: &str, window_id: u32) -> Result<()> { + let mut instance = self.get(id)?; + instance.window_id = Some(window_id); + self.register(&instance) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_instance_id() { + let id1 = generate_instance_id(); + let id2 = generate_instance_id(); + assert_ne!(id1, id2, "IDs should be unique"); + assert_eq!(id1.len(), 8, "ID should be 8 chars"); + assert!(id1.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_instance_registry_crud() { + let tmp = tempfile("crud"); + let registry = InstanceRegistry::new(tmp.clone()); + let instance = SandboxInstance::new( + "test1234".into(), + 15801, + 12345, + InstanceKind::Cli { + command: "echo".into(), + args: vec!["hello".into()], + }, + ); + + registry.register(&instance).unwrap(); + let got = registry.get("test1234").unwrap(); + assert_eq!(got.id, "test1234"); + assert_eq!(got.port, 15801); + assert_eq!(got.title, "echo"); + + let list = registry.list().unwrap(); + assert_eq!(list.len(), 1); + + registry + .update_status("test1234", InstanceStatus::Running) + .unwrap(); + let updated = registry.get("test1234").unwrap(); + assert!(matches!(updated.status, InstanceStatus::Running)); + + registry.unregister("test1234").unwrap(); + assert!(registry.get("test1234").is_err()); + } + + #[test] + fn test_instance_app_kind_title() { + let instance = SandboxInstance::new( + "app12345".into(), + 15802, + 12346, + InstanceKind::App { + path: "/Applications/TextEdit.app".into(), + }, + ); + assert_eq!(instance.title, "TextEdit"); + } + + #[test] + fn test_default_registry_uses_home_dir() { + let registry = InstanceRegistry::default(); + let expected = dirs_home().join(".sandbox").join("instances"); + assert_eq!(registry.base_dir, expected); + } + + #[test] + fn test_update_window_id() { + let tmp = tempfile("window_id"); + let registry = InstanceRegistry::new(tmp.clone()); + let instance = SandboxInstance::new( + "win_test".into(), + 15801, + 99999, + InstanceKind::Cli { + command: "vim".into(), + args: vec![], + }, + ); + registry.register(&instance).unwrap(); + + registry.update_window_id("win_test", 42).unwrap(); + let got = registry.get("win_test").unwrap(); + assert_eq!(got.window_id, Some(42)); + } + + #[test] + fn test_list_empty_dir() { + let tmp = tempfile("empty_list"); + let registry = InstanceRegistry::new(tmp.clone()); + let list = registry.list().unwrap(); + assert!(list.is_empty()); + } + + #[test] + fn test_list_multiple_sorted_by_created_at() { + let tmp = tempfile("multi_list"); + let registry = InstanceRegistry::new(tmp); + for i in 0..3 { + let instance = SandboxInstance::new( + format!("inst{i}"), + 15801 + i, + (1000 + i) as u32, + InstanceKind::Cli { + command: "echo".into(), + args: vec![], + }, + ); + registry.register(&instance).unwrap(); + } + let list = registry.list().unwrap(); + assert_eq!(list.len(), 3); + } + + #[test] + fn test_unregister_nonexistent_is_ok() { + let tmp = tempfile("noexist"); + let registry = InstanceRegistry::new(tmp); + assert!(registry.unregister("ghost").is_ok()); + } + + #[test] + fn test_get_nonexistent_returns_error() { + let tmp = tempfile("get_missing"); + let registry = InstanceRegistry::new(tmp); + assert!(registry.get("missing").is_err()); + } + + #[test] + fn test_instance_serialization_roundtrip() { + let instance = SandboxInstance::new( + "ser_1234".into(), + 15999, + 55555, + InstanceKind::App { + path: "/Applications/Notes.app".into(), + }, + ); + let json = serde_json::to_string(&instance).unwrap(); + let parsed: SandboxInstance = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id, "ser_1234"); + assert_eq!(parsed.port, 15999); + assert!(matches!(parsed.kind, InstanceKind::App { .. })); + assert!(matches!(parsed.status, InstanceStatus::Starting)); + } + + fn tempfile(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("sandbox_test_{}_{}", std::process::id(), tag)) + } +} diff --git a/crates/sandbox-core/src/lib.rs b/crates/sandbox-core/src/lib.rs index 30f6117..6567a9c 100644 --- a/crates/sandbox-core/src/lib.rs +++ b/crates/sandbox-core/src/lib.rs @@ -2,13 +2,10 @@ pub mod automation; pub mod capture; -pub mod diff; -pub mod player; +pub mod instance; pub mod process; -pub mod recorder; -pub mod report; pub mod sandbox; -pub mod scenario; +pub mod server; pub use error::{AppError, Result}; @@ -35,11 +32,17 @@ mod error { #[error("Sandbox not initialized")] SandboxNotInitialized, + #[error("Bad request: {0}")] + BadRequest(String), + #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + + #[error("Instance error: {0}")] + Instance(String), } pub type Result = std::result::Result; diff --git a/crates/sandbox-core/src/player.rs b/crates/sandbox-core/src/player.rs deleted file mode 100644 index c658d70..0000000 --- a/crates/sandbox-core/src/player.rs +++ /dev/null @@ -1,249 +0,0 @@ -use crate::automation::cg_event::{InputSimulator, MouseButton}; -use crate::capture::ScreenCapture; -use crate::diff::{diff_images, DiffOptions}; -use crate::error::{AppError, Result}; -use crate::process::ProcessManager; -use crate::recorder::Action; -use std::collections::HashMap; -use std::path::Path; -use std::time::Duration; - -/// Plays back recorded actions from a JSONL file or Vec -pub struct ActionPlayer { - /// Speed multiplier (1.0 = original speed, 2.0 = 2x speed) - speed: f64, - /// Screenshots taken during playback, keyed by label - screenshots: HashMap>, -} - -/// Result of playing back a single action -#[derive(Debug)] -pub enum ActionResult { - Ok, - Screenshot { - label: String, - data: Vec, - }, - DiffResult { - label: String, - diff: crate::diff::DiffResult, - }, - Error { - message: String, - }, -} - -impl ActionPlayer { - pub fn new(speed: f64) -> Self { - Self { - speed: speed.max(0.1), - screenshots: HashMap::new(), - } - } - - /// Load actions from a JSONL file - pub fn load_file(path: &Path) -> Result> { - let content = std::fs::read_to_string(path)?; - let mut actions = Vec::new(); - for line in content.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let action: Action = serde_json::from_str(line).map_err(|e| { - AppError::Screenshot(format!("Failed to parse action: {e} — {line}")) - })?; - actions.push(action); - } - Ok(actions) - } - - /// Play back a sequence of actions - #[cfg(target_os = "macos")] - pub async fn play(&mut self, actions: &[Action]) -> Vec { - let mut results = Vec::new(); - let mut last_timestamp: u64 = 0; - - for action in actions { - // Calculate wait time based on timestamp difference - let ts = self.get_timestamp(action); - if ts > last_timestamp { - let wait_ms = ((ts - last_timestamp) as f64 / self.speed) as u64; - if wait_ms > 0 { - tokio::time::sleep(Duration::from_millis(wait_ms)).await; - } - } - last_timestamp = ts; - - let result = self.execute(action).await; - results.push(result); - } - - results - } - - #[cfg(not(target_os = "macos"))] - pub async fn play(&mut self, _actions: &[Action]) -> Vec { - vec![ActionResult::Error { - message: "Playback only available on macOS".to_string(), - }] - } - - fn get_timestamp(&self, action: &Action) -> u64 { - match action { - Action::Click { timestamp_ms, .. } - | Action::DoubleClick { timestamp_ms, .. } - | Action::TypeText { timestamp_ms, .. } - | Action::PressKey { timestamp_ms, .. } - | Action::Scroll { timestamp_ms, .. } - | Action::Drag { timestamp_ms, .. } - | Action::Screenshot { timestamp_ms, .. } - | Action::SpawnApp { timestamp_ms, .. } - | Action::SpawnCli { timestamp_ms, .. } - | Action::Wait { timestamp_ms, .. } - | Action::AssertScreenshot { timestamp_ms, .. } => timestamp_ms.unwrap_or(0), - } - } - - #[cfg(target_os = "macos")] - async fn execute(&mut self, action: &Action) -> ActionResult { - match action { - Action::Click { x, y, button, .. } => { - let btn = match button.to_lowercase().as_str() { - "right" => MouseButton::Right, - "middle" => MouseButton::Middle, - _ => MouseButton::Left, - }; - match InputSimulator::click(*x, *y, btn) { - Ok(()) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - } - } - Action::DoubleClick { x, y, .. } => match InputSimulator::double_click(*x, *y) { - Ok(()) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - }, - Action::TypeText { text, .. } => match InputSimulator::type_text(text) { - Ok(()) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - }, - Action::PressKey { key, modifiers, .. } => { - let m: Vec<&str> = modifiers.iter().map(|s| s.as_str()).collect(); - match InputSimulator::press_key(key, &m) { - Ok(()) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - } - } - Action::Scroll { - x, - y, - direction, - amount, - .. - } => match InputSimulator::scroll(*x, *y, direction, *amount) { - Ok(()) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - }, - Action::Drag { - from_x, - from_y, - to_x, - to_y, - .. - } => match InputSimulator::drag(*from_x, *from_y, *to_x, *to_y) { - Ok(()) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - }, - Action::Screenshot { label, .. } => match ScreenCapture::capture_sandbox() { - Ok(data) => { - if let Some(lbl) = label { - self.screenshots.insert(lbl.clone(), data.clone()); - } - ActionResult::Screenshot { - label: label.clone().unwrap_or_default(), - data, - } - } - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - }, - Action::SpawnApp { path, .. } => match ProcessManager::spawn_app(path) { - Ok(_) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - }, - Action::SpawnCli { command, args, .. } => { - match ProcessManager::spawn_cli(command, args) { - Ok(_) => ActionResult::Ok, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - } - } - Action::Wait { duration_ms, .. } => { - tokio::time::sleep(Duration::from_millis(*duration_ms)).await; - ActionResult::Ok - } - Action::AssertScreenshot { - label, - max_diff_percentage, - .. - } => { - // Capture current screenshot and compare with stored one - match ScreenCapture::capture_sandbox() { - Ok(current) => { - if let Some(lbl) = label { - if let Some(expected) = self.screenshots.get(lbl) { - let options = DiffOptions { - max_diff_percentage: *max_diff_percentage, - ..Default::default() - }; - match diff_images(expected, ¤t, &options) { - Ok(diff) => ActionResult::DiffResult { - label: lbl.clone(), - diff, - }, - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - } - } else { - ActionResult::Error { - message: format!( - "No reference screenshot found for label: {lbl}" - ), - } - } - } else { - ActionResult::Error { - message: "AssertScreenshot requires a label".to_string(), - } - } - } - Err(e) => ActionResult::Error { - message: e.to_string(), - }, - } - } - } - } - - /// Get captured screenshots - pub fn screenshots(&self) -> &HashMap> { - &self.screenshots - } -} diff --git a/crates/sandbox-core/src/process/mod.rs b/crates/sandbox-core/src/process/mod.rs index bdf8c3d..1a83232 100644 --- a/crates/sandbox-core/src/process/mod.rs +++ b/crates/sandbox-core/src/process/mod.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::Write; use std::sync::Mutex; +use tracing::{debug, info, trace, warn}; #[cfg(target_os = "macos")] use { @@ -20,10 +21,15 @@ pub struct ProcessInfo { pub is_running: bool, } -/// PTY session holding the reader/writer handles for I/O +/// PTY session holding the reader/writer handles for I/O. +/// +/// The reader is wrapped in `Option` so `read_output` can temporarily +/// take it out of the session before performing a potentially-blocking +/// read — this avoids holding the global `SESSIONS` lock across I/O, +/// which would starve the tokio runtime. #[cfg(target_os = "macos")] struct PtySession { - reader: Box, + reader: Option>, writer: Box, #[allow(dead_code)] child_pid: u32, @@ -41,10 +47,20 @@ static NEXT_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new pub struct ProcessManager; impl ProcessManager { - /// Launch a macOS .app by path using the `open` command - /// This avoids ObjC NSExceptions that crash the Rust process + /// Launch a macOS .app by path using the `open` command. + /// This avoids ObjC NSExceptions that crash the Rust process. + /// Returns (ProcessInfo, Option) — the window is discovered by + /// searching for a title containing the app's stem name after a short delay. #[cfg(target_os = "macos")] pub fn spawn_app(app_path: &str) -> Result { + let (info, _window_id) = Self::spawn_app_with_window(app_path)?; + Ok(info) + } + + /// Launch a macOS .app and discover its SCWindow ID. + /// Returns both the process info and the discovered window ID (if found). + #[cfg(target_os = "macos")] + pub fn spawn_app_with_window(app_path: &str) -> Result<(ProcessInfo, Option)> { let path = std::path::Path::new(app_path); if !path.exists() { return Err(AppError::Process(format!( @@ -52,6 +68,12 @@ impl ProcessManager { ))); } + let app_name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let output = std::process::Command::new("open") .arg(app_path) .output() @@ -65,16 +87,23 @@ impl ProcessManager { } let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(ProcessInfo { + let info = ProcessInfo { pid: id, - name: std::path::Path::new(app_path) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(), + name: app_name.clone(), path: Some(app_path.to_string()), is_running: true, - }) + }; + + // Wait for the app window to appear, then discover its SCWindow ID + std::thread::sleep(std::time::Duration::from_millis(800)); + let window_id = crate::capture::ScreenCapture::find_window_by_title(&app_name).ok(); + + info!( + "Launched app: {} (tracked_id={}, window_id={:?})", + app_path, id, window_id + ); + + Ok((info, window_id)) } #[cfg(not(target_os = "macos"))] @@ -126,18 +155,16 @@ impl ProcessManager { sessions.insert( tracked_id, PtySession { - reader, + reader: Some(reader), writer, child_pid: child_pid.unwrap_or(0), command: command.to_string(), }, ); - tracing::info!( + info!( "Spawned CLI: {} (tracked_id={}, os_pid={:?})", - command, - tracked_id, - child_pid + command, tracked_id, child_pid ); Ok(ProcessInfo { @@ -189,7 +216,7 @@ impl ProcessManager { } // Dropping the master closes the PTY drop(session); - tracing::info!("Killed process: tracked_id={}, os_pid={}", pid, os_pid); + info!("Killed process: tracked_id={}, os_pid={}", pid, os_pid); } else { return Err(AppError::Process(format!( "Process {pid} not found in sandbox" @@ -235,24 +262,63 @@ impl ProcessManager { )) } - /// Read output from a PTY process (non-blocking) + /// Read output from a PTY process. + /// + /// Takes the reader out of the session before reading so that the + /// global `SESSIONS` lock is NOT held across the potentially-blocking + /// `read()` call. Without this, a PTY with no available data can + /// starve the tokio runtime by blocking a worker thread that holds + /// the lock. #[cfg(target_os = "macos")] pub fn read_output(pid: u32) -> Result> { use std::io::Read; - let mut sessions = SESSIONS - .lock() - .map_err(|e| AppError::Process(e.to_string()))?; - if let Some(session) = sessions.get_mut(&pid) { - let mut buf = [0u8; 4096]; - match session.reader.read(&mut buf) { - Ok(0) => Ok(None), - Ok(n) => Ok(Some(String::from_utf8_lossy(&buf[..n]).to_string())), - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None), - Err(e) => Err(AppError::Process(format!("Failed to read PTY: {e}"))), + + // Step 1 – take the reader out (briefly hold the lock) + let mut reader = { + let mut sessions = SESSIONS + .lock() + .map_err(|e| AppError::Process(e.to_string()))?; + sessions + .get_mut(&pid) + .and_then(|s| s.reader.take()) + .ok_or_else(|| { + AppError::Process(format!("Process {pid} not found or reader busy")) + })? + }; + + // Step 2 – read WITHOUT holding the global lock + let mut buf = [0u8; 4096]; + let result = match reader.read(&mut buf) { + Ok(0) => { + debug!("PTY pid={pid}: EOF (process exited)"); + Ok(None) + } + Ok(n) => { + let text = String::from_utf8_lossy(&buf[..n]).to_string(); + debug!("PTY pid={pid}: read {n} bytes"); + Ok(Some(text)) + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + trace!("PTY pid={pid}: no data available (WouldBlock)"); + Ok(None) + } + Err(e) => { + warn!("PTY pid={pid}: read error: {e}"); + Err(AppError::Process(format!("Failed to read PTY: {e}"))) + } + }; + + // Step 3 – put the reader back (briefly hold the lock) + { + let mut sessions = SESSIONS + .lock() + .map_err(|e| AppError::Process(e.to_string()))?; + if let Some(session) = sessions.get_mut(&pid) { + session.reader = Some(reader); } - } else { - Err(AppError::Process(format!("Process {pid} not found"))) } + + result } #[cfg(not(target_os = "macos"))] diff --git a/crates/sandbox-core/src/recorder.rs b/crates/sandbox-core/src/recorder.rs deleted file mode 100644 index d064b7d..0000000 --- a/crates/sandbox-core/src/recorder.rs +++ /dev/null @@ -1,194 +0,0 @@ -use crate::error::Result; -use serde::{Deserialize, Serialize}; -use std::fs::File; -use std::io::{BufWriter, Write}; -use std::path::PathBuf; -use std::sync::Mutex; -use std::time::{Duration, SystemTime}; - -/// A recorded user action -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Action { - Click { - x: f64, - y: f64, - button: String, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - DoubleClick { - x: f64, - y: f64, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - TypeText { - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - PressKey { - key: String, - modifiers: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - Scroll { - x: f64, - y: f64, - direction: String, - amount: i32, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - Drag { - from_x: f64, - from_y: f64, - to_x: f64, - to_y: f64, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - Screenshot { - label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - SpawnApp { - path: String, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - SpawnCli { - command: String, - args: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - Wait { - duration_ms: u64, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, - AssertScreenshot { - label: Option, - max_diff_percentage: f64, - #[serde(skip_serializing_if = "Option::is_none")] - timestamp_ms: Option, - }, -} - -/// Records user actions, flushing to a JSONL file -pub struct ActionRecorder { - actions: Mutex>, - output_path: Mutex>, - start_time: SystemTime, - enabled: Mutex, -} - -impl ActionRecorder { - pub fn new() -> Self { - Self { - actions: Mutex::new(Vec::new()), - output_path: Mutex::new(None), - start_time: SystemTime::now(), - enabled: Mutex::new(false), - } - } - - /// Start recording. If a path is provided, also flush to file. - pub fn start(&self, output_path: Option) -> Result<()> { - let mut enabled = self.enabled.lock().unwrap(); - *enabled = true; - if let Some(path) = output_path { - *self.output_path.lock().unwrap() = Some(path); - } - *self.actions.lock().unwrap() = Vec::new(); - Ok(()) - } - - /// Stop recording - pub fn stop(&self) -> Result> { - let mut enabled = self.enabled.lock().unwrap(); - *enabled = false; - let actions = self.actions.lock().unwrap().clone(); - Ok(actions) - } - - /// Record an action - pub fn record(&self, action: Action) -> Result<()> { - if !*self.enabled.lock().unwrap() { - return Ok(()); - } - let elapsed = self.start_time.elapsed().unwrap_or(Duration::ZERO); - let timestamp_ms = elapsed.as_millis() as u64; - - let mut action = action; - // Set timestamp on the action - match &mut action { - Action::Click { - timestamp_ms: ts, .. - } - | Action::DoubleClick { - timestamp_ms: ts, .. - } - | Action::TypeText { - timestamp_ms: ts, .. - } - | Action::PressKey { - timestamp_ms: ts, .. - } - | Action::Scroll { - timestamp_ms: ts, .. - } - | Action::Drag { - timestamp_ms: ts, .. - } - | Action::Screenshot { - timestamp_ms: ts, .. - } - | Action::SpawnApp { - timestamp_ms: ts, .. - } - | Action::SpawnCli { - timestamp_ms: ts, .. - } - | Action::Wait { - timestamp_ms: ts, .. - } - | Action::AssertScreenshot { - timestamp_ms: ts, .. - } => { - *ts = Some(timestamp_ms); - } - } - - self.actions.lock().unwrap().push(action.clone()); - - // Flush to file if path is set - if let Some(ref path) = *self.output_path.lock().unwrap() { - let file = File::options().create(true).append(true).open(path)?; - let mut writer = BufWriter::new(file); - let line = serde_json::to_string(&action)?; - writeln!(writer, "{line}")?; - writer.flush()?; - } - - Ok(()) - } - - pub fn is_enabled(&self) -> bool { - *self.enabled.lock().unwrap() - } - - pub fn actions(&self) -> Vec { - self.actions.lock().unwrap().clone() - } -} - -impl Default for ActionRecorder { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/sandbox-core/src/report.rs b/crates/sandbox-core/src/report.rs deleted file mode 100644 index ffaabe0..0000000 --- a/crates/sandbox-core/src/report.rs +++ /dev/null @@ -1,204 +0,0 @@ -use crate::error::Result; -use serde::{Deserialize, Serialize}; - -/// Represents the outcome of a single test step -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StepResult { - /// Step index (0-based) - pub index: usize, - /// Human-readable step description - pub description: String, - /// Step status - pub status: StepStatus, - /// Duration of this step - pub duration_ms: u64, - /// Optional screenshot captured during this step - #[serde(skip_serializing_if = "Option::is_none")] - pub screenshot_label: Option, - /// Optional error message - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - /// Optional diff result for assertion steps - #[serde(skip_serializing_if = "Option::is_none")] - pub diff_percentage: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum StepStatus { - Pass, - Fail, - Skip, -} - -/// Full test report -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TestReport { - /// Test scenario name - pub name: String, - /// Overall test status - pub status: StepStatus, - /// Total duration - pub duration_ms: u64, - /// Number of steps - pub total_steps: usize, - /// Number of passed steps - pub passed_steps: usize, - /// Number of failed steps - pub failed_steps: usize, - /// Per-step results - pub steps: Vec, -} - -impl TestReport { - pub fn new(name: &str) -> Self { - Self { - name: name.to_string(), - status: StepStatus::Pass, - duration_ms: 0, - total_steps: 0, - passed_steps: 0, - failed_steps: 0, - steps: Vec::new(), - } - } - - /// Add a step result and update summary - pub fn add_step(&mut self, step: StepResult) { - if step.status == StepStatus::Fail { - self.status = StepStatus::Fail; - self.failed_steps += 1; - } else if step.status == StepStatus::Pass { - self.passed_steps += 1; - } - self.total_steps += 1; - self.duration_ms += step.duration_ms; - self.steps.push(step); - } - - /// Render report as Markdown - pub fn to_markdown(&self) -> String { - let status_icon = match self.status { - StepStatus::Pass => "✅", - StepStatus::Fail => "❌", - StepStatus::Skip => "⏭️", - }; - - let mut md = format!( - "# {} Test Report: {}\n\n\ - **Status**: {} \n\ - **Duration**: {:.2}s \n\ - **Steps**: {} total, {} passed, {} failed\n\n\ - | # | Description | Status | Duration | Details |\n\ - |---|------------|--------|----------|--------|\n", - status_icon, - self.name, - match self.status { - StepStatus::Pass => "PASSED", - StepStatus::Fail => "FAILED", - StepStatus::Skip => "SKIPPED", - }, - self.duration_ms as f64 / 1000.0, - self.total_steps, - self.passed_steps, - self.failed_steps, - ); - - for step in &self.steps { - let icon = match step.status { - StepStatus::Pass => "✅", - StepStatus::Fail => "❌", - StepStatus::Skip => "⏭️", - }; - let details = if let Some(ref err) = step.error { - err.clone() - } else if let Some(diff) = step.diff_percentage { - format!("diff: {diff:.2}%") - } else { - String::new() - }; - md.push_str(&format!( - "| {} | {} | {} | {}ms | {} |\n", - step.index + 1, - step.description, - icon, - step.duration_ms, - details, - )); - } - - md - } - - /// Render report as JSON - pub fn to_json(&self) -> Result { - serde_json::to_string_pretty(self).map_err(Into::into) - } - - /// Render report as simple HTML - pub fn to_html(&self) -> String { - let status_class = match self.status { - StepStatus::Pass => "pass", - StepStatus::Fail => "fail", - StepStatus::Skip => "skip", - }; - let status_text = match self.status { - StepStatus::Pass => "PASSED", - StepStatus::Fail => "FAILED", - StepStatus::Skip => "SKIPPED", - }; - - let mut html = format!( - r#" -Test Report: {name} - -
-

Test Report: {name}

-
{status_text}
-

{passed}/{total} steps passed | {duration:.2}s

-
-"#, - name = self.name, - status_class = status_class, - status_text = status_text, - passed = self.passed_steps, - total = self.total_steps, - duration = self.duration_ms as f64 / 1000.0, - ); - - for step in &self.steps { - let cls = match step.status { - StepStatus::Pass => "pass", - StepStatus::Fail => "fail", - StepStatus::Skip => "skip", - }; - let icon = match step.status { - StepStatus::Pass => "PASS", - StepStatus::Fail => "FAIL", - StepStatus::Skip => "SKIP", - }; - let diff_str = step.diff_percentage.map(|d| format!("diff: {d:.2}%")); - let details: &str = step.error.as_deref().or(diff_str.as_deref()).unwrap_or(""); - html.push_str(&format!( - "", - step.index + 1, - step.description, - cls, - icon, - step.duration_ms, - details, - )); - } - - html.push_str("
#DescriptionStatusDurationDetails
{}{}{}{}ms{}
"); - html - } -} diff --git a/crates/sandbox-core/src/sandbox/mod.rs b/crates/sandbox-core/src/sandbox/mod.rs index b1396bd..6bdc8cf 100644 --- a/crates/sandbox-core/src/sandbox/mod.rs +++ b/crates/sandbox-core/src/sandbox/mod.rs @@ -1,10 +1,17 @@ use crate::capture::ScreenCapture; use crate::error::{AppError, Result}; +use crate::instance::InstanceKind; use serde::{Deserialize, Serialize}; +use std::time::Instant; /// Sandbox configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxConfig { + pub id: Option, + pub port: Option, + pub mode: Option, + pub command: Option, + pub args: Vec, pub width: u32, pub height: u32, pub title: String, @@ -13,6 +20,11 @@ pub struct SandboxConfig { impl Default for SandboxConfig { fn default() -> Self { Self { + id: None, + port: None, + mode: None, + command: None, + args: Vec::new(), width: 1280, height: 800, title: "System Test Sandbox".to_string(), @@ -30,8 +42,9 @@ pub struct SubWindow { /// Sandbox window state #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxState { + pub sandbox_id: Option, + pub port: Option, pub window_id: Option, - /// Additional sub-windows tracked by the sandbox pub sub_windows: Vec, pub width: u32, pub height: u32, @@ -43,32 +56,38 @@ pub struct SandboxState { pub struct Sandbox { config: SandboxConfig, state: SandboxState, + start_time: Option, } impl Sandbox { pub fn new(config: SandboxConfig) -> Self { let width = config.width; let height = config.height; + let sandbox_id = config.id.clone(); + let port = config.port; Self { config, state: SandboxState { + sandbox_id, + port, window_id: None, sub_windows: Vec::new(), width, height, is_running: false, }, + start_time: None, } } /// Initialize the sandbox with a given window ID (set from Tauri after window creation). - /// The window ID is used by ScreenCaptureKit to scope screenshots to this window only. pub fn init(&mut self, window_id: u32) -> Result<()> { if window_id == 0 { return Err(AppError::WindowNotFound("Invalid window ID (0)".into())); } self.state.window_id = Some(window_id); self.state.is_running = true; + self.start_time = Some(Instant::now()); tracing::info!("Sandbox initialized with window_id={}", window_id); Ok(()) } @@ -79,11 +98,33 @@ impl Sandbox { self.state.is_running = true; } - /// Get the current window ID if the sandbox is initialized pub fn window_id(&self) -> Option { self.state.window_id } + pub fn id(&self) -> Option<&str> { + self.state.sandbox_id.as_deref() + } + + pub fn port(&self) -> Option { + self.state.port + } + + pub fn kind(&self) -> Option { + match (self.config.mode.as_deref(), &self.config.command) { + (Some("cli"), Some(cmd)) => Some(InstanceKind::Cli { + command: cmd.clone(), + args: self.config.args.clone(), + }), + (Some("app"), Some(path)) => Some(InstanceKind::App { path: path.clone() }), + _ => None, + } + } + + pub fn uptime_secs(&self) -> u64 { + self.start_time.map(|t| t.elapsed().as_secs()).unwrap_or(0) + } + /// Take a screenshot of only the sandbox window pub fn screenshot(&self) -> Result> { let window_id = self @@ -93,35 +134,30 @@ impl Sandbox { ScreenCapture::capture_window(window_id) } - /// Get the sandbox configuration pub fn config(&self) -> &SandboxConfig { &self.config } - /// Get current sandbox state pub fn state(&self) -> &SandboxState { &self.state } - /// Shut down the sandbox pub fn shutdown(&mut self) { self.state.is_running = false; self.state.window_id = None; self.state.sub_windows.clear(); + self.start_time = None; tracing::info!("Sandbox shut down"); } - /// Add a sub-window to track pub fn add_window(&mut self, id: u32, title: String) { self.state.sub_windows.push(SubWindow { id, title }); } - /// Remove a sub-window by ID pub fn remove_window(&mut self, id: u32) { self.state.sub_windows.retain(|w| w.id != id); } - /// List all tracked windows (main + sub) pub fn list_windows(&self) -> Vec { let mut windows = self.state.sub_windows.clone(); if let Some(main_id) = self.state.window_id { @@ -142,12 +178,37 @@ mod tests { use super::*; #[test] - fn test_sandbox_new() { + fn test_sandbox_new_default() { let sandbox = Sandbox::new(SandboxConfig::default()); assert_eq!(sandbox.config().width, 1280); assert_eq!(sandbox.config().height, 800); assert!(sandbox.window_id().is_none()); assert!(!sandbox.state().is_running); + assert!(sandbox.id().is_none()); + assert!(sandbox.port().is_none()); + } + + #[test] + fn test_sandbox_new_with_instance_config() { + let config = SandboxConfig { + id: Some("abc123".into()), + port: Some(15801), + mode: Some("cli".into()), + command: Some("claude".into()), + args: vec!["--help".into()], + ..SandboxConfig::default() + }; + let sandbox = Sandbox::new(config); + assert_eq!(sandbox.id(), Some("abc123")); + assert_eq!(sandbox.port(), Some(15801)); + let kind = sandbox.kind().unwrap(); + match kind { + InstanceKind::Cli { command, args } => { + assert_eq!(command, "claude"); + assert_eq!(args, vec!["--help"]); + } + _ => panic!("Expected CLI kind"), + } } #[test] @@ -178,4 +239,24 @@ mod tests { let sandbox = Sandbox::new(SandboxConfig::default()); assert!(sandbox.screenshot().is_err()); } + + #[test] + fn test_sandbox_app_kind() { + let config = SandboxConfig { + id: Some("app123".into()), + port: Some(15802), + mode: Some("app".into()), + command: Some("/Applications/Safari.app".into()), + ..SandboxConfig::default() + }; + let sandbox = Sandbox::new(config); + let kind = sandbox.kind().unwrap(); + assert!(matches!(kind, InstanceKind::App { .. })); + } + + #[test] + fn test_sandbox_uptime_before_init() { + let sandbox = Sandbox::new(SandboxConfig::default()); + assert_eq!(sandbox.uptime_secs(), 0); + } } diff --git a/crates/sandbox-core/src/scenario.rs b/crates/sandbox-core/src/scenario.rs deleted file mode 100644 index ac5afc0..0000000 --- a/crates/sandbox-core/src/scenario.rs +++ /dev/null @@ -1,319 +0,0 @@ -use crate::error::Result; -use crate::player::{ActionPlayer, ActionResult}; -use crate::recorder::Action; -use crate::report::{StepResult, StepStatus, TestReport}; -use serde::Deserialize; -use std::path::Path; -use std::time::Instant; - -/// A test scenario loaded from YAML -#[derive(Debug, Deserialize)] -pub struct Scenario { - pub name: String, - #[serde(default)] - pub description: Option, - pub steps: Vec, -} - -/// A single step in a test scenario -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ScenarioStep { - Click { - x: f64, - y: f64, - #[serde(default = "default_button")] - button: String, - }, - DoubleClick { - x: f64, - y: f64, - }, - TypeText { - text: String, - }, - PressKey { - key: String, - #[serde(default)] - modifiers: Vec, - }, - Scroll { - x: f64, - y: f64, - direction: String, - amount: i32, - }, - Drag { - from_x: f64, - from_y: f64, - to_x: f64, - to_y: f64, - }, - Wait { - /// Wait time in milliseconds - duration_ms: u64, - }, - Screenshot { - #[serde(default)] - label: Option, - }, - SpawnApp { - path: String, - }, - SpawnCli { - command: String, - #[serde(default)] - args: Vec, - }, - AssertScreenshotDiff { - #[serde(default)] - label: Option, - #[serde(default = "default_threshold")] - max_diff_percentage: f64, - }, -} - -fn default_button() -> String { - "left".to_string() -} - -fn default_threshold() -> f64 { - 0.05 -} - -impl ScenarioStep { - /// Convert a ScenarioStep into an Action for the player - fn to_action(&self) -> Action { - match self { - ScenarioStep::Click { x, y, button } => Action::Click { - x: *x, - y: *y, - button: button.clone(), - timestamp_ms: None, - }, - ScenarioStep::DoubleClick { x, y } => Action::DoubleClick { - x: *x, - y: *y, - timestamp_ms: None, - }, - ScenarioStep::TypeText { text } => Action::TypeText { - text: text.clone(), - timestamp_ms: None, - }, - ScenarioStep::PressKey { key, modifiers } => Action::PressKey { - key: key.clone(), - modifiers: modifiers.clone(), - timestamp_ms: None, - }, - ScenarioStep::Scroll { - x, - y, - direction, - amount, - } => Action::Scroll { - x: *x, - y: *y, - direction: direction.clone(), - amount: *amount, - timestamp_ms: None, - }, - ScenarioStep::Drag { - from_x, - from_y, - to_x, - to_y, - } => Action::Drag { - from_x: *from_x, - from_y: *from_y, - to_x: *to_x, - to_y: *to_y, - timestamp_ms: None, - }, - ScenarioStep::Wait { duration_ms } => Action::Wait { - duration_ms: *duration_ms, - timestamp_ms: None, - }, - ScenarioStep::Screenshot { label } => Action::Screenshot { - label: label.clone(), - timestamp_ms: None, - }, - ScenarioStep::SpawnApp { path } => Action::SpawnApp { - path: path.clone(), - timestamp_ms: None, - }, - ScenarioStep::SpawnCli { command, args } => Action::SpawnCli { - command: command.clone(), - args: args.clone(), - timestamp_ms: None, - }, - ScenarioStep::AssertScreenshotDiff { - label, - max_diff_percentage, - } => Action::AssertScreenshot { - label: label.clone(), - max_diff_percentage: *max_diff_percentage, - timestamp_ms: None, - }, - } - } - - /// Human-readable description for the report - fn describe(&self) -> String { - match self { - ScenarioStep::Click { x, y, button } => { - format!("Click ({x}, {y}) button={button}") - } - ScenarioStep::DoubleClick { x, y } => format!("Double-click ({x}, {y})"), - ScenarioStep::TypeText { text } => format!("Type: {text}"), - ScenarioStep::PressKey { key, modifiers } => { - format!("Press key: {key} {modifiers:?}") - } - ScenarioStep::Scroll { - x, - y, - direction, - amount, - } => format!("Scroll ({x}, {y}) {direction} {amount}"), - ScenarioStep::Drag { - from_x, - from_y, - to_x, - to_y, - } => format!("Drag ({from_x}, {from_y}) -> ({to_x}, {to_y})"), - ScenarioStep::Wait { duration_ms } => format!("Wait {duration_ms}ms"), - ScenarioStep::Screenshot { label } => { - format!( - "Screenshot{}", - label - .as_deref() - .map_or(String::new(), |l| format!(" ({l})")) - ) - } - ScenarioStep::SpawnApp { path } => format!("Spawn app: {path}"), - ScenarioStep::SpawnCli { command, args } => { - format!("Spawn CLI: {command} {args:?}") - } - ScenarioStep::AssertScreenshotDiff { - label, - max_diff_percentage, - } => format!( - "Assert screenshot diff (threshold: {:.2}%){}", - max_diff_percentage * 100.0, - label - .as_deref() - .map_or(String::new(), |l| format!(", label: {l}")) - ), - } - } -} - -/// Run a test scenario and produce a report -pub struct ScenarioRunner; - -impl ScenarioRunner { - /// Load a scenario from a YAML file - pub fn load_from_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - let scenario: Scenario = serde_yaml::from_str(&content).map_err(|e| { - crate::error::AppError::Screenshot(format!("Failed to parse scenario: {e}")) - })?; - Ok(scenario) - } - - /// Load a scenario from a YAML string - pub fn load_from_str(yaml: &str) -> Result { - let scenario: Scenario = serde_yaml::from_str(yaml).map_err(|e| { - crate::error::AppError::Screenshot(format!("Failed to parse scenario: {e}")) - })?; - Ok(scenario) - } - - /// Run a scenario and return a test report - #[cfg(target_os = "macos")] - pub async fn run(scenario: &Scenario, speed: f64) -> TestReport { - let mut report = TestReport::new(&scenario.name); - let mut player = ActionPlayer::new(speed); - - let actions: Vec = scenario.steps.iter().map(|s| s.to_action()).collect(); - - let _start = Instant::now(); - let results = player.play(&actions).await; - - for (i, (step, result)) in scenario.steps.iter().zip(results.iter()).enumerate() { - let step_start = Instant::now(); - let duration_ms = step_start.elapsed().as_millis() as u64; - - let step_result = match result { - ActionResult::Ok => StepResult { - index: i, - description: step.describe(), - status: StepStatus::Pass, - duration_ms, - screenshot_label: None, - error: None, - diff_percentage: None, - }, - ActionResult::Screenshot { label, .. } => StepResult { - index: i, - description: step.describe(), - status: StepStatus::Pass, - duration_ms, - screenshot_label: Some(label.clone()), - error: None, - diff_percentage: None, - }, - ActionResult::DiffResult { label, diff } => { - let status = if diff.identical { - StepStatus::Pass - } else { - StepStatus::Fail - }; - StepResult { - index: i, - description: step.describe(), - status, - duration_ms, - screenshot_label: Some(label.clone()), - error: if diff.identical { - None - } else { - Some(format!( - "Screenshot diff: {:.2}% ({} pixels changed)", - diff.diff_percentage, diff.changed_pixels - )) - }, - diff_percentage: Some(diff.diff_percentage), - } - } - ActionResult::Error { message } => StepResult { - index: i, - description: step.describe(), - status: StepStatus::Fail, - duration_ms, - screenshot_label: None, - error: Some(message.clone()), - diff_percentage: None, - }, - }; - - report.add_step(step_result); - } - - report - } - - #[cfg(not(target_os = "macos"))] - pub async fn run(_scenario: &Scenario, _speed: f64) -> TestReport { - let mut report = TestReport::new(&_scenario.name); - report.add_step(StepResult { - index: 0, - description: "Scenario execution".to_string(), - status: StepStatus::Skip, - duration_ms: 0, - screenshot_label: None, - error: Some("Scenario execution only available on macOS".to_string()), - diff_percentage: None, - }); - report - } -} diff --git a/crates/sandbox-core/src/server/mod.rs b/crates/sandbox-core/src/server/mod.rs new file mode 100644 index 0000000..0c47903 --- /dev/null +++ b/crates/sandbox-core/src/server/mod.rs @@ -0,0 +1,1063 @@ +use crate::automation::ax_ui::{UiElement, UiInspector}; +use crate::automation::cg_event::{InputSimulator, MouseButton}; +use crate::capture::ScreenCapture; +use crate::error::AppError; +use crate::process::ProcessManager; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::Mutex; +use tower_http::cors::{Any, CorsLayer}; + +/// Shared application state for the HTTP server +pub struct AppState { + pub sandbox_id: Option, + pub start_time: Instant, + pub window_id: Option, + pub target_pid: Option, +} + +/// Health check response +#[derive(Serialize)] +struct HealthResponse { + status: String, + version: String, + uptime_secs: u64, + sandbox_id: Option, +} + +/// Sandbox info response +#[derive(Serialize)] +struct SandboxInfoResponse { + sandbox_id: Option, + window_id: Option, + uptime_secs: u64, +} + +#[derive(Deserialize)] +struct ClickRequest { + x: f64, + y: f64, + #[serde(default = "default_button")] + button: String, +} + +fn default_button() -> String { + "left".to_string() +} + +#[derive(Deserialize)] +struct TypeRequest { + text: String, +} + +#[derive(Deserialize)] +struct KeyRequest { + key: String, + #[serde(default)] + modifiers: Vec, +} + +#[derive(Deserialize)] +struct ScrollRequest { + x: f64, + y: f64, + direction: String, + amount: i32, +} + +#[derive(Deserialize)] +struct DragRequest { + from_x: f64, + from_y: f64, + to_x: f64, + to_y: f64, +} + +#[derive(Deserialize)] +struct SpawnAppRequest { + path: String, +} + +#[derive(Deserialize)] +struct SpawnCliRequest { + command: String, + #[serde(default)] + args: Vec, +} + +#[derive(Deserialize)] +struct KillRequest { + pid: u32, +} + +#[derive(Deserialize)] +struct RegionQuery { + x: i32, + y: i32, + width: u32, + height: u32, +} + +#[derive(Deserialize)] +struct ScreenshotQuery { + #[serde(default)] + window_id: Option, +} + +#[derive(Deserialize)] +struct PtyWriteRequest { + pid: u32, + data: String, +} + +#[derive(Deserialize)] +struct UiFindRequest { + window_id: u32, + #[serde(default)] + role: Option, + #[serde(default)] + title: Option, +} + +#[derive(Deserialize)] +struct UiValueQuery { + element_id: String, +} + +/// Build the HTTP API router +pub fn build_router(state: Arc>) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + Router::new() + .route("/health", get(health_handler)) + .route("/sandbox/info", get(sandbox_info_handler)) + .route("/shutdown", post(shutdown_handler)) + .route("/windows", get(list_windows_handler)) + .route("/processes", get(list_processes_handler)) + .route("/app/spawn", post(spawn_app_handler)) + .route("/cli/spawn", post(spawn_cli_handler)) + .route("/process/kill", post(kill_process_handler)) + .route("/input/click", post(click_handler)) + .route("/input/type", post(type_handler)) + .route("/input/key", post(key_handler)) + .route("/input/scroll", post(scroll_handler)) + .route("/input/drag", post(drag_handler)) + .route("/screenshot", get(screenshot_handler)) + .route("/screenshot/region", get(screenshot_region_handler)) + .route("/pty/write", post(pty_write_handler)) + .route("/pty/output/{pid}", get(pty_output_handler)) + .route("/ui/inspect/{window_id}", get(ui_inspect_handler)) + .route("/ui/find", post(ui_find_handler)) + .route("/ui/value", get(ui_value_handler)) + .layer(cors) + .with_state(state) +} + +// ── Handlers ────────────────────────────────────────────── + +async fn health_handler(State(state): State>>) -> Json { + let s = state.lock().await; + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_secs: s.start_time.elapsed().as_secs(), + sandbox_id: s.sandbox_id.clone(), + }) +} + +async fn sandbox_info_handler( + State(state): State>>, +) -> Json { + let s = state.lock().await; + Json(SandboxInfoResponse { + sandbox_id: s.sandbox_id.clone(), + window_id: s.window_id, + uptime_secs: s.start_time.elapsed().as_secs(), + }) +} + +async fn shutdown_handler() -> Json { + tracing::info!("Shutdown requested via HTTP"); + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_millis(100)); + std::process::exit(0); + }); + Json(serde_json::json!({"shutting_down": true})) +} + +async fn list_windows_handler() -> Result>, AppError> { + let windows = tokio::task::spawn_blocking(ScreenCapture::list_windows) + .await + .map_err(|e| AppError::Process(format!("list_windows panicked: {e}")))??; + tracing::debug!("list_windows: {} windows", windows.len()); + Ok(Json(windows)) +} + +async fn list_processes_handler() -> Result>, AppError> { + let processes = ProcessManager::list_processes()?; + tracing::debug!("list_processes: {} running", processes.len()); + Ok(Json(processes)) +} + +async fn spawn_app_handler( + Json(req): Json, +) -> Result, AppError> { + let path = req.path.clone(); + let info = tokio::task::spawn_blocking(move || ProcessManager::spawn_app(&req.path)) + .await + .map_err(|e| AppError::Process(format!("spawn_app panicked: {e}")))??; + tracing::info!("spawned app: {path}"); + Ok(Json(info)) +} + +async fn spawn_cli_handler( + Json(req): Json, +) -> Result, AppError> { + let cmd = req.command.clone(); + let info = + tokio::task::spawn_blocking(move || ProcessManager::spawn_cli(&req.command, &req.args)) + .await + .map_err(|e| AppError::Process(format!("spawn_cli panicked: {e}")))??; + tracing::info!("spawned cli: {cmd}"); + Ok(Json(info)) +} + +async fn kill_process_handler( + Json(req): Json, +) -> Result, AppError> { + ProcessManager::kill_process(req.pid)?; + Ok(Json(serde_json::json!({"killed": req.pid}))) +} + +const SANDBOX_WINDOW_REQUIRED: &str = + "Sandbox window not available. Build and run the Tauri app first, or use `sandbox-cli start --cli/--app`."; + +fn require_target_pid(target_pid: Option) -> Result { + target_pid.ok_or_else(|| AppError::BadRequest(SANDBOX_WINDOW_REQUIRED.to_string())) +} + +async fn click_handler( + State(state): State>>, + Json(req): Json, +) -> Result, AppError> { + let target_pid = require_target_pid(state.lock().await.target_pid)?; + let button = match req.button.to_lowercase().as_str() { + "left" => MouseButton::Left, + "right" => MouseButton::Right, + "middle" => MouseButton::Middle, + other => return Err(AppError::BadRequest(format!("Unknown button: {other}"))), + }; + InputSimulator::click(req.x, req.y, button, Some(target_pid))?; + Ok(Json( + serde_json::json!({"clicked": {"x": req.x, "y": req.y, "button": req.button}}), + )) +} + +async fn type_handler( + State(state): State>>, + Json(req): Json, +) -> Result, AppError> { + let target_pid = require_target_pid(state.lock().await.target_pid)?; + InputSimulator::type_text(&req.text, Some(target_pid))?; + Ok(Json(serde_json::json!({"typed": req.text}))) +} + +async fn key_handler( + State(state): State>>, + Json(req): Json, +) -> Result, AppError> { + let target_pid = require_target_pid(state.lock().await.target_pid)?; + let mod_refs: Vec<&str> = req.modifiers.iter().map(|s| s.as_str()).collect(); + InputSimulator::press_key(&req.key, &mod_refs, Some(target_pid))?; + Ok(Json( + serde_json::json!({"pressed": {"key": req.key, "modifiers": req.modifiers}}), + )) +} + +async fn scroll_handler( + State(state): State>>, + Json(req): Json, +) -> Result, AppError> { + let target_pid = require_target_pid(state.lock().await.target_pid)?; + InputSimulator::scroll(req.x, req.y, &req.direction, req.amount, Some(target_pid))?; + Ok(Json(serde_json::json!({"scrolled": true}))) +} + +async fn drag_handler( + State(state): State>>, + Json(req): Json, +) -> Result, AppError> { + let target_pid = require_target_pid(state.lock().await.target_pid)?; + InputSimulator::drag(req.from_x, req.from_y, req.to_x, req.to_y, Some(target_pid))?; + Ok(Json(serde_json::json!({"dragged": true}))) +} + +async fn screenshot_handler( + State(state): State>>, + Query(q): Query, +) -> Result { + let window_id = q.window_id.or(state.lock().await.window_id); + match window_id { + Some(id) => { + 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())), + } +} + +async fn screenshot_region_handler( + Query(q): Query, +) -> Result { + let png_data = ScreenCapture::capture_region(q.x, q.y, q.width, q.height)?; + Ok((StatusCode::OK, [("content-type", "image/png")], png_data)) +} + +async fn pty_write_handler( + Json(req): Json, +) -> Result, AppError> { + ProcessManager::send_input(req.pid, req.data.as_bytes())?; + Ok(Json(serde_json::json!({"written": true}))) +} + +async fn pty_output_handler(Path(pid): Path) -> Result, AppError> { + let output = tokio::task::spawn_blocking(move || ProcessManager::read_output(pid)) + .await + .map_err(|e| AppError::Process(format!("pty_output panicked: {e}")))??; + Ok(Json(serde_json::json!({"output": output}))) +} + +async fn ui_inspect_handler(Path(window_id): Path) -> Result, AppError> { + let result = tokio::task::spawn_blocking(move || UiInspector::inspect_window(window_id)) + .await + .map_err(|e| AppError::Accessibility(format!("UI inspect task failed: {e}")))?; + Ok(Json(result?)) +} + +async fn ui_find_handler(Json(req): Json) -> Result>, AppError> { + let window_id = req.window_id; + let role = req.role; + let title = req.title; + let result = tokio::task::spawn_blocking(move || { + UiInspector::find_elements(window_id, role.as_deref(), title.as_deref()) + }) + .await + .map_err(|e| AppError::Accessibility(format!("UI find task failed: {e}")))?; + Ok(Json(result?)) +} + +async fn ui_value_handler( + Query(q): Query, +) -> Result, AppError> { + let value = UiInspector::get_element_value(&q.element_id)?; + Ok(Json(serde_json::json!({ "value": value }))) +} + +// ── Error handling ──────────────────────────────────────── + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let (status, message) = match &self { + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::WindowNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + AppError::Instance(msg) => (StatusCode::NOT_FOUND, msg.clone()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + (status, Json(serde_json::json!({"error": message}))).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use std::sync::Arc; + use tokio::sync::Mutex; + use tower::ServiceExt; + + fn test_state() -> Arc> { + Arc::new(Mutex::new(AppState { + sandbox_id: Some("test-sandbox-01".into()), + start_time: Instant::now(), + window_id: Some(42), + target_pid: None, + })) + } + + fn test_router() -> Router { + build_router(test_state()) + } + + // ── Health ───────────────────────────────────────────────── + + #[tokio::test] + async fn health_returns_ok() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/health") + .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"], "ok"); + assert_eq!(json["sandbox_id"], "test-sandbox-01"); + } + + // ── Sandbox Info ─────────────────────────────────────────── + + #[tokio::test] + async fn sandbox_info_returns_data() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/sandbox/info") + .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["sandbox_id"], "test-sandbox-01"); + assert_eq!(json["window_id"], 42); + } + + // ── Input handlers ───────────────────────────────────────── + + #[tokio::test] + async fn click_with_valid_button() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/click") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"x": 100, "y": 200, "button": "left"}"#)) + .unwrap(), + ) + .await + .unwrap(); + // target_pid is None in test state — input ops require a sandbox window + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn click_with_right_button() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/click") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"x": 50, "y": 50, "button": "right"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn click_with_middle_button() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/click") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"x": 50, "y": 50, "button": "middle"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn click_bad_request() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/click") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"x": 100, "y": 200, "button": "unknown"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn type_text_handler() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/type") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"text": "hello world"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn key_handler() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/key") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key": "return", "modifiers": ["cmd"]}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn key_handler_no_modifiers() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/key") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key": "escape", "modifiers": []}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn scroll_handler() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/scroll") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"x": 0, "y": 0, "direction": "down", "amount": 3}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn scroll_handler_unknown_direction() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/scroll") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"x": 0, "y": 0, "direction": "diagonal", "amount": 3}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + // target_pid is None — rejected before direction validation + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn drag_handler() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/drag") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"from_x": 0, "from_y": 0, "to_x": 100, "to_y": 100}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + // ── Screenshot ───────────────────────────────────────────── + + #[tokio::test] + async fn screenshot_uses_window_id_from_state() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/screenshot") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!( + matches!(status.as_u16(), 200 | 404 | 500), + "unexpected status: {status}" + ); + } + + #[tokio::test] + async fn screenshot_region() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/screenshot/region?x=0&y=0&width=100&height=100") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!( + matches!(status.as_u16(), 200 | 404 | 500), + "unexpected status: {status}" + ); + } + + // ── Windows / Processes ──────────────────────────────────── + + #[tokio::test] + async fn list_windows() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/windows") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn list_processes() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/processes") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); + } + + // ── Spawn CLI / App ──────────────────────────────────────── + + #[tokio::test] + async fn spawn_cli_echo() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/cli/spawn") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"command": "echo", "args": ["test"]}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn spawn_app_nonexistent() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/app/spawn") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"path": "/tmp/__no_such_app__.app"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); + } + + // ── Kill process ─────────────────────────────────────────── + + #[tokio::test] + async fn kill_process_nonexistent() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/process/kill") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"pid": 99999}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::INTERNAL_SERVER_ERROR + || status == StatusCode::NOT_FOUND + ); + } + + // ── PTY ──────────────────────────────────────────────────── + + #[tokio::test] + async fn pty_write_nonexistent() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/pty/write") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"pid": 99999, "data": "test"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn pty_output_nonexistent() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/pty/output/99999") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR); + } + + // ── UI ───────────────────────────────────────────────────── + + #[tokio::test] + async fn ui_inspect_nonexistent() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/ui/inspect/99999") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status.is_server_error() || status.is_client_error() || status == StatusCode::OK); + } + + #[tokio::test] + async fn ui_find() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/ui/find") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"window_id": 42, "role": "button"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status.is_server_error() || status.is_client_error() || status == StatusCode::OK); + } + + #[tokio::test] + async fn ui_value() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/ui/value?element_id=test123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + assert!(status.is_server_error() || status.is_client_error() || status == StatusCode::OK); + } + + // ── Error handling ───────────────────────────────────────── + + #[tokio::test] + async fn app_error_into_response_bad_request() { + let err = AppError::BadRequest("test message".into()); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn app_error_into_response_not_found() { + let err = AppError::WindowNotFound("window x".into()); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn app_error_into_response_instance() { + let err = AppError::Instance("instance x".into()); + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn app_error_into_response_internal() { + let err = AppError::SandboxNotInitialized; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + // ── Route not found ──────────────────────────────────────── + + #[tokio::test] + async fn unknown_route_returns_404() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/nonexistent") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + // ── Standalone mode rejection ───────────────────────────────── + + #[tokio::test] + async fn standalone_rejects_click() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/click") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"x": 100, "y": 200, "button": "left"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + 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("Sandbox window not available")); + } + + #[tokio::test] + async fn standalone_rejects_type() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/type") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"text": "hello"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn standalone_rejects_key() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/key") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"key": "return", "modifiers": []}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn standalone_rejects_scroll() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/scroll") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"x": 0, "y": 0, "direction": "down", "amount": 3}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn standalone_rejects_drag() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/input/drag") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"from_x": 0, "from_y": 0, "to_x": 100, "to_y": 100}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn standalone_rejects_screenshot() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/screenshot") + .body(Body::empty()) + .unwrap(), + ) + .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 + let status = resp.status(); + assert!( + matches!(status.as_u16(), 200 | 404 | 500), + "unexpected status: {status}" + ); + } + + #[tokio::test] + async fn screenshot_without_window_id_returns_error() { + // Create a state with window_id = None + let state = Arc::new(Mutex::new(AppState { + sandbox_id: Some("test-sandbox-no-window".into()), + start_time: Instant::now(), + window_id: None, + target_pid: None, + })); + let app = build_router(state); + let resp = app + .oneshot( + Request::builder() + .uri("/screenshot") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Should return 400 Bad Request with descriptive error + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + 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("Sandbox window not available")); + } + + #[tokio::test] + async fn screenshot_with_query_window_id() { + // Test that window_id from query parameter is used + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/screenshot?window_id=999") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Should try to capture window 999, which doesn't exist, returning 404 or 500 + let status = resp.status(); + assert!( + matches!(status.as_u16(), 200 | 404 | 500), + "unexpected status: {status}" + ); + } +} diff --git a/crates/sandbox-core/tests/diff_integration.rs b/crates/sandbox-core/tests/diff_integration.rs deleted file mode 100644 index b27e982..0000000 --- a/crates/sandbox-core/tests/diff_integration.rs +++ /dev/null @@ -1,186 +0,0 @@ -use image::{GenericImageView, RgbaImage}; -use sandbox_core::diff::{diff_image, diff_images, DiffOptions}; - -/// Create an in-memory PNG from a solid-color RgbaImage -fn make_solid_png(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec { - let mut img = RgbaImage::new(width, height); - for y in 0..height { - for x in 0..width { - img.put_pixel(x, y, image::Rgba([r, g, b, a])); - } - } - let mut cursor = std::io::Cursor::new(Vec::new()); - img.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); - cursor.into_inner() -} - -// ── diff_images ────────────────────────────────────────────── - -#[test] -fn identical_images_are_detected() { - let png = make_solid_png(10, 10, 255, 0, 0, 255); - let result = diff_images(&png, &png, &DiffOptions::default()).unwrap(); - assert!(result.identical); - assert_eq!(result.diff_percentage, 0.0); - assert_eq!(result.changed_pixels, 0); - assert_eq!(result.total_pixels, 100); -} - -#[test] -fn completely_different_images_detected() { - let red = make_solid_png(10, 10, 255, 0, 0, 255); - let green = make_solid_png(10, 10, 0, 255, 0, 255); - let result = diff_images(&red, &green, &DiffOptions::default()).unwrap(); - assert!(!result.identical); - assert_eq!(result.diff_percentage, 100.0); - assert_eq!(result.changed_pixels, 100); -} - -#[test] -fn size_mismatch_returns_full_diff() { - let small = make_solid_png(10, 10, 255, 0, 0, 255); - let large = make_solid_png(20, 20, 255, 0, 0, 255); - let result = diff_images(&small, &large, &DiffOptions::default()).unwrap(); - assert!(!result.identical); - assert_eq!(result.diff_percentage, 100.0); -} - -#[test] -fn tolerance_respects_threshold() { - let mut img1 = RgbaImage::new(10, 10); - let mut img2 = RgbaImage::new(10, 10); - for y in 0..10 { - for x in 0..10 { - img1.put_pixel(x, y, image::Rgba([100, 100, 100, 255])); - img2.put_pixel(x, y, image::Rgba([105, 105, 105, 255])); // diff = 5 per channel - } - } - let mut cursor = std::io::Cursor::new(Vec::new()); - img1.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); - let png1 = cursor.into_inner(); - let mut cursor = std::io::Cursor::new(Vec::new()); - img2.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); - let png2 = cursor.into_inner(); - - // threshold=1: difference of 5 is detected → all pixels differ - let opts = DiffOptions { - threshold: 1, - ..Default::default() - }; - let result = diff_images(&png1, &png2, &opts).unwrap(); - assert!(!result.identical); - assert_eq!(result.changed_pixels, 100); - - // threshold=10: difference of 5 is within tolerance → identical - let opts = DiffOptions { - threshold: 10, - ..Default::default() - }; - let result = diff_images(&png1, &png2, &opts).unwrap(); - assert!(result.identical); - assert_eq!(result.changed_pixels, 0); -} - -#[test] -fn max_diff_percentage_allows_minor_changes() { - let red = make_solid_png(10, 10, 255, 0, 0, 255); - let green = make_solid_png(10, 10, 0, 255, 0, 255); - - // max_diff_percentage = 100 means even fully different is "identical" - let opts = DiffOptions { - max_diff_percentage: 100.0, - ..Default::default() - }; - let result = diff_images(&red, &green, &opts).unwrap(); - assert!(result.identical); - - // max_diff_percentage = 0 means any diff fails - let opts = DiffOptions { - max_diff_percentage: 0.0, - ..Default::default() - }; - let result = diff_images(&red, &green, &opts).unwrap(); - assert!(!result.identical); -} - -#[test] -fn ignore_border_excludes_edge_pixels() { - // 10x10 red image compared with green image — but border is ignored - let red = make_solid_png(10, 10, 255, 0, 0, 255); - let green = make_solid_png(10, 10, 0, 255, 0, 255); - - let opts = DiffOptions { - ignore_border: 2, - ..Default::default() - }; - let result = diff_images(&red, &green, &opts).unwrap(); - // Border of 2px on each side reduces from 10x10=100 to 6x6=36 pixels checked - assert_eq!(result.total_pixels, 100); - assert_eq!(result.changed_pixels, 36); // only inner 6x6 -} - -#[test] -fn partial_diff_calculated_correctly() { - let mut img1 = RgbaImage::new(10, 10); - let mut img2 = RgbaImage::new(10, 10); - for y in 0..10 { - for x in 0..10 { - if x < 5 { - img1.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); - img2.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); - } else { - img1.put_pixel(x, y, image::Rgba([255, 0, 0, 255])); - img2.put_pixel(x, y, image::Rgba([0, 255, 0, 255])); - } - } - } - let mut cursor = std::io::Cursor::new(Vec::new()); - img1.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); - let png1 = cursor.into_inner(); - let mut cursor = std::io::Cursor::new(Vec::new()); - img2.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); - let png2 = cursor.into_inner(); - - let result = diff_images(&png1, &png2, &DiffOptions::default()).unwrap(); - assert!(!result.identical); - assert_eq!(result.changed_pixels, 50); // half the image - assert!((result.diff_percentage - 50.0).abs() < 0.01); -} - -// ── diff_image (visual diff) ───────────────────────────────── - -#[test] -fn diff_image_generates_valid_png() { - let red = make_solid_png(10, 10, 255, 0, 0, 255); - let green = make_solid_png(10, 10, 0, 255, 0, 255); - let diff_png = diff_image(&red, &green, &DiffOptions::default()).unwrap(); - assert!(!diff_png.is_empty()); - // Should be parseable as PNG - let img = image::load_from_memory(&diff_png).unwrap(); - assert_eq!(img.dimensions(), (10, 10)); -} - -#[test] -fn diff_image_identical_produces_dimmed_original() { - let red = make_solid_png(10, 10, 255, 0, 0, 255); - let diff_png = diff_image(&red, &red, &DiffOptions::default()).unwrap(); - let img = image::load_from_memory(&diff_png).unwrap(); - // No red pixels in the diff (no changes highlighted) - for y in 0..10 { - for x in 0..10 { - let p = img.get_pixel(x, y); - // All pixels should be non-highlight (not pure red) - assert!(p.0[0] < 255 || p.0[1] > 0); - } - } -} - -// ── Default options ────────────────────────────────────────── - -#[test] -fn default_diff_options() { - let opts = DiffOptions::default(); - assert_eq!(opts.threshold, 10); - assert_eq!(opts.max_diff_percentage, 0.0); - assert_eq!(opts.ignore_border, 0); -} diff --git a/crates/sandbox-core/tests/recorder_integration.rs b/crates/sandbox-core/tests/recorder_integration.rs deleted file mode 100644 index 26ee047..0000000 --- a/crates/sandbox-core/tests/recorder_integration.rs +++ /dev/null @@ -1,249 +0,0 @@ -use sandbox_core::recorder::{Action, ActionRecorder}; - -#[test] -fn recorder_starts_disabled() { - let recorder = ActionRecorder::new(); - assert!(!recorder.is_enabled()); - assert!(recorder.actions().is_empty()); -} - -#[test] -fn start_enables_and_clears() { - let recorder = ActionRecorder::new(); - recorder.start(None).unwrap(); - assert!(recorder.is_enabled()); - assert!(recorder.actions().is_empty()); -} - -#[test] -fn record_while_disabled_is_ignored() { - let recorder = ActionRecorder::new(); - recorder - .record(Action::Wait { - duration_ms: 100, - timestamp_ms: None, - }) - .unwrap(); - assert!(recorder.actions().is_empty()); -} - -#[test] -fn record_while_enabled_captures() { - let recorder = ActionRecorder::new(); - recorder.start(None).unwrap(); - recorder - .record(Action::Click { - x: 10.0, - y: 20.0, - button: "left".into(), - timestamp_ms: None, - }) - .unwrap(); - - let actions = recorder.actions(); - assert_eq!(actions.len(), 1); - match &actions[0] { - Action::Click { - x, - y, - button, - timestamp_ms, - } => { - assert_eq!(*x, 10.0); - assert_eq!(*y, 20.0); - assert_eq!(button, "left"); - assert!(timestamp_ms.is_some()); // auto-filled - } - _ => panic!("expected Click"), - } -} - -#[test] -fn stop_disables_and_returns_actions() { - let recorder = ActionRecorder::new(); - recorder.start(None).unwrap(); - recorder - .record(Action::Wait { - duration_ms: 100, - timestamp_ms: None, - }) - .unwrap(); - recorder - .record(Action::Wait { - duration_ms: 200, - timestamp_ms: None, - }) - .unwrap(); - - let actions = recorder.stop().unwrap(); - assert_eq!(actions.len(), 2); - assert!(!recorder.is_enabled()); -} - -#[test] -fn timestamps_are_monotonic() { - let recorder = ActionRecorder::new(); - recorder.start(None).unwrap(); - recorder - .record(Action::Wait { - duration_ms: 10, - timestamp_ms: None, - }) - .unwrap(); - std::thread::sleep(std::time::Duration::from_millis(10)); - recorder - .record(Action::Wait { - duration_ms: 10, - timestamp_ms: None, - }) - .unwrap(); - - let actions = recorder.actions(); - let t0 = match &actions[0] { - Action::Wait { timestamp_ms, .. } => timestamp_ms.unwrap(), - _ => 0, - }; - let t1 = match &actions[1] { - Action::Wait { timestamp_ms, .. } => timestamp_ms.unwrap(), - _ => 0, - }; - assert!(t1 >= t0); -} - -#[test] -fn all_action_types_record_timestamps() { - let recorder = ActionRecorder::new(); - recorder.start(None).unwrap(); - - let actions = vec![ - Action::Click { - x: 0.0, - y: 0.0, - button: "left".into(), - timestamp_ms: None, - }, - Action::DoubleClick { - x: 0.0, - y: 0.0, - timestamp_ms: None, - }, - Action::TypeText { - text: "hi".into(), - timestamp_ms: None, - }, - Action::PressKey { - key: "return".into(), - modifiers: vec![], - timestamp_ms: None, - }, - Action::Scroll { - x: 0.0, - y: 0.0, - direction: "down".into(), - amount: 1, - timestamp_ms: None, - }, - Action::Drag { - from_x: 0.0, - from_y: 0.0, - to_x: 1.0, - to_y: 1.0, - timestamp_ms: None, - }, - Action::Screenshot { - label: Some("s".into()), - timestamp_ms: None, - }, - Action::SpawnApp { - path: "/a.app".into(), - timestamp_ms: None, - }, - Action::SpawnCli { - command: "ls".into(), - args: vec![], - timestamp_ms: None, - }, - Action::Wait { - duration_ms: 100, - timestamp_ms: None, - }, - Action::AssertScreenshot { - label: Some("s".into()), - max_diff_percentage: 0.05, - timestamp_ms: None, - }, - ]; - - for a in &actions { - recorder.record(a.clone()).unwrap(); - } - - let recorded = recorder.actions(); - assert_eq!(recorded.len(), actions.len()); - - // Verify every action got a timestamp - let check_ts = |a: &Action| { - let ts = match a { - Action::Click { timestamp_ms, .. } - | Action::DoubleClick { timestamp_ms, .. } - | Action::TypeText { timestamp_ms, .. } - | Action::PressKey { timestamp_ms, .. } - | Action::Scroll { timestamp_ms, .. } - | Action::Drag { timestamp_ms, .. } - | Action::Screenshot { timestamp_ms, .. } - | Action::SpawnApp { timestamp_ms, .. } - | Action::SpawnCli { timestamp_ms, .. } - | Action::Wait { timestamp_ms, .. } - | Action::AssertScreenshot { timestamp_ms, .. } => *timestamp_ms, - }; - assert!(ts.is_some(), "action missing timestamp"); - }; - - for a in &recorded { - check_ts(a); - } -} - -#[test] -fn action_json_roundtrip() { - let orig = Action::Click { - x: 1.5, - y: 2.5, - button: "right".into(), - timestamp_ms: Some(42), - }; - let json = serde_json::to_string(&orig).unwrap(); - let parsed: Action = serde_json::from_str(&json).unwrap(); - match parsed { - Action::Click { - x, - y, - button, - timestamp_ms, - } => { - assert_eq!(x, 1.5); - assert_eq!(y, 2.5); - assert_eq!(button, "right"); - assert_eq!(timestamp_ms, Some(42)); - } - _ => panic!("roundtrip failed"), - } -} - -#[test] -fn action_serde_tagged_enum() { - // Verify JSON uses "type" field - let action = Action::TypeText { - text: "hello".into(), - timestamp_ms: None, - }; - let json = serde_json::to_string(&action).unwrap(); - assert!(json.contains(r#""type":"type_text""#)); - assert!(json.contains("hello")); -} - -#[test] -fn default_recorder() { - let recorder: ActionRecorder = Default::default(); - assert!(!recorder.is_enabled()); -} diff --git a/crates/sandbox-core/tests/sandbox_integration.rs b/crates/sandbox-core/tests/sandbox_integration.rs index ea00672..677a7ed 100644 --- a/crates/sandbox-core/tests/sandbox_integration.rs +++ b/crates/sandbox-core/tests/sandbox_integration.rs @@ -106,6 +106,7 @@ fn custom_sandbox_config() { width: 1920, height: 1080, title: "Custom Sandbox".into(), + ..SandboxConfig::default() }; let sandbox = Sandbox::new(config.clone()); assert_eq!(sandbox.config().width, 1920); diff --git a/crates/sandbox-core/tests/scenario_integration.rs b/crates/sandbox-core/tests/scenario_integration.rs deleted file mode 100644 index c216a21..0000000 --- a/crates/sandbox-core/tests/scenario_integration.rs +++ /dev/null @@ -1,378 +0,0 @@ -use sandbox_core::report::{StepResult, StepStatus, TestReport}; -use sandbox_core::scenario::{ScenarioRunner, ScenarioStep}; - -const FULL_SCENARIO: &str = r#" -name: "Full feature test" -description: "Tests all 12 action types" -steps: - - type: click - x: 100 - y: 200 - button: left - - type: double_click - x: 150 - y: 250 - - type: type_text - text: "hello world" - - type: press_key - key: return - modifiers: - - cmd - - type: scroll - x: 100 - y: 200 - direction: down - amount: 3 - - type: drag - from_x: 100 - from_y: 100 - to_x: 200 - to_y: 200 - - type: wait - duration_ms: 500 - - type: screenshot - label: "before_action" - - type: spawn_app - path: "/Applications/Calculator.app" - - type: spawn_cli - command: "echo" - args: - - "hello" - - type: assert_screenshot_diff - label: "before_action" - max_diff_percentage: 0.05 -"#; - -#[test] -fn load_scenario_from_yaml() { - let scenario = ScenarioRunner::load_from_str(FULL_SCENARIO).unwrap(); - assert_eq!(scenario.name, "Full feature test"); - assert_eq!( - scenario.description.as_deref(), - Some("Tests all 12 action types") - ); - assert_eq!(scenario.steps.len(), 11); -} - -#[test] -fn parse_all_step_types() { - let scenario = ScenarioRunner::load_from_str(FULL_SCENARIO).unwrap(); - - // Verify each step parsed correctly - match &scenario.steps[0] { - ScenarioStep::Click { x, y, button } => { - assert_eq!(*x, 100.0); - assert_eq!(*y, 200.0); - assert_eq!(button, "left"); - } - _ => panic!("expected Click"), - } - - match &scenario.steps[1] { - ScenarioStep::DoubleClick { x, y } => { - assert_eq!(*x, 150.0); - assert_eq!(*y, 250.0); - } - _ => panic!("expected DoubleClick"), - } - - match &scenario.steps[2] { - ScenarioStep::TypeText { text } => assert_eq!(text, "hello world"), - _ => panic!("expected TypeText"), - } - - match &scenario.steps[3] { - ScenarioStep::PressKey { key, modifiers } => { - assert_eq!(key, "return"); - assert_eq!(modifiers, &["cmd"]); - } - _ => panic!("expected PressKey"), - } - - match &scenario.steps[4] { - ScenarioStep::Scroll { - x, - y, - direction, - amount, - } => { - assert_eq!(*x, 100.0); - assert_eq!(*y, 200.0); - assert_eq!(direction, "down"); - assert_eq!(*amount, 3); - } - _ => panic!("expected Scroll"), - } - - match &scenario.steps[5] { - ScenarioStep::Drag { - from_x, - from_y, - to_x, - to_y, - } => { - assert_eq!(*from_x, 100.0); - assert_eq!(*from_y, 100.0); - assert_eq!(*to_x, 200.0); - assert_eq!(*to_y, 200.0); - } - _ => panic!("expected Drag"), - } - - match &scenario.steps[6] { - ScenarioStep::Wait { duration_ms } => assert_eq!(*duration_ms, 500), - _ => panic!("expected Wait"), - } - - match &scenario.steps[7] { - ScenarioStep::Screenshot { label } => assert_eq!(label.as_deref(), Some("before_action")), - _ => panic!("expected Screenshot"), - } - - match &scenario.steps[8] { - ScenarioStep::SpawnApp { path } => assert!(path.contains("Calculator.app")), - _ => panic!("expected SpawnApp"), - } - - match &scenario.steps[9] { - ScenarioStep::SpawnCli { command, args } => { - assert_eq!(command, "echo"); - assert_eq!(args, &["hello"]); - } - _ => panic!("expected SpawnCli"), - } - - match &scenario.steps[10] { - ScenarioStep::AssertScreenshotDiff { - label, - max_diff_percentage, - } => { - assert_eq!(label.as_deref(), Some("before_action")); - assert!((*max_diff_percentage - 0.05).abs() < 0.001); - } - _ => panic!("expected AssertScreenshotDiff"), - } -} - -// ── TestReport ─────────────────────────────────────────────── - -#[test] -fn empty_report_passes() { - let report = TestReport::new("empty test"); - assert_eq!(report.name, "empty test"); - assert_eq!(report.status, StepStatus::Pass); - assert_eq!(report.total_steps, 0); - assert_eq!(report.passed_steps, 0); - assert_eq!(report.failed_steps, 0); -} - -#[test] -fn report_tracks_pass_fail_counts() { - let mut report = TestReport::new("mixed test"); - - report.add_step(StepResult { - index: 0, - description: "step 1".into(), - status: StepStatus::Pass, - duration_ms: 100, - screenshot_label: None, - error: None, - diff_percentage: None, - }); - - report.add_step(StepResult { - index: 1, - description: "step 2".into(), - status: StepStatus::Fail, - duration_ms: 200, - screenshot_label: None, - error: Some("something broke".into()), - diff_percentage: None, - }); - - report.add_step(StepResult { - index: 2, - description: "step 3".into(), - status: StepStatus::Pass, - duration_ms: 50, - screenshot_label: Some("s1".into()), - error: None, - diff_percentage: None, - }); - - assert_eq!(report.status, StepStatus::Fail); - assert_eq!(report.total_steps, 3); - assert_eq!(report.passed_steps, 2); - assert_eq!(report.failed_steps, 1); - assert_eq!(report.duration_ms, 350); -} - -#[test] -fn report_renders_markdown() { - let mut report = TestReport::new("smoke test"); - report.add_step(StepResult { - index: 0, - description: "click button".into(), - status: StepStatus::Pass, - duration_ms: 123, - screenshot_label: None, - error: None, - diff_percentage: None, - }); - - let md = report.to_markdown(); - assert!(md.contains("smoke test")); - assert!(md.contains("click button")); - assert!(md.contains("PASSED")); - assert!(md.contains("123ms")); -} - -#[test] -fn report_renders_failure_markdown() { - let mut report = TestReport::new("failing test"); - report.add_step(StepResult { - index: 0, - description: "bad step".into(), - status: StepStatus::Fail, - duration_ms: 10, - screenshot_label: None, - error: Some("crash".into()), - diff_percentage: None, - }); - - let md = report.to_markdown(); - assert!(md.contains("FAILED")); - assert!(md.contains("crash")); -} - -#[test] -fn report_renders_json() { - let mut report = TestReport::new("json test"); - report.add_step(StepResult { - index: 0, - description: "step".into(), - status: StepStatus::Pass, - duration_ms: 5, - screenshot_label: None, - error: None, - diff_percentage: None, - }); - - let json = report.to_json().unwrap(); - assert!(json.contains("json test")); - assert!(json.contains("step")); -} - -#[test] -fn report_renders_html() { - let mut report = TestReport::new("html test"); - report.add_step(StepResult { - index: 0, - description: "step".into(), - status: StepStatus::Pass, - duration_ms: 5, - screenshot_label: None, - error: None, - diff_percentage: None, - }); - - let html = report.to_html(); - assert!(html.contains("")); - assert!(html.contains("html test")); - assert!(html.contains("step")); - assert!(html.contains("")); -} - -#[test] -fn report_with_diff_shows_percentage() { - let mut report = TestReport::new("diff report"); - report.add_step(StepResult { - index: 0, - description: "compare screenshots".into(), - status: StepStatus::Fail, - duration_ms: 10, - screenshot_label: Some("ref".into()), - error: None, - diff_percentage: Some(12.34), - }); - - let md = report.to_markdown(); - assert!(md.contains("12.34%")); - assert!(md.contains("FAILED")); -} - -// ── StepStatus ─────────────────────────────────────────────── - -#[test] -fn step_status_serialization() { - let pass = serde_json::to_string(&StepStatus::Pass).unwrap(); - assert_eq!(pass, r#""pass""#); - - let fail: StepStatus = serde_json::from_str(r#""fail""#).unwrap(); - assert_eq!(fail, StepStatus::Fail); - - let skip: StepStatus = serde_json::from_str(r#""skip""#).unwrap(); - assert_eq!(skip, StepStatus::Skip); -} - -// ── Minimal YAML scenarios ─────────────────────────────────── - -#[test] -fn minimal_scenario_no_description() { - let yaml = r#" -name: "minimal" -steps: - - type: wait - duration_ms: 100 -"#; - let scenario = ScenarioRunner::load_from_str(yaml).unwrap(); - assert_eq!(scenario.name, "minimal"); - assert!(scenario.description.is_none()); - assert_eq!(scenario.steps.len(), 1); -} - -#[test] -fn scenario_with_default_values() { - let yaml = r#" -name: "defaults" -steps: - - type: click - x: 10 - y: 20 - - type: assert_screenshot_diff - label: "foo" -"#; - let scenario = ScenarioRunner::load_from_str(yaml).unwrap(); - // Click defaults to button=left - match &scenario.steps[0] { - ScenarioStep::Click { button, .. } => assert_eq!(button, "left"), - _ => panic!(), - } - // AssertScreenshotDiff defaults to threshold=0.05 - match &scenario.steps[1] { - ScenarioStep::AssertScreenshotDiff { - max_diff_percentage, - .. - } => { - assert!((*max_diff_percentage - 0.05).abs() < 0.001); - } - _ => panic!(), - } -} - -#[test] -fn invalid_yaml_returns_error() { - let yaml = "not: valid: yaml: ["; - let result = ScenarioRunner::load_from_str(yaml); - assert!(result.is_err()); -} - -#[test] -fn empty_steps_allowed() { - let yaml = r#" -name: "no steps" -steps: [] -"#; - let scenario = ScenarioRunner::load_from_str(yaml).unwrap(); - assert!(scenario.steps.is_empty()); -} diff --git a/docs/design/phase-8-fixes.md b/docs/design/phase-8-fixes.md new file mode 100644 index 0000000..f684476 --- /dev/null +++ b/docs/design/phase-8-fixes.md @@ -0,0 +1,244 @@ +# Phase 8 — Release Test Bug 修复方案 + +**日期**: 2026-05-17 +**状态**: Draft + +--- + +## B1: `capture_region` 按 x/y 裁剪 + +### 问题 + +```rust +// capture/mod.rs:48 +pub fn capture_region(_x: i32, _y: i32, width: u32, height: u32) -> Result> { + // _x, _y 从未使用 + // 创建全显示器 filter,width/height 只缩放不裁剪 +} +``` + +### 方案 + +使用 `image` crate(已是依赖)做软件裁剪: + +1. 获取第一个显示器的完整尺寸 +2. 以显示器原生分辨率截图 +3. 用 `image::imageops::crop` 裁剪到 `(x, y, width, height)` +4. 编码为 PNG + +``` +capture_region(x, y, w, h): + 1. SCShareableContent::get() → 取第一个 display + 2. SCContentFilter 基于 display + 3. SCStreamConfiguration 使用 display 原生宽高 + 4. SCScreenshotManager::capture_image → RGBA 全屏数据 + 5. image::RgbaImage::from_raw(display_w, display_h, rgba) + 6. image::imageops::crop(x, y, w, h) + 7. 编码裁剪后的图像为 PNG +``` + +**边界处理**:若 `x + width > display_width` 或 `y + height > display_height`,裁剪到显示器边界。 + +--- + +## B2: `window_id` 传播 + +### 问题 + +``` +Tauri SandboxState.window_id ← init_sandbox() 可设置(但前端不调用) +HTTP AppState.window_id ← 初始化 None,永不更新 +``` + +两个 state 隔离,HTTP `/screenshot` 永远拿不到 window_id。 + +### 方案 + +#### Step 1: 在 Tauri setup 中自动发现窗口 ID + +``` +src-tauri/src/main.rs setup(): + // 当 Tauri 窗口创建后,延迟 1s 等待窗口渲染 + tauri::async_runtime::spawn(async { + sleep(1s).await; + + // 用 ScreenCaptureKit 按标题查找窗口 + let window_id = ScreenCapture::find_window_by_title("System Test Sandbox"); + + // 设置到 HTTP AppState + if let Some(id) = window_id { + http_state.lock().await.window_id = Some(id); + } + + // 也设置到 Sandbox state(通过 Tauri 命令或直接访问) + }); +``` + +#### Step 2: 添加 `set_window_id` HTTP 端点(可选) + +``` +POST /window/set { "window_id": 12345 } +→ 更新 AppState.window_id +``` + +这允许前端或外部工具主动注入窗口 ID。 + +#### Step 3: 让 `sandbox-cli screenshot --id` 传递 `?window_id=N` + +当 CLI 客户端请求沙箱截图时,自动带上 `window_id` 查询参数: + +```rust +// client.rs screenshot() +let url = format!("http://127.0.0.1:{port}/screenshot?window_id={wid}"); +``` + +但这需要 CLI 先知道 window_id。可以在 `start` 命令中,等 Tauri 窗口启动后自动获取 window_id 并写入 instance registry。 + +--- + +## B3+B5: 前端 API 连接 + +### 当前状态 + +``` +main.tsx handlers: + handleScreenshot → setScreenshotCount(c => c+1) // 假操作 + handleSpawnApp → setProcesses([...fake...]) // 假进程 + handleSpawnCli → setProcesses([...fake...]) // 假进程 + handleClick → // 空 + handleTypeText → // 空 + handlePressKey → // 空 + handleTerminalInput → // 空 +``` + +### 方案 + +#### Step 1: 创建 `sandbox-web/src/api.ts` + +定义沙箱 API 客户端,封装 HTTP 调用: + +```typescript +const BASE_URL = `http://127.0.0.1:${getPort()}`; + +export async function health(): Promise; +export async function takeScreenshot(): Promise; +export async function takeScreenshotRegion(x, y, w, h): Promise; +export async function click(x, y, button): Promise; +export async function typeText(text): Promise; +export async function pressKey(key, modifiers): Promise; +export async function spawnApp(path): Promise; +export async function spawnCli(command, args): Promise; +export async function listProcesses(): Promise; +export async function killProcess(pid): Promise; +export async function ptyWrite(pid, data): Promise; +export async function ptyRead(pid): Promise; +``` + +端口优先从 URL search params 读取,fallback 到 `5801`。 + +#### Step 2: 替换 `main.tsx` 中的空桩 + +```typescript +const handleScreenshot = useCallback(async () => { + setScreenshotLoading(true); + try { + const blob = await api.takeScreenshot(); + // 触发下载或显示预览 + const url = URL.createObjectURL(blob); + // ... 处理截图结果 + setScreenshotCount((c) => c + 1); + } catch (e) { + console.error("Screenshot failed:", e); + } finally { + setScreenshotLoading(false); + } +}, []); +``` + +其他 handler 类似用真实 API 调用替换。 + +#### Step 3: 连接 PTY 终端 + +在 `Terminal.tsx` 中: +- 添加 `useEffect` 定时轮询 `/pty/output/:pid`(200ms 间隔) +- `term.onData` 回调调用 `api.ptyWrite(pid, data)` +- 新增 `pid` prop 用于指定 PTY 进程 + +```typescript +// Terminal.tsx +useEffect(() => { + if (!pid) return; + const interval = setInterval(async () => { + const result = await api.ptyRead(pid); + if (result?.output) { + term.write(result.output); + } + }, 200); + return () => clearInterval(interval); +}, [pid]); +``` + +--- + +## B4: `spawn_app` 窗口追踪 + +### 问题 + +`spawn_app` 使用 `open` 启动独立应用,不追踪窗口 ID。 + +### 方案(最小改动) + +在 `spawn_app` 后延迟 500ms,用 ScreenCaptureKit 搜索应用窗口并按标题匹配: + +```rust +pub fn spawn_app_with_window(app_path: &str) -> Result<(ProcessInfo, Option)> { + let info = Self::spawn_app(app_path)?; + // 等待窗口出现 + std::thread::sleep(Duration::from_millis(500)); + // 按应用名搜索窗口 + let app_name = Path::new(app_path).file_stem()...; + let window_id = ScreenCapture::find_window_by_title(&app_name).ok(); + Ok((info, window_id)) +} +``` + +同时在 `src-tauri/src/main.rs` 的 app 模式下,自动调用 `find_window_by_title` 发现窗口并按标题关联。 + +> **注意**:真正嵌入 macOS 应用到 Tauri webview 在技术上不可行。替代方案是追踪应用的 SCWindow ID,后续截图用 `capture_window(app_window_id)` 截取应用窗口。 + +--- + +## B6: 沙箱相对坐标区域截图 + +### 方案 + +新增 `screenshot_sandbox_region` HTTP 端点和 MCP tool,接受相对于沙箱窗口的坐标: + +``` +GET /screenshot/sandbox-region?x=10&y=20&width=300&height=200 +``` + +实现: +1. 获取沙箱窗口的 frame origin (窗口在屏幕上的位置) +2. 将沙箱相对坐标转为全局坐标:`global_x = sandbox_frame.x + x` +3. 调用 `capture_region(global_x, global_y, width, height)` + +```rust +async fn screenshot_sandbox_region_handler( + State(state): State>>, + Query(q): Query, +) -> Result { + let window_id = q.window_id.or(state.lock().await.window_id) + .ok_or(AppError::BadRequest("No sandbox window"))?; + + // 获取沙箱窗口在屏幕上的位置 + let content = SCShareableContent::get()...; + let window = content.windows().iter().find(|w| w.window_id() == window_id)...; + let frame = window.frame(); + + let global_x = frame.x as i32 + q.x; + let global_y = frame.y as i32 + q.y; + + ScreenCapture::capture_region(global_x, global_y, q.width, q.height) +} +``` diff --git a/docs/design/phase-8-post-to-pid.md b/docs/design/phase-8-post-to-pid.md new file mode 100644 index 0000000..82ef106 --- /dev/null +++ b/docs/design/phase-8-post-to-pid.md @@ -0,0 +1,83 @@ +# Phase 8: CGEvent 定向投递 (post_to_pid) + +## 问题 + +当前 `InputSimulator` 所有方法通过 `CGEvent::post(CGEventTapLocation::HID)` 发送事件。这是一个全局投递 API — 鼠标事件基于屏幕绝对坐标,键盘事件发送给当前焦点窗口。 + +如果 VS Code 覆盖了 Tauri 沙箱窗口: +- 鼠标点击落到 VS Code 而不是沙箱 +- 键盘输入被 VS Code 接收 +- 沙箱窗口不需要在前台(截图已经通过 ScreenCaptureKit 按 window_id 解决) + +## 方案 + +使用 macOS `CGEvent::post_to_pid(pid)` API,将 CGEvent 直接投递到目标进程的事件队列。`core-graphics 0.25.0` 已暴露此方法。 + +### 核心变更 + +``` +// 之前 (全局投递) +event.post(CGEventTapLocation::HID) + +// 之后 (定向投递到 Tauri 进程) +event.post_to_pid(target_pid) +``` + +### API 设计 + +`InputSimulator` 所有公开方法添加 `target_pid: Option` 参数: + +```rust +pub fn click(x: f64, y: f64, button: MouseButton, target_pid: Option) -> Result<()> +pub fn type_text(text: &str, target_pid: Option) -> Result<()> +pub fn press_key(key: &str, modifiers: &[&str], target_pid: Option) -> Result<()> +pub fn scroll(x: f64, y: f64, direction: &str, amount: i32, target_pid: Option) -> Result<()> +pub fn drag(from_x: f64, from_y: f64, to_x: f64, to_y: f64, target_pid: Option) -> Result<()> +pub fn double_click(x: f64, y: f64, target_pid: Option) -> Result<()> +``` + +- `Some(pid)`: 使用 `post_to_pid(pid)` — 事件直接投递到目标进程 +- `None`: 使用 `post(HID)` — 保持当前行为(向后兼容 standalone 模式) + +### 数据流 + +``` +┌─────────────────────────────────────────────────────────┐ +│ AppState { │ +│ target_pid: Option, // Tauri 进程的 PID │ +│ window_id: Option, │ +│ ... │ +│ } │ +└──────────────┬──────────────────────────────────────────┘ + │ + ┌──────────┼──────────────────────────────┐ + ▼ ▼ ▼ +click_handler type_handler playback_handler + │ │ │ + │ target_pid │ target_pid ActionPlayer { target_pid } + ▼ ▼ │ +InputSimulator::click(x, y, btn, Some(pid)) + │ + ├─ Some(pid) → CGEvent::post_to_pid(pid) + └─ None → CGEvent::post(HID) +``` + +### 变更文件清单 + +| 文件 | 变更 | +|------|------| +| `crates/sandbox-core/src/automation/cg_event.rs` | 所有方法添加 `target_pid` 参数,内部 `post_event` helper | +| `crates/sandbox-core/src/server/mod.rs` | AppState 添加 `target_pid`,handlers 传递给 InputSimulator | +| `crates/sandbox-core/src/player.rs` | ActionPlayer 添加 `target_pid`,传递给 InputSimulator | +| `crates/sandbox-cli/src/mcp_server.rs` | 调用点传递 `None`(MCP 无 PID 上下文) | +| `crates/sandbox-cli/src/main.rs` | `start` 命令设置 `target_pid`,本地操作传 `None` | + +### Standalone 模式保护 + +standalone 模式无 Tauri 窗口,`target_pid` 为 `None`,回退到全局 `post(HID)`。这是正确的——standalone 模式下输入操作本来就面向当前桌面。 + +### 向后兼容 + +- 所有现有调用点只需在末尾添加 `None` 参数 +- 行为不变(`None` = 全局投递) +- 未来 Tauri 窗口启动后,`target_pid` 自动生效 diff --git a/docs/design/rebuild-plan.md b/docs/design/rebuild-plan.md new file mode 100644 index 0000000..22d3256 --- /dev/null +++ b/docs/design/rebuild-plan.md @@ -0,0 +1,204 @@ +# Rebuild Plan: macOS Desktop Automation Sandbox v2 + +> **Branch**: `feat/rebuild-sandbox-v2` | **Date**: 2026-05-18 | **Status**: Phase 1 complete + +## Overview + +从 `feat/5-multi-instance` 重建项目,删除 Tauri/React 前端,简化核心库,分三个阶段逐步实现沙箱自动化功能。 + +## Architecture Decisions + +1. **沙箱容器**: 使用原生 macOS Terminal.app (AppleScript),不使用 Tauri/xterm.js/React +2. **Phase 1-2**: PTY-based CLI 交互(输入/输出通过 portable-pty) +3. **Phase 3**: CGEvent-based macOS 桌面自动化 +4. **简化原则**: 删除 recorder/player/scenario/report/diff 高级模块,保留核心自动化能力 + +## New Project Structure + +``` +system-test-sandbox/ +├── Cargo.toml # workspace: sandbox-core + sandbox-cli +├── crates/ +│ ├── sandbox-core/ # 6 个模块(从 11 个简化) +│ │ └── src/ +│ │ ├── lib.rs # automation, capture, instance, process, sandbox, server +│ │ ├── automation/ +│ │ │ ├── cg_event.rs # CGEvent 输入模拟 +│ │ │ ├── ax_ui.rs # AXUIElement UI 检查 +│ │ │ └── keycodes.rs # CGKeyCode 映射 +│ │ ├── capture/mod.rs # ScreenCaptureKit 截图 +│ │ ├── process/mod.rs # PTY + .app 进程管理 +│ │ ├── sandbox/mod.rs # 沙箱窗口状态机 +│ │ ├── instance/mod.rs # 文件系统注册中心 +│ │ └── server/mod.rs # Axum HTTP API (17 routes) +│ └── sandbox-cli/ # 全新 CLI +│ └── src/ +│ ├── main.rs # CLI 入口 (clap) +│ └── client.rs # HTTP 客户端 (Phase 2) +└── docs/ + └── design/ + └── rebuild-plan.md # This document +``` + +## Phase 1 (Complete): Claude in Sandbox + Screenshot + +**目标**: 在 Terminal.app 窗口中启动 CLI 命令,截取窗口截图,列出所有窗口,关闭沙箱。 + +### 已实现的命令 + +```bash +sandbox start [args...] # 在 Terminal.app 中启动命令 +sandbox screenshot [-o output] [--window-id ID] # 截取沙箱窗口截图 +sandbox windows # 列出所有可见窗口 +sandbox shutdown # 关闭 Terminal 窗口 +``` + +### 实现细节 + +- **start**: 使用 `osascript` 执行 AppleScript,打开 Terminal.app 并运行目标命令 +- **screenshot**: 使用 `ScreenCapture::capture_window()` (ScreenCaptureKit),自动发现 Terminal 窗口或手动指定 window_id +- **windows**: 使用 `ScreenCapture::list_windows()` 列出所有 SCWindow +- **shutdown**: 使用 AppleScript 关闭 Terminal 第一个窗口 + +### 关键文件 + +- `crates/sandbox-cli/src/main.rs` — CLI 实现 +- `crates/sandbox-core/src/capture/mod.rs` — 截图引擎 +- `crates/sandbox-core/src/server/mod.rs` — HTTP API(简化版 17 routes) + +## Phase 2 (Planned): Input Operations + +**目标**: 通过 PTY 向沙箱 Claude 发送文本和读取输出。 + +### 计划命令 + +```bash +sandbox serve [--port PORT] [--command CMD] [args...] # 启动 HTTP 服务器 + PTY +sandbox type [--port PORT] # 发送文本到 PTY +sandbox enter [--port PORT] # 发送 Enter 到 PTY +sandbox read [--port PORT] # 读取 PTY 输出 +``` + +### 实现要点 + +- `serve` 命令启动 Axum HTTP 服务器 + 可选 PTY 会话 +- `type/enter/read` 通过 HTTP 客户端与服务器通信 +- PTY 通过 `ProcessManager::spawn_cli` 管理 +- 需要创建 `crates/sandbox-cli/src/client.rs` HTTP 客户端 + +### 交互流程 + +``` +Terminal 1: sandbox serve --command claude +Terminal 2: sandbox screenshot -o before.png +Terminal 2: sandbox type --text "Write a hello world in Rust" +Terminal 2: sandbox enter +Terminal 2: sleep 5 +Terminal 2: sandbox read +Terminal 2: sandbox screenshot -o after.png +``` + +## Phase 3 (Planned): macOS Desktop Automation + +**目标**: 启动 .app 应用,截取任意窗口截图,通过 CGEvent 模拟鼠标键盘操作。 + +### 计划命令 + +```bash +sandbox spawn-app # 启动 macOS .app +sandbox click [--button left|right|middle] # 鼠标点击 +sandbox type-text [--target-pid PID] # CGEvent 文本输入 +sandbox press-key [--modifiers cmd,shift...] # 按键 +sandbox screenshot-window [-o output] # 截取指定窗口 +sandbox screenshot-region [-o output] # 截取屏幕区域 +``` + +### 实现要点 + +- `spawn-app` 使用 `ProcessManager::spawn_app_with_window()` +- 输入模拟使用 `InputSimulator` (CGEvent),支持 `target_pid` 定向发送 +- 截图使用 `ScreenCapture::capture_window()` / `capture_region()` + +### 使用示例 + +```bash +sandbox spawn-app /System/Applications/Calculator.app +sandbox windows | grep -i calculator +sandbox screenshot-window -o calc.png +sandbox click 300 400 +sandbox type-text "123+456" +sandbox press-key Return +sandbox screenshot-region 0 0 500 500 -o top_left.png +``` + +## Server HTTP API (17 routes) + +``` +GET /health # 健康检查 +GET /sandbox/info # 沙箱信息 +POST /shutdown # 关闭服务器 +GET /windows # 列出所有窗口 +GET /processes # 列出进程 +POST /app/spawn # 启动 macOS .app +POST /cli/spawn # 在 PTY 中启动 CLI +POST /process/kill # 终止进程 +POST /input/click # 鼠标点击 +POST /input/type # 文本输入 +POST /input/key # 按键 +POST /input/scroll # 滚动 +POST /input/drag # 拖拽 +GET /screenshot # 截取沙箱窗口 +GET /screenshot/region # 截取区域 +POST /pty/write # PTY 写入 +GET /pty/output/:pid # PTY 读取 +GET /ui/inspect/:window_id # UI 树检查 +POST /ui/find # 查找 UI 元素 +GET /ui/value # 获取元素值 +``` + +## Removed Modules + +| Module | Reason | +|--------|--------| +| `diff.rs` | 图片差异对比 — Phase 2+ 可能重新添加 | +| `player.rs` | 操作回放引擎 — 复杂功能,暂不需要 | +| `recorder.rs` | 操作录制 — 复杂功能,暂不需要 | +| `report.rs` | 测试报告生成 — 依赖 scenario | +| `scenario.rs` | YAML 场景运行器 — 依赖 player/recorder | + +## Removed Directories + +| Directory | Reason | +|-----------|--------| +| `sandbox-web/` | React + Vite + xterm.js 前端 | +| `src-tauri/` | Tauri v2 桌面应用 | +| `crates/sandbox-cli/` (旧的) | 复杂的 22 子命令 CLI | + +## Verification + +### Pre-Work +- [x] `cargo check -p sandbox-core` passes +- [x] `cargo test -p sandbox-core` passes (94 tests) +- [x] `cargo fmt --all -- --check` passes +- [x] `cargo clippy --all-targets` passes + +### Phase 1 +- [x] `cargo build -p sandbox-cli` succeeds +- [ ] `sandbox start claude` opens Terminal with Claude Code (requires macOS) +- [ ] `sandbox screenshot -o test.png` saves a valid PNG (requires macOS + permissions) +- [ ] `sandbox windows` lists system windows (requires macOS + permissions) +- [ ] `sandbox shutdown` closes the Terminal (requires macOS) + +### Phase 2 (to be verified) +- [ ] `sandbox serve --command claude` starts HTTP server + PTY +- [ ] `sandbox type --text "hello"` sends text to PTY +- [ ] `sandbox enter` sends newline to PTY +- [ ] `sandbox read` returns PTY output + +### Phase 3 (to be verified) +- [ ] `sandbox spawn-app /System/Applications/Calculator.app` launches Calculator +- [ ] `sandbox screenshot-window ` captures Calculator +- [ ] `sandbox click 300 400` clicks in Calculator +- [ ] `sandbox type-text "123+456"` types via CGEvent +- [ ] `sandbox press-key Return` presses Enter +- [ ] `sandbox screenshot-region 0 0 500 500` captures desktop region diff --git a/docs/task/phase-8.md b/docs/task/phase-8.md new file mode 100644 index 0000000..97ffb0a --- /dev/null +++ b/docs/task/phase-8.md @@ -0,0 +1,66 @@ +# Phase 8 — Release Test Bug 修复 + +**日期**:2026-05-17 +**分支**:`feat/5-multi-instance` +**状态**:进行中 + +## 背景 + +Phase 5-8 实现后进行了 release test,生成了测试报告 `release_test/2026-05-17-19-09-00/REPORT.md`。报告显示 29 项测试通过,但发现 6 个核心 bug 导致截图和沙箱操作不符合预期。经代码分析定位到以下根因。 + +## Bug 清单 + +### B1: `capture_region` 忽略 x/y 参数,截取全屏 + +- **文件**: `crates/sandbox-core/src/capture/mod.rs:48` +- **根因**: `capture_region(_x: i32, _y: i32, ...)` — 参数前缀下划线表示未使用。函数取第一个显示器全屏截图,width/height 只缩放输出而非裁剪。 +- **影响**: 区域截图实际是全屏截图,`screenshot_region.png` 包含整个桌面。 + +### B2: HTTP 服务器 `AppState.window_id` 永远为 `None` + +- **文件**: `src-tauri/src/main.rs:157`, `crates/sandbox-core/src/server/mod.rs:331-342` +- **根因**: + 1. `AppState.window_id` 初始化为 `None`,从未被设置 + 2. `init_sandbox` Tauri 命令已注册但前端从未调用 + 3. `Sandbox.window_id` 和 HTTP `AppState.window_id` 是两个独立状态,互不同步 +- **影响**: 所有 `/screenshot` HTTP 请求返回 400 错误;通过 `sandbox-cli screenshot --id ` 截图永远失败。 + +### B3: 前端所有操作 handler 为空桩 + +- **文件**: `sandbox-web/src/main.tsx:25-69` +- **根因**: `handleScreenshot`、`handleSpawnApp`、`handleSpawnCli`、`handleClick`、`handleTypeText`、`handlePressKey` 全部为空函数体或只做前端状态更新(如 `Date.now()` 假 PID),没有调用任何 Tauri 命令或 HTTP API。 +- **影响**: ControlPanel 上的任何按钮点击都不会触发后端操作。 + +### B4: `spawn_app` 使用 `open` 命令,应用在沙箱外独立运行 + +- **文件**: `crates/sandbox-core/src/process/mod.rs:47-78` +- **根因**: `spawn_app` 调用 `std::process::Command::new("open").arg(app_path)`,启动的应用是独立 macOS 窗口,不会嵌入 Tauri webview。 +- **影响**: cc-switch 等应用在沙箱外运行,截图只能截到空沙箱。 + +> **设计决策**:真正做到 macOS 应用嵌入 Tauri webview 在技术上不可行(需要 NSView reparenting,Tauri/Wry 不支持)。修复策略是接受应用作为独立窗口运行,但通过 ScreenCaptureKit 追踪并截取其窗口。 + +### B5: xterm.js 终端与 Rust PTY 完全断开 + +- **文件**: `sandbox-web/src/main.tsx:25-27`, `sandbox-web/src/components/Terminal.tsx` +- **根因**: + 1. `handleTerminalInput` 为空 — 按键不转发给 PTY + 2. 前端没有轮询 `/pty/output/:pid` — 终端不显示 PTY 输出 + 3. 后端 PTY 读写基础设施已完备(`process/mod.rs`),但前端未连接 +- **影响**: CLI 进程(如 claude code)在 PTY 中运行但前端终端看不到任何输出。 + +### B6: Region 截图坐标语义不明确 + +- **文件**: `crates/sandbox-core/src/capture/mod.rs:48`, `crates/sandbox-core/src/server/mod.rs:345-350` +- **根因**: Region 截图是全局屏幕坐标,但用户期望可能是相对于沙箱窗口的坐标。缺少窗口相对坐标的截图接口。 +- **影响**: 无法精确截取沙箱窗口内的特定区域。 + +## 修复任务 + +| 任务 ID | 描述 | 优先级 | +|---------|------|--------| +| P8-06 | 修复 `capture_region`:使用 image crate 裁剪 RGBA 数据 | P0 | +| P8-07 | Tauri setup 中自动发现并设置 window_id | P0 | +| P8-08 | 创建前端 API 客户端层 `api.ts` | P0 | +| P8-09 | 连接 main.tsx stub handler 到真实 API | P0 | +| P8-10 | 连接 xterm.js 终端到 Rust PTY | P1 | +| P8-11 | `spawn_app` 增强:追踪启动的应用窗口 ID | P1 | diff --git a/docs/task/task_records.json b/docs/task/task_records.json index ac43730..d40a93c 100644 --- a/docs/task/task_records.json +++ b/docs/task/task_records.json @@ -1,62 +1,898 @@ [ - {"task_id": "P0-01", "task_type": "功能开发", "phase": "Phase 0", "module": "config", "layer": "rust", "task_desc": "Cargo Workspace 初始化", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-13 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P0-02", "task_type": "功能开发", "phase": "Phase 0", "module": "sandbox", "layer": "rust", "task_desc": "sandbox-core 骨架:lib.rs + error.rs + 各模块 mod.rs", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-13 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P0-03", "task_type": "功能开发", "phase": "Phase 0", "module": "cli", "layer": "rust", "task_desc": "sandbox-cli 骨架:clap 参数解析,serve/screenshot/windows/spawn/click/type/key 命令", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-13 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P0-04", "task_type": "功能开发", "phase": "Phase 0", "module": "ui", "layer": "both", "task_desc": "Tauri 2 骨架:src-tauri 初始化,sandbox 窗口配置", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-13 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P0-05", "task_type": "功能开发", "phase": "Phase 0", "module": "ui", "layer": "ts", "task_desc": "sandbox-web 骨架:React + xterm.js + Vite + TailwindCSS", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-13 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P0-06", "task_type": "功能开发", "phase": "Phase 0", "module": "sandbox", "layer": "rust", "task_desc": "沙箱窗口管理:Sandbox struct,init/screenshot/state 方法,Tauri 命令", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P0-07", "task_type": "功能开发", "phase": "Phase 0", "module": "process", "layer": "rust", "task_desc": "PTY 进程管理:spawn_cli/spawn_app/list_processes/kill_process", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "portable-pty 0.9 + nix + NSWorkspace"}, - - {"task_id": "P1-01", "task_type": "功能开发", "phase": "Phase 1", "module": "automation", "layer": "rust", "task_desc": "CGEvent 输入模拟:click/type_text/press_key/scroll/drag/double_click", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "core-graphics 0.25 + keycodes 映射表"}, - {"task_id": "P1-02", "task_type": "功能开发", "phase": "Phase 1", "module": "capture", "layer": "rust", "task_desc": "ScreenCaptureKit 窗口截图:capture_window/capture_sandbox", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "screencapturekit 2.1"}, - {"task_id": "P1-03", "task_type": "功能开发", "phase": "Phase 1", "module": "capture", "layer": "rust", "task_desc": "ScreenCaptureKit 区域截图:capture_region", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P1-04", "task_type": "功能开发", "phase": "Phase 1", "module": "sandbox", "layer": "rust", "task_desc": "沙箱截图集成:Sandbox::screenshot() 调用 ScreenCapture + Tauri 命令", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P1-05", "task_type": "功能开发", "phase": "Phase 1", "module": "process", "layer": "rust", "task_desc": "进程管理增强:spawn_app via NSWorkspace", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "objc + msg_send!"}, - {"task_id": "P1-06", "task_type": "测试", "phase": "Phase 1", "module": "automation/capture/process", "layer": "rust", "task_desc": "核心模块单元测试:CGEvent、SCK 截图、PTY 进程", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "sandbox 5 tests + diff 3 tests"}, - - {"task_id": "P2-01", "task_type": "功能开发", "phase": "Phase 2", "module": "server", "layer": "rust", "task_desc": "HTTP API 服务器结构:axum Router + AppState + 中间件", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "axum 0.8"}, - {"task_id": "P2-02", "task_type": "功能开发", "phase": "Phase 2", "module": "server", "layer": "rust", "task_desc": "核心 HTTP 端点:/health, /windows, /processes, /app/spawn, /cli/spawn, /process/kill", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P2-03", "task_type": "功能开发", "phase": "Phase 2", "module": "server", "layer": "rust", "task_desc": "输入 HTTP 端点:/input/click, /input/type, /input/key, /input/scroll, /input/drag", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P2-04", "task_type": "功能开发", "phase": "Phase 2", "module": "server", "layer": "rust", "task_desc": "截图 HTTP 端点:/screenshot (PNG), /screenshot/region", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P2-05", "task_type": "功能开发", "phase": "Phase 2", "module": "mcp", "layer": "rust", "task_desc": "MCP 服务器结构:rmcp stdio transport + SandboxMcpServer", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "rmcp 1.7, manual ServerHandler impl"}, - {"task_id": "P2-06", "task_type": "功能开发", "phase": "Phase 2", "module": "mcp", "layer": "rust", "task_desc": "MCP Tool 处理器:screenshot/click/type_text/press_key/spawn_cli/kill_process/list_processes/list_windows/double_click", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "9 tools + 完整参数类型"}, - {"task_id": "P2-07", "task_type": "测试", "phase": "Phase 2", "module": "server/mcp", "layer": "rust", "task_desc": "服务器集成测试:HTTP 端点 + MCP tools", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "compile check passes"}, - - {"task_id": "P3-01", "task_type": "功能开发", "phase": "Phase 3", "module": "automation", "layer": "rust", "task_desc": "AXUIElement 树遍历:inspect_window, ax_to_ui_element 递归", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "ApplicationServices FFI + CFType 转换"}, - {"task_id": "P3-02", "task_type": "功能开发", "phase": "Phase 3", "module": "automation", "layer": "rust", "task_desc": "AXUIElement 搜索:find_elements by role/title", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P3-03", "task_type": "功能开发", "phase": "Phase 3", "module": "automation", "layer": "rust", "task_desc": "AXUIElement 值读取:get_element_value by path ID", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "PID:window_idx:child_idx 路径导航"}, - {"task_id": "P3-04", "task_type": "功能开发", "phase": "Phase 3", "module": "server", "layer": "rust", "task_desc": "HTTP UI 端点:/ui/inspect, /ui/find, /ui/value", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P3-05", "task_type": "功能开发", "phase": "Phase 3", "module": "mcp", "layer": "rust", "task_desc": "MCP UI 工具:inspect_ui, find_element, get_element_value", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P3-06", "task_type": "测试", "phase": "Phase 3", "module": "automation", "layer": "rust", "task_desc": "UI 单元测试:AX 树遍历、搜索、序列化", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "compile check passes"}, - - {"task_id": "P4-01", "task_type": "功能开发", "phase": "Phase 4", "module": "sandbox", "layer": "rust", "task_desc": "多窗口支持:SubWindow 追踪、add_window/remove_window/list_windows", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P4-02", "task_type": "功能开发", "phase": "Phase 4", "module": "recorder", "layer": "rust", "task_desc": "动作录制:ActionRecorder + Action 枚举 + JSONL 持久化", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "12 Action variants"}, - {"task_id": "P4-03", "task_type": "功能开发", "phase": "Phase 4", "module": "player", "layer": "rust", "task_desc": "动作回放:ActionPlayer + 速度控制 + JSONL 加载", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "supports all 12 action types"}, - {"task_id": "P4-04", "task_type": "功能开发", "phase": "Phase 4", "module": "scenario", "layer": "rust", "task_desc": "测试场景框架:YAML 解析 + ScenarioRunner + TestReport", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "serde_yaml + HTML/Markdown/JSON 报告"}, - {"task_id": "P4-05", "task_type": "功能开发", "phase": "Phase 4", "module": "diff", "layer": "rust", "task_desc": "截图差异对比:像素级比较 + 差异图像生成 + 3 单元测试", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "identical/different/size_mismatch tests"}, - {"task_id": "P4-06", "task_type": "功能开发", "phase": "Phase 4", "module": "report", "layer": "rust", "task_desc": "测试报告生成:TestReport + Markdown/JSON/HTML 输出", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": ""}, - {"task_id": "P4-07", "task_type": "功能开发", "phase": "Phase 4", "module": "release", "layer": "rust", "task_desc": "发布分发:CI release 流水线(已有 ef36b5d)", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "已有 .github/workflows/release.yml"}, - {"task_id": "P4-08", "task_type": "功能开发", "phase": "Phase 4", "module": "server/mcp", "layer": "rust", "task_desc": "录制/回放/场景/差异 HTTP + MCP 端点", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-13 00:00:00", "finish_time": "2026-05-14 00:00:00", "check_result": "通过", "remark": "6 HTTP endpoints + 6 MCP tools"}, - - {"task_id": "P5-01", "task_type": "功能开发", "phase": "Phase 5", "module": "instance", "layer": "rust", "task_desc": "实例注册中心:InstanceRegistry + SandboxInstance + ID 生成 (sandbox-core/src/instance.rs)", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "新增 sandbox-core/src/instance/mod.rs,文件系统注册 ~/.sandbox/instances/"}, - {"task_id": "P5-02", "task_type": "功能开发", "phase": "Phase 5", "module": "sandbox", "layer": "rust", "task_desc": "增强 Sandbox struct:添加 id/port/kind/start_time 字段,支持多实例", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "修改 sandbox/mod.rs,SandboxConfig 增加 mode/command/args"}, - {"task_id": "P5-03", "task_type": "功能开发", "phase": "Phase 5", "module": "server", "layer": "rust", "task_desc": "HTTP 服务器迁移到 sandbox-core:库化 server.rs,添加 PTY 和 shutdown 端点", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "创建 sandbox-core/src/server/mod.rs,sandbox-cli 改为 re-export"}, - {"task_id": "P5-04", "task_type": "功能开发", "phase": "Phase 5", "module": "cli", "layer": "rust", "task_desc": "HTTP 客户端模块:SandboxClient 封装 reqwest 调用 (sandbox-cli/src/client.rs)", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "新增 sandbox-cli/src/client.rs,封装所有沙箱 HTTP 端点"}, - {"task_id": "P5-05", "task_type": "功能开发", "phase": "Phase 5", "module": "cli", "layer": "rust", "task_desc": "新增 CLI 命令:start --cli/--app、list、close 、inspect ", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "修改 sandbox-cli/src/main.rs,添加新 Commands 变体"}, - {"task_id": "P5-06", "task_type": "功能开发", "phase": "Phase 5", "module": "cli", "layer": "rust", "task_desc": "实例作用域操作:screenshot/click/type/key/windows/processes 支持 --id 参数", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "修改现有命令添加 --id,通过 SandboxClient 代理到目标实例"}, - {"task_id": "P5-07", "task_type": "功能开发", "phase": "Phase 5", "module": "ui", "layer": "rust", "task_desc": "Tauri 多实例支持:CLI 参数解析 + 内嵌 HTTP 服务器 + 窗口关闭清理", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "修改 src-tauri/src/main.rs,解析 --sandbox-id/--port/--mode/--cmd"}, - {"task_id": "P5-08", "task_type": "功能开发", "phase": "Phase 5", "module": "config", "layer": "rust", "task_desc": "workspace Cargo.toml:添加 reqwest、uuid 依赖", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "sandbox-cli 增加 reqwest 依赖,sandbox-core 可选增加 uuid"}, - - {"task_id": "P6-01", "task_type": "功能开发", "phase": "Phase 6", "module": "process", "layer": "both", "task_desc": "Tauri --cli 模式:启动时在 PTY 中运行 CLI,输出流式传输到前端 Terminal", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "Tauri 启动时调用 ProcessManager::spawn_cli,前端轮询 /pty/output/:pid"}, - {"task_id": "P6-02", "task_type": "功能开发", "phase": "Phase 6", "module": "process", "layer": "rust", "task_desc": "Tauri --app 模式:启动 macOS 应用,发现窗口 ID,关联到沙箱", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "调用 spawn_app + find_window_by_title + add_window"}, - {"task_id": "P6-03", "task_type": "功能开发", "phase": "Phase 6", "module": "ui", "layer": "ts", "task_desc": "前端 API 客户端层:fetch 封装所有沙箱操作 (sandbox-web/src/api.ts)", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "新增 api.ts,封装 screenshot/click/type/key/spawnCli 等 API 调用"}, - {"task_id": "P6-04", "task_type": "功能开发", "phase": "Phase 6", "module": "ui", "layer": "ts", "task_desc": "连接 main.tsx:将所有 stub handler 替换为真实 API 调用", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "handleScreenshot → api.takeScreenshot(),handleClick → api.click(),etc."}, - {"task_id": "P6-05", "task_type": "功能开发", "phase": "Phase 6", "module": "ui", "layer": "ts", "task_desc": "连接 Terminal 组件:PTY 读写通过 API 轮询,xterm.js 数据流", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "Terminal.tsx 添加 PTY output 轮询和 onData → api.ptyWrite()"}, - {"task_id": "P6-06", "task_type": "功能开发", "phase": "Phase 6", "module": "ui", "layer": "ts", "task_desc": "更新 StatusBar 组件:显示沙箱 ID、端口、进程信息", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "StatusBar.tsx 添加 sandboxId, port, processCount 显示"}, - - {"task_id": "P7-01", "task_type": "测试", "phase": "Phase 7", "module": "instance", "layer": "rust", "task_desc": "实例注册中心单元测试:CRUD、并发访问、过期清理", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "新增 tests/instance_integration.rs"}, - {"task_id": "P7-02", "task_type": "测试", "phase": "Phase 7", "module": "cli", "layer": "rust", "task_desc": "CLI 集成测试:SandboxClient + mock HTTP server 端到端验证", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "新增 tests/cli_integration.rs"}, - {"task_id": "P7-03", "task_type": "功能开发", "phase": "Phase 7", "module": "mcp", "layer": "rust", "task_desc": "MCP 服务器更新:添加 list_sandboxes、start_sandbox、close_sandbox 工具", "executor": "Claude Code", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "修改 mcp_server.rs,添加 3 个新 MCP tools + InstanceRegistry 集成"}, - {"task_id": "P7-04", "task_type": "测试", "phase": "Phase 7", "module": "all", "layer": "both", "task_desc": "端到端冒烟测试:start --cli echo → screenshot → close 完整流程", "executor": "人工", "status": "待执行", "create_time": "2026-05-16 00:00:00", "finish_time": null, "check_result": null, "remark": "需要 macOS 环境,验证完整工作流"}, - {"task_id": "P7-05", "task_type": "文档", "phase": "Phase 7", "module": "docs", "layer": "both", "task_desc": "更新文档:CLAUDE.md、README.md、docs/task/* 反映多实例架构", "executor": "Claude Code", "status": "已完成", "create_time": "2026-05-16 00:00:00", "finish_time": "2026-05-16 00:00:00", "check_result": "通过", "remark": "CLAUDE.md 架构图 + 接口更新,README.md 工作流更新,phase-5/6/7 docs 新建"} -] + { + "task_id": "P0-01", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "config", + "layer": "rust", + "task_desc": "Cargo Workspace 初始化", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-13 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P0-02", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "sandbox", + "layer": "rust", + "task_desc": "sandbox-core 骨架:lib.rs + error.rs + 各模块 mod.rs", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-13 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P0-03", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "cli", + "layer": "rust", + "task_desc": "sandbox-cli 骨架:clap 参数解析,serve/screenshot/windows/spawn/click/type/key 命令", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-13 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P0-04", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "ui", + "layer": "both", + "task_desc": "Tauri 2 骨架:src-tauri 初始化,sandbox 窗口配置", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-13 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P0-05", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "ui", + "layer": "ts", + "task_desc": "sandbox-web 骨架:React + xterm.js + Vite + TailwindCSS", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-13 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P0-06", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "sandbox", + "layer": "rust", + "task_desc": "沙箱窗口管理:Sandbox struct,init/screenshot/state 方法,Tauri 命令", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P0-07", + "task_type": "功能开发", + "phase": "Phase 0", + "module": "process", + "layer": "rust", + "task_desc": "PTY 进程管理:spawn_cli/spawn_app/list_processes/kill_process", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "portable-pty 0.9 + nix + NSWorkspace" + }, + { + "task_id": "P1-01", + "task_type": "功能开发", + "phase": "Phase 1", + "module": "automation", + "layer": "rust", + "task_desc": "CGEvent 输入模拟:click/type_text/press_key/scroll/drag/double_click", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "core-graphics 0.25 + keycodes 映射表" + }, + { + "task_id": "P1-02", + "task_type": "功能开发", + "phase": "Phase 1", + "module": "capture", + "layer": "rust", + "task_desc": "ScreenCaptureKit 窗口截图:capture_window/capture_sandbox", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "screencapturekit 2.1" + }, + { + "task_id": "P1-03", + "task_type": "功能开发", + "phase": "Phase 1", + "module": "capture", + "layer": "rust", + "task_desc": "ScreenCaptureKit 区域截图:capture_region", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P1-04", + "task_type": "功能开发", + "phase": "Phase 1", + "module": "sandbox", + "layer": "rust", + "task_desc": "沙箱截图集成:Sandbox::screenshot() 调用 ScreenCapture + Tauri 命令", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P1-05", + "task_type": "功能开发", + "phase": "Phase 1", + "module": "process", + "layer": "rust", + "task_desc": "进程管理增强:spawn_app via NSWorkspace", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "objc + msg_send!" + }, + { + "task_id": "P1-06", + "task_type": "测试", + "phase": "Phase 1", + "module": "automation/capture/process", + "layer": "rust", + "task_desc": "核心模块单元测试:CGEvent、SCK 截图、PTY 进程", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "sandbox 5 tests + diff 3 tests" + }, + { + "task_id": "P2-01", + "task_type": "功能开发", + "phase": "Phase 2", + "module": "server", + "layer": "rust", + "task_desc": "HTTP API 服务器结构:axum Router + AppState + 中间件", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "axum 0.8" + }, + { + "task_id": "P2-02", + "task_type": "功能开发", + "phase": "Phase 2", + "module": "server", + "layer": "rust", + "task_desc": "核心 HTTP 端点:/health, /windows, /processes, /app/spawn, /cli/spawn, /process/kill", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P2-03", + "task_type": "功能开发", + "phase": "Phase 2", + "module": "server", + "layer": "rust", + "task_desc": "输入 HTTP 端点:/input/click, /input/type, /input/key, /input/scroll, /input/drag", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P2-04", + "task_type": "功能开发", + "phase": "Phase 2", + "module": "server", + "layer": "rust", + "task_desc": "截图 HTTP 端点:/screenshot (PNG), /screenshot/region", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P2-05", + "task_type": "功能开发", + "phase": "Phase 2", + "module": "mcp", + "layer": "rust", + "task_desc": "MCP 服务器结构:rmcp stdio transport + SandboxMcpServer", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "rmcp 1.7, manual ServerHandler impl" + }, + { + "task_id": "P2-06", + "task_type": "功能开发", + "phase": "Phase 2", + "module": "mcp", + "layer": "rust", + "task_desc": "MCP Tool 处理器:screenshot/click/type_text/press_key/spawn_cli/kill_process/list_processes/list_windows/double_click", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "9 tools + 完整参数类型" + }, + { + "task_id": "P2-07", + "task_type": "测试", + "phase": "Phase 2", + "module": "server/mcp", + "layer": "rust", + "task_desc": "服务器集成测试:HTTP 端点 + MCP tools", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "compile check passes" + }, + { + "task_id": "P3-01", + "task_type": "功能开发", + "phase": "Phase 3", + "module": "automation", + "layer": "rust", + "task_desc": "AXUIElement 树遍历:inspect_window, ax_to_ui_element 递归", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "ApplicationServices FFI + CFType 转换" + }, + { + "task_id": "P3-02", + "task_type": "功能开发", + "phase": "Phase 3", + "module": "automation", + "layer": "rust", + "task_desc": "AXUIElement 搜索:find_elements by role/title", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P3-03", + "task_type": "功能开发", + "phase": "Phase 3", + "module": "automation", + "layer": "rust", + "task_desc": "AXUIElement 值读取:get_element_value by path ID", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "PID:window_idx:child_idx 路径导航" + }, + { + "task_id": "P3-04", + "task_type": "功能开发", + "phase": "Phase 3", + "module": "server", + "layer": "rust", + "task_desc": "HTTP UI 端点:/ui/inspect, /ui/find, /ui/value", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P3-05", + "task_type": "功能开发", + "phase": "Phase 3", + "module": "mcp", + "layer": "rust", + "task_desc": "MCP UI 工具:inspect_ui, find_element, get_element_value", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P3-06", + "task_type": "测试", + "phase": "Phase 3", + "module": "automation", + "layer": "rust", + "task_desc": "UI 单元测试:AX 树遍历、搜索、序列化", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "compile check passes" + }, + { + "task_id": "P4-01", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "sandbox", + "layer": "rust", + "task_desc": "多窗口支持:SubWindow 追踪、add_window/remove_window/list_windows", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P4-02", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "recorder", + "layer": "rust", + "task_desc": "动作录制:ActionRecorder + Action 枚举 + JSONL 持久化", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "12 Action variants" + }, + { + "task_id": "P4-03", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "player", + "layer": "rust", + "task_desc": "动作回放:ActionPlayer + 速度控制 + JSONL 加载", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "supports all 12 action types" + }, + { + "task_id": "P4-04", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "scenario", + "layer": "rust", + "task_desc": "测试场景框架:YAML 解析 + ScenarioRunner + TestReport", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "serde_yaml + HTML/Markdown/JSON 报告" + }, + { + "task_id": "P4-05", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "diff", + "layer": "rust", + "task_desc": "截图差异对比:像素级比较 + 差异图像生成 + 3 单元测试", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "identical/different/size_mismatch tests" + }, + { + "task_id": "P4-06", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "report", + "layer": "rust", + "task_desc": "测试报告生成:TestReport + Markdown/JSON/HTML 输出", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "" + }, + { + "task_id": "P4-07", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "release", + "layer": "rust", + "task_desc": "发布分发:CI release 流水线(已有 ef36b5d)", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "已有 .github/workflows/release.yml" + }, + { + "task_id": "P4-08", + "task_type": "功能开发", + "phase": "Phase 4", + "module": "server/mcp", + "layer": "rust", + "task_desc": "录制/回放/场景/差异 HTTP + MCP 端点", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-13 00:00:00", + "finish_time": "2026-05-14 00:00:00", + "check_result": "通过", + "remark": "6 HTTP endpoints + 6 MCP tools" + }, + { + "task_id": "P5-01", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "instance", + "layer": "rust", + "task_desc": "实例注册中心:InstanceRegistry + SandboxInstance + ID 生成 (sandbox-core/src/instance.rs)", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "新增 sandbox-core/src/instance/mod.rs,文件系统注册 ~/.sandbox/instances/" + }, + { + "task_id": "P5-02", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "sandbox", + "layer": "rust", + "task_desc": "增强 Sandbox struct:添加 id/port/kind/start_time 字段,支持多实例", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 sandbox/mod.rs,SandboxConfig 增加 mode/command/args" + }, + { + "task_id": "P5-03", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "server", + "layer": "rust", + "task_desc": "HTTP 服务器迁移到 sandbox-core:库化 server.rs,添加 PTY 和 shutdown 端点", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "创建 sandbox-core/src/server/mod.rs,sandbox-cli 改为 re-export" + }, + { + "task_id": "P5-04", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "cli", + "layer": "rust", + "task_desc": "HTTP 客户端模块:SandboxClient 封装 reqwest 调用 (sandbox-cli/src/client.rs)", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "新增 sandbox-cli/src/client.rs,封装所有沙箱 HTTP 端点" + }, + { + "task_id": "P5-05", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "cli", + "layer": "rust", + "task_desc": "新增 CLI 命令:start --cli/--app、list、close 、inspect ", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 sandbox-cli/src/main.rs,添加新 Commands 变体" + }, + { + "task_id": "P5-06", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "cli", + "layer": "rust", + "task_desc": "实例作用域操作:screenshot/click/type/key/windows/processes 支持 --id 参数", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改现有命令添加 --id,通过 SandboxClient 代理到目标实例" + }, + { + "task_id": "P5-07", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "ui", + "layer": "rust", + "task_desc": "Tauri 多实例支持:CLI 参数解析 + 内嵌 HTTP 服务器 + 窗口关闭清理", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 src-tauri/src/main.rs,解析 --sandbox-id/--port/--mode/--cmd" + }, + { + "task_id": "P5-08", + "task_type": "功能开发", + "phase": "Phase 5", + "module": "config", + "layer": "rust", + "task_desc": "workspace Cargo.toml:添加 reqwest、uuid 依赖", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "sandbox-cli 增加 reqwest 依赖,sandbox-core 可选增加 uuid" + }, + { + "task_id": "P6-01", + "task_type": "功能开发", + "phase": "Phase 6", + "module": "process", + "layer": "both", + "task_desc": "Tauri --cli 模式:启动时在 PTY 中运行 CLI,输出流式传输到前端 Terminal", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "Tauri 启动时调用 ProcessManager::spawn_cli,前端轮询 /pty/output/:pid" + }, + { + "task_id": "P6-02", + "task_type": "功能开发", + "phase": "Phase 6", + "module": "process", + "layer": "rust", + "task_desc": "Tauri --app 模式:启动 macOS 应用,发现窗口 ID,关联到沙箱", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "调用 spawn_app + find_window_by_title + add_window" + }, + { + "task_id": "P6-03", + "task_type": "功能开发", + "phase": "Phase 6", + "module": "ui", + "layer": "ts", + "task_desc": "前端 API 客户端层:fetch 封装所有沙箱操作 (sandbox-web/src/api.ts)", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "新增 api.ts,封装 screenshot/click/type/key/spawnCli 等 API 调用" + }, + { + "task_id": "P6-04", + "task_type": "功能开发", + "phase": "Phase 6", + "module": "ui", + "layer": "ts", + "task_desc": "连接 main.tsx:将所有 stub handler 替换为真实 API 调用", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "handleScreenshot → api.takeScreenshot(),handleClick → api.click(),etc." + }, + { + "task_id": "P6-05", + "task_type": "功能开发", + "phase": "Phase 6", + "module": "ui", + "layer": "ts", + "task_desc": "连接 Terminal 组件:PTY 读写通过 API 轮询,xterm.js 数据流", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "Terminal.tsx 添加 PTY output 轮询和 onData → api.ptyWrite()" + }, + { + "task_id": "P6-06", + "task_type": "功能开发", + "phase": "Phase 6", + "module": "ui", + "layer": "ts", + "task_desc": "更新 StatusBar 组件:显示沙箱 ID、端口、进程信息", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "StatusBar.tsx 添加 sandboxId, port, processCount 显示" + }, + { + "task_id": "P7-01", + "task_type": "测试", + "phase": "Phase 7", + "module": "instance", + "layer": "rust", + "task_desc": "实例注册中心单元测试:CRUD、并发访问、过期清理", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "新增 tests/instance_integration.rs" + }, + { + "task_id": "P7-02", + "task_type": "测试", + "phase": "Phase 7", + "module": "cli", + "layer": "rust", + "task_desc": "CLI 集成测试:SandboxClient + mock HTTP server 端到端验证", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "新增 tests/cli_integration.rs" + }, + { + "task_id": "P7-03", + "task_type": "功能开发", + "phase": "Phase 7", + "module": "mcp", + "layer": "rust", + "task_desc": "MCP 服务器更新:添加 list_sandboxes、start_sandbox、close_sandbox 工具", + "executor": "Claude Code", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "修改 mcp_server.rs,添加 3 个新 MCP tools + InstanceRegistry 集成" + }, + { + "task_id": "P7-04", + "task_type": "测试", + "phase": "Phase 7", + "module": "all", + "layer": "both", + "task_desc": "端到端冒烟测试:start --cli echo → screenshot → close 完整流程", + "executor": "人工", + "status": "待执行", + "create_time": "2026-05-16 00:00:00", + "finish_time": null, + "check_result": null, + "remark": "需要 macOS 环境,验证完整工作流" + }, + { + "task_id": "P7-05", + "task_type": "文档", + "phase": "Phase 7", + "module": "docs", + "layer": "both", + "task_desc": "更新文档:CLAUDE.md、README.md、docs/task/* 反映多实例架构", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-16 00:00:00", + "finish_time": "2026-05-16 00:00:00", + "check_result": "通过", + "remark": "CLAUDE.md 架构图 + 接口更新,README.md 工作流更新,phase-5/6/7 docs 新建" + }, + { + "task_id": "P8-01", + "task_type": "功能开发", + "phase": "Phase 8", + "module": "automation", + "layer": "rust", + "task_desc": "InputSimulator 添加 target_pid 支持:所有方法增加 Option 参数,使用 post_to_pid 定向投递", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "core-graphics 0.25.0 elcapitan feature + post_to_pid, 新增 post_event helper" + }, + { + "task_id": "P8-02", + "task_type": "功能开发", + "phase": "Phase 8", + "module": "server", + "layer": "rust", + "task_desc": "AppState 添加 target_pid 字段,server handlers 传递 target_pid 给 InputSimulator", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "AppState.target_pid: Option, handlers 读取 state.target_pid 传递给 InputSimulator" + }, + { + "task_id": "P8-03", + "task_type": "功能开发", + "phase": "Phase 8", + "module": "player", + "layer": "rust", + "task_desc": "ActionPlayer 添加 target_pid 支持,回放时使用定向投递", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "ActionPlayer::new(speed, target_pid), execute() 中传递 self.target_pid" + }, + { + "task_id": "P8-04", + "task_type": "功能开发", + "phase": "Phase 8", + "module": "cli", + "layer": "rust", + "task_desc": "CLI start 命令设置 target_pid,本地操作和 MCP 传递 None", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "Tauri 进程设置 target_pid=Some(self_pid), standalone 传 None, MCP 传 None" + }, + { + "task_id": "P8-05", + "task_type": "测试", + "phase": "Phase 8", + "module": "automation", + "layer": "rust", + "task_desc": "验证 cargo fmt + clippy + check + test 全部通过", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "188 tests passed, 0 failed" + }, + { + "task_id": "P8-06", + "task_type": "Bug修复", + "phase": "Phase 8", + "module": "capture", + "layer": "rust", + "task_desc": "修复 capture_region:使用 image crate 按 x/y 裁剪 RGBA 数据,而非忽略坐标参数截全屏", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 capture/mod.rs capture_region,用 image::imageops::crop 裁剪" + }, + { + "task_id": "P8-07", + "task_type": "Bug修复", + "phase": "Phase 8", + "module": "ui", + "layer": "rust", + "task_desc": "Tauri setup 中自动发现并设置 window_id 到 HTTP AppState", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 src-tauri/src/main.rs setup 闭包,延迟后用 SCK 发现窗口设置 window_id" + }, + { + "task_id": "P8-08", + "task_type": "Bug修复", + "phase": "Phase 8", + "module": "ui", + "layer": "ts", + "task_desc": "创建前端 API 客户端层 api.ts:封装所有 HTTP 端点的 fetch 调用", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "新增 sandbox-web/src/api.ts,含 screenshot/click/type/key/spawnCli/spawnApp/ptyWrite/ptyRead 等" + }, + { + "task_id": "P8-09", + "task_type": "Bug修复", + "phase": "Phase 8", + "module": "ui", + "layer": "ts", + "task_desc": "连接 main.tsx 所有 stub handler 到 api.ts 真实调用", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 sandbox-web/src/main.tsx,替换空桩为 api.xxx() 调用" + }, + { + "task_id": "P8-10", + "task_type": "Bug修复", + "phase": "Phase 8", + "module": "ui", + "layer": "ts", + "task_desc": "连接 xterm.js 终端到 Rust PTY:轮询 /pty/output/:pid,转发 onData 输入", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 Terminal.tsx 添加 PTY 轮询和 onData handler,修改 main.tsx handleTerminalInput" + }, + { + "task_id": "P8-11", + "task_type": "Bug修复", + "phase": "Phase 8", + "module": "process", + "layer": "rust", + "task_desc": "spawn_app 增强:启动后自动发现关联窗口 ID,回写到 instance registry 和 AppState", + "executor": "Claude Code", + "status": "已完成", + "create_time": "2026-05-17 00:00:00", + "finish_time": "2026-05-17 00:00:00", + "check_result": "通过", + "remark": "修改 process/mod.rs spawn_app 返回值增加 Option window_id" + } +] \ No newline at end of file diff --git a/entitlements.plist b/entitlements.plist new file mode 100644 index 0000000..0f05cde --- /dev/null +++ b/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.developer.screen-capture + + + diff --git a/release.sh b/release.sh index f05651a..e2fe3c1 100755 --- a/release.sh +++ b/release.sh @@ -4,12 +4,12 @@ set -euo pipefail # ============================================================ # system-test-sandbox — Release Build Script # ============================================================ -# Builds both the CLI binary and the macOS app, then packages -# them into ./release/ for distribution. +# Builds the Tauri sandbox app + CLI binary and packages +# them into ./release/. # # Prerequisites: # - Rust >= 1.88 -# - Node.js + pnpm +# - Node.js >= 20 + pnpm # - macOS (Apple Silicon or Intel) # ============================================================ @@ -18,6 +18,7 @@ cd "$SCRIPT_DIR" RELEASE_DIR="$SCRIPT_DIR/release" VERSION="0.1.0" +APP_NAME="System Test Sandbox" # --- helpers --- info() { echo " ➜ $*"; } @@ -33,7 +34,7 @@ check() { echo "" echo "==============================================" -echo " system-test-sandbox v${VERSION} — Release Build" +echo " ${APP_NAME} v${VERSION} — Release Build" echo "==============================================" echo "" @@ -41,122 +42,82 @@ echo "" info "Checking prerequisites..." check rustc check cargo -check node check pnpm +check node ok "All prerequisites met" -# --- step 2: build frontend --- +# --- step 2: clean up old processes & registries --- +echo "" +info "Cleaning up old sandbox processes..." +pkill -f "system-test-sandbox" 2>/dev/null || true +pkill -f "sandbox-cli" 2>/dev/null || true +pkill -x "sandbox" 2>/dev/null || true +rm -f ~/.sandbox/instances/*.json 2>/dev/null || true +ok "Cleanup done" + +# --- step 3: build frontend --- echo "" info "Building frontend (sandbox-web)..." cd "$SCRIPT_DIR/sandbox-web" +pnpm install --silent 2>&1 | tail -1 +pnpm build 2>&1 | tail -5 +ok "Frontend built" + +# --- step 4: build Tauri app (includes Rust build) --- +echo "" +info "Building Tauri sandbox app..." +cd "$SCRIPT_DIR" +cargo tauri build 2>&1 | tail -10 -if [ ! -d "node_modules" ]; then - pnpm install --frozen-lockfile +TAURI_BUNDLE="$SCRIPT_DIR/target/release/bundle/macos/${APP_NAME}.app" +if [ ! -d "$TAURI_BUNDLE" ]; then + err "Tauri app bundle not found at $TAURI_BUNDLE" fi -pnpm build -ok "Frontend built -> sandbox-web/dist/" +ok "Tauri app built: $(du -sh "$TAURI_BUNDLE" | cut -f1)" -# --- step 3: build CLI binary --- +# --- step 5: build CLI binary (release) --- echo "" info "Building CLI binary (release)..." -cd "$SCRIPT_DIR" cargo build --release -p sandbox-cli CLI_BIN="$SCRIPT_DIR/target/release/sandbox" -ok "CLI binary built: $(du -h "$CLI_BIN" | cut -f1)" - -# --- step 4: build Tauri app --- -echo "" -info "Building Tauri desktop app..." - -cd "$SCRIPT_DIR" - -# Try cargo-tauri if installed, otherwise install it -if ! cargo tauri --version &>/dev/null; then - info "Installing tauri-cli (one-time) ..." - cargo install tauri-cli --version "^2" -fi - -APP_NAME="System Test Sandbox" -cargo tauri build --target universal-apple-darwin 2>/dev/null || cargo tauri build - -TAURI_BUILD_DIR="$SCRIPT_DIR/target/release/bundle/macos" -APP_BUNDLE="$TAURI_BUILD_DIR/${APP_NAME}.app" -DMG_PATH="$SCRIPT_DIR/target/release/bundle/dmg" - -if [ -d "$APP_BUNDLE" ]; then - ok "Tauri app built: $APP_BUNDLE" -else - # Fallback: manually assemble .app from cargo binary - info "Manually assembling .app bundle..." - cargo build --release -p system-test-sandbox - APP_BUNDLE="$TAURI_BUILD_DIR/${APP_NAME}.app" - mkdir -p "$APP_BUNDLE/Contents/MacOS" - mkdir -p "$APP_BUNDLE/Contents/Resources" - cp "$SCRIPT_DIR/target/release/system-test-sandbox" "$APP_BUNDLE/Contents/MacOS/" - # Copy Info.plist if exists - if [ -f "$SCRIPT_DIR/src-tauri/Info.plist" ]; then - cp "$SCRIPT_DIR/src-tauri/Info.plist" "$APP_BUNDLE/Contents/" - fi - # Copy icons - if [ -f "$SCRIPT_DIR/src-tauri/icons/icon.icns" ]; then - cp "$SCRIPT_DIR/src-tauri/icons/icon.icns" "$APP_BUNDLE/Contents/Resources/" - fi - ok ".app bundle manually assembled" +if [ ! -f "$CLI_BIN" ]; then + err "CLI binary not found at $CLI_BIN" fi +ok "CLI binary built: $(du -h "$CLI_BIN" | cut -f1)" -# --- step 5: assemble release folder --- +# --- step 6: assemble release folder --- echo "" info "Assembling release artifacts -> $RELEASE_DIR" rm -rf "$RELEASE_DIR" mkdir -p "$RELEASE_DIR" -# CLI binary +# Copy CLI cp "$CLI_BIN" "$RELEASE_DIR/sandbox" chmod +x "$RELEASE_DIR/sandbox" - -# Fix rpath for Swift Concurrency (required by ScreenCaptureKit) -install_name_tool -add_rpath /usr/lib/swift "$RELEASE_DIR/sandbox" 2>/dev/null || true ok "sandbox CLI binary" -# Tauri .app -if [ -d "$APP_BUNDLE" ]; then - cp -R "$APP_BUNDLE" "$RELEASE_DIR/" - # Fix rpath for the app binary too - APP_EXEC="$RELEASE_DIR/${APP_NAME}.app/Contents/MacOS/system-test-sandbox" - if [ -f "$APP_EXEC" ]; then - install_name_tool -add_rpath /usr/lib/swift "$APP_EXEC" 2>/dev/null || true - fi - ok "$APP_NAME.app" -fi - -# DMG installer -DMG_FILE=$(ls "$DMG_PATH"/*.dmg 2>/dev/null | head -1) -if [ -n "$DMG_FILE" ]; then - cp "$DMG_FILE" "$RELEASE_DIR/" - ok "$(basename "$DMG_FILE")" -fi +# Copy Tauri app bundle +cp -R "$TAURI_BUNDLE" "$RELEASE_DIR/${APP_NAME}.app" +ok "${APP_NAME}.app bundle" -# Generate README (inline, see step 6) -ok "Release artifacts ready" - -# --- step 6: generate README --- +# --- step 7: generate README --- echo "" info "Generating README.md..." BUILD_DATE="$(date '+%Y-%m-%d %H:%M')" cat > "$RELEASE_DIR/README.md" << 'RELEASEREADME' -# System Test Sandbox — Release v0.1.0 +# System Test Sandbox — Release v${VERSION} -macOS 桌面自动化沙箱。模拟鼠标/键盘操作、截图、读取 UI 元素树,通过 CLI 或 MCP 协议供 Agent 工具调用。 +macOS 桌面自动化沙箱。通过 CLI 启动 Tauri 沙箱窗口,内置 xterm.js 终端运行命令行工具(如 Claude Code),支持截图和输入模拟。 ## 文件说明 ``` release/ -├── sandbox # CLI 工具(命令行) -├── System Test Sandbox.app # macOS 桌面应用(Tauri) -└── README.md # 本文件 +├── sandbox # CLI 工具(命令行入口) +├── System Test Sandbox.app/ # Tauri 沙箱 macOS 应用 +└── README.md # 本文件 ``` ## 一、前置条件 @@ -173,111 +134,99 @@ release/ 1. **辅助功能 (Accessibility)**:用于 CGEvent 输入模拟 + AXUIElement UI 读取 2. **屏幕录制 (Screen Recording)**:用于 ScreenCaptureKit 截图 -授予方式:`系统设置 → 隐私与安全性 → 辅助功能 / 屏幕录制`,将 `sandbox` 或 `System Test Sandbox.app` 添加进去并勾选。 - -## 二、CLI 使用方法 - -### 启动 HTTP + MCP 服务(最常用) - -```bash -./sandbox serve --port 5801 -``` - -启动后可用端点: - -``` -GET http://127.0.0.1:5801/health # 健康检查 -GET http://127.0.0.1:5801/screenshot # 截取沙箱窗口 (PNG) -POST http://127.0.0.1:5801/input/click # 鼠标点击 -POST http://127.0.0.1:5801/input/type # 键盘输入 -POST http://127.0.0.1:5801/input/key # 按键 -POST http://127.0.0.1:5801/cli/spawn # 启动 CLI 进程 -POST http://127.0.0.1:5801/app/spawn # 启动 macOS 应用 -GET http://127.0.0.1:5801/windows # 列出窗口 -GET http://127.0.0.1:5801/processes # 列出进程 -GET http://127.0.0.1:5801/ui/inspect/:window # 读取 UI 树 -``` - -### 启动 MCP 服务(供 Claude Code / Codex 调用) - -```bash -./sandbox mcp-serve -``` - -在 `.claude/settings.json` 中配置: - -```json -{ - "mcpServers": { - "sandbox": { - "command": "/absolute/path/to/release/sandbox", - "args": ["mcp-serve"] - } - } -} -``` - -### 一次性命令 - -```bash -# 截图 -./sandbox screenshot -o result.png +授予方式:\`系统设置 → 隐私与安全性 → 辅助功能 / 屏幕录制\`。 -# 列出所有窗口 -./sandbox windows - -# 模拟点击 -./sandbox click 500 300 +将 \`sandbox\` 和 \`System Test Sandbox.app\` 添加进去并勾选。 -# 模拟打字 -./sandbox type "Hello World" +## 二、使用方法 -# 模拟按键 -./sandbox key Return -./sandbox key c --modifiers cmd +### 启动沙箱 -# 启动 App -./sandbox spawn-app /Applications/Calculator.app +\`\`\`bash +# 在沙箱中启动 Claude Code(交互模式) +./sandbox start claude -# 启动 CLI -./sandbox spawn-cli ls -la +# 非交互式:直接向 Claude 提问(约 30 秒响应) +./sandbox start claude -- -p "你的问题" -# 终止进程 -./sandbox kill 12345 -``` +# 启动交互式 Shell +./sandbox start zsh +./sandbox start bash -### curl 调用示例 +# 启动其他 CLI 工具 +./sandbox start node +./sandbox start npm -- test +\`\`\` -```bash -# 截图 -curl http://127.0.0.1:5801/screenshot -o screenshot.png +> **注意**:命令与参数之间用 \`--\` 分隔,如 \`./sandbox start -- \`。 -# 点击 -curl -X POST http://127.0.0.1:5801/input/click \ - -H "Content-Type: application/json" \ - -d '{"x": 100, "y": 200, "button": "left"}' +### 截图 -# 输入文字 -curl -X POST http://127.0.0.1:5801/input/type \ - -H "Content-Type: application/json" \ - -d '{"text": "hello"}' +\`\`\`bash +# 自动发现沙箱窗口并截图(保存为 PNG) +./sandbox screenshot -o screenshot.png -# 按回车 -curl -X POST http://127.0.0.1:5801/input/key \ - -H "Content-Type: application/json" \ - -d '{"key": "Return", "modifiers": []}' +# 指定窗口 ID 截图 +./sandbox screenshot --window-id 12345 -o screenshot.png +\`\`\` -# 启动 CLI -curl -X POST http://127.0.0.1:5801/cli/spawn \ - -H "Content-Type: application/json" \ - -d '{"command": "ls", "args": ["-la"]}' -``` +### 其他命令 -## 三、桌面应用使用方法 +\`\`\`bash +# 列出所有可见窗口 +./sandbox windows -1. 双击 `System Test Sandbox.app` 启动 -2. 应用窗口内嵌 xterm.js 终端,可直接运行 CLI 命令 -3. 顶部状态栏提供截图按钮 +# 关闭沙箱 +./sandbox shutdown +\`\`\` + +### 示例工作流 + +\`\`\`bash +# 1. 启动 Claude Code +./sandbox start claude + +# 2. 等待 Claude 启动(约 10 秒) +sleep 10 + +# 3. 截图查看状态 +./sandbox screenshot -o screenshot.png + +# 4. 关闭沙箱 +./sandbox shutdown +\`\`\` + +\`\`\`bash +# 非交互式快速提问 +./sandbox start claude -- -p "用 Python 写一个 hello world" +sleep 30 +./sandbox screenshot -o claude_response.png +./sandbox shutdown +\`\`\` + +## 三、架构 + +\`\`\` +sandbox start claude + │ + ▼ +CLI (sandbox) + │ spawn System Test Sandbox.app --mode=cli --cmd=claude + ▼ +Tauri 沙箱窗口 + ┌────────────────────────────────────────────┐ + │ 终端面板 (xterm.js) │ Screenshot Preview │ + │ ← Claude 运行在这里 │ │ + ├────────────────────────────────────────────┤ + │ Control Panel: Screenshot / Spawn / Click │ + ├────────────────────────────────────────────┤ + │ Status: Server :5801 | Processes: X | ... │ + └────────────────────────────────────────────┘ + │ HTTP :5801 + ▼ + 内嵌 HTTP API (axum) + - /screenshot, /input/click, /pty/write, ... +\`\`\` ## 四、常见问题 @@ -287,15 +236,15 @@ A: 检查「屏幕录制」权限是否已授予。 **Q: 点击/输入无效?** A: 检查「辅助功能」权限是否已授予。 -**Q: `serve` 端口被占用?** -A: 使用 `./sandbox serve --port 5802` 更换端口。 +**Q: 无法启动沙箱?** +A: 确保 \`System Test Sandbox.app\` 与 \`sandbox\` 在同一目录下。 -**Q: MCP 连接失败?** -A: 确认 `settings.json` 中的 `command` 路径是绝对路径。 +**Q: 沙箱窗口内终端空白?** +A: 等待几秒让 Claude 启动,终端会自动连接 PTY 输出。 --- -**版本**: v0.1.0 | **构建时间**: __BUILD_DATE__ +**版本**: v${VERSION} | **构建时间**: __BUILD_DATE__ RELEASEREADME # Inject build date @@ -311,3 +260,5 @@ echo " Artifacts -> $RELEASE_DIR" echo "==============================================" ls -lh "$RELEASE_DIR" echo "" +echo " $(du -sh "$RELEASE_DIR" | cut -f1) total" +echo "" diff --git a/release/README.md b/release/README.md new file mode 100644 index 0000000..629f060 --- /dev/null +++ b/release/README.md @@ -0,0 +1,138 @@ +# System Test Sandbox — Release v${VERSION} + +macOS 桌面自动化沙箱。通过 CLI 启动 Tauri 沙箱窗口,内置 xterm.js 终端运行命令行工具(如 Claude Code),支持截图和输入模拟。 + +## 文件说明 + +``` +release/ +├── sandbox # CLI 工具(命令行入口) +├── System Test Sandbox.app/ # Tauri 沙箱 macOS 应用 +└── README.md # 本文件 +``` + +## 一、前置条件 + +| 依赖 | 版本要求 | +|------|---------| +| macOS | 14.0+ (Sonoma) | +| 芯片 | Apple Silicon (M1–M4),Intel 也支持 | + +### 必须授予的权限 + +> **没有这两个权限,sandbox 无法工作。** + +1. **辅助功能 (Accessibility)**:用于 CGEvent 输入模拟 + AXUIElement UI 读取 +2. **屏幕录制 (Screen Recording)**:用于 ScreenCaptureKit 截图 + +授予方式:\`系统设置 → 隐私与安全性 → 辅助功能 / 屏幕录制\`。 + +将 \`sandbox\` 和 \`System Test Sandbox.app\` 添加进去并勾选。 + +## 二、使用方法 + +### 启动沙箱 + +\`\`\`bash +# 在沙箱中启动 Claude Code(交互模式) +./sandbox start claude + +# 非交互式:直接向 Claude 提问(约 30 秒响应) +./sandbox start claude -- -p "你的问题" + +# 启动交互式 Shell +./sandbox start zsh +./sandbox start bash + +# 启动其他 CLI 工具 +./sandbox start node +./sandbox start npm -- test +\`\`\` + +> **注意**:命令与参数之间用 \`--\` 分隔,如 \`./sandbox start -- \`。 + +### 截图 + +\`\`\`bash +# 自动发现沙箱窗口并截图(保存为 PNG) +./sandbox screenshot -o screenshot.png + +# 指定窗口 ID 截图 +./sandbox screenshot --window-id 12345 -o screenshot.png +\`\`\` + +### 其他命令 + +\`\`\`bash +# 列出所有可见窗口 +./sandbox windows + +# 关闭沙箱 +./sandbox shutdown +\`\`\` + +### 示例工作流 + +\`\`\`bash +# 1. 启动 Claude Code +./sandbox start claude + +# 2. 等待 Claude 启动(约 10 秒) +sleep 10 + +# 3. 截图查看状态 +./sandbox screenshot -o screenshot.png + +# 4. 关闭沙箱 +./sandbox shutdown +\`\`\` + +\`\`\`bash +# 非交互式快速提问 +./sandbox start claude -- -p "用 Python 写一个 hello world" +sleep 30 +./sandbox screenshot -o claude_response.png +./sandbox shutdown +\`\`\` + +## 三、架构 + +\`\`\` +sandbox start claude + │ + ▼ +CLI (sandbox) + │ spawn System Test Sandbox.app --mode=cli --cmd=claude + ▼ +Tauri 沙箱窗口 + ┌────────────────────────────────────────────┐ + │ 终端面板 (xterm.js) │ Screenshot Preview │ + │ ← Claude 运行在这里 │ │ + ├────────────────────────────────────────────┤ + │ Control Panel: Screenshot / Spawn / Click │ + ├────────────────────────────────────────────┤ + │ Status: Server :5801 | Processes: X | ... │ + └────────────────────────────────────────────┘ + │ HTTP :5801 + ▼ + 内嵌 HTTP API (axum) + - /screenshot, /input/click, /pty/write, ... +\`\`\` + +## 四、常见问题 + +**Q: 截图全黑?** +A: 检查「屏幕录制」权限是否已授予。 + +**Q: 点击/输入无效?** +A: 检查「辅助功能」权限是否已授予。 + +**Q: 无法启动沙箱?** +A: 确保 \`System Test Sandbox.app\` 与 \`sandbox\` 在同一目录下。 + +**Q: 沙箱窗口内终端空白?** +A: 等待几秒让 Claude 启动,终端会自动连接 PTY 输出。 + +--- + +**版本**: v${VERSION} | **构建时间**: __BUILD_DATE__ diff --git a/sandbox-web/package.json b/sandbox-web/package.json index 3c41408..c99bc84 100644 --- a/sandbox-web/package.json +++ b/sandbox-web/package.json @@ -17,13 +17,14 @@ "@tauri-apps/plugin-shell": "^2", "react": "^18.3.1", "react-dom": "^18.3.1", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.11.0" }, "devDependencies": { "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^2.1.9", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "prettier": "^3.4.2", @@ -31,5 +32,10 @@ "typescript": "^5.7.2", "vite": "^6.0.5", "vitest": "^2.1.8" + }, + "pnpm": { + "overrides": { + "glob": "^10.5.0" + } } } diff --git a/sandbox-web/pnpm-lock.yaml b/sandbox-web/pnpm-lock.yaml index 5777ba7..c1c0458 100644 --- a/sandbox-web/pnpm-lock.yaml +++ b/sandbox-web/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + glob: ^10.5.0 + importers: .: @@ -14,18 +17,18 @@ importers: '@tauri-apps/plugin-shell': specifier: ^2 version: 2.3.5 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - xterm: - specifier: ^5.3.0 - version: 5.3.0 - xterm-addon-fit: - specifier: ^0.8.0 - version: 0.8.0(xterm@5.3.0) devDependencies: '@types/react': specifier: ^18.3.1 @@ -36,6 +39,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@6.4.2(jiti@1.21.7)) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9) autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.14) @@ -64,6 +70,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -147,6 +157,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -441,6 +454,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -469,6 +490,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -651,6 +676,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -680,6 +714,28 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -701,6 +757,13 @@ packages: peerDependencies: postcss: ^8.1.0 + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.29: resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} @@ -710,6 +773,13 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -742,6 +812,13 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -749,6 +826,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -776,9 +857,18 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.356: resolution: {integrity: sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -827,6 +917,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -850,10 +944,22 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -866,6 +972,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -874,6 +984,28 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -905,12 +1037,22 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -919,6 +1061,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -945,9 +1099,20 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1073,9 +1238,26 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1086,11 +1268,31 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1100,6 +1302,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1251,20 +1457,23 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - xterm-addon-fit@0.8.0: - resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==} - deprecated: This package is now deprecated. Move to @xterm/addon-fit instead. - peerDependencies: - xterm: ^5.0.0 + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} - xterm@5.3.0: - resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==} - deprecated: This package is now deprecated. Move to @xterm/xterm instead. + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1273,6 +1482,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -1385,6 +1599,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1532,6 +1748,17 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1563,6 +1790,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@pkgjs/parseargs@0.11.0': + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -1694,6 +1924,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@2.1.9(vitest@2.1.9)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9 + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1734,6 +1982,20 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -1754,10 +2016,22 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.29: {} binary-extensions@2.3.0: {} + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1798,10 +2072,22 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@4.1.1: {} convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -1816,8 +2102,14 @@ snapshots: dlv@1.1.3: {} + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.356: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + es-errors@1.3.0: {} es-module-lexer@1.7.0: {} @@ -1905,6 +2197,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fraction.js@5.3.4: {} fsevents@2.3.3: @@ -1922,10 +2219,23 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + has-flag@4.0.0: {} + hasown@2.0.3: dependencies: function-bind: 1.1.2 + html-escaper@2.0.2: {} + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -1936,12 +2246,43 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-number@7.0.0: {} + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@1.21.7: {} js-tokens@4.0.0: {} @@ -1960,6 +2301,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -1968,6 +2311,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + merge2@1.4.1: {} micromatch@4.0.8: @@ -1975,6 +2328,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minipass@7.1.3: {} + ms@2.1.3: {} mz@2.7.0: @@ -1993,8 +2356,17 @@ snapshots: object-hash@3.0.0: {} + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@1.1.2: {} pathval@2.0.1: {} @@ -2120,14 +2492,44 @@ snapshots: semver@6.3.1: {} + semver@7.8.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2138,6 +2540,10 @@ snapshots: tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} tailwindcss@3.4.19: @@ -2168,6 +2574,12 @@ snapshots: - tsx - yaml + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -2278,15 +2690,25 @@ snapshots: - supports-color - terser + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - xterm-addon-fit@0.8.0(xterm@5.3.0): + wrap-ansi@7.0.0: dependencies: - xterm: 5.3.0 + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 - xterm@5.3.0: {} + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 yallist@3.1.1: {} diff --git a/sandbox-web/src/api.ts b/sandbox-web/src/api.ts new file mode 100644 index 0000000..9bb5658 --- /dev/null +++ b/sandbox-web/src/api.ts @@ -0,0 +1,229 @@ +/** + * Sandbox HTTP API client. + * + * Port resolution order: + * 1. `?sandbox_port=` in the page URL + * 2. `SANDOX_PORT` env var (Vite-injected at build time) + * 3. Default `5801` + */ + +function getPort(): number { + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const p = params.get("sandbox_port"); + if (p) return Number(p); + } + return 5801; +} + +const BASE = () => `http://127.0.0.1:${getPort()}`; + +// ── Types ────────────────────────────────────────────── + +export interface ProcessInfo { + pid: number; + name: string; + path: string | null; + is_running: boolean; +} + +export interface HealthResponse { + status: string; + version: string; + uptime_secs: number; + sandbox_id: string | null; +} + +export interface SandboxInfo { + sandbox_id: string | null; + window_id: number | null; + uptime_secs: number; +} + +// ── Generic fetch helper ─────────────────────────────── + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE()}${path}`, { + ...options, + headers: { "Content-Type": "application/json", ...options?.headers }, + }); + if (!res.ok) { + const body = await res.text(); + let msg = body; + try { + msg = JSON.parse(body).error ?? body; + } catch { + /* keep raw text */ + } + throw new Error(`HTTP ${res.status}: ${msg}`); + } + // Some endpoints return binary (image/png), caller handles raw response + return res as unknown as T; +} + +// ── Health & Info ────────────────────────────────────── + +export async function health(): Promise { + const res = await fetch(`${BASE()}/health`); + return res.json(); +} + +export async function sandboxInfo(): Promise { + const res = await fetch(`${BASE()}/sandbox/info`); + return res.json(); +} + +// ── Screenshot ───────────────────────────────────────── + +/** Capture the sandbox window. Returns a Blob URL. */ +export async function takeScreenshot(): Promise { + const res = await fetch(`${BASE()}/screenshot`); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Screenshot failed: ${body}`); + } + const blob = await res.blob(); + return URL.createObjectURL(blob); +} + +/** Capture a screen region. Returns a Blob URL. */ +export async function takeScreenshotRegion( + x: number, + y: number, + width: number, + height: number, +): Promise { + const res = await fetch( + `${BASE()}/screenshot/region?x=${x}&y=${y}&width=${width}&height=${height}`, + ); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Screenshot region failed: ${body}`); + } + const blob = await res.blob(); + return URL.createObjectURL(blob); +} + +// ── Input ────────────────────────────────────────────── + +export async function click( + x: number, + y: number, + button: "left" | "right" | "middle" = "left", +): Promise { + await request("/input/click", { + method: "POST", + body: JSON.stringify({ x, y, button }), + }); +} + +export async function typeText(text: string): Promise { + await request("/input/type", { + method: "POST", + body: JSON.stringify({ text }), + }); +} + +export async function pressKey( + key: string, + modifiers: string[] = [], +): Promise { + await request("/input/key", { + method: "POST", + body: JSON.stringify({ key, modifiers }), + }); +} + +export async function scroll( + x: number, + y: number, + direction: string, + amount: number, +): Promise { + await request("/input/scroll", { + method: "POST", + body: JSON.stringify({ x, y, direction, amount }), + }); +} + +export async function drag( + fromX: number, + fromY: number, + toX: number, + toY: number, +): Promise { + await request("/input/drag", { + method: "POST", + body: JSON.stringify({ + from_x: fromX, + from_y: fromY, + to_x: toX, + to_y: toY, + }), + }); +} + +// ── Process ──────────────────────────────────────────── + +export async function spawnApp(path: string): Promise { + const res = await fetch(`${BASE()}/app/spawn`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`spawnApp failed: ${body}`); + } + return res.json(); +} + +export async function spawnCli( + command: string, + args: string[], +): Promise { + const res = await fetch(`${BASE()}/cli/spawn`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ command, args }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`spawnCli failed: ${body}`); + } + return res.json(); +} + +export async function listProcesses(): Promise { + const res = await fetch(`${BASE()}/processes`); + return res.json(); +} + +export async function killProcess(pid: number): Promise { + await request("/process/kill", { + method: "POST", + body: JSON.stringify({ pid }), + }); +} + +// ── PTY ──────────────────────────────────────────────── + +export async function ptyWrite(pid: number, data: string): Promise { + await request("/pty/write", { + method: "POST", + body: JSON.stringify({ pid, data }), + }); +} + +export async function ptyRead(pid: number): Promise<{ output: string | null }> { + const res = await fetch(`${BASE()}/pty/output/${pid}`); + return res.json(); +} + +// ── Windows ──────────────────────────────────────────── + +export async function listWindows(): Promise<[number, string][]> { + const res = await fetch(`${BASE()}/windows`); + return res.json(); +} + diff --git a/sandbox-web/src/components/RecordControls.tsx b/sandbox-web/src/components/RecordControls.tsx deleted file mode 100644 index 5a736d6..0000000 --- a/sandbox-web/src/components/RecordControls.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useState } from "react"; - -type RecordStatus = "idle" | "recording" | "playing"; - -interface RecordControlsProps { - onRecordStart: () => void; - onRecordStop: () => void; - onPlay: (speed: number) => void; - onPlayStop: () => void; - status: RecordStatus; - actionCount?: number; -} - -export default function RecordControls({ - onRecordStart, - onRecordStop, - onPlay, - onPlayStop, - status, - actionCount = 0, -}: RecordControlsProps) { - const [speed, setSpeed] = useState(1.0); - - return ( -
-
- Recording & Playback -
- -
- {/* Record */} - {status === "recording" ? ( - - ) : ( - - )} - - {/* Play */} - {status === "playing" ? ( - - ) : ( - - )} - - {/* Speed */} - -
- - {/* Status */} -
- - Status:{" "} - - {status} - - - Actions: {actionCount} -
- - {/* Timeline scrubber placeholder */} - {actionCount > 0 && ( -
-
-
-
-
- 0 - {actionCount} actions -
-
- )} -
- ); -} diff --git a/sandbox-web/src/components/Terminal.tsx b/sandbox-web/src/components/Terminal.tsx index 40e6ef7..3e4505a 100644 --- a/sandbox-web/src/components/Terminal.tsx +++ b/sandbox-web/src/components/Terminal.tsx @@ -1,23 +1,29 @@ -import { useEffect, useRef } from "react"; -import { Terminal } from "xterm"; -import { FitAddon } from "xterm-addon-fit"; -import "xterm/css/xterm.css"; +import { useEffect, useRef, useCallback } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import * as api from "../api"; +import "@xterm/xterm/css/xterm.css"; interface TerminalProps { /** Callback when terminal receives input */ onInput?: (data: string) => void; /** Whether the terminal is connected to a PTY */ connected?: boolean; + /** The tracked PID of the active PTY process (null = none) */ + activePid?: number | null; } export default function SandboxTerminal({ onInput, connected = false, + activePid = null, }: TerminalProps) { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); + const pollRef = useRef | null>(null); + // Initialize xterm.js once useEffect(() => { if (!terminalRef.current || xtermRef.current) return; @@ -69,21 +75,51 @@ export default function SandboxTerminal({ window.removeEventListener("resize", handleResize); term.dispose(); }; - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Public write method exposed via ref pattern + // PTY output polling — runs while activePid is set useEffect(() => { - if (xtermRef.current) { - ( - xtermRef.current as Terminal & { _write?: (data: string) => void } - )._write = (data: string) => { - xtermRef.current?.write(data); - }; + // Clear any existing poll + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + + if (activePid === null || activePid === undefined) return; + + pollRef.current = setInterval(async () => { + try { + const result = await api.ptyRead(activePid); + if (result.output) { + xtermRef.current?.write(result.output); + } + } catch { + // Process may have exited; stop polling + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + } + }, 200); + + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + }, [activePid]); + + // Refit on window resize + const containerRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + // Trigger fit after layout + requestAnimationFrame(() => fitAddonRef.current?.fit()); } }, []); return ( -
+
Terminal ([]); const [screenshotCount, setScreenshotCount] = useState(0); const [serverStatus] = useState<"running" | "stopped" | "error">("running"); - const [recordStatus, setRecordStatus] = useState("idle"); - const [actionCount, setActionCount] = useState(0); const [screenshotLoading, setScreenshotLoading] = useState(false); - - const handleTerminalInput = useCallback((_data: string) => { - // Terminal input is forwarded to the PTY via Tauri shell plugin + const [screenshotUrl, setScreenshotUrl] = useState(null); + const [activePid, setActivePid] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const hasConnectedRef = useRef(false); + + const showError = useCallback((msg: string) => { + setErrorMsg(msg); + setTimeout(() => setErrorMsg(null), 4000); }, []); + // ── Auto-connect to spawned processes ────────────────── + // Polls for running processes and auto-connects to the first PTY + useEffect(() => { + const pollProcesses = async () => { + try { + const list = await api.listProcesses(); + if (list.length > 0) { + setProcesses(list.map((p) => ({ pid: p.pid, name: p.name, is_running: p.is_running }))); + // Auto-connect to the first running process + if (activePid === null && !hasConnectedRef.current) { + const running = list.find((p) => p.is_running); + if (running) { + setActivePid(running.pid); + hasConnectedRef.current = true; + } + } + } + } catch { + // Server may not be ready yet + } + }; + + pollProcesses(); + const interval = setInterval(pollProcesses, 1000); + return () => clearInterval(interval); + }, [activePid]); + + // ── Terminal input → PTY ───────────────────────────── + + const handleTerminalInput = useCallback( + (data: string) => { + if (activePid !== null) { + api.ptyWrite(activePid, data).catch(() => { + // PTY write failures are expected when the process exits + }); + } + }, + [activePid], + ); + + // ── Screenshot ─────────────────────────────────────── + const handleScreenshot = useCallback(async () => { setScreenshotLoading(true); try { - // Invoke Tauri command or call HTTP API + const url = await api.takeScreenshot(); + setScreenshotUrl(url); setScreenshotCount((c) => c + 1); - } catch { - // Silently handle screenshot failures + } catch (e) { + showError(`Screenshot failed: ${e}`); } finally { setScreenshotLoading(false); } - }, []); - - const handleSpawnApp = useCallback((path: string) => { - setProcesses((prev) => [ - ...prev, - { - pid: Date.now(), - name: path.split("/").pop() ?? path, - is_running: true, - }, - ]); - }, []); - - const handleSpawnCli = useCallback((command: string, _args: string[]) => { - setProcesses((prev) => [ - ...prev, - { pid: Date.now(), name: command, is_running: true }, - ]); - }, []); + }, [showError]); + + // ── Spawn App ──────────────────────────────────────── + + const handleSpawnApp = useCallback( + (path: string) => { + api + .spawnApp(path) + .then((info) => { + setProcesses((prev) => [ + ...prev, + { pid: info.pid, name: info.name, is_running: info.is_running }, + ]); + }) + .catch((e) => showError(`spawnApp failed: ${e}`)); + }, + [showError], + ); - const handleClick = useCallback((_x: number, _y: number, _button: string) => { - // Invoke Tauri or HTTP click - }, []); + // ── Spawn CLI ──────────────────────────────────────── + + const handleSpawnCli = useCallback( + (command: string, args: string[]) => { + api + .spawnCli(command, args) + .then((info) => { + setProcesses((prev) => [ + ...prev, + { pid: info.pid, name: info.name, is_running: info.is_running }, + ]); + // Auto-connect terminal to this PTY + setActivePid(info.pid); + }) + .catch((e) => showError(`spawnCli failed: ${e}`)); + }, + [showError], + ); - const handleTypeText = useCallback((_text: string) => { - // Invoke Tauri or HTTP type_text - }, []); + // ── Click ──────────────────────────────────────────── - const handlePressKey = useCallback((_key: string, _modifiers: string[]) => { - // Invoke Tauri or HTTP press_key - }, []); + const handleClick = useCallback( + (x: number, y: number, button: string) => { + api + .click(x, y, button as "left" | "right" | "middle") + .catch((e) => showError(`Click failed: ${e}`)); + }, + [showError], + ); - const handleRecordStart = useCallback(() => { - setRecordStatus("recording"); - setActionCount(0); - }, []); + // ── Type Text ──────────────────────────────────────── - const handleRecordStop = useCallback(() => { - setRecordStatus("idle"); - }, []); + const handleTypeText = useCallback( + (text: string) => { + api.typeText(text).catch((e) => showError(`Type failed: ${e}`)); + }, + [showError], + ); - const handlePlay = useCallback((_speed: number) => { - setRecordStatus("playing"); - }, []); + // ── Press Key ──────────────────────────────────────── - const handlePlayStop = useCallback(() => { - setRecordStatus("idle"); - }, []); + const handlePressKey = useCallback( + (key: string, modifiers: string[]) => { + api.pressKey(key, modifiers).catch((e) => showError(`Key failed: ${e}`)); + }, + [showError], + ); return (
@@ -99,35 +161,57 @@ function App() { - {/* Content: Terminal + App view */} + {/* Error toast */} + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* Content: Terminal + Screenshot / App view */}
{/* Terminal — left half */}
- +
- {/* App view — right half */} + {/* Screenshot preview / App view — right half */}
-
-
🖥
-

App View Area

-

- Embedded macOS app will render here -

-
+ {screenshotUrl ? ( +
+
+ + Latest Screenshot + + +
+ Sandbox screenshot +
+ ) : ( +
+
🖥
+

Screenshot Preview

+

+ Click "Screenshot" to capture +

+
+ )}
- {/* Record controls — bottom strip above status bar */} - - {/* Status bar */} + + + + NSScreenCaptureUsageDescription + System Test Sandbox requires screen capture permission to take automated screenshots of sandbox windows. + NSAppleEventsUsageDescription + System Test Sandbox requires accessibility permission to simulate mouse and keyboard input. + NSMicrophoneUsageDescription + System Test Sandbox requires microphone permission for audio capture capabilities. + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..4576fd6 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,10 @@ fn main() { - tauri_build::build() + tauri_build::build(); + + #[cfg(target_os = "macos")] + { + // Fix @rpath/libswift_Concurrency.dylib crash on macOS 26+ + println!("cargo:rustc-link-arg=-rpath"); + println!("cargo:rustc-link-arg=/usr/lib/swift"); + } } diff --git a/src-tauri/entitlements.plist b/src-tauri/entitlements.plist new file mode 100644 index 0000000..0f05cde --- /dev/null +++ b/src-tauri/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.developer.screen-capture + + + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 95d7b24..b850c6b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,16 +1,31 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use sandbox_core::sandbox::{Sandbox, SandboxConfig, SandboxState}; -use std::sync::Mutex; +use sandbox_core::instance::{InstanceKind, InstanceRegistry, SandboxInstance}; +use sandbox_core::process::ProcessManager; +use sandbox_core::sandbox::{Sandbox, SandboxConfig}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use tauri::Manager; +#[allow(dead_code)] struct AppState { sandbox: Mutex, + sandbox_id: Option, + port: Option, + kind: Option, } #[tauri::command] -fn get_sandbox_state(state: tauri::State) -> Result { +fn get_sandbox_state(state: tauri::State) -> Result { let sandbox = state.sandbox.lock().map_err(|e| e.to_string())?; - Ok(sandbox.state().clone()) + let s = sandbox.state(); + Ok(serde_json::json!({ + "sandbox_id": s.sandbox_id, + "port": s.port, + "window_id": s.window_id, + "is_running": s.is_running, + "uptime_secs": sandbox.uptime_secs(), + })) } #[tauri::command] @@ -31,11 +46,105 @@ fn init_sandbox(state: tauri::State, window_id: u32) -> Result<(), Str sandbox.init(window_id).map_err(|e| e.to_string()) } +#[derive(Debug, Default)] +struct SandboxLaunchArgs { + sandbox_id: Option, + sandbox_port: Option, + mode: Option, + cmd: Option, + args: Vec, +} + +fn parse_sandbox_args() -> SandboxLaunchArgs { + let args: Vec = std::env::args().collect(); + let mut result = SandboxLaunchArgs::default(); + let mut i = 1; + while i < args.len() { + let arg = &args[i]; + if let Some(val) = arg.strip_prefix("--sandbox-id=") { + result.sandbox_id = Some(val.to_string()); + } else if arg == "--sandbox-id" && i + 1 < args.len() { + i += 1; + result.sandbox_id = Some(args[i].clone()); + } else if let Some(val) = arg.strip_prefix("--sandbox-port=") { + result.sandbox_port = val.parse().ok(); + } else if arg == "--sandbox-port" && i + 1 < args.len() { + i += 1; + result.sandbox_port = args[i].parse().ok(); + } else if let Some(val) = arg.strip_prefix("--mode=") { + result.mode = Some(val.to_string()); + } else if arg == "--mode" && i + 1 < args.len() { + i += 1; + result.mode = Some(args[i].clone()); + } else if let Some(val) = arg.strip_prefix("--cmd=") { + result.cmd = Some(val.to_string()); + } else if arg == "--cmd" && i + 1 < args.len() { + i += 1; + result.cmd = Some(args[i].clone()); + } else if arg == "--" { + // Everything after -- is passed as trailing args to the CLI command + result.args = args[(i + 1)..].to_vec(); + break; + } + i += 1; + } + result +} + fn main() { + let launch_args = parse_sandbox_args(); + + // Auto-generate sandbox_id and port if not provided + let sandbox_id = launch_args.sandbox_id.clone().or_else(|| { + Some(format!( + "{}", + uuid::Uuid::new_v4().to_string()[..8].to_string() + )) + }); + let sandbox_port = launch_args.sandbox_port.or(Some(5801)); + + let config = SandboxConfig { + id: launch_args.sandbox_id.clone(), + port: launch_args.sandbox_port, + mode: launch_args.mode.clone(), + command: launch_args.cmd.clone(), + args: launch_args.args.clone(), + ..SandboxConfig::default() + }; + + let kind = match (launch_args.mode.as_deref(), &launch_args.cmd) { + (Some("cli"), Some(cmd)) => Some(InstanceKind::Cli { + command: cmd.clone(), + args: launch_args.args.clone(), + }), + (Some("app"), Some(path)) => Some(InstanceKind::App { path: path.clone() }), + _ => None, + }; + + let title = match &kind { + Some(InstanceKind::Cli { command, .. }) => format!("System Test Sandbox [{command}]"), + Some(InstanceKind::App { path }) => { + let name = std::path::Path::new(path) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + format!("System Test Sandbox [{name}]") + } + None => "System Test Sandbox".to_string(), + }; + + let kind_for_setup = kind.clone(); + let sandbox_id_for_close = sandbox_id.clone(); + let port_for_close = sandbox_port; + tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(AppState { - sandbox: Mutex::new(Sandbox::new(SandboxConfig::default())), + sandbox: Mutex::new(Sandbox::new(config)), + sandbox_id: sandbox_id.clone(), + port: sandbox_port, + kind: kind.clone(), }) .invoke_handler(tauri::generate_handler![ get_sandbox_state, @@ -43,6 +152,110 @@ fn main() { get_sandbox_config, init_sandbox, ]) + .setup(move |app_handle| { + // Set window title + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.set_title(&title); + } + + // Start embedded HTTP server if in managed mode + if let (Some(id), Some(port)) = (&sandbox_id, sandbox_port) { + let state = Arc::new(tokio::sync::Mutex::new(sandbox_core::server::AppState { + sandbox_id: Some(id.clone()), + start_time: Instant::now(), + window_id: None, + target_pid: Some(std::process::id()), + })); + + // Clone for window discovery task + let state_for_window = state.clone(); + + let router = sandbox_core::server::build_router(state); + let port_val = port; + + tauri::async_runtime::spawn(async move { + let addr = format!("127.0.0.1:{port_val}"); + match tokio::net::TcpListener::bind(&addr).await { + Ok(listener) => { + tracing::info!("Sandbox HTTP API listening on http://{addr}"); + if let Err(e) = axum::serve(listener, router).await { + tracing::error!("HTTP server error: {e}"); + } + } + Err(e) => { + tracing::error!("Failed to bind HTTP server on port {port_val}: {e}"); + } + } + }); + + // Register instance + let registry = InstanceRegistry::default(); + let instance = SandboxInstance::new( + id.clone(), + port, + std::process::id(), + kind_for_setup.unwrap_or(InstanceKind::Cli { + command: "unknown".into(), + args: vec![], + }), + ); + if let Err(e) = registry.register(&instance) { + tracing::error!("Failed to register instance: {e}"); + } + + // Auto-spawn CLI if in CLI mode + if let Some(InstanceKind::Cli { command, args }) = &kind { + let cmd = command.clone(); + let cmd_args = args.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + match ProcessManager::spawn_cli(&cmd, &cmd_args) { + Ok(info) => { + tracing::info!("Auto-spawned CLI: {} (pid={})", cmd, info.pid); + } + Err(e) => { + tracing::error!("Failed to auto-spawn CLI '{cmd}': {e}"); + } + } + }); + } + + // Auto-discover the Tauri window's SCWindow ID for screenshot support. + // The window needs time to render before ScreenCaptureKit can find it. + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + match sandbox_core::capture::ScreenCapture::find_window_by_title( + "System Test Sandbox", + ) { + Ok(id) => { + tracing::info!("Discovered sandbox window: SCWindow ID={id}"); + state_for_window.lock().await.window_id = Some(id); + } + Err(e) => { + tracing::warn!("Failed to discover sandbox window: {e}"); + } + } + }); + } + + // Window close cleanup + if let Some(window) = app_handle.get_webview_window("main") { + let close_id = sandbox_id_for_close.clone(); + let _close_port = port_for_close; + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + if let Some(ref id) = close_id { + tracing::info!("Sandbox window closing, cleaning up instance {id}"); + let registry = InstanceRegistry::default(); + let _ = registry.unregister(id); + tracing::info!("Instance {id} unregistered"); + } + } + }); + } + + Ok(()) + }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e92de13..322c8c6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "System Test Sandbox", "version": "0.1.0", - "identifier": "com.system-test-sandbox.app", + "identifier": "com.system-test-sandbox", "build": { "frontendDist": "../sandbox-web/dist", "devUrl": "http://localhost:5173", @@ -31,6 +31,9 @@ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png" - ] + ], + "macOS": { + "entitlements": "./entitlements.plist" + } } }