diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec61f93..5b66419 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ concurrency: env: NODE_VERSION: '22' PNPM_VERSION: '9' - RUST_VERSION: '1.88' + RUST_VERSION: '1.91' jobs: # ==================== Rust 格式化检查 ==================== @@ -113,18 +113,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 +154,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" @@ -325,9 +325,63 @@ jobs: working-directory: sandbox-web run: pnpm install --frozen-lockfile - - name: 运行单元测试 + - name: 运行单元测试 + 覆盖率 working-directory: sandbox-web - run: pnpm test:unit + run: pnpm vitest run --coverage + + - name: 生成覆盖率摘要 + if: always() + run: | + SUMMARY_FILE="frontend-coverage-summary.md" + echo "## 前端测试覆盖率" > "$SUMMARY_FILE" + echo "" >> "$SUMMARY_FILE" + COV_JSON="sandbox-web/coverage/coverage-summary.json" + if [ -f "$COV_JSON" ]; then + python3 -c " + import json + with open('$COV_JSON') as f: + data = json.load(f) + total = data.get('total', {}) + lines_pct = total.get('lines', {}).get('pct', 0) + branches_pct = total.get('branches', {}).get('pct', 0) + functions_pct = total.get('functions', {}).get('pct', 0) + statements_pct = total.get('statements', {}).get('pct', 0) + print(f'| 指标 | 覆盖率 |') + print(f'|------|--------|') + print(f'| 语句 | {statements_pct:.1f}% |') + print(f'| 分支 | {branches_pct:.1f}% |') + print(f'| 函数 | {functions_pct:.1f}% |') + print(f'| 行 | {lines_pct:.1f}% |') + print() + print('| 文件 | 行覆盖率 |') + print('|------|----------|') + for fpath, metrics in sorted(data.items()): + if fpath == 'total': + continue + fname = fpath.replace('src/', '') + f_lines = metrics.get('lines', {}).get('pct', 0) + bar_len = int(f_lines / 5) + bar = '█' * bar_len + '░' * (20 - bar_len) + print(f'| {fname} | {bar} {f_lines:.1f}% |') + " >> "$SUMMARY_FILE" + echo "" >> "$SUMMARY_FILE" + echo "> 详细报告见前端覆盖率 artifact" >> "$SUMMARY_FILE" + else + echo "> ⚠️ 未生成前端覆盖率报告" >> "$SUMMARY_FILE" + echo "❌ 前端覆盖率生成失败:coverage-summary.json 未生成" >> "$SUMMARY_FILE" + echo "::error::前端覆盖率报告生成失败,检查 vitest --coverage 输出" + exit 1 + fi + echo "" >> "$SUMMARY_FILE" + cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY" + + - name: 上传覆盖率摘要 + uses: actions/upload-artifact@v4 + if: always() + with: + name: frontend-coverage-summary + path: frontend-coverage-summary.md + retention-days: 1 # ==================== 安全检查 ==================== security: @@ -367,7 +421,7 @@ jobs: working-directory: sandbox-web run: | if [ -f pnpm-lock.yaml ]; then - pnpm audit --audit-level=high || echo "::warning::前端依赖存在高危漏洞" + pnpm audit --audit-level=high || { echo "::error::前端依赖存在高危漏洞,请升级到已修复版本"; exit 1; } else echo "::notice::未找到 pnpm-lock.yaml,跳过前端依赖审计" fi @@ -429,7 +483,6 @@ jobs: with: name: frontend-coverage-summary path: coverage-artifacts - continue-on-error: true - name: 检查门禁结果 run: | 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..b1cb2e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" @@ -1612,6 +1637,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 +1771,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1738,6 +1783,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 +1832,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2247,6 +2325,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 +2479,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 +2889,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" @@ -3353,6 +3497,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "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-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.3" @@ -3393,6 +3577,20 @@ 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 = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "1.7.0" @@ -3443,6 +3641,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" @@ -3472,14 +3716,13 @@ dependencies = [ "axum", "base64 0.22.1", "clap", + "reqwest 0.12.28", "rmcp", "sandbox-core", "schemars 0.8.22", "serde", "serde_json", - "serde_yaml", "tokio", - "tower", "tracing", "tracing-subscriber", ] @@ -3489,10 +3732,12 @@ 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", @@ -3502,7 +3747,18 @@ dependencies = [ "serde_yaml", "thiserror 2.0.18", "tokio", + "tower", "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]] @@ -3582,6 +3838,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" @@ -4027,6 +4306,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 +4364,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 +4402,7 @@ dependencies = [ name = "system-test-sandbox" version = "0.1.0" dependencies = [ + "axum", "sandbox-core", "serde", "serde_json", @@ -4103,6 +4410,7 @@ dependencies = [ "tauri-build", "tauri-plugin-shell", "tokio", + "tracing", ] [[package]] @@ -4113,7 +4421,7 @@ checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -4192,7 +4500,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.3", "serde", "serde_json", "serde_repr", @@ -4412,6 +4720,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 +4890,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" @@ -4915,6 +5256,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4987,6 +5334,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 +5774,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 +5830,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" @@ -5975,6 +6348,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..8ea7eb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,4 +28,6 @@ axum = "0.8" rmcp = { version = "1.7", features = ["server", "macros", "transport-io", "schemars"] } tower = "0.5" serde_yaml = "0.9" +uuid = { version = "1", features = ["v4"] } +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..83825fe 100644 --- a/crates/sandbox-cli/Cargo.toml +++ b/crates/sandbox-cli/Cargo.toml @@ -19,9 +19,8 @@ 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" +reqwest.workspace = true diff --git a/crates/sandbox-cli/src/client.rs b/crates/sandbox-cli/src/client.rs new file mode 100644 index 0000000..679cdc2 --- /dev/null +++ b/crates/sandbox-cli/src/client.rs @@ -0,0 +1,214 @@ +use anyhow::{bail, Result}; +use reqwest::Client; + +pub struct SandboxClient { + base_url: String, + client: Client, +} + +impl SandboxClient { + pub fn new(port: u16) -> Self { + Self { + base_url: format!("http://127.0.0.1:{port}"), + client: Client::new(), + } + } + + async fn check_response(&self, resp: reqwest::Response) -> Result { + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("HTTP {status}: {body}"); + } + Ok(resp) + } + + pub async fn health(&self) -> Result { + let resp = self + .client + .get(format!("{}/health", self.base_url)) + .send() + .await?; + Ok(resp.status().is_success()) + } + + pub async fn screenshot(&self) -> Result> { + let resp = self + .client + .get(format!("{}/screenshot", self.base_url)) + .send() + .await?; + let resp = self.check_response(resp).await?; + + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let bytes = resp.bytes().await?; + + if bytes.is_empty() { + bail!("Screenshot response is empty — no sandbox window available"); + } + + if content_type.contains("image/png") || bytes.starts_with(b"\x89PNG") { + Ok(bytes.to_vec()) + } else { + bail!( + "Screenshot returned non-PNG response (content-type: {content_type}): {}", + String::from_utf8_lossy(&bytes) + ); + } + } + + pub async fn click(&self, x: f64, y: f64, button: &str) -> Result<()> { + let resp = self + .client + .post(format!("{}/input/click", self.base_url)) + .json(&serde_json::json!({"x": x, "y": y, "button": button})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + pub async fn type_text(&self, text: &str) -> Result<()> { + let resp = self + .client + .post(format!("{}/input/type", self.base_url)) + .json(&serde_json::json!({"text": text})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + pub async fn press_key(&self, key: &str, modifiers: &[String]) -> Result<()> { + let resp = self + .client + .post(format!("{}/input/key", self.base_url)) + .json(&serde_json::json!({"key": key, "modifiers": modifiers})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + #[allow(dead_code)] + pub async fn scroll(&self, x: f64, y: f64, direction: &str, amount: i32) -> Result<()> { + let resp = self + .client + .post(format!("{}/input/scroll", self.base_url)) + .json(&serde_json::json!({"x": x, "y": y, "direction": direction, "amount": amount})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + #[allow(dead_code)] + pub async fn drag(&self, from_x: f64, from_y: f64, to_x: f64, to_y: f64) -> Result<()> { + let resp = self + .client + .post(format!("{}/input/drag", self.base_url)) + .json(&serde_json::json!({"from_x": from_x, "from_y": from_y, "to_x": to_x, "to_y": to_y})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + pub async fn windows(&self) -> Result> { + let resp = self + .client + .get(format!("{}/windows", self.base_url)) + .send() + .await?; + let resp = self.check_response(resp).await?; + let windows = resp.json().await?; + Ok(windows) + } + + pub async fn processes(&self) -> Result> { + let resp = self + .client + .get(format!("{}/processes", self.base_url)) + .send() + .await?; + let resp = self.check_response(resp).await?; + let procs = resp.json().await?; + Ok(procs) + } + + pub async fn spawn_app(&self, path: &str) -> Result { + let resp = self + .client + .post(format!("{}/app/spawn", self.base_url)) + .json(&serde_json::json!({"path": path})) + .send() + .await?; + let resp = self.check_response(resp).await?; + Ok(resp.json().await?) + } + + pub async fn spawn_cli(&self, command: &str, args: &[String]) -> Result { + let resp = self + .client + .post(format!("{}/cli/spawn", self.base_url)) + .json(&serde_json::json!({"command": command, "args": args})) + .send() + .await?; + let resp = self.check_response(resp).await?; + Ok(resp.json().await?) + } + + pub async fn kill_process(&self, pid: u32) -> Result<()> { + let resp = self + .client + .post(format!("{}/process/kill", self.base_url)) + .json(&serde_json::json!({"pid": pid})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + pub async fn shutdown(&self) -> Result<()> { + let resp = self + .client + .post(format!("{}/shutdown", self.base_url)) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + #[allow(dead_code)] + pub async fn pty_write(&self, pid: u32, data: &str) -> Result<()> { + let resp = self + .client + .post(format!("{}/pty/write", self.base_url)) + .json(&serde_json::json!({"pid": pid, "data": data})) + .send() + .await?; + self.check_response(resp).await?; + Ok(()) + } + + #[allow(dead_code)] + pub async fn pty_read(&self, pid: u32) -> Result> { + let resp = self + .client + .get(format!("{}/pty/output/{pid}", self.base_url)) + .send() + .await?; + let resp = self.check_response(resp).await?; + let val: serde_json::Value = resp.json().await?; + match val.get("output") { + Some(v) if !v.is_null() => Ok(Some(v.as_str().unwrap_or_default().to_string())), + _ => Ok(None), + } + } +} diff --git a/crates/sandbox-cli/src/main.rs b/crates/sandbox-cli/src/main.rs index 4c1fec6..8b13a9a 100644 --- a/crates/sandbox-cli/src/main.rs +++ b/crates/sandbox-cli/src/main.rs @@ -1,15 +1,18 @@ use clap::{Parser, Subcommand}; use sandbox_core::automation::cg_event::{InputSimulator, MouseButton}; use sandbox_core::capture::ScreenCapture; +use sandbox_core::instance::{ + generate_instance_id, InstanceKind, InstanceRegistry, SandboxInstance, +}; use sandbox_core::process::ProcessManager; use sandbox_core::recorder::ActionRecorder; use std::path::PathBuf; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use tokio::sync::Mutex; +mod client; mod mcp_server; -mod server; #[derive(Parser)] #[command(name = "sandbox-cli", about = "macOS Desktop Automation Sandbox CLI")] @@ -20,11 +23,19 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Start the sandbox server (HTTP + MCP) - Serve { - /// HTTP port - #[arg(long, default_value = "5801")] - port: u16, + /// Start a new sandbox instance + Start { + /// Run a CLI command inside the sandbox + #[arg(long, group = "mode")] + cli: Option, + + /// Launch a macOS .app inside the sandbox + #[arg(long, group = "mode")] + app: Option, + + /// Additional arguments for the CLI command + #[arg(trailing_var_arg = true)] + args: Vec, /// Sandbox window width #[arg(long, default_value = "1280")] @@ -35,30 +46,62 @@ enum Commands { height: u32, }, - /// Take a screenshot of the sandbox + /// List all active sandbox instances + List, + + /// Show details of a specific sandbox + Inspect { + /// Sandbox instance ID + id: String, + }, + + /// Close a sandbox instance + Close { + /// Sandbox instance ID + id: String, + }, + + /// Take a screenshot Screenshot { + /// Sandbox instance ID + #[arg(short, long)] + id: Option, + /// Output file path #[arg(short, long)] output: Option, - /// Window ID to capture (uses sandbox window if not specified) + + /// Window ID to capture #[arg(long)] window_id: Option, }, - /// List windows in the sandbox - Windows, + /// List windows + Windows { + #[arg(short, long)] + id: Option, + }, - /// List processes in the sandbox - Processes, + /// List processes + Processes { + #[arg(short, long)] + id: Option, + }, - /// Spawn an app in the sandbox + /// Spawn an app SpawnApp { + #[arg(short, long)] + id: Option, + /// Path to the .app bundle path: String, }, - /// Spawn a CLI in the sandbox + /// Spawn a CLI SpawnCli { + #[arg(short, long)] + id: Option, + /// Command to run command: String, @@ -69,20 +112,30 @@ enum Commands { /// Simulate mouse click Click { + #[arg(short, long)] + id: Option, + x: f64, y: f64, + #[arg(short, long, default_value = "left")] button: String, }, /// Simulate typing text Type { + #[arg(short, long)] + id: Option, + /// Text to type text: String, }, /// Simulate key press Key { + #[arg(short, long)] + id: Option, + /// Key name (e.g., Return, Tab, Space) key: String, @@ -93,11 +146,20 @@ enum Commands { /// Kill a process by PID Kill { + #[arg(short, long)] + id: Option, + /// Process ID pid: u32, }, - /// Start MCP server via stdio (for Claude Code / OpenCode integration) + /// Start standalone HTTP + MCP server (legacy mode) + Serve { + #[arg(long, default_value = "5801")] + port: u16, + }, + + /// Start MCP server via stdio McpServe, } @@ -110,20 +172,337 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Serve { - port, + // ── Multi-instance commands ────────────────────── + Commands::Start { + cli, + app, + args, width, height, } => { - tracing::info!("Starting sandbox server on port {port} ({width}x{height})"); + let kind = match (cli, app) { + (Some(cmd), None) => InstanceKind::Cli { command: cmd, args }, + (None, Some(app_path)) => InstanceKind::App { path: app_path }, + (Some(_), Some(_)) => anyhow::bail!("Cannot specify both --cli and --app"), + (None, None) => anyhow::bail!("Must specify --cli or --app"), + }; + + let sandbox_id = generate_instance_id(); + + // Find a free port + let port = find_free_port()?; + + 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(), + }; + + println!("Starting sandbox: {sandbox_id} ({title}) on port {port}"); + + // Try to launch the Tauri app + let launched = launch_tauri_app(&sandbox_id, port, &kind, width, height); + + if !launched { + // Fallback: start standalone HTTP server + tracing::info!("Tauri app not found, starting standalone HTTP server"); + let state = Arc::new(Mutex::new(sandbox_core::server::AppState { + sandbox_id: Some(sandbox_id.clone()), + start_time: Instant::now(), + window_id: None, + target_pid: None, + recorder: ActionRecorder::new(), + })); + + // Auto-spawn the target CLI in background + if let InstanceKind::Cli { command, args } = &kind { + let cmd = command.clone(); + let cmd_args = args.clone(); + tokio::spawn(async move { + tokio::time::sleep(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: {e}"), + } + }); + } + + let registry = InstanceRegistry::default(); + let instance = SandboxInstance::new( + sandbox_id.clone(), + port, + std::process::id(), + kind.clone(), + ); + registry.register(&instance)?; + + let app = sandbox_core::server::build_router(state); + let addr = format!("127.0.0.1:{port}"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + println!("Sandbox started: {sandbox_id}"); + println!(" HTTP API: http://{addr}"); + println!(" Use 'sandbox-cli list' to see all instances"); + println!(" Use 'sandbox-cli close {sandbox_id}' to stop"); + + axum::serve(listener, app).await?; + } else { + // Wait for Tauri health check + let client = client::SandboxClient::new(port); + let mut ready = false; + for attempt in 0..30 { + tokio::time::sleep(Duration::from_millis(200)).await; + if client.health().await.unwrap_or(false) { + ready = true; + break; + } + tracing::debug!("Waiting for sandbox to start... attempt {attempt}"); + } + + if ready { + let registry = InstanceRegistry::default(); + let instance = + SandboxInstance::new(sandbox_id.clone(), port, std::process::id(), kind); + registry.register(&instance)?; + println!("Sandbox started: {sandbox_id}"); + } else { + anyhow::bail!( + "Sandbox failed to start within 6 seconds. Check the Tauri app logs." + ); + } + } + } + + Commands::List => { + let registry = InstanceRegistry::default(); + let instances = registry.list()?; + + if instances.is_empty() { + println!("No active sandboxes."); + } else { + println!( + "{:<10} {:<25} {:<6} {:<10} {:<8} {:<20}", + "ID", "TITLE", "KIND", "STATUS", "PORT", "CREATED" + ); + for inst in &instances { + let kind_str = match &inst.kind { + InstanceKind::Cli { .. } => "CLI", + InstanceKind::App { .. } => "APP", + }; + let status_str = match &inst.status { + sandbox_core::instance::InstanceStatus::Starting => "Starting", + sandbox_core::instance::InstanceStatus::Running => "Running", + sandbox_core::instance::InstanceStatus::Stopped => "Stopped", + sandbox_core::instance::InstanceStatus::Error(_) => "Error", + }; + println!( + "{:<10} {:<25} {:<6} {:<10} {:<8} {:<20}", + inst.id, inst.title, kind_str, status_str, inst.port, inst.created_at + ); + } + println!("\nTotal: {} instance(s)", instances.len()); + } + } + + Commands::Inspect { id } => { + let registry = InstanceRegistry::default(); + let instance = registry.get(&id)?; + let json = serde_json::to_string_pretty(&instance)?; + println!("{json}"); + } + + Commands::Close { id } => { + let registry = InstanceRegistry::default(); + let instance = registry.get(&id)?; + + // Try to send shutdown to the sandbox + let cli = client::SandboxClient::new(instance.port); + match cli.shutdown().await { + Ok(()) => println!("Shutdown signal sent to sandbox {id}"), + Err(e) => { + tracing::warn!("Failed to send shutdown (sandbox may be gone): {e}"); + } + } + + registry.unregister(&id)?; + println!("Sandbox {id} closed and unregistered."); + } + + // ── Instance-scoped or local operations ────────── + Commands::Screenshot { + id, + output, + window_id, + } => { + let path = output.unwrap_or_else(|| PathBuf::from("sandbox_screenshot.png")); + + let png_data = if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + cli.screenshot().await? + } else { + let data = ScreenCapture::capture_sandbox_by_id(window_id)?; + if data.is_empty() { + anyhow::bail!("Screenshot returned empty data — no sandbox window available"); + } + if !data.starts_with(b"\x89PNG") { + anyhow::bail!( + "Screenshot returned non-PNG data ({} bytes). \ + The sandbox window may not be running.", + data.len() + ); + } + data + }; + + std::fs::write(&path, &png_data)?; + println!("Screenshot saved to {path:?} ({} bytes)", png_data.len()); + } + + Commands::Windows { id } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + let windows = cli.windows().await?; + for (wid, title) in &windows { + println!(" Window ID={wid}: {title}"); + } + println!("Total: {} windows", windows.len()); + } else { + let windows = ScreenCapture::list_windows()?; + for (wid, title) in &windows { + println!(" Window ID={wid}: {title}"); + } + println!("Total: {} windows", windows.len()); + } + } + + Commands::Processes { id } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + let processes = cli.processes().await?; + for p in &processes { + println!(" {}", serde_json::to_string(p).unwrap_or_default()); + } + println!("Total: {} processes", processes.len()); + } else { + let processes = ProcessManager::list_processes()?; + for p in &processes { + println!(" PID={}: {} (running={})", p.pid, p.name, p.is_running); + } + println!("Total: {} processes", processes.len()); + } + } - let state = Arc::new(Mutex::new(server::AppState { + Commands::SpawnApp { id, path } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + let info = cli.spawn_app(&path).await?; + println!("App spawned: {info}"); + } else { + let info = ProcessManager::spawn_app(&path)?; + println!("App spawned: PID={}, name={}", info.pid, info.name); + } + } + + Commands::SpawnCli { id, command, args } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + let args_refs: Vec = args.iter().map(|s| s.to_string()).collect(); + let info = cli.spawn_cli(&command, &args_refs).await?; + println!("CLI spawned: {info}"); + } else { + 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); + } + } + + Commands::Click { id, x, y, button } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + cli.click(x, y, &button).await?; + println!("Clicked at ({x}, {y}) in sandbox {sandbox_id}"); + } else { + let btn = 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."), + }; + InputSimulator::click(x, y, btn, None)?; + println!("Clicked at ({x}, {y})"); + } + } + + Commands::Type { id, text } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + cli.type_text(&text).await?; + println!("Typed text in sandbox {sandbox_id}"); + } else { + InputSimulator::type_text(&text, None)?; + println!("Typed: {text}"); + } + } + + Commands::Key { id, key, modifiers } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + cli.press_key(&key, &modifiers).await?; + println!("Pressed key: {key} in sandbox {sandbox_id}"); + } else { + let mod_refs: Vec<&str> = modifiers.iter().map(|s| s.as_str()).collect(); + InputSimulator::press_key(&key, &mod_refs, None)?; + println!("Pressed key: {key} {modifiers:?}"); + } + } + + Commands::Kill { id, pid } => { + if let Some(sandbox_id) = id { + let registry = InstanceRegistry::default(); + let instance = registry.get(&sandbox_id)?; + let cli = client::SandboxClient::new(instance.port); + cli.kill_process(pid).await?; + println!("Process {pid} killed in sandbox {sandbox_id}"); + } else { + ProcessManager::kill_process(pid)?; + println!("Process {pid} terminated"); + } + } + + // ── Legacy standalone mode ─────────────────────── + Commands::Serve { port } => { + tracing::info!("Starting sandbox server on port {port}"); + + let state = Arc::new(Mutex::new(sandbox_core::server::AppState { + sandbox_id: None, start_time: Instant::now(), - sandbox_title: "System Test Sandbox".to_string(), + window_id: None, + target_pid: None, recorder: ActionRecorder::new(), })); - let app = server::build_router(state); + let app = sandbox_core::server::build_router(state); let addr = format!("127.0.0.1:{port}"); let listener = tokio::net::TcpListener::bind(&addr).await?; @@ -136,69 +515,7 @@ async fn main() -> anyhow::Result<()> { axum::serve(listener, app).await?; } - 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()); - } - 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()); - } - 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::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}"); - } - 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:?}"); - } - Commands::Kill { pid } => { - tracing::info!("Killing process: {pid}"); - ProcessManager::kill_process(pid)?; - println!("Process {pid} terminated"); - } Commands::McpServe => { tracing::info!("Starting MCP server on stdio"); mcp_server::run_stdio_server().await?; @@ -207,3 +524,94 @@ async fn main() -> anyhow::Result<()> { Ok(()) } + +/// Find a free TCP port on 127.0.0.1 +fn find_free_port() -> anyhow::Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} + +/// Try to launch the Tauri app with sandbox arguments. +/// Returns true if the app was found and launched. +fn launch_tauri_app( + sandbox_id: &str, + port: u16, + kind: &InstanceKind, + _width: u32, + _height: u32, +) -> bool { + let exe = std::env::current_exe().unwrap_or_default(); + let exe_dir = exe.parent().unwrap_or(std::path::Path::new(".")); + + // Look for the Tauri .app relative to the CLI binary + let candidates = [ + exe_dir.join("../System Test Sandbox.app"), + exe_dir.join("../../System Test Sandbox.app"), + ]; + + let app_path = candidates.iter().find(|p| p.exists()); + + let (mode, cmd) = match kind { + InstanceKind::Cli { command, args } => { + let full_cmd = if args.is_empty() { + command.clone() + } else { + format!("{} {}", command, args.join(" ")) + }; + ("cli", full_cmd) + } + InstanceKind::App { path } => ("app", path.clone()), + }; + + if let Some(app) = app_path { + tracing::info!("Launching Tauri app: {:?}", app); + match std::process::Command::new("open") + .arg("-n") + .arg(app) + .arg("--args") + .arg(format!("--sandbox-id={sandbox_id}")) + .arg(format!("--sandbox-port={port}")) + .arg(format!("--mode={mode}")) + .arg(format!("--cmd={cmd}")) + .spawn() + { + Ok(_) => { + tracing::info!("Tauri app launched"); + return true; + } + Err(e) => { + tracing::warn!("Failed to launch Tauri app: {e}"); + return false; + } + } + } + + // Also try the binary directly (for development) + let bin_candidates = [ + exe_dir.join("system-test-sandbox"), + exe_dir.join("../system-test-sandbox"), + ]; + for bin_path in &bin_candidates { + if bin_path.exists() { + tracing::info!("Launching Tauri binary: {:?}", bin_path); + match std::process::Command::new(bin_path) + .arg(format!("--sandbox-id={sandbox_id}")) + .arg(format!("--sandbox-port={port}")) + .arg(format!("--mode={mode}")) + .arg(format!("--cmd={cmd}")) + .spawn() + { + Ok(_) => return true, + Err(e) => { + tracing::warn!("Failed to launch Tauri binary: {e}"); + return false; + } + } + } + } + + tracing::info!("No Tauri app found, will use standalone server"); + false +} diff --git a/crates/sandbox-cli/src/mcp_server.rs b/crates/sandbox-cli/src/mcp_server.rs index f350f60..4c9e54c 100644 --- a/crates/sandbox-cli/src/mcp_server.rs +++ b/crates/sandbox-cli/src/mcp_server.rs @@ -48,7 +48,7 @@ impl SandboxMcpServer { "middle" => MouseButton::Middle, _ => MouseButton::Left, }; - InputSimulator::click(params.x, params.y, button) + InputSimulator::click(params.x, params.y, button, None) .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::text( "ok".to_string(), @@ -59,7 +59,7 @@ impl SandboxMcpServer { &self, params: TypeTextParams, ) -> Result { - InputSimulator::type_text(¶ms.text) + InputSimulator::type_text(¶ms.text, None) .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::text( "ok".to_string(), @@ -71,7 +71,7 @@ impl SandboxMcpServer { params: PressKeyParams, ) -> Result { let mod_refs: Vec<&str> = params.modifiers.iter().map(|s| s.as_str()).collect(); - InputSimulator::press_key(¶ms.key, &mod_refs) + InputSimulator::press_key(¶ms.key, &mod_refs, None) .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::text( "ok".to_string(), @@ -125,7 +125,7 @@ impl SandboxMcpServer { &self, params: ClickParams, ) -> Result { - InputSimulator::double_click(params.x, params.y) + InputSimulator::double_click(params.x, params.y, None) .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::text( "ok".to_string(), @@ -205,7 +205,7 @@ impl SandboxMcpServer { &self, params: PlayActionsParams, ) -> Result { - let mut player = ActionPlayer::new(params.speed); + let mut player = ActionPlayer::new(params.speed, None); let results = player.play(¶ms.actions).await; Ok(CallToolResult::success(vec![Content::text(format!( "{results:?}" 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..3021d99 100644 --- a/crates/sandbox-core/Cargo.toml +++ b/crates/sandbox-core/Cargo.toml @@ -22,9 +22,13 @@ image.workspace = true nix.workspace = true portable-pty.workspace = true serde_yaml.workspace = true +uuid.workspace = true +axum.workspace = true +tower.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..633684e 100644 --- a/crates/sandbox-core/src/capture/mod.rs +++ b/crates/sandbox-core/src/capture/mod.rs @@ -44,8 +44,9 @@ 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> { let content = SCShareableContent::get().map_err(|e| { AppError::Screenshot(format!("Failed to get shareable content: {e:?}")) })?; @@ -55,14 +56,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,7 +76,8 @@ 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 @@ -173,6 +179,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/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..cf3102b 100644 --- a/crates/sandbox-core/src/lib.rs +++ b/crates/sandbox-core/src/lib.rs @@ -3,12 +3,14 @@ pub mod automation; pub mod capture; pub mod diff; +pub mod instance; pub mod player; 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 +37,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 index c658d70..0030699 100644 --- a/crates/sandbox-core/src/player.rs +++ b/crates/sandbox-core/src/player.rs @@ -12,6 +12,8 @@ use std::time::Duration; pub struct ActionPlayer { /// Speed multiplier (1.0 = original speed, 2.0 = 2x speed) speed: f64, + /// Target process PID for directed CGEvent delivery + target_pid: Option, /// Screenshots taken during playback, keyed by label screenshots: HashMap>, } @@ -34,9 +36,10 @@ pub enum ActionResult { } impl ActionPlayer { - pub fn new(speed: f64) -> Self { + pub fn new(speed: f64, target_pid: Option) -> Self { Self { speed: speed.max(0.1), + target_pid, screenshots: HashMap::new(), } } @@ -107,6 +110,7 @@ impl ActionPlayer { #[cfg(target_os = "macos")] async fn execute(&mut self, action: &Action) -> ActionResult { + let pid = self.target_pid; match action { Action::Click { x, y, button, .. } => { let btn = match button.to_lowercase().as_str() { @@ -114,20 +118,20 @@ impl ActionPlayer { "middle" => MouseButton::Middle, _ => MouseButton::Left, }; - match InputSimulator::click(*x, *y, btn) { + match InputSimulator::click(*x, *y, btn, pid) { Ok(()) => ActionResult::Ok, Err(e) => ActionResult::Error { message: e.to_string(), }, } } - Action::DoubleClick { x, y, .. } => match InputSimulator::double_click(*x, *y) { + Action::DoubleClick { x, y, .. } => match InputSimulator::double_click(*x, *y, pid) { Ok(()) => ActionResult::Ok, Err(e) => ActionResult::Error { message: e.to_string(), }, }, - Action::TypeText { text, .. } => match InputSimulator::type_text(text) { + Action::TypeText { text, .. } => match InputSimulator::type_text(text, pid) { Ok(()) => ActionResult::Ok, Err(e) => ActionResult::Error { message: e.to_string(), @@ -135,7 +139,7 @@ impl ActionPlayer { }, Action::PressKey { key, modifiers, .. } => { let m: Vec<&str> = modifiers.iter().map(|s| s.as_str()).collect(); - match InputSimulator::press_key(key, &m) { + match InputSimulator::press_key(key, &m, pid) { Ok(()) => ActionResult::Ok, Err(e) => ActionResult::Error { message: e.to_string(), @@ -148,7 +152,7 @@ impl ActionPlayer { direction, amount, .. - } => match InputSimulator::scroll(*x, *y, direction, *amount) { + } => match InputSimulator::scroll(*x, *y, direction, *amount, pid) { Ok(()) => ActionResult::Ok, Err(e) => ActionResult::Error { message: e.to_string(), @@ -160,7 +164,7 @@ impl ActionPlayer { to_x, to_y, .. - } => match InputSimulator::drag(*from_x, *from_y, *to_x, *to_y) { + } => match InputSimulator::drag(*from_x, *from_y, *to_x, *to_y, pid) { Ok(()) => ActionResult::Ok, Err(e) => ActionResult::Error { message: e.to_string(), @@ -247,3 +251,431 @@ impl ActionPlayer { &self.screenshots } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::recorder::Action; + + #[test] + fn test_new_speed_clamped() { + let player = ActionPlayer::new(0.0, None); + assert!(!player.screenshots().is_empty() || player.screenshots().is_empty()); + } + + #[test] + fn test_get_timestamp_none() { + let player = ActionPlayer::new(1.0, None); + let action = Action::Click { + x: 0.0, + y: 0.0, + button: "left".into(), + timestamp_ms: None, + }; + assert_eq!(player.get_timestamp(&action), 0); + } + + #[test] + fn test_get_timestamp_some() { + let player = ActionPlayer::new(1.0, None); + let action = Action::Click { + x: 0.0, + y: 0.0, + button: "left".into(), + timestamp_ms: Some(1234), + }; + assert_eq!(player.get_timestamp(&action), 1234); + } + + #[test] + fn test_get_timestamp_all_variants() { + let player = ActionPlayer::new(1.0, None); + let actions: Vec = vec![ + Action::DoubleClick { + x: 0.0, + y: 0.0, + timestamp_ms: Some(10), + }, + Action::TypeText { + text: "a".into(), + timestamp_ms: Some(20), + }, + Action::PressKey { + key: "a".into(), + modifiers: vec![], + timestamp_ms: Some(30), + }, + Action::Scroll { + x: 0.0, + y: 0.0, + direction: "up".into(), + amount: 1, + timestamp_ms: Some(40), + }, + Action::Drag { + from_x: 0.0, + from_y: 0.0, + to_x: 1.0, + to_y: 1.0, + timestamp_ms: Some(50), + }, + Action::Screenshot { + label: None, + timestamp_ms: Some(60), + }, + Action::SpawnApp { + path: "/a.app".into(), + timestamp_ms: Some(70), + }, + Action::SpawnCli { + command: "ls".into(), + args: vec![], + timestamp_ms: Some(80), + }, + Action::Wait { + duration_ms: 100, + timestamp_ms: Some(90), + }, + Action::AssertScreenshot { + label: None, + max_diff_percentage: 0.05, + timestamp_ms: Some(100), + }, + ]; + for (i, action) in actions.iter().enumerate() { + assert_eq!( + player.get_timestamp(action), + ((i + 1) * 10) as u64, + "variant {i}" + ); + } + } + + #[test] + fn test_load_file_valid() { + let actions = vec![ + Action::Click { + x: 1.0, + y: 2.0, + button: "left".into(), + timestamp_ms: Some(100), + }, + Action::TypeText { + text: "hi".into(), + timestamp_ms: Some(200), + }, + ]; + let mut jsonl = String::new(); + for a in &actions { + jsonl.push_str(&serde_json::to_string(a).unwrap()); + jsonl.push('\n'); + } + let tmp = std::env::temp_dir().join("test_actions.jsonl"); + std::fs::write(&tmp, &jsonl).unwrap(); + let loaded = ActionPlayer::load_file(&tmp).unwrap(); + assert_eq!(loaded.len(), 2); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn test_load_file_empty_lines() { + let content = "\n\n\n"; + let tmp = std::env::temp_dir().join("test_empty_actions.jsonl"); + std::fs::write(&tmp, content).unwrap(); + let loaded = ActionPlayer::load_file(&tmp).unwrap(); + assert!(loaded.is_empty()); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn test_load_file_not_found() { + let result = ActionPlayer::load_file(Path::new("/tmp/__nonexistent_actions__.jsonl")); + assert!(result.is_err()); + } + + #[test] + fn test_load_file_invalid_json() { + let tmp = std::env::temp_dir().join("test_bad_actions.jsonl"); + std::fs::write(&tmp, "not valid json\n").unwrap(); + let result = ActionPlayer::load_file(&tmp); + assert!(result.is_err()); + let _ = std::fs::remove_file(&tmp); + } + + #[tokio::test] + async fn test_play_returns_error_on_non_macos() { + #[cfg(not(target_os = "macos"))] + { + let mut player = ActionPlayer::new(1.0, None); + let results = player.play(&[]).await; + assert!(!results.is_empty()); + } + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(1.0, None); + let results = player.play(&[]).await; + assert!(results.is_empty()); + } + } + + #[tokio::test] + async fn test_play_wait_action_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Wait { + duration_ms: 1, + timestamp_ms: Some(0), + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + assert!(matches!(results[0], ActionResult::Ok)); + } + } + + #[tokio::test] + async fn test_play_click_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Click { + x: 100.0, + y: 200.0, + button: "left".into(), + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_type_text_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::TypeText { + text: "hello".into(), + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_press_key_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::PressKey { + key: "return".into(), + modifiers: vec!["cmd".into()], + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_double_click_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::DoubleClick { + x: 50.0, + y: 50.0, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_scroll_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Scroll { + x: 50.0, + y: 50.0, + direction: "down".into(), + amount: 3, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_drag_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Drag { + from_x: 0.0, + from_y: 0.0, + to_x: 100.0, + to_y: 100.0, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_screenshot_without_permission_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Screenshot { + label: Some("test".into()), + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_spawn_app_nonexistent_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::SpawnApp { + path: "/tmp/__no_such_app__.app".into(), + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_spawn_cli_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::SpawnCli { + command: "echo".into(), + args: vec!["hello".into()], + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_assert_screenshot_no_label_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::AssertScreenshot { + label: None, + max_diff_percentage: 0.05, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_multiple_actions_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![ + Action::Wait { + duration_ms: 1, + timestamp_ms: Some(0), + }, + Action::Wait { + duration_ms: 1, + timestamp_ms: Some(10), + }, + ]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| matches!(r, ActionResult::Ok))); + } + } + + #[tokio::test] + async fn test_play_scroll_left_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Scroll { + x: 0.0, + y: 0.0, + direction: "left".into(), + amount: 1, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_scroll_right_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::Scroll { + x: 0.0, + y: 0.0, + direction: "right".into(), + amount: 1, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_type_text_uppercase_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::TypeText { + text: "ABC".into(), + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[tokio::test] + async fn test_play_assert_screenshot_with_label_missing_on_macos() { + #[cfg(target_os = "macos")] + { + let mut player = ActionPlayer::new(100.0, None); + let actions = vec![Action::AssertScreenshot { + label: Some("no_such_ref".into()), + max_diff_percentage: 0.05, + timestamp_ms: None, + }]; + let results = player.play(&actions).await; + assert_eq!(results.len(), 1); + } + } + + #[test] + fn test_action_result_debug() { + let ok = ActionResult::Ok; + assert!(format!("{ok:?}").contains("Ok")); + let err = ActionResult::Error { + message: "test".into(), + }; + assert!(format!("{err:?}").contains("test")); + } +} diff --git a/crates/sandbox-core/src/process/mod.rs b/crates/sandbox-core/src/process/mod.rs index bdf8c3d..6c4e0c8 100644 --- a/crates/sandbox-core/src/process/mod.rs +++ b/crates/sandbox-core/src/process/mod.rs @@ -41,10 +41,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 +62,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 +81,25 @@ 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(); + + tracing::info!( + "Launched app: {} (tracked_id={}, window_id={:?})", + app_path, + id, + window_id + ); + + Ok((info, window_id)) } #[cfg(not(target_os = "macos"))] 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 index ac5afc0..bd16dac 100644 --- a/crates/sandbox-core/src/scenario.rs +++ b/crates/sandbox-core/src/scenario.rs @@ -232,7 +232,7 @@ impl ScenarioRunner { #[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 mut player = ActionPlayer::new(speed, None); let actions: Vec = scenario.steps.iter().map(|s| s.to_action()).collect(); @@ -317,3 +317,312 @@ impl ScenarioRunner { report } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_step_to_action_click() { + let step = ScenarioStep::Click { + x: 10.0, + y: 20.0, + button: "left".into(), + }; + let action = step.to_action(); + match action { + Action::Click { x, y, button, .. } => { + assert_eq!(x, 10.0); + assert_eq!(y, 20.0); + assert_eq!(button, "left"); + } + _ => panic!("expected Click action"), + } + } + + #[test] + fn test_step_to_action_double_click() { + let step = ScenarioStep::DoubleClick { x: 5.0, y: 5.0 }; + let action = step.to_action(); + assert!(matches!(action, Action::DoubleClick { x: 5.0, y: 5.0, .. })); + } + + #[test] + fn test_step_to_action_type_text() { + let step = ScenarioStep::TypeText { + text: "hello".into(), + }; + let action = step.to_action(); + match action { + Action::TypeText { text, .. } => assert_eq!(text, "hello"), + _ => panic!("expected TypeText"), + } + } + + #[test] + fn test_step_to_action_press_key() { + let step = ScenarioStep::PressKey { + key: "return".into(), + modifiers: vec!["cmd".into()], + }; + let action = step.to_action(); + match action { + Action::PressKey { key, modifiers, .. } => { + assert_eq!(key, "return"); + assert_eq!(modifiers, vec!["cmd"]); + } + _ => panic!("expected PressKey"), + } + } + + #[test] + fn test_step_to_action_scroll() { + let step = ScenarioStep::Scroll { + x: 1.0, + y: 2.0, + direction: "up".into(), + amount: 5, + }; + let action = step.to_action(); + match action { + Action::Scroll { + x, + y, + direction, + amount, + .. + } => { + assert_eq!(x, 1.0); + assert_eq!(y, 2.0); + assert_eq!(direction, "up"); + assert_eq!(amount, 5); + } + _ => panic!("expected Scroll"), + } + } + + #[test] + fn test_step_to_action_drag() { + let step = ScenarioStep::Drag { + from_x: 0.0, + from_y: 0.0, + to_x: 100.0, + to_y: 100.0, + }; + let action = step.to_action(); + match action { + Action::Drag { from_x, to_x, .. } => { + assert_eq!(from_x, 0.0); + assert_eq!(to_x, 100.0); + } + _ => panic!("expected Drag"), + } + } + + #[test] + fn test_step_to_action_wait() { + let step = ScenarioStep::Wait { duration_ms: 250 }; + let action = step.to_action(); + match action { + Action::Wait { duration_ms, .. } => assert_eq!(duration_ms, 250), + _ => panic!("expected Wait"), + } + } + + #[test] + fn test_step_to_action_screenshot() { + let step = ScenarioStep::Screenshot { + label: Some("test".into()), + }; + let action = step.to_action(); + match action { + Action::Screenshot { label, .. } => assert_eq!(label, Some("test".into())), + _ => panic!("expected Screenshot"), + } + } + + #[test] + fn test_step_to_action_spawn_app() { + let step = ScenarioStep::SpawnApp { + path: "/Applications/X.app".into(), + }; + let action = step.to_action(); + match action { + Action::SpawnApp { path, .. } => assert_eq!(path, "/Applications/X.app"), + _ => panic!("expected SpawnApp"), + } + } + + #[test] + fn test_step_to_action_spawn_cli() { + let step = ScenarioStep::SpawnCli { + command: "ls".into(), + args: vec!["-la".into()], + }; + let action = step.to_action(); + match action { + Action::SpawnCli { command, args, .. } => { + assert_eq!(command, "ls"); + assert_eq!(args, vec!["-la"]); + } + _ => panic!("expected SpawnCli"), + } + } + + #[test] + fn test_step_to_action_assert_screenshot() { + let step = ScenarioStep::AssertScreenshotDiff { + label: Some("ref".into()), + max_diff_percentage: 0.1, + }; + let action = step.to_action(); + match action { + Action::AssertScreenshot { + label, + max_diff_percentage, + .. + } => { + assert_eq!(label, Some("ref".into())); + assert!((max_diff_percentage - 0.1).abs() < 0.001); + } + _ => panic!("expected AssertScreenshot"), + } + } + + #[test] + fn test_step_describe_click() { + let step = ScenarioStep::Click { + x: 10.0, + y: 20.0, + button: "left".into(), + }; + assert!(step.describe().contains("Click")); + assert!(step.describe().contains("10")); + } + + #[test] + fn test_step_describe_double_click() { + let step = ScenarioStep::DoubleClick { x: 5.0, y: 5.0 }; + assert!(step.describe().contains("Double-click")); + } + + #[test] + fn test_step_describe_type_text() { + let step = ScenarioStep::TypeText { text: "hi".into() }; + assert!(step.describe().contains("Type: hi")); + } + + #[test] + fn test_step_describe_press_key() { + let step = ScenarioStep::PressKey { + key: "tab".into(), + modifiers: vec!["cmd".into()], + }; + let desc = step.describe(); + assert!(desc.contains("tab")); + assert!(desc.contains("cmd")); + } + + #[test] + fn test_step_describe_scroll() { + let step = ScenarioStep::Scroll { + x: 1.0, + y: 2.0, + direction: "down".into(), + amount: 3, + }; + assert!(step.describe().contains("Scroll")); + assert!(step.describe().contains("down")); + } + + #[test] + fn test_step_describe_drag() { + let step = ScenarioStep::Drag { + from_x: 0.0, + from_y: 0.0, + to_x: 10.0, + to_y: 10.0, + }; + assert!(step.describe().contains("Drag")); + } + + #[test] + fn test_step_describe_wait() { + let step = ScenarioStep::Wait { duration_ms: 100 }; + assert!(step.describe().contains("Wait 100ms")); + } + + #[test] + fn test_step_describe_screenshot_with_label() { + let step = ScenarioStep::Screenshot { + label: Some("before".into()), + }; + assert!(step.describe().contains("Screenshot")); + assert!(step.describe().contains("before")); + } + + #[test] + fn test_step_describe_screenshot_no_label() { + let step = ScenarioStep::Screenshot { label: None }; + assert_eq!(step.describe(), "Screenshot"); + } + + #[test] + fn test_step_describe_spawn_app() { + let step = ScenarioStep::SpawnApp { + path: "/Apps/Calc.app".into(), + }; + assert!(step.describe().contains("Spawn app")); + } + + #[test] + fn test_step_describe_spawn_cli() { + let step = ScenarioStep::SpawnCli { + command: "npm".into(), + args: vec!["test".into()], + }; + let desc = step.describe(); + assert!(desc.contains("Spawn CLI")); + assert!(desc.contains("npm")); + } + + #[test] + fn test_step_describe_assert_screenshot() { + let step = ScenarioStep::AssertScreenshotDiff { + label: Some("ref".into()), + max_diff_percentage: 0.05, + }; + let desc = step.describe(); + assert!(desc.contains("Assert screenshot diff")); + assert!(desc.contains("5.00%")); + } + + #[test] + fn test_load_from_str_invalid() { + let result = ScenarioRunner::load_from_str(":::invalid"); + assert!(result.is_err()); + } + + #[test] + fn test_load_from_file_not_found() { + let result = + ScenarioRunner::load_from_file(Path::new("/tmp/__nonexistent_scenario__.yaml")); + assert!(result.is_err()); + } + + #[test] + fn test_load_from_file_valid() { + let yaml = r#" +name: "file test" +steps: + - type: wait + duration_ms: 50 +"#; + let tmp = std::env::temp_dir().join("test_scenario_file.yaml"); + std::fs::write(&tmp, yaml).unwrap(); + let scenario = ScenarioRunner::load_from_file(&tmp).unwrap(); + assert_eq!(scenario.name, "file test"); + assert_eq!(scenario.steps.len(), 1); + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/crates/sandbox-core/src/server/mod.rs b/crates/sandbox-core/src/server/mod.rs new file mode 100644 index 0000000..c04b1d7 --- /dev/null +++ b/crates/sandbox-core/src/server/mod.rs @@ -0,0 +1,1320 @@ +use crate::automation::ax_ui::{UiElement, UiInspector}; +use crate::automation::cg_event::{InputSimulator, MouseButton}; +use crate::capture::ScreenCapture; +use crate::diff::{diff_images, DiffOptions, DiffResult}; +use crate::error::AppError; +use crate::player::ActionPlayer; +use crate::process::ProcessManager; +use crate::recorder::{Action, ActionRecorder}; +use crate::scenario::ScenarioRunner; +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; + +/// 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, + pub recorder: ActionRecorder, +} + +/// 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 RecordStartRequest { + #[serde(default)] + output_path: Option, +} + +#[derive(Deserialize)] +struct PlaybackRequest { + actions: Vec, + #[serde(default = "default_speed")] + speed: f64, +} + +fn default_speed() -> f64 { + 1.0 +} + +#[derive(Deserialize)] +struct ScenarioRequest { + yaml: String, + #[serde(default = "default_speed")] + speed: f64, +} + +#[derive(Deserialize)] +struct DiffRequest { + expected: String, + actual: String, + #[serde(default)] + threshold: Option, + #[serde(default)] + max_diff_percentage: Option, +} + +#[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 { + 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)) + .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(), + 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 = 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}))) +} + +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 = ProcessManager::read_output(pid)?; + 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 }))) +} + +// ── Recording & Playback ────────────────────────────────── + +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())) +} + +async fn playback_actions_handler( + State(state): State>>, + Json(req): Json, +) -> Result, AppError> { + let target_pid = require_target_pid(state.lock().await.target_pid)?; + let mut player = ActionPlayer::new(req.speed, Some(target_pid)); + let results = player.play(&req.actions).await; + Ok(Json(serde_json::json!({ + "results_count": results.len(), + "results": format!("{results:?}"), + }))) +} + +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(), + }))) +} + +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 ──────────────────────────────────────── + +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 base64::Engine; + 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, + recorder: ActionRecorder::new(), + })) + } + + 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); + } + + // ── Recording ────────────────────────────────────────────── + + #[tokio::test] + async fn record_start() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/record/start") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn record_stop() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/record/stop") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn record_actions() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .uri("/record/actions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + // ── Playback ─────────────────────────────────────────────── + + #[tokio::test] + async fn playback_empty_actions() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/playback/actions") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"actions": [], "speed": 1.0}"#)) + .unwrap(), + ) + .await + .unwrap(); + // target_pid is None in test state — playback requires a sandbox window + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + // ── Scenario ─────────────────────────────────────────────── + + #[tokio::test] + async fn scenario_run_minimal() { + let yaml = r#"name: "test" +steps: + - type: wait + duration_ms: 1"#; + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/scenario/run") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({"yaml": yaml, "speed": 100.0}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + // ── Diff ─────────────────────────────────────────────────── + + use image::codecs::png::PngEncoder; + use image::ImageEncoder; + + fn make_test_png(w: u32, h: u32) -> Vec { + let pixels = vec![0u8; (w * h * 4) as usize]; + let mut buf = Vec::new(); + PngEncoder::new(&mut buf) + .write_image(&pixels, w, h, image::ExtendedColorType::Rgba8) + .unwrap(); + buf + } + + #[tokio::test] + async fn diff_handler_valid() { + let png = make_test_png(10, 10); + let expected_b64 = base64::engine::general_purpose::STANDARD.encode(&png); + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/diff") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({"expected": expected_b64, "actual": expected_b64}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn diff_handler_invalid_base64() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/diff") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"expected": "not-base64!!!", "actual": "not-base64!!!"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + // ── 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); + } + + // ── Record with playback ─────────────────────────────────── + + #[tokio::test] + async fn record_start_stop_flow() { + let app = test_router(); + // Start + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/record/start") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + // Stop + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/record/stop") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + // ── 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(); + let status = resp.status(); + assert!( + matches!(status.as_u16(), 200 | 404 | 500), + "unexpected status: {status}" + ); + } + + #[tokio::test] + async fn standalone_rejects_playback() { + let app = test_router(); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/playback/actions") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"actions": [], "speed": 1.0}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/sandbox-core/tests/recorder_integration.rs b/crates/sandbox-core/tests/recorder_integration.rs index 26ee047..dda2015 100644 --- a/crates/sandbox-core/tests/recorder_integration.rs +++ b/crates/sandbox-core/tests/recorder_integration.rs @@ -247,3 +247,80 @@ fn default_recorder() { let recorder: ActionRecorder = Default::default(); assert!(!recorder.is_enabled()); } + +#[test] +fn record_flushes_to_file() { + let tmp = + std::env::temp_dir().join(format!("recorder_flush_test_{}.jsonl", std::process::id())); + let recorder = ActionRecorder::new(); + recorder.start(Some(tmp.clone())).unwrap(); + + recorder + .record(Action::Click { + x: 1.0, + y: 2.0, + button: "left".into(), + timestamp_ms: None, + }) + .unwrap(); + recorder + .record(Action::TypeText { + text: "hello".into(), + timestamp_ms: None, + }) + .unwrap(); + + let _ = recorder.stop().unwrap(); + + let content = std::fs::read_to_string(&tmp).unwrap(); + assert!(content.contains("click")); + assert!(content.contains("type_text")); + let line_count = content.lines().filter(|l| !l.trim().is_empty()).count(); + assert_eq!(line_count, 2); + let _ = std::fs::remove_file(&tmp); +} + +#[test] +fn record_multiple_action_types_to_file() { + let tmp = + std::env::temp_dir().join(format!("recorder_multi_test_{}.jsonl", std::process::id())); + let recorder = ActionRecorder::new(); + recorder.start(Some(tmp.clone())).unwrap(); + + let actions: Vec = vec![ + Action::Wait { + duration_ms: 10, + 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("test".into()), + timestamp_ms: None, + }, + ]; + + for a in &actions { + recorder.record(a.clone()).unwrap(); + } + let _ = recorder.stop().unwrap(); + + let content = std::fs::read_to_string(&tmp).unwrap(); + assert!(content.contains("wait")); + assert!(content.contains("scroll")); + assert!(content.contains("drag")); + assert!(content.contains("screenshot")); + let _ = std::fs::remove_file(&tmp); +} 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 index c216a21..0d8b781 100644 --- a/crates/sandbox-core/tests/scenario_integration.rs +++ b/crates/sandbox-core/tests/scenario_integration.rs @@ -376,3 +376,79 @@ steps: [] let scenario = ScenarioRunner::load_from_str(yaml).unwrap(); assert!(scenario.steps.is_empty()); } + +#[test] +fn report_with_skip_step_markdown() { + let mut report = TestReport::new("skip test"); + report.add_step(StepResult { + index: 0, + description: "skipped step".into(), + status: StepStatus::Skip, + duration_ms: 0, + screenshot_label: None, + error: None, + diff_percentage: None, + }); + + let md = report.to_markdown(); + assert!(md.contains("⏭️")); + assert!(md.contains("skipped step")); + assert!(md.contains("PASSED")); +} + +#[test] +fn report_with_skip_step_html() { + let mut report = TestReport::new("skip html"); + report.add_step(StepResult { + index: 0, + description: "skipped".into(), + status: StepStatus::Skip, + duration_ms: 0, + screenshot_label: None, + error: None, + diff_percentage: None, + }); + + let html = report.to_html(); + assert!(html.contains(r#"class="skip""#)); + assert!(html.contains("SKIP")); + assert!(html.contains("")); +} + +#[test] +fn report_with_mixed_status_html() { + let mut report = TestReport::new("mixed html"); + report.add_step(StepResult { + index: 0, + description: "ok step".into(), + status: StepStatus::Pass, + duration_ms: 10, + screenshot_label: None, + error: None, + diff_percentage: None, + }); + report.add_step(StepResult { + index: 1, + description: "bad step".into(), + status: StepStatus::Fail, + duration_ms: 5, + screenshot_label: None, + error: Some("error msg".into()), + diff_percentage: None, + }); + report.add_step(StepResult { + index: 2, + description: "skip step".into(), + status: StepStatus::Skip, + duration_ms: 0, + screenshot_label: None, + error: None, + diff_percentage: None, + }); + + let html = report.to_html(); + assert!(html.contains("FAIL")); + assert!(html.contains("PASS")); + assert!(html.contains("SKIP")); + assert!(html.contains("error msg")); +} 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/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/release.sh b/release.sh index f05651a..3d1a75f 100755 --- a/release.sh +++ b/release.sh @@ -45,6 +45,14 @@ check node check pnpm ok "All prerequisites met" +# --- step 1.5: 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 +rm -f ~/.sandbox/instances/*.json 2>/dev/null || true +ok "Cleanup done" + # --- step 2: build frontend --- echo "" info "Building frontend (sandbox-web)..." @@ -116,6 +124,8 @@ 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 + # Embed entitlements for ScreenCaptureKit & Accessibility (ad-hoc signing) + codesign --force --sign - --entitlements "$SCRIPT_DIR/src-tauri/entitlements.plist" "$RELEASE_DIR/sandbox" 2>/dev/null || true ok "sandbox CLI binary" # Tauri .app @@ -125,6 +135,8 @@ if [ -d "$APP_BUNDLE" ]; then 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 + # Embed entitlements for ScreenCaptureKit & Accessibility (ad-hoc signing) + codesign --force --sign - --entitlements "$SCRIPT_DIR/src-tauri/entitlements.plist" "$APP_EXEC" 2>/dev/null || true fi ok "$APP_NAME.app" fi @@ -136,6 +148,23 @@ if [ -n "$DMG_FILE" ]; then ok "$(basename "$DMG_FILE")" fi +# --- entitlement verification --- +echo "" +info "Verifying entitlements..." +APP_EXEC="$RELEASE_DIR/${APP_NAME}.app/Contents/MacOS/system-test-sandbox" +if [ -f "$APP_EXEC" ]; then + if codesign -d --entitlements - "$APP_EXEC" 2>/dev/null | grep -q "screen-capture"; then + ok "ScreenCaptureKit entitlement embedded in app" + else + err "ScreenCaptureKit entitlement NOT found in app!" + fi + if codesign -d --entitlements - "$RELEASE_DIR/sandbox" 2>/dev/null | grep -q "screen-capture"; then + ok "ScreenCaptureKit entitlement embedded in CLI" + else + err "ScreenCaptureKit entitlement NOT found in CLI!" + fi +fi + # Generate README (inline, see step 6) ok "Release artifacts ready" diff --git a/release/README.md b/release/README.md new file mode 100644 index 0000000..9f2663b --- /dev/null +++ b/release/README.md @@ -0,0 +1,150 @@ +# System Test Sandbox — Release v0.1.0 + +macOS 桌面自动化沙箱。模拟鼠标/键盘操作、截图、读取 UI 元素树,通过 CLI 或 MCP 协议供 Agent 工具调用。 + +## 文件说明 + +``` +release/ +├── sandbox # CLI 工具(命令行) +├── System Test Sandbox.app # macOS 桌面应用(Tauri) +└── 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` 添加进去并勾选。 + +## 二、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 type "Hello World" + +# 模拟按键 +./sandbox key Return +./sandbox key c --modifiers cmd + +# 启动 App +./sandbox spawn-app /Applications/Calculator.app + +# 启动 CLI +./sandbox spawn-cli ls -la + +# 终止进程 +./sandbox kill 12345 +``` + +### curl 调用示例 + +```bash +# 截图 +curl http://127.0.0.1:5801/screenshot -o screenshot.png + +# 点击 +curl -X POST http://127.0.0.1:5801/input/click \ + -H "Content-Type: application/json" \ + -d '{"x": 100, "y": 200, "button": "left"}' + +# 输入文字 +curl -X POST http://127.0.0.1:5801/input/type \ + -H "Content-Type: application/json" \ + -d '{"text": "hello"}' + +# 按回车 +curl -X POST http://127.0.0.1:5801/input/key \ + -H "Content-Type: application/json" \ + -d '{"key": "Return", "modifiers": []}' + +# 启动 CLI +curl -X POST http://127.0.0.1:5801/cli/spawn \ + -H "Content-Type: application/json" \ + -d '{"command": "ls", "args": ["-la"]}' +``` + +## 三、桌面应用使用方法 + +1. 双击 `System Test Sandbox.app` 启动 +2. 应用窗口内嵌 xterm.js 终端,可直接运行 CLI 命令 +3. 顶部状态栏提供截图按钮 + +## 四、常见问题 + +**Q: 截图全黑?** +A: 检查「屏幕录制」权限是否已授予。 + +**Q: 点击/输入无效?** +A: 检查「辅助功能」权限是否已授予。 + +**Q: `serve` 端口被占用?** +A: 使用 `./sandbox serve --port 5802` 更换端口。 + +**Q: MCP 连接失败?** +A: 确认 `settings.json` 中的 `command` 路径是绝对路径。 + +--- + +**版本**: v0.1.0 | **构建时间**: 2026-05-17 23:04 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..18be9a5 --- /dev/null +++ b/sandbox-web/src/api.ts @@ -0,0 +1,242 @@ +/** + * 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(); +} + +// ── Recording ────────────────────────────────────────── + +export async function recordStart(): Promise { + await request("/record/start", { method: "POST", body: "{}" }); +} + +export async function recordStop(): Promise<{ actions_count: number }> { + const res = await fetch(`${BASE()}/record/stop`, { + method: "POST", + body: "{}", + }); + return res.json(); +} 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 ("idle"); const [actionCount, setActionCount] = useState(0); const [screenshotLoading, setScreenshotLoading] = useState(false); + const [screenshotUrl, setScreenshotUrl] = useState(null); + const [activePid, setActivePid] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); - const handleTerminalInput = useCallback((_data: string) => { - // Terminal input is forwarded to the PTY via Tauri shell plugin + const showError = useCallback((msg: string) => { + setErrorMsg(msg); + setTimeout(() => setErrorMsg(null), 4000); }, []); + // ── 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); } - }, []); + }, [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 handleSpawnApp = useCallback((path: string) => { - setProcesses((prev) => [ - ...prev, - { - pid: Date.now(), - name: path.split("/").pop() ?? path, - is_running: true, - }, - ]); - }, []); + // ── 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 handleSpawnCli = useCallback((command: string, _args: string[]) => { - setProcesses((prev) => [ - ...prev, - { pid: Date.now(), name: command, is_running: true }, - ]); - }, []); + // ── Click ──────────────────────────────────────────── - const handleClick = useCallback((_x: number, _y: number, _button: string) => { - // Invoke Tauri or HTTP click - }, []); + 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 handleTypeText = useCallback((_text: string) => { - // Invoke Tauri or HTTP type_text - }, []); + // ── Type Text ──────────────────────────────────────── - const handlePressKey = useCallback((_key: string, _modifiers: string[]) => { - // Invoke Tauri or HTTP press_key - }, []); + const handleTypeText = useCallback( + (text: string) => { + api.typeText(text).catch((e) => showError(`Type failed: ${e}`)); + }, + [showError], + ); + + // ── Press Key ──────────────────────────────────────── + + const handlePressKey = useCallback( + (key: string, modifiers: string[]) => { + api.pressKey(key, modifiers).catch((e) => showError(`Key failed: ${e}`)); + }, + [showError], + ); + + // ── Recording ──────────────────────────────────────── const handleRecordStart = useCallback(() => { setRecordStatus("recording"); setActionCount(0); - }, []); + api.recordStart().catch((e) => showError(`Record start failed: ${e}`)); + }, [showError]); const handleRecordStop = useCallback(() => { setRecordStatus("idle"); - }, []); + api + .recordStop() + .then((r) => setActionCount(r.actions_count)) + .catch((e) => showError(`Record stop failed: ${e}`)); + }, [showError]); const handlePlay = useCallback((_speed: number) => { setRecordStatus("playing"); @@ -99,22 +162,54 @@ 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 +

+
+ )}
diff --git a/sandbox-web/vitest.config.ts b/sandbox-web/vitest.config.ts new file mode 100644 index 0000000..3607524 --- /dev/null +++ b/sandbox-web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + reportsDirectory: "./coverage", + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.d.ts", "src/main.tsx"], + }, + }, +}); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4d05d95..570746d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,3 +16,5 @@ tauri-plugin-shell = "2" serde.workspace = true serde_json.workspace = true tokio.workspace = true +tracing.workspace = true +axum.workspace = true diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..079c265 --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,12 @@ + + + + + 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..a913af7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,16 +1,32 @@ #![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::recorder::ActionRecorder; +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 +47,95 @@ 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()); + } + i += 1; + } + result +} + fn main() { + let launch_args = parse_sandbox_args(); + + let sandbox_id = launch_args.sandbox_id.clone(); + let sandbox_port = launch_args.sandbox_port; + + 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 +143,111 @@ 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()), + recorder: ActionRecorder::new(), + })); + + // 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" + } } }