From 7fd2c8f37fccc2b5fdb3ac53e1c55d168e79d09c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:37:59 +0000 Subject: [PATCH 1/3] feat(android): bootstrap rust android connection --- Cargo.lock | 131 +--- Cargo.toml | 29 +- LICENSE | 37 +- README.md | 412 +++--------- ...624_000000_bootstrap_android_connection.md | 6 + examples/basic_usage.rs | 17 +- src/bin/android-connection.rs | 54 ++ src/lib.rs | 261 ++++++- src/main.rs | 138 ++-- src/mcp.rs | 635 ++++++++++++++++++ src/sum.rs | 4 - tests/integration/cli.rs | 45 ++ tests/integration/mod.rs | 2 +- tests/integration/sum.rs | 36 - tests/unit/android_connection.rs | 44 ++ tests/unit/mod.rs | 2 +- tests/unit/sum.rs | 26 - 17 files changed, 1280 insertions(+), 599 deletions(-) create mode 100644 changelog.d/20260624_000000_bootstrap_android_connection.md create mode 100644 src/bin/android-connection.rs create mode 100644 src/mcp.rs delete mode 100644 src/sum.rs create mode 100644 tests/integration/cli.rs delete mode 100644 tests/integration/sum.rs create mode 100644 tests/unit/android_connection.rs delete mode 100644 tests/unit/sum.rs diff --git a/Cargo.lock b/Cargo.lock index 2fd813b..cdd7221 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -73,9 +73,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -108,49 +108,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "ctor" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - -[[package]] -name = "example-sum-package-name" -version = "0.19.2" +name = "docker-git-android-connection" +version = "0.0.0" dependencies = [ "clap", - "lino-arguments", "regex", + "serde", "serde_json", "walkdir", ] @@ -173,31 +136,11 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "lino-arguments" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be512a5c5eacea6ef5ec015fb0c7e1725c8e4cda1befd31606e203f281069968" -dependencies = [ - "clap", - "ctor", - "dotenvy", - "lino-env", - "serde", - "thiserror", -] - -[[package]] -name = "lino-env" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f453c53827aabe91a3d3856d61d14ae3867ab1a4344db22f9fa5396664c8d0e" - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "once_cell_polyfill" @@ -207,27 +150,27 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -248,9 +191,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "same-file" @@ -312,40 +255,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "utf8parse" diff --git a/Cargo.toml b/Cargo.toml index 70d0b92..a763d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "example-sum-package-name" -version = "0.19.2" +name = "docker-git-android-connection" +version = "0.0.0" edition = "2021" -description = "A Rust package template for AI-driven development" +description = "Rust Android MCP and lifecycle module for docker-git" readme = "README.md" -license = "Unlicense" -keywords = ["template", "rust", "ai-driven"] -categories = ["development-tools"] -repository = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" -documentation = "https://github.com/link-foundation/rust-ai-driven-development-pipeline-template" +license = "MIT" +keywords = ["docker-git", "android", "adb", "mcp", "rust"] +categories = ["command-line-utilities", "development-tools"] +repository = "https://github.com/ProverCoderAI/rust-android-connection" +documentation = "https://github.com/ProverCoderAI/rust-android-connection" rust-version = "1.70" # Narrow allowlist of files shipped in the published `.crate` archive. @@ -24,16 +24,21 @@ include = [ ] [lib] -name = "example_sum_package_name" +name = "docker_git_android_connection" path = "src/lib.rs" [[bin]] -name = "example-sum-package-name" +name = "docker-git-android-connection" path = "src/main.rs" +[[bin]] +name = "android-connection" +path = "src/bin/android-connection.rs" + [dependencies] -lino-arguments = "0.3" -clap = { version = "4.4", features = ["derive", "env"] } +clap = { version = "4.5.53", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" [dev-dependencies] regex = "1" diff --git a/LICENSE b/LICENSE index fdddb29..8738395 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,21 @@ -This is free and unencumbered software released into the public domain. +MIT License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2026 ProverCoderAI Contributors -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -For more information, please refer to +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4862d48..a7e0f20 100644 --- a/README.md +++ b/README.md @@ -1,365 +1,107 @@ -# rust-ai-driven-development-pipeline-template +# rust-android-connection -A comprehensive template for AI-driven Rust development with full CI/CD pipeline support. +Rust Android MCP and lifecycle module for docker-git. -[![CI/CD Pipeline](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/workflows/CI%2FCD%20Pipeline/badge.svg)](https://github.com/link-foundation/rust-ai-driven-development-pipeline-template/actions?workflow=CI%2FCD+Pipeline) -[![Crates.io](https://img.shields.io/crates/v/example-sum-package-name?label=crates.io&style=flat)](https://crates.io/crates/example-sum-package-name) -[![Docs.rs](https://docs.rs/example-sum-package-name/badge.svg)](https://docs.rs/example-sum-package-name) -[![Rust Version](https://img.shields.io/badge/rust-1.70%2B-blue.svg)](https://www.rust-lang.org/) -[![Codecov](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template/branch/main/graph/badge.svg)](https://codecov.io/gh/link-foundation/rust-ai-driven-development-pipeline-template) -[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) - -## Features - -- **Rust stable support**: Works with Rust stable version -- **Cross-platform testing**: CI runs on Ubuntu, macOS, and Windows -- **Comprehensive testing**: Unit tests, integration tests, and doc tests -- **Code quality**: rustfmt + Clippy with pedantic lints -- **Pre-commit hooks**: Automated code quality checks before commits -- **CI/CD pipeline**: GitHub Actions with multi-platform support -- **Changelog management**: Fragment-based changelog (like Changesets/Scriv) -- **Code coverage**: Automated coverage reports with cargo-llvm-cov and Codecov -- **Release automation**: Automatic GitHub releases, crates.io publishing, post-publish smoke tests, and optional Docker Hub image publishing -- **Template-safe defaults**: CI/CD skips publishing when package name is `example-sum-package-name` - -## Quick Start - -### Using This Template - -1. Click "Use this template" on GitHub to create a new repository -2. Clone your new repository -3. Update `Cargo.toml`: - - Change `name` from `example-sum-package-name` to your package name - - Update `description`, `repository`, and `documentation` URLs - - Update `[lib]` name and `[[bin]]` name -4. Keep `Cargo.lock` committed when the project has a binary target (`[[bin]]` or `src/main.rs`) -5. Update imports in `src/main.rs`, `tests/`, and `examples/` -6. Build and start developing! - -### Development Setup +## Install ```bash -# Clone the repository -git clone https://github.com/link-foundation/rust-ai-driven-development-pipeline-template.git -cd rust-ai-driven-development-pipeline-template - -# Build the project -cargo build - -# Run tests -cargo test - -# Run the CLI binary -cargo run -- --a 3 --b 7 - -# Run an example -cargo run --example basic_usage +cargo install --git https://github.com/ProverCoderAI/rust-android-connection --branch main --locked --bins ``` -### Running Tests +Installs two binaries: -```bash -# Run all tests -cargo test - -# Run tests with verbose output -cargo test --verbose - -# Run doc tests -cargo test --doc - -# Run a specific test -cargo test test_sum_positive_numbers - -# Run tests with output -cargo test -- --nocapture +```text +docker-git-android-connection # start/status/stop Android runtime container +android-connection # MCP stdio server for Codex, Claude, Gemini, and Grok ``` -CI caps each test-matrix job at 10 minutes. `cargo test` does not provide a portable global per-test timeout, so long-running network, IO, or async tests should use explicit test-level timeouts. Repositories that adopt `cargo nextest` can configure runner deadlines with options such as `--slow-timeout` and `--leak-timeout`. - -### Code Quality Checks +## Lifecycle CLI ```bash -# Format code -cargo fmt - -# Check formatting (CI style) -cargo fmt --check - -# Run Clippy lints -cargo clippy --all-targets --all-features - -# Check file size limits (requires rust-script: cargo install rust-script) -rust-script scripts/check-file-size.rs - -# Check the packaged crate stays under the crates.io 10 MiB upload limit -rust-script scripts/check-crate-size.rs - -# Run all checks -cargo fmt --check && cargo clippy --all-targets --all-features && rust-script scripts/check-file-size.rs -``` - -## Project Structure - +docker-git-android-connection status --project dg-my-project +docker-git-android-connection start --project dg-my-project --dry-run +docker-git-android-connection stop --project dg-my-project --dry-run ``` -. -├── .github/ -│ └── workflows/ -│ └── release.yml # CI/CD pipeline configuration -├── changelog.d/ # Changelog fragments -│ ├── README.md # Fragment instructions -│ └── *.md # Individual changelog entries -├── examples/ -│ └── basic_usage.rs # Usage examples -├── experiments/ # Experiment and debug scripts -│ ├── test-changelog-parsing.rs # Changelog parsing validation -│ └── test-crates-io-check.rs # Crates.io version check validation -├── scripts/ # Rust scripts (via rust-script) -│ ├── bump-version.rs # Version bumping utility -│ ├── check-changelog-fragment.rs # Changelog fragment validation -│ ├── check-crate-size.rs # Crate archive size guard (crates.io 10 MiB limit) -│ ├── check-file-size.rs # File size validation script -│ ├── check-release-needed.rs # Release necessity check -│ ├── check-version-modification.rs # Version modification detection -│ ├── collect-changelog.rs # Changelog collection script -│ ├── create-changelog-fragment.rs # Changelog fragment creation -│ ├── create-github-release.rs # GitHub release creation -│ ├── detect-code-changes.rs # Code change detection for CI -│ ├── get-bump-type.rs # Version bump type determination -│ ├── get-version.rs # Version extraction from Cargo.toml -│ ├── git-config.rs # Git configuration for CI -│ ├── publish-crate.rs # Crates.io publishing -│ ├── release-naming.rs # Release tag/title/badge naming helpers -│ ├── rust-paths.rs # Rust root path detection -│ ├── smoke-test-published-crate.rs # Install/import/run smoke test for published crates -│ ├── version-and-commit.rs # CI/CD version management -│ └── wait-for-crate.rs # Crates.io availability wait before image publishing -├── src/ -│ ├── lib.rs # Library entry point -│ ├── main.rs # CLI binary (uses lino-arguments) -│ └── sum.rs # Sum function module -├── tests/ -│ ├── unit_tests.rs # Unit test entry point -│ ├── unit/ -│ │ ├── mod.rs -│ │ ├── sum.rs # Unit tests for sum function -│ │ └── ci-cd/ -│ │ ├── mod.rs -│ │ └── changelog_parsing.rs # CI/CD changelog parsing tests -│ ├── integration_tests.rs # Integration test entry point -│ └── integration/ -│ ├── mod.rs -│ └── sum.rs # CLI integration tests -├── .gitignore # Git ignore patterns -├── .pre-commit-config.yaml # Pre-commit hooks configuration -├── Cargo.toml # Project configuration -├── CHANGELOG.md # Project changelog -├── CONTRIBUTING.md # Contribution guidelines -├── LICENSE # Unlicense (public domain) -└── README.md # This file -``` - -## Design Choices - -### Example Application - -The template includes a simple CLI sum application using [lino-arguments](https://github.com/link-foundation/lino-arguments) (a drop-in replacement for clap that also supports `.lenv` and `.env` files). This demonstrates: - -- Library module (`src/sum.rs`) with a pure function -- CLI binary (`src/main.rs`) using `lino-arguments` for argument parsing -- Unit tests (`tests/unit/sum.rs`) testing the function directly -- Integration tests (`tests/integration/sum.rs`) testing the full CLI binary -### Code Quality Tools +The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. -- **rustfmt**: Standard Rust code formatter -- **Clippy**: Rust linter with pedantic and nursery lints enabled -- **Pre-commit hooks**: Automated checks before each commit - -### Testing Strategy - -The template supports multiple levels of testing: - -- **Unit tests**: In `tests/unit/` directory, testing functions directly -- **Integration tests**: In `tests/integration/` directory, testing CLI binary -- **CI/CD tests**: In `tests/unit/ci-cd/` directory, testing CI/CD script logic -- **Doc tests**: In documentation examples using `///` comments -- **Examples**: In `examples/` directory (also serve as documentation) - -Users can easily delete CI/CD tests in `tests/unit/ci-cd/` if not needed. - -### Changelog Management - -This template uses a fragment-based changelog system similar to [Changesets](https://github.com/changesets/changesets) and [Scriv](https://scriv.readthedocs.io/). +## MCP Server ```bash -# Create a changelog fragment -touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md - -# Edit the fragment to document your changes +android-connection --project dg-my-project --network docker-git-shared --endpoint dg-my-project-android:5555 --workspace . ``` -### CI/CD Pipeline - -The GitHub Actions workflow provides: - -1. **Change detection**: Only runs relevant jobs based on changed files -2. **Changelog check**: Validates changelog fragments on PRs with code changes -3. **Version check**: Prevents manual version modification in PRs -4. **Linting**: rustfmt and Clippy checks -5. **Test matrix**: 3 OS (Ubuntu, macOS, Windows) with Rust stable -6. **Code coverage**: cargo-llvm-cov with Codecov upload -7. **Building**: Release build and package validation -8. **Auto release**: Automatic releases when changelog fragments are merged to main -9. **Manual release**: Workflow dispatch with version bump type selection -10. **Published crate smoke test**: Installs the just-published crate from crates.io, runs CLI entry points with captured output, and compiles a fresh dependent crate against the library -11. **Optional Docker Hub publishing**: Pushes `latest` and version tags after the matching crates.io version is visible and smoke-tested -12. **Documentation**: Automatic docs deployment to GitHub Pages after release - -#### Multi-Language Monorepos - -Release scripts auto-detect the Rust layout with no extra configuration. A root `Cargo.toml` is treated as a single-language repository and keeps the plain `v` tag plus ` ` GitHub release title. A `rust/Cargo.toml` layout is treated as a multi-language monorepo and uses `rust_v` tags plus `[Rust] ` titles so Rust releases do not collide with JavaScript or other language releases in the same GitHub Releases list. - -GitHub release notes include a crates.io badge that links to the exact published version page, for example `https://crates.io/crates//`. - -### Template-Safe Defaults - -The default package name `example-sum-package-name` triggers skip logic in CI/CD scripts: -- `publish-crate.rs` skips crates.io publishing -- `smoke-test-published-crate.rs` skips install-from-package verification -- `create-github-release.rs` skips GitHub release creation -- Docker Hub publishing stays disabled unless `DOCKERHUB_IMAGE` is configured and a root `Dockerfile` exists - -Rename the package in `Cargo.toml` to enable full CI/CD publishing. - -## Configuration - -### Updating Package Name - -After creating a repository from this template: - -1. Update `Cargo.toml`: - - Change `name` field from `example-sum-package-name` - - Update `repository` and `documentation` URLs - - Change `[lib]` name and `[[bin]]` name - - Keep `Cargo.lock` committed for executable crates - -2. Update imports: - - `src/main.rs` - - `tests/unit/sum.rs` - - `tests/integration/sum.rs` - - `examples/basic_usage.rs` - -3. Update badges in this `README.md` - -### Cargo.lock for Binary Crates - -This template leaves `Cargo.lock` committed because it includes a CLI binary. -Downstream executable crates should do the same. The CI workflow runs -`scripts/check-cargo-lock.rs` and fails when a binary package has no -`Cargo.lock` committed at `HEAD`. - -This prevents fresh dependency resolution from changing between CI runs. It also -keeps cargo cache keys deterministic: without a lockfile, GitHub Actions' -`hashFiles('**/Cargo.lock')` expression resolves to the same empty hash, so an -unpinned dependency graph can be cached and hide resolution drift. - -If the guard fails, generate the lockfile and commit it: +For handshake tests without ADB access: ```bash -cargo generate-lockfile -git add Cargo.lock +android-connection --project dg-my-project --no-adb-probe ``` -### Optional Docker Hub Publishing - -Projects that ship a Docker image can publish Docker Hub releases from the same Rust release workflow. Add a root `Dockerfile`, then configure: - -| Name | Type | Example | Purpose | -| ---- | ---- | ------- | ------- | -| `DOCKERHUB_IMAGE` | Repository variable | `my-dockerhub-user/my-image` | Docker Hub repository to publish | -| `DOCKERHUB_USERNAME` | Repository variable or secret | `my-dockerhub-user` | Docker Hub login username | -| `DOCKERHUB_TOKEN` | Repository secret | Docker Hub access token | Docker Hub login token | - -When configured, the release workflow publishes both `latest` and the Cargo package version tag, for example `my-dockerhub-user/my-image:0.10.0`. Docker publishing runs only after crates.io reports the matching version as available, and release checks rerun missing Docker Hub or GitHub release artifacts without bumping the version again. - -Add a visible Docker Hub badge next to the crates.io badge in repositories that enable image publishing: - -```markdown -[![Docker Hub](https://img.shields.io/docker/v/my-dockerhub-user/my-image?label=docker%20hub)](https://hub.docker.com/r/my-dockerhub-user/my-image) +## MCP Tools + +```text +android_status() +android_devices() +android_screenshot(path?) +android_tap(x, y) +android_swipe(startX, startY, endX, endY, durationMs?) +android_type_text(text) +android_press_key(keycode) +android_launch_app(package, activity?) +android_open_url(url) +android_logcat(lines?) +android_install_apk(path) ``` -## Deploying API documentation +`android_install_apk` is disabled unless the server is started with `--allow-install`. -The `deploy-docs` job in `.github/workflows/release.yml` publishes `cargo doc --no-deps --all-features` output to GitHub Pages on every push to `main` and on `workflow_dispatch` with `release_mode == 'instant'`. It adds a root `index.html` redirect to the generated crate documentation and a `.nojekyll` marker so rustdoc assets are served verbatim. It uses the official `actions/configure-pages` / `actions/upload-pages-artifact` / `actions/deploy-pages` flow, which requires the repository's Pages source to be set to **GitHub Actions**. +## Smoke Test -Before the first run on `main`, open **Settings → Pages** of the new repository and set **Source = GitHub Actions**. This is a one-time manual step and cannot be configured from a workflow. The `deploy-docs` job will then provision the Pages site on its first run. - -If this step is skipped, the first `deploy-docs` run fails on `actions/deploy-pages@v5` with `Error: Get Pages site failed.` / `Error: Failed to create deployment`. Flip the Pages source as described above and re-run the failed job; no workflow changes are required. - -## Scripts Reference - -All scripts in `scripts/` are Rust scripts that use [rust-script](https://github.com/fornwall/rust-script). -Install rust-script with: `cargo install rust-script` - -| Command | Description | -| ------------------------------------- | ------------------------ | -| `cargo test` | Run all tests | -| `cargo fmt` | Format code | -| `cargo clippy` | Run lints | -| `cargo run -- --a 3 --b 7` | Run CLI (sum 3 + 7) | -| `cargo run --example basic_usage` | Run example | -| `rust-script scripts/check-cargo-lock.rs` | Require committed Cargo.lock for binary crates | -| `rust-script scripts/check-file-size.rs` | Check file size limits | -| `rust-script scripts/check-crate-size.rs` | Check crate archive size (crates.io 10 MiB limit) | -| `rust-script scripts/smoke-test-published-crate.rs --release-version ` | Verify the published crates.io artifact from a clean install | -| `rust-script scripts/bump-version.rs` | Bump version | - -## Example Usage - -```rust -use example_sum_package_name::sum; - -fn main() { - let result = sum(2, 3); - println!("2 + 3 = {result}"); -} +```bash +python3 - <<'PY' | android-connection --project dg-my-project --no-adb-probe | python3 - <<'PY' +import json +import sys + +messages = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, +] + +for message in messages: + body = json.dumps(message, separators=(",", ":")).encode() + sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode()) + sys.stdout.buffer.write(body) +PY +import json +import sys + +stream = sys.stdin.buffer +while True: + header = {} + while True: + line = stream.readline() + if not line: + raise SystemExit(0) + stripped = line.strip() + if not stripped: + break + name, value = line.decode().split(":", 1) + header[name.lower()] = value.strip() + + length = int(header["content-length"]) + body = stream.read(length) + print(json.dumps(json.loads(body), indent=2)) +PY ``` -See `examples/basic_usage.rs` for more examples. - -## Contributing - -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +Expected: server `android-connection` and tools such as `android_status`, `android_tap`, and `android_screenshot`. -### Development Workflow +## Development -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes and add tests -4. Run quality checks: `cargo fmt && cargo clippy && cargo test` -5. Add a changelog fragment -6. Commit your changes (pre-commit hooks will run automatically) -7. Push and create a Pull Request - -## License - -[Unlicense](LICENSE) - Public Domain - -This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details. - -## Acknowledgments - -Inspired by: -- [js-ai-driven-development-pipeline-template](https://github.com/link-foundation/js-ai-driven-development-pipeline-template) -- [python-ai-driven-development-pipeline-template](https://github.com/link-foundation/python-ai-driven-development-pipeline-template) -- [lino-arguments](https://github.com/link-foundation/lino-arguments) -- [trees-rs](https://github.com/linksplatform/trees-rs) - -## Resources - -- [Rust Book](https://doc.rust-lang.org/book/) -- [Cargo Book](https://doc.rust-lang.org/cargo/) -- [Clippy Documentation](https://rust-lang.github.io/rust-clippy/) -- [rustfmt Documentation](https://rust-lang.github.io/rustfmt/) -- [Pre-commit Documentation](https://pre-commit.com/) +```bash +cargo fmt --check +cargo test --locked +cargo build --locked --bins +cargo clippy --locked --all-targets --all-features -- -D warnings +``` diff --git a/changelog.d/20260624_000000_bootstrap_android_connection.md b/changelog.d/20260624_000000_bootstrap_android_connection.md new file mode 100644 index 0000000..fe71c68 --- /dev/null +++ b/changelog.d/20260624_000000_bootstrap_android_connection.md @@ -0,0 +1,6 @@ +--- +bump: minor +--- + +### Added +- Bootstrap `docker-git-android-connection` as the Rust Android MCP and lifecycle crate. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index be9a37f..4509e41 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,7 +1,16 @@ -use example_sum_package_name::sum; +use docker_git_android_connection::{ + android_spec, docker_run_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, +}; fn main() { - println!("2 + 3 = {}", sum(2, 3)); - println!("-5 + 10 = {}", sum(-5, 10)); - println!("1000 + 2000 = {}", sum(1000, 2000)); + let spec = android_spec( + "dg-my-project", + "docker-git-shared", + DEFAULT_ADB_ENDPOINT, + DEFAULT_ANDROID_IMAGE, + ) + .expect("default Android spec is valid"); + + println!("container: {}", spec.android_container_name); + println!("docker args: {}", docker_run_args(&spec).join(" ")); } diff --git a/src/bin/android-connection.rs b/src/bin/android-connection.rs new file mode 100644 index 0000000..ec26d60 --- /dev/null +++ b/src/bin/android-connection.rs @@ -0,0 +1,54 @@ +use clap::Parser; +use docker_git_android_connection::mcp::{run_stdio, McpState}; +use docker_git_android_connection::{ + android_spec, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, +}; +use std::io::{self, BufReader}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command(version, about = "Android MCP stdio server for docker-git")] +struct Cli { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long, default_value = ".")] + workspace: PathBuf, + #[arg(long)] + allow_install: bool, + #[arg(long)] + no_adb_probe: bool, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let spec = android_spec(&cli.project, &cli.network, &cli.endpoint, &cli.image)?; + let state = McpState { + spec, + workspace: cli.workspace, + adb_probe: !cli.no_adb_probe, + allow_install: cli.allow_install, + }; + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + let mut writer = stdout.lock(); + run_stdio(&mut reader, &mut writer, &state)?; + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 490125b..083f292 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,260 @@ -pub mod sum; +pub mod mcp; -pub use sum::sum; +use serde::Serialize; + +pub const SERVER_NAME: &str = "android-connection"; +pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; +pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; +pub const DEFAULT_PROJECT_ID: &str = "docker-git"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EndpointError { + pub value: String, +} + +impl std::fmt::Display for EndpointError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "invalid ADB endpoint {:?}; allowed characters are ASCII letters, digits, '.', '-', '_' and ':'", + self.value + ) + } +} + +impl std::error::Error for EndpointError {} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct AndroidSpec { + pub project_id: String, + pub project_container_name: String, + pub android_container_name: String, + pub android_volume_name: String, + pub docker_network: String, + pub adb_endpoint: String, + pub image: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct McpToolSpec { + pub name: &'static str, + pub description: &'static str, +} + +// CHANGE: normalize externally supplied project ids into Docker-safe names +// WHY: Android sidecar names are pure functions of the project id, so MCP clients and lifecycle CLI agree +// QUOTE(TZ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall s: normalize(s) in [a-z0-9-]+ and normalize(s) != "" +// PURITY: CORE +// INVARIANT: output is non-empty, lowercase, and contains only Docker-name-safe characters +// COMPLEXITY: O(n)/O(n) +#[must_use] +pub fn normalize_project_id(raw: &str) -> String { + let mut normalized = String::new(); + let mut previous_dash = false; + + for byte in raw.bytes() { + let next = match byte { + b'a'..=b'z' | b'0'..=b'9' => Some(byte as char), + b'A'..=b'Z' => Some(byte.to_ascii_lowercase() as char), + _ => { + if normalized.is_empty() || previous_dash { + None + } else { + Some('-') + } + } + }; + + if let Some(character) = next { + previous_dash = character == '-'; + normalized.push(character); + } + } + + while normalized.ends_with('-') { + normalized.pop(); + } + + if normalized.is_empty() { + DEFAULT_PROJECT_ID.to_string() + } else { + normalized + } +} + +#[must_use] +pub fn android_container_name(project_id: &str) -> String { + format!("{}-android", normalize_project_id(project_id)) +} + +#[must_use] +pub fn android_volume_name(project_id: &str) -> String { + format!("{}-home-android", normalize_project_id(project_id)) +} + +#[must_use] +pub fn is_safe_adb_endpoint(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && value.contains(':') + && value.bytes().all(|byte| { + matches!( + byte, + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b':' + ) + }) +} + +pub fn validate_adb_endpoint(value: &str) -> Result { + if is_safe_adb_endpoint(value) { + Ok(value.to_string()) + } else { + Err(EndpointError { + value: value.to_string(), + }) + } +} + +pub fn android_spec( + project_id: &str, + docker_network: &str, + adb_endpoint: &str, + image: &str, +) -> Result { + let normalized = normalize_project_id(project_id); + Ok(AndroidSpec { + project_id: normalized.clone(), + project_container_name: normalized.clone(), + android_container_name: android_container_name(&normalized), + android_volume_name: android_volume_name(&normalized), + docker_network: docker_network.to_string(), + adb_endpoint: validate_adb_endpoint(adb_endpoint)?, + image: image.to_string(), + }) +} + +#[must_use] +pub fn android_tools() -> Vec { + vec![ + McpToolSpec { + name: "android_status", + description: "Return the configured Android runtime and optional ADB status.", + }, + McpToolSpec { + name: "android_devices", + description: "List Android devices visible to adb.", + }, + McpToolSpec { + name: "android_screenshot", + description: "Capture a PNG screenshot into the workspace.", + }, + McpToolSpec { + name: "android_tap", + description: "Tap screen coordinates.", + }, + McpToolSpec { + name: "android_swipe", + description: "Swipe between screen coordinates.", + }, + McpToolSpec { + name: "android_type_text", + description: "Type text into the active Android input field.", + }, + McpToolSpec { + name: "android_press_key", + description: "Send an Android keycode.", + }, + McpToolSpec { + name: "android_launch_app", + description: "Launch an installed Android package.", + }, + McpToolSpec { + name: "android_open_url", + description: "Open a URL through Android intent handling.", + }, + McpToolSpec { + name: "android_logcat", + description: "Read recent logcat output.", + }, + McpToolSpec { + name: "android_install_apk", + description: "Install an APK from the workspace when explicitly enabled.", + }, + ] +} + +#[must_use] +pub fn docker_run_args(spec: &AndroidSpec) -> Vec { + vec![ + "run".to_string(), + "--detach".to_string(), + "--name".to_string(), + spec.android_container_name.clone(), + "--privileged".to_string(), + "--network".to_string(), + spec.docker_network.clone(), + "--env".to_string(), + "EMULATOR_HEADLESS=true".to_string(), + "--env".to_string(), + "WEB_VNC=true".to_string(), + "--volume".to_string(), + format!("{}:/root/.android", spec.android_volume_name), + spec.image.clone(), + ] +} + +#[must_use] +pub fn docker_stop_args(spec: &AndroidSpec) -> Vec { + vec![ + "rm".to_string(), + "--force".to_string(), + spec.android_container_name.clone(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_project_id_to_docker_safe_name() { + assert_eq!( + normalize_project_id("Org/Repo:Feature_X"), + "org-repo-feature-x" + ); + assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); + } + + #[test] + fn rejects_shell_fragments_in_adb_endpoint() { + assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); + assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); + assert!(validate_adb_endpoint("$(whoami):5555").is_err()); + } + + #[test] + fn builds_deterministic_android_spec() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + + assert_eq!(spec.project_container_name, "dg-test"); + assert_eq!(spec.android_container_name, "dg-test-android"); + assert_eq!(spec.android_volume_name, "dg-test-home-android"); + } + + #[test] + fn advertises_android_mcp_tools() { + let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); + assert!(names.contains(&"android_status")); + assert!(names.contains(&"android_tap")); + assert!(names.contains(&"android_install_apk")); + } +} diff --git a/src/main.rs b/src/main.rs index 397ec09..493a558 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,74 +1,104 @@ -use lino_arguments::Parser; -use std::io::{self, Write}; - -use example_sum_package_name::sum; +use clap::{Args, Parser, Subcommand}; +use docker_git_android_connection::{ + android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, + DEFAULT_PROJECT_ID, +}; +use serde_json::json; +use std::process::{Command, ExitCode}; #[derive(Parser, Debug)] -#[command(name = "example-sum-package-name", about = "Sum two numbers")] -struct Args { - #[arg(long, env = "A", default_value = "0", allow_hyphen_values = true)] - a: i64, - - #[arg(long, env = "B", default_value = "0", allow_hyphen_values = true)] - b: i64, -} - -fn write_output(writer: &mut impl Write, output: &str) -> io::Result<()> { - match writer - .write_all(output.as_bytes()) - .and_then(|()| writer.flush()) - { - Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), - result => result, - } +#[command(version, about = "docker-git Android runtime lifecycle CLI")] +struct Cli { + #[command(subcommand)] + command: LifecycleCommand, } -fn write_stdout(output: &str) -> io::Result<()> { - write_output(&mut io::stdout(), output) +#[derive(Subcommand, Debug)] +enum LifecycleCommand { + Start(LifecycleArgs), + Status(LifecycleArgs), + Stop(LifecycleArgs), } -fn main() -> io::Result<()> { - let args = Args::parse(); - write_stdout(&format!("{}\n", sum(args.a, args.b))) +#[derive(Args, Clone, Debug)] +struct LifecycleArgs { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long)] + dry_run: bool, } -#[cfg(test)] -mod tests { - use super::*; - - struct BrokenPipeWriter; - - impl Write for BrokenPipeWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::BrokenPipe)) +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) } + } +} - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + match cli.command { + LifecycleCommand::Start(args) => start(&args), + LifecycleCommand::Status(args) => status(&args), + LifecycleCommand::Stop(args) => stop(&args), } +} - struct OtherErrorWriter; +fn start(args: &LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_run_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); + } - impl Write for OtherErrorWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - } + run_docker(&docker_args) +} - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } +fn status(args: &LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + println!("{}", serde_json::to_string_pretty(&spec)?); + Ok(()) +} - #[test] - fn write_output_treats_broken_pipe_as_clean_exit() { - assert!(write_output(&mut BrokenPipeWriter, "1\n").is_ok()); +fn stop(args: &LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_stop_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); } - #[test] - fn write_output_preserves_other_io_errors() { - let err = write_output(&mut OtherErrorWriter, "1\n").unwrap_err(); + run_docker(&docker_args) +} - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); +fn run_docker(args: &[String]) -> Result<(), Box> { + let output = Command::new("docker").args(args).output()?; + if output.status.success() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + return Ok(()); } + + Err(format!( + "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()) } diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..cf275c4 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,635 @@ +use crate::{android_tools, AndroidSpec, SERVER_NAME}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; + +#[derive(Clone, Debug)] +pub struct McpState { + pub spec: AndroidSpec, + pub workspace: PathBuf, + pub adb_probe: bool, + pub allow_install: bool, +} + +#[derive(Debug)] +enum McpToolError { + MissingArgument(&'static str), + InvalidArgument(String), + AdbProbeDisabled, + CommandFailed(String), + Io(String), +} + +impl std::fmt::Display for McpToolError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingArgument(name) => write!(formatter, "missing required argument: {name}"), + Self::InvalidArgument(message) => write!(formatter, "invalid argument: {message}"), + Self::AdbProbeDisabled => write!(formatter, "ADB probing is disabled for this server"), + Self::CommandFailed(message) | Self::Io(message) => write!(formatter, "{message}"), + } + } +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Option, + method: String, + params: Option, +} + +pub fn run_stdio(reader: &mut R, writer: &mut W, state: &McpState) -> io::Result<()> +where + R: BufRead, + W: Write, +{ + while let Some(raw) = read_next_message(reader)? { + let response = match serde_json::from_str::(&raw) { + Ok(request) => handle_request(&request, state), + Err(error) => Some(json_rpc_error( + &Value::Null, + -32700, + &format!("invalid JSON-RPC request: {error}"), + )), + }; + + if let Some(value) = response { + write_json_message(writer, &value)?; + } + } + + Ok(()) +} + +fn read_next_message(reader: &mut R) -> io::Result> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(None); + } + + let first_line = line.trim_end_matches(['\r', '\n']); + if first_line.is_empty() { + continue; + } + + if let Some(length) = parse_content_length(first_line)? { + read_headers(reader)?; + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload)?; + return String::from_utf8(payload) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + + return Ok(Some(first_line.to_string())); + } +} + +fn parse_content_length(header: &str) -> io::Result> { + let lowercase = header.to_ascii_lowercase(); + if !lowercase.starts_with("content-length:") { + return Ok(None); + } + + let raw_length = header + .split_once(':') + .map(|(_, value)| value.trim()) + .unwrap_or_default(); + raw_length + .parse::() + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn read_headers(reader: &mut R) -> io::Result<()> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(()); + } + if line.trim().is_empty() { + return Ok(()); + } + } +} + +fn write_json_message(writer: &mut W, value: &Value) -> io::Result<()> { + let body = serde_json::to_string(value) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?; + writer.flush() +} + +fn handle_request(request: &JsonRpcRequest, state: &McpState) -> Option { + if request.id.is_none() && request.method.starts_with("notifications/") { + return None; + } + + let id = request.id.clone().unwrap_or(Value::Null); + let response = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION") + } + }), + "tools/list" => json!({ "tools": render_tools() }), + "tools/call" => return Some(handle_tools_call(&id, request.params.as_ref(), state)), + method => { + return Some(json_rpc_error( + &id, + -32601, + &format!("method not found: {method}"), + )) + } + }; + + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": response + })) +} + +fn json_rpc_error(id: &Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +fn render_tools() -> Value { + Value::Array( + android_tools() + .into_iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": { + "type": "object", + "additionalProperties": true + } + }) + }) + .collect(), + ) +} + +fn handle_tools_call(id: &Value, params: Option<&Value>, state: &McpState) -> Value { + let result = call_tool_from_params(params, state); + let (text, is_error) = match result { + Ok(text) => (text, false), + Err(error) => (error.to_string(), true), + }; + + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [ + { + "type": "text", + "text": text + } + ], + "isError": is_error + } + }) +} + +fn call_tool_from_params(params: Option<&Value>, state: &McpState) -> Result { + let params = + params.ok_or_else(|| McpToolError::InvalidArgument("missing params".to_string()))?; + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| McpToolError::InvalidArgument("missing tool name".to_string()))?; + let arguments = params.get("arguments").unwrap_or(&Value::Null); + + match name { + "android_status" => android_status(state), + "android_devices" => android_devices(state), + "android_screenshot" => android_screenshot(state, arguments), + "android_tap" => android_tap(state, arguments), + "android_swipe" => android_swipe(state, arguments), + "android_type_text" => android_type_text(state, arguments), + "android_press_key" => android_press_key(state, arguments), + "android_launch_app" => android_launch_app(state, arguments), + "android_open_url" => android_open_url(state, arguments), + "android_logcat" => android_logcat(state, arguments), + "android_install_apk" => android_install_apk(state, arguments), + unknown => Err(McpToolError::InvalidArgument(format!( + "unknown Android MCP tool: {unknown}" + ))), + } +} + +fn android_status(state: &McpState) -> Result { + if !state.adb_probe { + return serde_json::to_string_pretty(&json!({ + "server": SERVER_NAME, + "adbProbe": false, + "spec": state.spec + })) + .map_err(|error| McpToolError::Io(error.to_string())); + } + + match run_adb(state, &["devices".to_string()]) { + Ok(output) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\n\n{}", + state.spec.android_container_name, state.spec.adb_endpoint, output + )), + Err(error) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\nADB status error: {}", + state.spec.android_container_name, state.spec.adb_endpoint, error + )), + } +} + +fn android_devices(state: &McpState) -> Result { + run_adb(state, &["devices".to_string()]) +} + +fn android_tap(state: &McpState, arguments: &Value) -> Result { + let x = integer_argument(arguments, "x")?; + let y = integer_argument(arguments, "y")?; + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "tap".to_string(), + x.to_string(), + y.to_string(), + ], + ) +} + +fn android_swipe(state: &McpState, arguments: &Value) -> Result { + let start_x = integer_argument(arguments, "startX")?; + let start_y = integer_argument(arguments, "startY")?; + let end_x = integer_argument(arguments, "endX")?; + let end_y = integer_argument(arguments, "endY")?; + let duration_ms = optional_integer_argument(arguments, "durationMs")?.unwrap_or(300); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "swipe".to_string(), + start_x.to_string(), + start_y.to_string(), + end_x.to_string(), + end_y.to_string(), + duration_ms.to_string(), + ], + ) +} + +fn android_type_text(state: &McpState, arguments: &Value) -> Result { + let text = string_argument(arguments, "text")?.replace(' ', "%s"); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "text".to_string(), + text, + ], + ) +} + +fn android_press_key(state: &McpState, arguments: &Value) -> Result { + let keycode = string_argument(arguments, "keycode")?; + if !keycode + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) + { + return Err(McpToolError::InvalidArgument( + "keycode may contain only ASCII letters, digits, and '_'".to_string(), + )); + } + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "keyevent".to_string(), + keycode, + ], + ) +} + +fn android_launch_app(state: &McpState, arguments: &Value) -> Result { + let package_name = string_argument(arguments, "package")?; + let activity = optional_string_argument(arguments, "activity")?; + match activity { + Some(activity) if !activity.is_empty() => run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + format!("{package_name}/{activity}"), + ], + ), + _ => run_adb( + state, + &[ + "shell".to_string(), + "monkey".to_string(), + "-p".to_string(), + package_name, + "-c".to_string(), + "android.intent.category.LAUNCHER".to_string(), + "1".to_string(), + ], + ), + } +} + +fn android_open_url(state: &McpState, arguments: &Value) -> Result { + let url = string_argument(arguments, "url")?; + run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + url, + ], + ) +} + +fn android_logcat(state: &McpState, arguments: &Value) -> Result { + let lines = optional_integer_argument(arguments, "lines")? + .unwrap_or(200) + .clamp(1, 1000); + run_adb( + state, + &[ + "logcat".to_string(), + "-d".to_string(), + "-t".to_string(), + lines.to_string(), + ], + ) +} + +fn android_screenshot(state: &McpState, arguments: &Value) -> Result { + let output_path = optional_string_argument(arguments, "path")? + .unwrap_or_else(|| "android-screenshot.png".to_string()); + let target_path = workspace_path(&state.workspace, &output_path)?; + let output = run_adb_raw( + state, + &[ + "exec-out".to_string(), + "screencap".to_string(), + "-p".to_string(), + ], + )?; + if !output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb screenshot", + &output, + ))); + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| McpToolError::Io(error.to_string()))?; + } + fs::write(&target_path, output.stdout).map_err(|error| McpToolError::Io(error.to_string()))?; + Ok(format!("screenshot written to {}", target_path.display())) +} + +fn android_install_apk(state: &McpState, arguments: &Value) -> Result { + if !state.allow_install { + return Err(McpToolError::InvalidArgument( + "APK installation requires --allow-install".to_string(), + )); + } + let apk_path = string_argument(arguments, "path")?; + let target_path = workspace_path(&state.workspace, &apk_path)?; + run_adb( + state, + &["install".to_string(), target_path.display().to_string()], + ) +} + +fn run_adb(state: &McpState, args: &[String]) -> Result { + let output = run_adb_raw(state, args)?; + output_to_text("adb", &output) +} + +fn run_adb_raw(state: &McpState, args: &[String]) -> Result { + if !state.adb_probe { + return Err(McpToolError::AdbProbeDisabled); + } + + let connect_output = Command::new("adb") + .arg("connect") + .arg(&state.spec.adb_endpoint) + .output() + .map_err(|error| { + McpToolError::CommandFailed(format!("failed to execute adb connect: {error}")) + })?; + if !connect_output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb connect", + &connect_output, + ))); + } + + Command::new("adb") + .args(args) + .output() + .map_err(|error| McpToolError::CommandFailed(format!("failed to execute adb: {error}"))) +} + +fn output_to_text(label: &str, output: &Output) -> Result { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Ok(match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => format!("{label} completed successfully"), + (false, true) => stdout, + (true, false) => stderr, + (false, false) => format!("{stdout}\n{stderr}"), + }); + } + + Err(McpToolError::CommandFailed(command_failure(label, output))) +} + +fn command_failure(label: &str, output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!( + "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout.trim(), + stderr.trim() + ) +} + +fn integer_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_i64) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_integer_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + arguments.get(name).map_or(Ok(None), |value| { + value + .as_i64() + .map(Some) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be an integer"))) + }) +} + +fn string_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_string_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + arguments.get(name).map_or(Ok(None), |value| { + value + .as_str() + .map(|text| Some(text.to_string())) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be a string"))) + }) +} + +fn workspace_path(workspace: &Path, value: &str) -> Result { + let candidate = PathBuf::from(value); + if value.is_empty() + || candidate.is_absolute() + || candidate.components().any(|component| { + matches!( + component, + Component::Prefix(_) | Component::RootDir | Component::ParentDir + ) + }) + { + return Err(McpToolError::InvalidArgument( + "path must be relative, non-empty, and must not contain '..'".to_string(), + )); + } + + Ok(workspace.join(candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{android_spec, DEFAULT_ANDROID_IMAGE}; + use std::io::Cursor; + + fn test_state() -> McpState { + McpState { + spec: android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid android spec"), + workspace: PathBuf::from("/workspace"), + adb_probe: false, + allow_install: false, + } + } + + fn frame(value: &Value) -> String { + let payload = serde_json::to_string(&value).expect("serializable request"); + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) + } + + #[test] + fn serves_initialize_and_tools_list_over_framed_stdio() { + let input = format!( + "{}{}", + frame(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })), + frame(&json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + ); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, &test_state()).expect("stdio server succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains(SERVER_NAME)); + assert!(output_text.contains("android_status")); + assert!(output_text.contains("android_tap")); + } + + #[test] + fn reports_status_without_adb_when_probe_is_disabled() { + let input = frame(&json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "android_status", + "arguments": {} + } + })); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, &test_state()).expect("status succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains("\"isError\":false")); + assert!(output_text.contains("adbProbe")); + assert!(output_text.contains("false")); + assert!(output_text.contains("dg-test-android")); + } + + #[test] + fn rejects_workspace_paths_outside_workspace() { + let workspace = PathBuf::from("/workspace"); + + assert!(workspace_path(&workspace, "screenshots/current.png").is_ok()); + assert!(workspace_path(&workspace, "/tmp/outside.png").is_err()); + assert!(workspace_path(&workspace, "../outside.png").is_err()); + assert!(workspace_path(&workspace, "screenshots/../outside.png").is_err()); + } +} diff --git a/src/sum.rs b/src/sum.rs deleted file mode 100644 index e0960d2..0000000 --- a/src/sum.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[must_use] -pub const fn sum(a: i64, b: i64) -> i64 { - a + b -} diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs new file mode 100644 index 0000000..2b98e92 --- /dev/null +++ b/tests/integration/cli.rs @@ -0,0 +1,45 @@ +use std::process::Command; + +#[test] +fn lifecycle_cli_renders_status_json() { + let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + .args([ + "status", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + ]) + .output() + .expect("failed to execute lifecycle binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("\"project_id\": \"dg-test\"")); + assert!(stdout.contains("\"android_container_name\": \"dg-test-android\"")); +} + +#[test] +fn lifecycle_cli_rejects_invalid_endpoint() { + let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + .args(["status", "--endpoint", "$(whoami):5555"]) + .output() + .expect("failed to execute lifecycle binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("invalid ADB endpoint")); +} + +#[test] +fn mcp_cli_exposes_help() { + let output = Command::new(env!("CARGO_BIN_EXE_android-connection")) + .arg("--help") + .output() + .expect("failed to execute MCP binary"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Android MCP stdio server")); + assert!(stdout.contains("--no-adb-probe")); +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index 47e2280..26710c1 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1 +1 @@ -mod sum; +mod cli; diff --git a/tests/integration/sum.rs b/tests/integration/sum.rs deleted file mode 100644 index b0d15bc..0000000 --- a/tests/integration/sum.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::process::Command; - -#[test] -fn test_cli_default_args() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "0"); -} - -#[test] -fn test_cli_sum_two_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "3", "--b", "7"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "10"); -} - -#[test] -fn test_cli_negative_numbers() { - let output = Command::new(env!("CARGO_BIN_EXE_example-sum-package-name")) - .args(["--a", "-5", "--b", "3"]) - .output() - .expect("Failed to execute binary"); - - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert_eq!(stdout.trim(), "-2"); -} diff --git a/tests/unit/android_connection.rs b/tests/unit/android_connection.rs new file mode 100644 index 0000000..59a4d9a --- /dev/null +++ b/tests/unit/android_connection.rs @@ -0,0 +1,44 @@ +use docker_git_android_connection::{ + android_spec, android_tools, normalize_project_id, validate_adb_endpoint, + DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, +}; + +#[test] +fn normalizes_project_id_to_docker_safe_name() { + assert_eq!( + normalize_project_id("Org/Repo:Feature_X"), + "org-repo-feature-x" + ); + assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); +} + +#[test] +fn rejects_shell_fragments_in_adb_endpoint() { + assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); + assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); + assert!(validate_adb_endpoint("$(whoami):5555").is_err()); +} + +#[test] +fn builds_deterministic_android_spec() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + + assert_eq!(spec.project_container_name, "dg-test"); + assert_eq!(spec.android_container_name, "dg-test-android"); + assert_eq!(spec.android_volume_name, "dg-test-home-android"); +} + +#[test] +fn advertises_android_mcp_tools() { + let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); + + assert!(names.contains(&"android_status")); + assert!(names.contains(&"android_tap")); + assert!(names.contains(&"android_install_apk")); +} diff --git a/tests/unit/mod.rs b/tests/unit/mod.rs index 7c158e6..67a6ab8 100644 --- a/tests/unit/mod.rs +++ b/tests/unit/mod.rs @@ -1,4 +1,4 @@ -mod sum; +mod android_connection; #[path = "ci-cd/mod.rs"] mod ci_cd; diff --git a/tests/unit/sum.rs b/tests/unit/sum.rs deleted file mode 100644 index 84937f6..0000000 --- a/tests/unit/sum.rs +++ /dev/null @@ -1,26 +0,0 @@ -use example_sum_package_name::sum; - -#[test] -fn test_sum_positive_numbers() { - assert_eq!(sum(2, 3), 5); -} - -#[test] -fn test_sum_negative_numbers() { - assert_eq!(sum(-1, -2), -3); -} - -#[test] -fn test_sum_zero() { - assert_eq!(sum(5, 0), 5); -} - -#[test] -fn test_sum_large_numbers() { - assert_eq!(sum(1_000_000, 2_000_000), 3_000_000); -} - -#[test] -fn test_sum_mixed_sign() { - assert_eq!(sum(-100, 50), -50); -} From 3660e4c87704b3dfc7445c872c6101625561c3ad Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:53:13 +0000 Subject: [PATCH 2/3] fix(android): constrain runtime resources --- README.md | 19 +- examples/basic_usage.rs | 11 +- src/lib.rs | 224 +++++++++++++++++++++- src/main.rs | 317 ++++++++++++++++++++++++++++--- tests/integration/cli.rs | 190 ++++++++++++++++-- tests/unit/android_connection.rs | 111 ++++++++++- 6 files changed, 822 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index a7e0f20..172b801 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,24 @@ docker-git-android-connection start --project dg-my-project --dry-run docker-git-android-connection stop --project dg-my-project --dry-run ``` -The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. +The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. By default it publishes a Docker-Android noVNC bridge to `127.0.0.1:6080` and returns `noVncUrl` in lifecycle JSON: + +```json +{ + "androidContainerName": "dg-my-project-android", + "resourceLimits": { + "memory": "3g", + "memorySwap": "3g", + "cpus": "1.0" + }, + "noVncPublished": true, + "noVncUrl": "http://127.0.0.1:6080/?autoconnect=true&resize=remote" +} +``` + +Use `--novnc-port ` to request a different host port, `--novnc-bind-host ` to bind Docker publishing somewhere other than loopback, `--novnc-host ` to control the browser-facing URL host, or `--no-novnc-publish` to disable host publication. + +Android containers are resource-limited by default with `--memory 3g --memory-swap 3g --cpus 1.0`. Use `--memory `, `--memory-swap `, and `--cpus ` to override those limits for a specific run. ## MCP Server diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 4509e41..cfc5623 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,5 +1,6 @@ use docker_git_android_connection::{ - android_spec, docker_run_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, + android_spec, default_android_resource_limits, docker_run_args, no_vnc_endpoint, + DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT, }; fn main() { @@ -12,5 +13,11 @@ fn main() { .expect("default Android spec is valid"); println!("container: {}", spec.android_container_name); - println!("docker args: {}", docker_run_args(&spec).join(" ")); + let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); + let resource_limits = default_android_resource_limits(); + println!("noVNC: {}", no_vnc.url); + println!( + "docker args: {}", + docker_run_args(&spec, Some(&no_vnc), &resource_limits).join(" ") + ); } diff --git a/src/lib.rs b/src/lib.rs index 083f292..5426a4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,14 @@ pub const SERVER_NAME: &str = "android-connection"; pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; pub const DEFAULT_PROJECT_ID: &str = "docker-git"; +pub const DEFAULT_NOVNC_HOST: &str = "127.0.0.1"; +pub const DEFAULT_NOVNC_PORT: u16 = 6080; +pub const DEFAULT_NOVNC_CONTAINER_PORT: u16 = 6081; +pub const DEFAULT_NOVNC_WEB_PORT: u16 = 6080; +pub const DEFAULT_ANDROID_MEMORY_LIMIT: &str = "3g"; +pub const DEFAULT_ANDROID_MEMORY_SWAP_LIMIT: &str = "3g"; +pub const DEFAULT_ANDROID_CPUS: &str = "1.0"; +pub const NOVNC_DOCKER_BRIDGE_COMMAND: &str = "while true; do container_ip=$(hostname -i | awk '{print $1}'); /usr/bin/socat TCP-LISTEN:6081,bind=${container_ip},fork,reuseaddr TCP:127.0.0.1:6080; sleep 1; done & exec ${APP_PATH}/mixins/scripts/run.sh"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct EndpointError { @@ -35,6 +43,24 @@ pub struct AndroidSpec { pub image: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoVncEndpoint { + pub bind_host: String, + pub url_host: String, + pub host_port: u16, + pub container_port: u16, + pub url: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidResourceLimits { + pub memory: String, + pub memory_swap: String, + pub cpus: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct McpToolSpec { pub name: &'static str, @@ -187,23 +213,124 @@ pub fn android_tools() -> Vec { } #[must_use] -pub fn docker_run_args(spec: &AndroidSpec) -> Vec { - vec![ +pub fn no_vnc_url_host_for_bind_host(bind_host: &str) -> String { + let trimmed = bind_host + .trim() + .trim_start_matches('[') + .trim_end_matches(']'); + let visible_host = match trimmed { + "" | "0.0.0.0" | "::" => DEFAULT_NOVNC_HOST, + host => host, + }; + + if visible_host.contains(':') && !visible_host.starts_with('[') { + format!("[{visible_host}]") + } else { + visible_host.to_string() + } +} + +#[must_use] +pub fn no_vnc_endpoint(bind_host: &str, url_host: &str, host_port: u16) -> NoVncEndpoint { + let url_host = no_vnc_url_host_for_bind_host(url_host); + NoVncEndpoint { + bind_host: bind_host.to_string(), + url_host: url_host.clone(), + host_port, + container_port: DEFAULT_NOVNC_CONTAINER_PORT, + url: format!("http://{url_host}:{host_port}/?autoconnect=true&resize=remote"), + } +} + +#[must_use] +pub fn parse_docker_no_vnc_port(output: &str, default_url_host: &str) -> Option { + output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .find_map(|line| { + let (raw_host, raw_port) = line.rsplit_once(':')?; + let host_port = raw_port.parse::().ok()?; + if host_port == 0 { + return None; + } + + let bind_host = raw_host.trim_start_matches('[').trim_end_matches(']'); + let url_host = match bind_host { + "" | "0.0.0.0" | "::" => default_url_host, + host => host, + }; + Some(no_vnc_endpoint(bind_host, url_host, host_port)) + }) +} + +#[must_use] +pub fn android_resource_limits( + memory: &str, + memory_swap: &str, + cpus: &str, +) -> AndroidResourceLimits { + AndroidResourceLimits { + memory: memory.to_string(), + memory_swap: memory_swap.to_string(), + cpus: cpus.to_string(), + } +} + +#[must_use] +pub fn default_android_resource_limits() -> AndroidResourceLimits { + android_resource_limits( + DEFAULT_ANDROID_MEMORY_LIMIT, + DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, + DEFAULT_ANDROID_CPUS, + ) +} + +#[must_use] +pub fn docker_run_args( + spec: &AndroidSpec, + no_vnc: Option<&NoVncEndpoint>, + resource_limits: &AndroidResourceLimits, +) -> Vec { + let mut args = vec![ "run".to_string(), "--detach".to_string(), "--name".to_string(), spec.android_container_name.clone(), + "--memory".to_string(), + resource_limits.memory.clone(), + "--memory-swap".to_string(), + resource_limits.memory_swap.clone(), + "--cpus".to_string(), + resource_limits.cpus.clone(), "--privileged".to_string(), "--network".to_string(), spec.docker_network.clone(), "--env".to_string(), - "EMULATOR_HEADLESS=true".to_string(), + "EMULATOR_HEADLESS=false".to_string(), "--env".to_string(), "WEB_VNC=true".to_string(), + "--env".to_string(), + format!("WEB_VNC_PORT={DEFAULT_NOVNC_WEB_PORT}"), "--volume".to_string(), format!("{}:/root/.android", spec.android_volume_name), - spec.image.clone(), - ] + ]; + + if let Some(no_vnc) = no_vnc { + args.extend([ + "--publish".to_string(), + format!( + "{}:{}:{}", + no_vnc.bind_host, no_vnc.host_port, no_vnc.container_port + ), + ]); + } + + args.push(spec.image.clone()); + if no_vnc.is_some() { + args.push(NOVNC_DOCKER_BRIDGE_COMMAND.to_string()); + } + args } #[must_use] @@ -257,4 +384,91 @@ mod tests { assert!(names.contains(&"android_tap")); assert!(names.contains(&"android_install_apk")); } + + #[test] + fn builds_no_vnc_endpoint_for_host_browser_access() { + let endpoint = no_vnc_endpoint("127.0.0.1", "127.0.0.1", 16_080); + + assert_eq!(endpoint.bind_host, "127.0.0.1"); + assert_eq!(endpoint.url_host, "127.0.0.1"); + assert_eq!(endpoint.host_port, 16_080); + assert_eq!(endpoint.container_port, DEFAULT_NOVNC_CONTAINER_PORT); + assert_eq!( + endpoint.url, + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + } + + #[test] + fn renders_wildcard_no_vnc_binding_as_loopback_url() { + let endpoint = no_vnc_endpoint("0.0.0.0", "0.0.0.0", DEFAULT_NOVNC_PORT); + + assert_eq!(endpoint.bind_host, "0.0.0.0"); + assert_eq!(endpoint.url_host, DEFAULT_NOVNC_HOST); + assert_eq!( + endpoint.url, + "http://127.0.0.1:6080/?autoconnect=true&resize=remote" + ); + } + + #[test] + fn parses_docker_port_output_into_no_vnc_endpoint() { + let endpoint = parse_docker_no_vnc_port("0.0.0.0:16080\n[::]:16080\n", DEFAULT_NOVNC_HOST) + .expect("published noVNC port"); + + assert_eq!(endpoint.bind_host, "0.0.0.0"); + assert_eq!(endpoint.url_host, DEFAULT_NOVNC_HOST); + assert_eq!(endpoint.host_port, 16_080); + } + + #[test] + fn docker_run_args_publish_no_vnc_before_image() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let endpoint = no_vnc_endpoint("127.0.0.1", "127.0.0.1", DEFAULT_NOVNC_PORT); + + let resource_limits = default_android_resource_limits(); + let args = docker_run_args(&spec, Some(&endpoint), &resource_limits); + let publish_position = args + .iter() + .position(|value| value == "--publish") + .expect("publish flag"); + let image_position = args + .iter() + .position(|value| value == DEFAULT_ANDROID_IMAGE) + .expect("image argument"); + + assert!(publish_position < image_position); + assert_eq!( + args.get(image_position + 1), + Some(&NOVNC_DOCKER_BRIDGE_COMMAND.to_string()) + ); + assert_eq!( + args.get(publish_position + 1), + Some(&"127.0.0.1:6080:6081".to_string()) + ); + assert!(args + .windows(2) + .any(|window| window == ["--memory", DEFAULT_ANDROID_MEMORY_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--memory-swap", DEFAULT_ANDROID_MEMORY_SWAP_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--cpus", DEFAULT_ANDROID_CPUS])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC_PORT=6080"])); + } } diff --git a/src/main.rs b/src/main.rs index 493a558..75c8933 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,20 @@ use clap::{Args, Parser, Subcommand}; use docker_git_android_connection::{ - android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, - DEFAULT_PROJECT_ID, + android_resource_limits, android_spec, docker_run_args, docker_stop_args, no_vnc_endpoint, + parse_docker_no_vnc_port, AndroidResourceLimits, NoVncEndpoint, DEFAULT_ADB_ENDPOINT, + DEFAULT_ANDROID_CPUS, DEFAULT_ANDROID_IMAGE, DEFAULT_ANDROID_MEMORY_LIMIT, + DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, DEFAULT_NOVNC_CONTAINER_PORT, DEFAULT_NOVNC_HOST, + DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, }; -use serde_json::json; -use std::process::{Command, ExitCode}; +use serde_json::{json, Value}; +use std::env; +use std::io::{self, Read, Write}; +use std::net::TcpListener; +use std::process::{Command, ExitCode, Output, Stdio}; +use std::thread; + +const DOCKER_BIN_ENV: &str = "DOCKER_GIT_ANDROID_DOCKER"; +const NO_VNC_PORT_FALLBACK_SPAN: u16 = 100; #[derive(Parser, Debug)] #[command(version, about = "docker-git Android runtime lifecycle CLI")] @@ -30,6 +40,24 @@ struct LifecycleArgs { endpoint: String, #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] image: String, + #[arg(long = "memory", default_value = DEFAULT_ANDROID_MEMORY_LIMIT, value_parser = parse_docker_size)] + memory: String, + #[arg(long = "memory-swap", default_value = DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, value_parser = parse_docker_size)] + memory_swap: String, + #[arg(long = "cpus", default_value = DEFAULT_ANDROID_CPUS, value_parser = parse_cpus)] + cpus: String, + #[arg(long = "novnc-bind-host", default_value = DEFAULT_NOVNC_HOST)] + novnc_bind_host: String, + #[arg(long = "novnc-host", default_value = DEFAULT_NOVNC_HOST)] + novnc_host: String, + #[arg( + long = "novnc-port", + default_value_t = DEFAULT_NOVNC_PORT, + value_parser = parse_no_vnc_port + )] + novnc_port: u16, + #[arg(long)] + no_novnc_publish: bool, #[arg(long)] dry_run: bool, } @@ -55,50 +83,293 @@ fn run() -> Result<(), Box> { fn start(args: &LifecycleArgs) -> Result<(), Box> { let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - let docker_args = docker_run_args(&spec); + let no_vnc = configured_no_vnc(args, !args.dry_run)?; + let resource_limits = configured_resource_limits(args); + let docker_args = docker_run_args(&spec, no_vnc.as_ref(), &resource_limits); if args.dry_run { - println!( - "{}", - serde_json::to_string_pretty(&json!({ "docker": docker_args }))? - ); + print_json(&with_docker_args( + lifecycle_output(&spec, no_vnc.as_ref(), &resource_limits, None, None), + &docker_args, + ))?; return Ok(()); } - run_docker(&docker_args) + ensure_image_available(&spec.image)?; + let container_id = run_docker_capture_stdout(&docker_args)?; + print_json(&lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + Some(container_id.trim()), + None, + )) } fn status(args: &LifecycleArgs) -> Result<(), Box> { let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - println!("{}", serde_json::to_string_pretty(&spec)?); - Ok(()) + let resource_limits = configured_resource_limits(args); + let no_vnc = if args.no_novnc_publish { + None + } else { + docker_no_vnc_endpoint(&spec.android_container_name, &args.novnc_host) + .or(configured_no_vnc(args, false)?) + }; + print_json(&lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + None, + None, + )) } fn stop(args: &LifecycleArgs) -> Result<(), Box> { let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let resource_limits = configured_resource_limits(args); let docker_args = docker_stop_args(&spec); if args.dry_run { - println!( - "{}", - serde_json::to_string_pretty(&json!({ "docker": docker_args }))? - ); + print_json(&with_docker_args( + lifecycle_output(&spec, None, &resource_limits, None, None), + &docker_args, + ))?; + return Ok(()); + } + + run_docker_capture_stdout(&docker_args)?; + print_json(&lifecycle_output( + &spec, + None, + &resource_limits, + None, + Some(true), + )) +} + +fn parse_docker_size(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("Docker size must not be empty".to_string()); + } + + let digits = trimmed.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 { + return Err(format!("Docker size {value:?} must start with a number")); + } + + let suffix = &trimmed[digits..]; + if suffix.is_empty() + || matches!( + suffix, + "b" | "B" | "k" | "K" | "m" | "M" | "g" | "G" | "kb" | "KB" | "mb" | "MB" | "gb" | "GB" + ) + { + Ok(trimmed.to_string()) + } else { + Err(format!( + "Docker size {value:?} must use bytes or b/k/m/g suffix" + )) + } +} + +fn parse_cpus(value: &str) -> Result { + let cpus = value + .parse::() + .map_err(|error| format!("invalid CPU limit {value:?}: {error}"))?; + if cpus.is_finite() && cpus > 0.0 { + Ok(value.to_string()) + } else { + Err("CPU limit must be a positive finite number".to_string()) + } +} + +fn parse_no_vnc_port(value: &str) -> Result { + let port = value + .parse::() + .map_err(|error| format!("invalid noVNC port {value:?}: {error}"))?; + if port == 0 { + Err("noVNC port must be in 1..=65535".to_string()) + } else { + Ok(port) + } +} + +fn configured_resource_limits(args: &LifecycleArgs) -> AndroidResourceLimits { + android_resource_limits(&args.memory, &args.memory_swap, &args.cpus) +} + +fn configured_no_vnc( + args: &LifecycleArgs, + reserve_free_port: bool, +) -> Result, Box> { + if args.no_novnc_publish { + return Ok(None); + } + + let host_port = if reserve_free_port { + first_available_port(&args.novnc_bind_host, args.novnc_port)? + } else { + args.novnc_port + }; + Ok(Some(no_vnc_endpoint( + &args.novnc_bind_host, + &args.novnc_host, + host_port, + ))) +} + +fn first_available_port(bind_host: &str, requested_port: u16) -> Result { + let last_port = requested_port.saturating_add(NO_VNC_PORT_FALLBACK_SPAN - 1); + for port in requested_port..=last_port { + if TcpListener::bind((bind_host, port)).is_ok() { + return Ok(port); + } + } + + Err(format!( + "no free noVNC port on {bind_host} in range {requested_port}..={last_port}" + )) +} + +fn docker_no_vnc_endpoint(container_name: &str, default_url_host: &str) -> Option { + let container_port = format!("{DEFAULT_NOVNC_CONTAINER_PORT}/tcp"); + let output = Command::new(docker_binary()) + .args(["port", container_name, &container_port]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + parse_docker_no_vnc_port(&String::from_utf8_lossy(&output.stdout), default_url_host) +} + +fn lifecycle_output( + spec: &docker_git_android_connection::AndroidSpec, + no_vnc: Option<&NoVncEndpoint>, + resource_limits: &AndroidResourceLimits, + container_id: Option<&str>, + removed: Option, +) -> Value { + json!({ + "projectId": spec.project_id, + "projectContainerName": spec.project_container_name, + "androidContainerName": spec.android_container_name, + "androidVolumeName": spec.android_volume_name, + "dockerNetwork": spec.docker_network, + "adbEndpoint": spec.adb_endpoint, + "image": spec.image, + "resourceLimits": resource_limits, + "noVncPublished": no_vnc.is_some(), + "noVncUrl": no_vnc.map(|endpoint| endpoint.url.as_str()), + "noVncBindHost": no_vnc.map(|endpoint| endpoint.bind_host.as_str()), + "noVncHost": no_vnc.map(|endpoint| endpoint.url_host.as_str()), + "noVncPort": no_vnc.map(|endpoint| endpoint.host_port), + "noVncContainerPort": no_vnc.map(|endpoint| endpoint.container_port), + "containerId": container_id, + "removed": removed + }) +} + +fn with_docker_args(mut output: Value, docker_args: &[String]) -> Value { + if let Value::Object(object) = &mut output { + object.insert("docker".to_string(), json!(docker_args)); + } + output +} + +fn print_json(value: &Value) -> Result<(), Box> { + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} + +fn ensure_image_available(image: &str) -> Result<(), Box> { + let inspect_args = vec![ + "image".to_string(), + "inspect".to_string(), + image.to_string(), + ]; + if run_docker_status(&inspect_args).is_ok() { return Ok(()); } - run_docker(&docker_args) + let pull_args = vec!["pull".to_string(), image.to_string()]; + run_docker_status_streaming_to_stderr(&pull_args) } -fn run_docker(args: &[String]) -> Result<(), Box> { - let output = Command::new("docker").args(args).output()?; +fn run_docker_status(args: &[String]) -> Result<(), Box> { + let output = Command::new(docker_binary()).args(args).output()?; if output.status.success() { - print!("{}", String::from_utf8_lossy(&output.stdout)); return Ok(()); } - Err(format!( - "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + Err(docker_error(args, &output).into()) +} + +fn run_docker_capture_stdout(args: &[String]) -> Result> { + let output = Command::new(docker_binary()).args(args).output()?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + + Err(docker_error(args, &output).into()) +} + +fn run_docker_status_streaming_to_stderr( + args: &[String], +) -> Result<(), Box> { + let mut child = Command::new(docker_binary()) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let stdout = child.stdout.take().map(copy_to_stderr); + let stderr = child.stderr.take().map(copy_to_stderr); + let status = child.wait()?; + + if let Some(handle) = stdout { + handle + .join() + .map_err(|_| "failed to join docker stdout")??; + } + if let Some(handle) = stderr { + handle + .join() + .map_err(|_| "failed to join docker stderr")??; + } + + if status.success() { + Ok(()) + } else { + Err(format!( + "docker {} failed with status {:?}", + args.join(" "), + status.code() + ) + .into()) + } +} + +fn copy_to_stderr(mut reader: R) -> thread::JoinHandle> +where + R: Read + Send + 'static, +{ + thread::spawn(move || { + let mut stderr = io::stderr().lock(); + io::copy(&mut reader, &mut stderr)?; + stderr.flush() + }) +} + +fn docker_error(args: &[String], output: &Output) -> String { + format!( + "docker {} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), output.status.code(), String::from_utf8_lossy(&output.stdout).trim(), String::from_utf8_lossy(&output.stderr).trim() ) - .into()) +} + +fn docker_binary() -> String { + env::var(DOCKER_BIN_ENV).unwrap_or_else(|_| "docker".to_string()) } diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index 2b98e92..5727bb0 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -1,30 +1,169 @@ +use serde_json::Value; use std::process::Command; #[test] fn lifecycle_cli_renders_status_json() { - let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) - .args([ - "status", - "--project", - "dg-test", - "--endpoint", - "dg-test-android:5555", - ]) - .output() - .expect("failed to execute lifecycle binary"); + let output = lifecycle_output(&[ + "status", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--novnc-port", + "16080", + ]); assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"project_id\": \"dg-test\"")); - assert!(stdout.contains("\"android_container_name\": \"dg-test-android\"")); + let json = parse_stdout_json(&output); + + assert_eq!(json["projectId"], "dg-test"); + assert_eq!(json["androidContainerName"], "dg-test-android"); + assert_eq!( + json["noVncUrl"], + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + assert_eq!(json["noVncPublished"], true); + assert_eq!(json["resourceLimits"]["memory"], "3g"); + assert_eq!(json["resourceLimits"]["memorySwap"], "3g"); + assert_eq!(json["resourceLimits"]["cpus"], "1.0"); +} + +#[test] +fn lifecycle_cli_can_disable_no_vnc_publication() { + let output = lifecycle_output(&[ + "status", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--no-novnc-publish", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + + assert_eq!(json["projectId"], "dg-test"); + assert_eq!(json["noVncPublished"], false); + assert!(json["noVncUrl"].is_null()); +} + +#[test] +fn start_dry_run_includes_publish_and_no_vnc_url_json() { + let output = lifecycle_output(&[ + "start", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--novnc-port", + "16080", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let docker_args = json["docker"] + .as_array() + .expect("docker args array") + .iter() + .filter_map(Value::as_str) + .collect::>(); + let publish_position = docker_args + .iter() + .position(|argument| *argument == "--publish") + .expect("publish flag"); + let image_position = docker_args + .iter() + .position(|argument| *argument == "budtmo/docker-android:emulator_14.0") + .expect("image argument"); + + assert!(publish_position < image_position); + assert!(docker_args + .get(image_position + 1) + .is_some_and(|argument| argument.contains("socat TCP-LISTEN:6081"))); + assert_eq!( + docker_args.get(publish_position + 1), + Some(&"127.0.0.1:16080:6081") + ); + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory", "3g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory-swap", "3g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--cpus", "1.0"])); + assert_eq!( + json["noVncUrl"], + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + assert_eq!(json["resourceLimits"]["memory"], "3g"); + assert_eq!(json["resourceLimits"]["memorySwap"], "3g"); + assert_eq!(json["resourceLimits"]["cpus"], "1.0"); +} + +#[test] +fn start_dry_run_accepts_custom_resource_limits() { + let output = lifecycle_output(&[ + "start", + "--project", + "dg-test", + "--endpoint", + "dg-test-android:5555", + "--memory", + "4g", + "--memory-swap", + "4g", + "--cpus", + "2.0", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let docker_args = json["docker"] + .as_array() + .expect("docker args array") + .iter() + .filter_map(Value::as_str) + .collect::>(); + + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory", "4g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--memory-swap", "4g"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--cpus", "2.0"])); + assert_eq!(json["resourceLimits"]["memory"], "4g"); + assert_eq!(json["resourceLimits"]["memorySwap"], "4g"); + assert_eq!(json["resourceLimits"]["cpus"], "2.0"); +} + +#[test] +fn lifecycle_cli_rejects_invalid_no_vnc_port() { + let output = lifecycle_output(&["status", "--novnc-port", "0"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("noVNC port must be in 1..=65535")); +} + +#[test] +fn lifecycle_cli_rejects_invalid_cpu_limit() { + let output = lifecycle_output(&["status", "--cpus", "0"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("CPU limit must be a positive finite number")); } #[test] fn lifecycle_cli_rejects_invalid_endpoint() { - let output = Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) - .args(["status", "--endpoint", "$(whoami):5555"]) - .output() - .expect("failed to execute lifecycle binary"); + let output = lifecycle_output(&["status", "--endpoint", "$(whoami):5555"]); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -43,3 +182,20 @@ fn mcp_cli_exposes_help() { assert!(stdout.contains("Android MCP stdio server")); assert!(stdout.contains("--no-adb-probe")); } + +fn lifecycle_output(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + .args(args) + .output() + .expect("failed to execute lifecycle binary") +} + +fn parse_stdout_json(output: &std::process::Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "stdout is not valid JSON: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }) +} diff --git a/tests/unit/android_connection.rs b/tests/unit/android_connection.rs index 59a4d9a..1ed9c54 100644 --- a/tests/unit/android_connection.rs +++ b/tests/unit/android_connection.rs @@ -1,6 +1,9 @@ use docker_git_android_connection::{ - android_spec, android_tools, normalize_project_id, validate_adb_endpoint, - DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, + android_resource_limits, android_spec, android_tools, default_android_resource_limits, + docker_run_args, no_vnc_endpoint, normalize_project_id, parse_docker_no_vnc_port, + validate_adb_endpoint, DEFAULT_ANDROID_CPUS, DEFAULT_ANDROID_IMAGE, + DEFAULT_ANDROID_MEMORY_LIMIT, DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, DEFAULT_NOVNC_HOST, + DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, NOVNC_DOCKER_BRIDGE_COMMAND, }; #[test] @@ -42,3 +45,107 @@ fn advertises_android_mcp_tools() { assert!(names.contains(&"android_tap")); assert!(names.contains(&"android_install_apk")); } + +#[test] +fn docker_run_args_expose_no_vnc_on_host_port() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); + let resource_limits = default_android_resource_limits(); + + let args = docker_run_args(&spec, Some(&no_vnc), &resource_limits); + + assert!(args + .windows(2) + .any(|window| window == ["--publish", "127.0.0.1:6080:6081"])); + assert_eq!(args.last(), Some(&NOVNC_DOCKER_BRIDGE_COMMAND.to_string())); + assert!(args + .windows(2) + .any(|window| window == ["--memory", DEFAULT_ANDROID_MEMORY_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--memory-swap", DEFAULT_ANDROID_MEMORY_SWAP_LIMIT])); + assert!(args + .windows(2) + .any(|window| window == ["--cpus", DEFAULT_ANDROID_CPUS])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC_PORT=6080"])); +} + +#[test] +fn docker_run_args_can_disable_no_vnc_publication() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let resource_limits = default_android_resource_limits(); + + let args = docker_run_args(&spec, None, &resource_limits); + + assert!(!args.iter().any(|argument| argument == "--publish")); + assert_eq!(args.last(), Some(&DEFAULT_ANDROID_IMAGE.to_string())); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); +} + +#[test] +fn docker_run_args_allow_custom_resource_limits() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let resource_limits = android_resource_limits("4g", "4g", "2.0"); + + let args = docker_run_args(&spec, None, &resource_limits); + + assert!(args.windows(2).any(|window| window == ["--memory", "4g"])); + assert!(args + .windows(2) + .any(|window| window == ["--memory-swap", "4g"])); + assert!(args.windows(2).any(|window| window == ["--cpus", "2.0"])); +} + +#[test] +fn parses_docker_port_for_actual_no_vnc_url() { + let endpoint = + parse_docker_no_vnc_port("127.0.0.1:16080\n", DEFAULT_NOVNC_HOST).expect("port binding"); + + assert_eq!(endpoint.bind_host, "127.0.0.1"); + assert_eq!(endpoint.url_host, "127.0.0.1"); + assert_eq!( + endpoint.url, + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); +} + +#[test] +fn wildcard_docker_port_does_not_leak_into_no_vnc_url() { + let endpoint = + parse_docker_no_vnc_port("0.0.0.0:16080\n", DEFAULT_NOVNC_HOST).expect("port binding"); + + assert_eq!(endpoint.bind_host, "0.0.0.0"); + assert_eq!(endpoint.url_host, DEFAULT_NOVNC_HOST); + assert_eq!( + endpoint.url, + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); +} From dc7727f91e70b3376a1eac652871878ced199fb6 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:18:43 +0000 Subject: [PATCH 3/3] feat(android): add adb proxy and webusb bridge - Rename CLI package/binary to rust-android-connection - Add container ADB proxy, phone WebUSB bridge, active browser lease, and browser-side screenshots - Remove MCP server entrypoint in favor of CLI/WebUSB commands - Validate with cargo fmt -- --check, node --check src/web/app.js, and cargo test --locked --- Cargo.lock | 29 +- Cargo.toml | 14 +- README.md | 154 +- ...624_000000_bootstrap_android_connection.md | 3 +- examples/basic_usage.rs | 8 +- src/bin/android-connection.rs | 54 - src/lib.rs | 344 +++- src/main.rs | 1310 ++++++++++++++- src/mcp.rs | 635 ------- src/web/app.js | 1480 +++++++++++++++++ src/web/index.html | 107 ++ src/web/styles.css | 532 ++++++ tests/integration/cli.rs | 420 ++++- tests/unit/android_connection.rs | 144 +- 14 files changed, 4316 insertions(+), 918 deletions(-) delete mode 100644 src/bin/android-connection.rs delete mode 100644 src/mcp.rs create mode 100644 src/web/app.js create mode 100644 src/web/index.html create mode 100644 src/web/styles.css diff --git a/Cargo.lock b/Cargo.lock index cdd7221..4db865d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "clap" version = "4.6.1" @@ -107,17 +113,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "docker-git-android-connection" -version = "0.0.0" -dependencies = [ - "clap", - "regex", - "serde", - "serde_json", - "walkdir", -] - [[package]] name = "heck" version = "0.5.0" @@ -195,6 +190,18 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rust-android-connection" +version = "0.0.0" +dependencies = [ + "base64", + "clap", + "regex", + "serde", + "serde_json", + "walkdir", +] + [[package]] name = "same-file" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index a763d44..913c002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "docker-git-android-connection" +name = "rust-android-connection" version = "0.0.0" edition = "2021" -description = "Rust Android MCP and lifecycle module for docker-git" +description = "Rust Android lifecycle and ADB command module for docker-git" readme = "README.md" license = "MIT" -keywords = ["docker-git", "android", "adb", "mcp", "rust"] +keywords = ["docker-git", "android", "adb", "emulator", "rust"] categories = ["command-line-utilities", "development-tools"] repository = "https://github.com/ProverCoderAI/rust-android-connection" documentation = "https://github.com/ProverCoderAI/rust-android-connection" @@ -17,6 +17,7 @@ rust-version = "1.70" # archive past the crates.io 10 MiB upload limit. See issue #58. include = [ "src/**/*.rs", + "src/web/**/*", "examples/**/*.rs", "README.md", "LICENSE", @@ -28,14 +29,11 @@ name = "docker_git_android_connection" path = "src/lib.rs" [[bin]] -name = "docker-git-android-connection" +name = "rust-android-connection" path = "src/main.rs" -[[bin]] -name = "android-connection" -path = "src/bin/android-connection.rs" - [dependencies] +base64 = "0.22.1" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" diff --git a/README.md b/README.md index 172b801..4e6a896 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rust-android-connection -Rust Android MCP and lifecycle module for docker-git. +Rust Android lifecycle and ADB command module for docker-git. ## Install @@ -8,22 +8,21 @@ Rust Android MCP and lifecycle module for docker-git. cargo install --git https://github.com/ProverCoderAI/rust-android-connection --branch main --locked --bins ``` -Installs two binaries: +Installs one binary: ```text -docker-git-android-connection # start/status/stop Android runtime container -android-connection # MCP stdio server for Codex, Claude, Gemini, and Grok +rust-android-connection # start/status/stop Android runtime container and proxy ADB commands ``` ## Lifecycle CLI ```bash -docker-git-android-connection status --project dg-my-project -docker-git-android-connection start --project dg-my-project --dry-run -docker-git-android-connection stop --project dg-my-project --dry-run +rust-android-connection dg-my-project status +rust-android-connection dg-my-project start --dry-run +rust-android-connection dg-my-project stop --dry-run ``` -The lifecycle CLI computes deterministic Docker names from the project id and validates the configured ADB endpoint before constructing Docker arguments. By default it publishes a Docker-Android noVNC bridge to `127.0.0.1:6080` and returns `noVncUrl` in lifecycle JSON: +The project id is the first positional argument. The lifecycle CLI computes deterministic Docker names from it and validates the configured ADB endpoint before constructing Docker arguments. By default it publishes a Docker-Android noVNC bridge to `127.0.0.1:6080` and returns `noVncUrl` in lifecycle JSON: ```json { @@ -33,6 +32,10 @@ The lifecycle CLI computes deterministic Docker names from the project id and va "memorySwap": "3g", "cpus": "1.0" }, + "runtime": { + "profile": "interactive", + "emulatorHeadless": false + }, "noVncPublished": true, "noVncUrl": "http://127.0.0.1:6080/?autoconnect=true&resize=remote" } @@ -42,77 +45,106 @@ Use `--novnc-port ` to request a different host port, `--novnc-bind-host < Android containers are resource-limited by default with `--memory 3g --memory-swap 3g --cpus 1.0`. Use `--memory `, `--memory-swap `, and `--cpus ` to override those limits for a specific run. -## MCP Server +### Runtime Profiles + +Use `app-test` when the goal is to build, install, and launch an APK through ADB rather than manually control the device through noVNC: + +```bash +rust-android-connection dg-my-project start \ + --endpoint dg-my-project-android:5555 \ + --runtime-profile app-test +``` + +The `app-test` profile keeps the Docker limit at `3g/1 CPU` by default but reduces emulator pressure by running headless and disabling noVNC, Appium, web logs, skin, audio, cameras, boot animation, snapshots, and the emulated GSM modem. This is a tight minimum for APK install/launch smoke checks; use `--memory 4g --memory-swap 4g --cpus 2.0` if the app or UI tests are heavy. + +It is intended for: + +```text +build APK -> install-apk -> launch-app --package [--activity ] +``` + +Use `app-test-vnc` when the same lightweight app-test setup needs noVNC for manual debugging: ```bash -android-connection --project dg-my-project --network docker-git-shared --endpoint dg-my-project-android:5555 --workspace . +rust-android-connection dg-my-project start \ + --endpoint dg-my-project-android:5555 \ + --runtime-profile app-test-vnc \ + --novnc-port 16080 ``` -For handshake tests without ADB access: +`app-test-vnc` keeps Appium and web logs disabled, disables audio, cameras, snapshots, skin, and GSM modem, but runs a visible emulator window through noVNC. It is more expensive than `app-test`; use `--memory 4g --memory-swap 4g --cpus 2.0` if Android 14 or the tested app becomes unstable. + +Use `interactive` when a human needs the full visual Docker-Android session through noVNC. For Android 14 with UI, expect to raise limits to roughly `--memory 5g --memory-swap 5g --cpus 2.0`. + +## ADB Commands ```bash -android-connection --project dg-my-project --no-adb-probe +rust-android-connection dg-my-project adb \ + shell getprop sys.boot_completed + +rust-android-connection dg-my-project install-apk \ + app/build/outputs/apk/debug/app-debug.apk + +rust-android-connection dg-my-project launch-app \ + --package com.example.app ``` -## MCP Tools +By default, `adb` is a container proxy: + +```bash +rust-android-connection dg-my-project adb devices +rust-android-connection dg-my-project adb shell getprop sys.boot_completed +``` + +The command above runs ADB inside the Android container: + +```bash +docker exec dg-my-project-android adb shell getprop sys.boot_completed +``` + +ADB execution is still selectable with `--adb-mode container|auto|host` when an explicit override is needed. `auto` first tries host `adb connect ` and then runs host `adb ...`; if host ADB is unavailable or cannot connect, it falls back to `docker exec adb ...`. + +For APK installation in `container` mode, the CLI copies the APK into the Android container and installs that internal path: ```text -android_status() -android_devices() -android_screenshot(path?) -android_tap(x, y) -android_swipe(startX, startY, endX, endY, durationMs?) -android_type_text(text) -android_press_key(keycode) -android_launch_app(package, activity?) -android_open_url(url) -android_logcat(lines?) -android_install_apk(path) +docker cp app.apk dg-my-project-android:/tmp/docker-git-install.apk +docker exec dg-my-project-android adb -s emulator-5554 install /tmp/docker-git-install.apk ``` -`android_install_apk` is disabled unless the server is started with `--allow-install`. +## Browser WebUSB Phone UI + +Use the built-in browser connector when you want to attach a real Android phone from the user's computer without installing host ADB: + +```bash +rust-android-connection web --port 8080 +``` + +Then open: + +```text +http://127.0.0.1:8080/ +``` + +The page uses WebUSB/WebADB in Chromium-compatible browsers, serves its HTML/CSS/JS shell from the Rust binary, and imports a pinned Tango WebADB stack from `esm.sh`. It does not start Docker, does not require `adb` on the host, and does not silently enumerate new USB devices. The browser can list only already-authorized WebUSB devices until the user clicks `Connect phone` and grants USB access. If Android authorization stalls, use `Copy diagnostics` from the session log and reset browser-side USB/ADB state with `Forget USB/ADB keys`. + +Requirements for a physical phone: + +- Chromium-compatible browser with WebUSB support. +- Localhost or HTTPS secure context. +- USB debugging enabled on the Android phone. +- User approval for the browser USB picker and the Android RSA debugging prompt. ## Smoke Test ```bash -python3 - <<'PY' | android-connection --project dg-my-project --no-adb-probe | python3 - <<'PY' -import json -import sys - -messages = [ - {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, - {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, - {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, -] - -for message in messages: - body = json.dumps(message, separators=(",", ":")).encode() - sys.stdout.buffer.write(f"Content-Length: {len(body)}\r\n\r\n".encode()) - sys.stdout.buffer.write(body) -PY -import json -import sys - -stream = sys.stdin.buffer -while True: - header = {} - while True: - line = stream.readline() - if not line: - raise SystemExit(0) - stripped = line.strip() - if not stripped: - break - name, value = line.decode().split(":", 1) - header[name.lower()] = value.strip() - - length = int(header["content-length"]) - body = stream.read(length) - print(json.dumps(json.loads(body), indent=2)) -PY +rust-android-connection dg-my-project start --runtime-profile app-test --dry-run +rust-android-connection dg-my-project adb --dry-run shell getprop sys.boot_completed +rust-android-connection dg-my-project install-apk --dry-run app.apk +rust-android-connection dg-my-project launch-app --dry-run --package com.example.app +rust-android-connection web --port 8080 --dry-run ``` -Expected: server `android-connection` and tools such as `android_status`, `android_tap`, and `android_screenshot`. +Expected: every command returns deterministic JSON with the Docker/ADB command or browser endpoint it would use. Remove `--dry-run` from lifecycle/ADB commands to run against a started Android container, or from `web` to serve the local browser connector. ## Development diff --git a/changelog.d/20260624_000000_bootstrap_android_connection.md b/changelog.d/20260624_000000_bootstrap_android_connection.md index fe71c68..f88e566 100644 --- a/changelog.d/20260624_000000_bootstrap_android_connection.md +++ b/changelog.d/20260624_000000_bootstrap_android_connection.md @@ -3,4 +3,5 @@ bump: minor --- ### Added -- Bootstrap `docker-git-android-connection` as the Rust Android MCP and lifecycle crate. +- Bootstrap `rust-android-connection` as the Rust Android lifecycle and container ADB proxy crate. +- Add `rust-android-connection web` for a browser WebUSB/WebADB physical Android phone connector that does not require host ADB. diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index cfc5623..ed24123 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,6 +1,7 @@ use docker_git_android_connection::{ - android_spec, default_android_resource_limits, docker_run_args, no_vnc_endpoint, - DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT, + android_spec, default_android_resource_limits, docker_run_args, interactive_runtime_options, + no_vnc_endpoint, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_NOVNC_HOST, + DEFAULT_NOVNC_PORT, }; fn main() { @@ -15,9 +16,10 @@ fn main() { println!("container: {}", spec.android_container_name); let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); let resource_limits = default_android_resource_limits(); + let runtime_options = interactive_runtime_options(); println!("noVNC: {}", no_vnc.url); println!( "docker args: {}", - docker_run_args(&spec, Some(&no_vnc), &resource_limits).join(" ") + docker_run_args(&spec, Some(&no_vnc), &resource_limits, &runtime_options).join(" ") ); } diff --git a/src/bin/android-connection.rs b/src/bin/android-connection.rs deleted file mode 100644 index ec26d60..0000000 --- a/src/bin/android-connection.rs +++ /dev/null @@ -1,54 +0,0 @@ -use clap::Parser; -use docker_git_android_connection::mcp::{run_stdio, McpState}; -use docker_git_android_connection::{ - android_spec, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, -}; -use std::io::{self, BufReader}; -use std::path::PathBuf; -use std::process::ExitCode; - -#[derive(Parser, Debug)] -#[command(version, about = "Android MCP stdio server for docker-git")] -struct Cli { - #[arg(long, default_value = DEFAULT_PROJECT_ID)] - project: String, - #[arg(long, default_value = "docker-git-shared")] - network: String, - #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] - endpoint: String, - #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] - image: String, - #[arg(long, default_value = ".")] - workspace: PathBuf, - #[arg(long)] - allow_install: bool, - #[arg(long)] - no_adb_probe: bool, -} - -fn main() -> ExitCode { - match run() { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error}"); - ExitCode::from(1) - } - } -} - -fn run() -> Result<(), Box> { - let cli = Cli::parse(); - let spec = android_spec(&cli.project, &cli.network, &cli.endpoint, &cli.image)?; - let state = McpState { - spec, - workspace: cli.workspace, - adb_probe: !cli.no_adb_probe, - allow_install: cli.allow_install, - }; - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut reader = BufReader::new(stdin.lock()); - let mut writer = stdout.lock(); - run_stdio(&mut reader, &mut writer, &state)?; - Ok(()) -} diff --git a/src/lib.rs b/src/lib.rs index 5426a4e..9609bc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,5 @@ -pub mod mcp; - use serde::Serialize; -pub const SERVER_NAME: &str = "android-connection"; pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; pub const DEFAULT_PROJECT_ID: &str = "docker-git"; @@ -13,7 +10,32 @@ pub const DEFAULT_NOVNC_WEB_PORT: u16 = 6080; pub const DEFAULT_ANDROID_MEMORY_LIMIT: &str = "3g"; pub const DEFAULT_ANDROID_MEMORY_SWAP_LIMIT: &str = "3g"; pub const DEFAULT_ANDROID_CPUS: &str = "1.0"; +pub const DEFAULT_ANDROID_RUNTIME_PROFILE: &str = "interactive"; +pub const APP_TEST_ANDROID_RUNTIME_PROFILE: &str = "app-test"; +pub const APP_TEST_VNC_ANDROID_RUNTIME_PROFILE: &str = "app-test-vnc"; +pub const APP_TEST_EMULATOR_CONFIG_PATH: &str = "/tmp/docker-git-app-test-emulator.ini"; +pub const APP_TEST_EMULATOR_ADDITIONAL_ARGS: &str = "-no-window -no-audio -no-boot-anim -no-snapshot -lowram -memory 1536 -camera-back none -camera-front none"; +pub const APP_TEST_VNC_EMULATOR_ADDITIONAL_ARGS: &str = + "-no-audio -no-boot-anim -no-snapshot -lowram -memory 1536 -camera-back none -camera-front none"; pub const NOVNC_DOCKER_BRIDGE_COMMAND: &str = "while true; do container_ip=$(hostname -i | awk '{print $1}'); /usr/bin/socat TCP-LISTEN:6081,bind=${container_ip},fork,reuseaddr TCP:127.0.0.1:6080; sleep 1; done & exec ${APP_PATH}/mixins/scripts/run.sh"; +pub const APP_TEST_EMULATOR_CONFIG_LINES: [&str; 16] = [ + "hw.gsmModem = no", + "hw.ramSize = 1536", + "hw.gpu.enabled = no", + "hw.gpu.mode = swiftshader_indirect", + "hw.camera.back = none", + "hw.camera.front = none", + "hw.audioInput = no", + "hw.audioOutput = no", + "hw.accelerometer = no", + "hw.gyroscope = no", + "hw.sensors.orientation = no", + "hw.sensors.light = no", + "hw.sensors.pressure = no", + "showDeviceFrame = no", + "hw.lcd.width = 720", + "hw.lcd.height = 1280", +]; #[derive(Clone, Debug, Eq, PartialEq)] pub struct EndpointError { @@ -62,14 +84,56 @@ pub struct AndroidResourceLimits { } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub struct McpToolSpec { - pub name: &'static str, - pub description: &'static str, +#[serde(rename_all = "camelCase")] +pub struct AndroidRuntimeOptions { + pub profile: String, + pub emulator_headless: RuntimeSwitch, + pub appium_enabled: RuntimeSwitch, + pub web_log_enabled: RuntimeSwitch, + pub web_vnc_enabled: RuntimeSwitch, + pub emulator_no_skin: RuntimeSwitch, + pub emulator_device: String, + pub emulator_data_partition: String, + pub emulator_additional_args: String, + pub emulator_config_path: Option, + #[serde(skip)] + pub emulator_config_lines: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeSwitch { + Enabled, + Disabled, +} + +impl RuntimeSwitch { + #[must_use] + pub const fn as_bool(self) -> bool { + matches!(self, Self::Enabled) + } + + #[must_use] + pub const fn as_env(self) -> &'static str { + if self.as_bool() { + "true" + } else { + "false" + } + } +} + +impl Serialize for RuntimeSwitch { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bool(self.as_bool()) + } } // CHANGE: normalize externally supplied project ids into Docker-safe names -// WHY: Android sidecar names are pure functions of the project id, so MCP clients and lifecycle CLI agree -// QUOTE(TZ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// WHY: Android sidecar names are pure functions of the project id, so lifecycle and ADB commands agree +// QUOTE(TZ): "Пусть он с помощью команд подключается к контейнеру" // REF: issue-436 // SOURCE: n/a // FORMAT THEOREM: forall s: normalize(s) in [a-z0-9-]+ and normalize(s) != "" @@ -162,56 +226,6 @@ pub fn android_spec( }) } -#[must_use] -pub fn android_tools() -> Vec { - vec![ - McpToolSpec { - name: "android_status", - description: "Return the configured Android runtime and optional ADB status.", - }, - McpToolSpec { - name: "android_devices", - description: "List Android devices visible to adb.", - }, - McpToolSpec { - name: "android_screenshot", - description: "Capture a PNG screenshot into the workspace.", - }, - McpToolSpec { - name: "android_tap", - description: "Tap screen coordinates.", - }, - McpToolSpec { - name: "android_swipe", - description: "Swipe between screen coordinates.", - }, - McpToolSpec { - name: "android_type_text", - description: "Type text into the active Android input field.", - }, - McpToolSpec { - name: "android_press_key", - description: "Send an Android keycode.", - }, - McpToolSpec { - name: "android_launch_app", - description: "Launch an installed Android package.", - }, - McpToolSpec { - name: "android_open_url", - description: "Open a URL through Android intent handling.", - }, - McpToolSpec { - name: "android_logcat", - description: "Read recent logcat output.", - }, - McpToolSpec { - name: "android_install_apk", - description: "Install an APK from the workspace when explicitly enabled.", - }, - ] -} - #[must_use] pub fn no_vnc_url_host_for_bind_host(bind_host: &str) -> String { let trimmed = bind_host @@ -286,12 +300,84 @@ pub fn default_android_resource_limits() -> AndroidResourceLimits { ) } +#[must_use] +pub fn interactive_runtime_options() -> AndroidRuntimeOptions { + AndroidRuntimeOptions { + profile: DEFAULT_ANDROID_RUNTIME_PROFILE.to_string(), + emulator_headless: RuntimeSwitch::Disabled, + appium_enabled: RuntimeSwitch::Disabled, + web_log_enabled: RuntimeSwitch::Disabled, + web_vnc_enabled: RuntimeSwitch::Enabled, + emulator_no_skin: RuntimeSwitch::Disabled, + emulator_device: "Nexus 5".to_string(), + emulator_data_partition: "2g".to_string(), + emulator_additional_args: "-no-audio -no-boot-anim".to_string(), + emulator_config_path: Some(APP_TEST_EMULATOR_CONFIG_PATH.to_string()), + emulator_config_lines: APP_TEST_EMULATOR_CONFIG_LINES + .iter() + .map(|line| (*line).to_string()) + .collect(), + } +} + +#[must_use] +pub fn app_test_runtime_options() -> AndroidRuntimeOptions { + AndroidRuntimeOptions { + profile: APP_TEST_ANDROID_RUNTIME_PROFILE.to_string(), + emulator_headless: RuntimeSwitch::Enabled, + appium_enabled: RuntimeSwitch::Disabled, + web_log_enabled: RuntimeSwitch::Disabled, + web_vnc_enabled: RuntimeSwitch::Disabled, + emulator_no_skin: RuntimeSwitch::Enabled, + emulator_device: "Nexus 5".to_string(), + emulator_data_partition: "2g".to_string(), + emulator_additional_args: APP_TEST_EMULATOR_ADDITIONAL_ARGS.to_string(), + emulator_config_path: Some(APP_TEST_EMULATOR_CONFIG_PATH.to_string()), + emulator_config_lines: APP_TEST_EMULATOR_CONFIG_LINES + .iter() + .map(|line| (*line).to_string()) + .collect(), + } +} + +#[must_use] +pub fn app_test_vnc_runtime_options() -> AndroidRuntimeOptions { + AndroidRuntimeOptions { + profile: APP_TEST_VNC_ANDROID_RUNTIME_PROFILE.to_string(), + emulator_headless: RuntimeSwitch::Disabled, + appium_enabled: RuntimeSwitch::Disabled, + web_log_enabled: RuntimeSwitch::Disabled, + web_vnc_enabled: RuntimeSwitch::Enabled, + emulator_no_skin: RuntimeSwitch::Enabled, + emulator_device: "Nexus 5".to_string(), + emulator_data_partition: "2g".to_string(), + emulator_additional_args: APP_TEST_VNC_EMULATOR_ADDITIONAL_ARGS.to_string(), + emulator_config_path: Some(APP_TEST_EMULATOR_CONFIG_PATH.to_string()), + emulator_config_lines: APP_TEST_EMULATOR_CONFIG_LINES + .iter() + .map(|line| (*line).to_string()) + .collect(), + } +} + +#[must_use] +pub fn android_runtime_options(profile: &str) -> Option { + match profile { + DEFAULT_ANDROID_RUNTIME_PROFILE => Some(interactive_runtime_options()), + APP_TEST_ANDROID_RUNTIME_PROFILE => Some(app_test_runtime_options()), + APP_TEST_VNC_ANDROID_RUNTIME_PROFILE => Some(app_test_vnc_runtime_options()), + _ => None, + } +} + #[must_use] pub fn docker_run_args( spec: &AndroidSpec, no_vnc: Option<&NoVncEndpoint>, resource_limits: &AndroidResourceLimits, + runtime_options: &AndroidRuntimeOptions, ) -> Vec { + let web_vnc_enabled = runtime_options.web_vnc_enabled.as_bool() && no_vnc.is_some(); let mut args = vec![ "run".to_string(), "--detach".to_string(), @@ -307,15 +393,48 @@ pub fn docker_run_args( "--network".to_string(), spec.docker_network.clone(), "--env".to_string(), - "EMULATOR_HEADLESS=false".to_string(), + "USER_BEHAVIOR_ANALYTICS=false".to_string(), + "--env".to_string(), + format!( + "EMULATOR_HEADLESS={}", + runtime_options.emulator_headless.as_env() + ), + "--env".to_string(), + format!("APPIUM={}", runtime_options.appium_enabled.as_env()), "--env".to_string(), - "WEB_VNC=true".to_string(), + format!("WEB_LOG={}", runtime_options.web_log_enabled.as_env()), + "--env".to_string(), + format!("WEB_VNC={web_vnc_enabled}"), "--env".to_string(), format!("WEB_VNC_PORT={DEFAULT_NOVNC_WEB_PORT}"), + "--env".to_string(), + format!( + "EMULATOR_NO_SKIN={}", + runtime_options.emulator_no_skin.as_env() + ), + "--env".to_string(), + format!("EMULATOR_DEVICE={}", runtime_options.emulator_device), + "--env".to_string(), + format!( + "EMULATOR_DATA_PARTITION={}", + runtime_options.emulator_data_partition + ), + "--env".to_string(), + format!( + "EMULATOR_ADDITIONAL_ARGS={}", + runtime_options.emulator_additional_args + ), "--volume".to_string(), format!("{}:/root/.android", spec.android_volume_name), ]; + if let Some(config_path) = &runtime_options.emulator_config_path { + args.extend([ + "--env".to_string(), + format!("EMULATOR_CONFIG_PATH={config_path}"), + ]); + } + if let Some(no_vnc) = no_vnc { args.extend([ "--publish".to_string(), @@ -327,12 +446,57 @@ pub fn docker_run_args( } args.push(spec.image.clone()); - if no_vnc.is_some() { - args.push(NOVNC_DOCKER_BRIDGE_COMMAND.to_string()); + if let Some(command) = docker_startup_command(no_vnc.is_some(), runtime_options) { + args.push(command); } args } +#[must_use] +pub fn docker_startup_command( + no_vnc_enabled: bool, + runtime_options: &AndroidRuntimeOptions, +) -> Option { + let mut commands = Vec::new(); + if let Some(config_path) = &runtime_options.emulator_config_path { + if !runtime_options.emulator_config_lines.is_empty() { + commands.push(write_emulator_config_command( + config_path, + &runtime_options.emulator_config_lines, + )); + } + } + + if no_vnc_enabled { + commands.push("while true; do container_ip=$(hostname -i | awk '{print $1}'); /usr/bin/socat TCP-LISTEN:6081,bind=${container_ip},fork,reuseaddr TCP:127.0.0.1:6080; sleep 1; done &".to_string()); + } + + if commands.is_empty() { + None + } else { + commands.push("exec ${APP_PATH}/mixins/scripts/run.sh".to_string()); + Some(commands.join(" ")) + } +} + +#[must_use] +pub fn write_emulator_config_command(config_path: &str, config_lines: &[String]) -> String { + let quoted_lines = config_lines + .iter() + .map(|line| shell_single_quote(line)) + .collect::>() + .join(" "); + format!( + "printf '%s\\n' {quoted_lines} > {};", + shell_single_quote(config_path) + ) +} + +#[must_use] +pub fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + #[must_use] pub fn docker_stop_args(spec: &AndroidSpec) -> Vec { vec![ @@ -377,14 +541,6 @@ mod tests { assert_eq!(spec.android_volume_name, "dg-test-home-android"); } - #[test] - fn advertises_android_mcp_tools() { - let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); - assert!(names.contains(&"android_status")); - assert!(names.contains(&"android_tap")); - assert!(names.contains(&"android_install_apk")); - } - #[test] fn builds_no_vnc_endpoint_for_host_browser_access() { let endpoint = no_vnc_endpoint("127.0.0.1", "127.0.0.1", 16_080); @@ -433,7 +589,8 @@ mod tests { let endpoint = no_vnc_endpoint("127.0.0.1", "127.0.0.1", DEFAULT_NOVNC_PORT); let resource_limits = default_android_resource_limits(); - let args = docker_run_args(&spec, Some(&endpoint), &resource_limits); + let runtime_options = interactive_runtime_options(); + let args = docker_run_args(&spec, Some(&endpoint), &resource_limits, &runtime_options); let publish_position = args .iter() .position(|value| value == "--publish") @@ -444,10 +601,12 @@ mod tests { .expect("image argument"); assert!(publish_position < image_position); - assert_eq!( - args.get(image_position + 1), - Some(&NOVNC_DOCKER_BRIDGE_COMMAND.to_string()) - ); + assert!(args + .get(image_position + 1) + .is_some_and(|argument| argument.contains("socat TCP-LISTEN:6081"))); + assert!(args + .get(image_position + 1) + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); assert_eq!( args.get(publish_position + 1), Some(&"127.0.0.1:6080:6081".to_string()) @@ -464,6 +623,12 @@ mod tests { assert!(args .windows(2) .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "APPIUM=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_LOG=false"])); assert!(args .windows(2) .any(|window| window == ["--env", "WEB_VNC=true"])); @@ -471,4 +636,33 @@ mod tests { .windows(2) .any(|window| window == ["--env", "WEB_VNC_PORT=6080"])); } + + #[test] + fn docker_run_args_can_use_app_test_runtime_profile() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let resource_limits = default_android_resource_limits(); + let runtime_options = app_test_runtime_options(); + + let args = docker_run_args(&spec, None, &resource_limits, &runtime_options); + + assert!(!args.iter().any(|argument| argument == "--publish")); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=true"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=false"])); + assert!(args + .last() + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); + assert!(args + .last() + .is_some_and(|argument| argument.contains("exec ${APP_PATH}/mixins/scripts/run.sh"))); + } } diff --git a/src/main.rs b/src/main.rs index 75c8933..1f03d09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,24 +1,56 @@ +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use clap::{Args, Parser, Subcommand}; use docker_git_android_connection::{ - android_resource_limits, android_spec, docker_run_args, docker_stop_args, no_vnc_endpoint, - parse_docker_no_vnc_port, AndroidResourceLimits, NoVncEndpoint, DEFAULT_ADB_ENDPOINT, - DEFAULT_ANDROID_CPUS, DEFAULT_ANDROID_IMAGE, DEFAULT_ANDROID_MEMORY_LIMIT, - DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, DEFAULT_NOVNC_CONTAINER_PORT, DEFAULT_NOVNC_HOST, - DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, + android_resource_limits, android_runtime_options, android_spec, docker_run_args, + docker_stop_args, no_vnc_endpoint, parse_docker_no_vnc_port, AndroidResourceLimits, + AndroidRuntimeOptions, NoVncEndpoint, APP_TEST_ANDROID_RUNTIME_PROFILE, + APP_TEST_VNC_ANDROID_RUNTIME_PROFILE, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_CPUS, + DEFAULT_ANDROID_IMAGE, DEFAULT_ANDROID_MEMORY_LIMIT, DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, + DEFAULT_ANDROID_RUNTIME_PROFILE, DEFAULT_NOVNC_CONTAINER_PORT, DEFAULT_NOVNC_HOST, + DEFAULT_NOVNC_PORT, }; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::{HashMap, VecDeque}; use std::env; +use std::ffi::{OsStr, OsString}; +use std::fs::File; use std::io::{self, Read, Write}; -use std::net::TcpListener; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Output, Stdio}; +use std::sync::{Arc, Mutex}; use std::thread; +use std::time::{Duration, Instant}; const DOCKER_BIN_ENV: &str = "DOCKER_GIT_ANDROID_DOCKER"; const NO_VNC_PORT_FALLBACK_SPAN: u16 = 100; +const DEFAULT_ADB_MODE: &str = "container"; +const CONTAINER_ADB_SERIAL: &str = "emulator-5554"; +const CONTAINER_INSTALL_APK_PATH: &str = "/tmp/docker-git-install.apk"; +const WEB_COMMAND_NAME: &str = "web"; +const PHONE_COMMAND_NAME: &str = "phone"; +const DEFAULT_WEB_BIND_HOST: &str = "127.0.0.1"; +const DEFAULT_WEB_PORT: u16 = 8080; +const DEFAULT_PHONE_BRIDGE_URL: &str = "http://127.0.0.1:8080"; +const WEB_INDEX_HTML: &str = include_str!("web/index.html"); +const WEB_APP_JS: &str = include_str!("web/app.js"); +const WEB_STYLES_CSS: &str = include_str!("web/styles.css"); +const MAX_WEB_CLIENT_LOG_BYTES: usize = 16 * 1024; +const MAX_WEB_BRIDGE_BODY_BYTES: usize = 32 * 1024 * 1024; +const PHONE_BRIDGE_TIMEOUT: Duration = Duration::from_secs(900); +const PHONE_BRIDGE_POLL_INTERVAL: Duration = Duration::from_millis(500); +const BRIDGE_CLIENT_ID_HEADER: &str = "x-bridge-client-id"; #[derive(Parser, Debug)] -#[command(version, about = "docker-git Android runtime lifecycle CLI")] +#[command( + version, + about = "rust-android-connection lifecycle, ADB proxy, and browser WebUSB CLI", + after_help = "Global browser UI: rust-android-connection web --port 8080" +)] struct Cli { + #[arg(value_name = "PROJECT")] + project: String, #[command(subcommand)] command: LifecycleCommand, } @@ -28,24 +60,73 @@ enum LifecycleCommand { Start(LifecycleArgs), Status(LifecycleArgs), Stop(LifecycleArgs), + Adb(AdbArgs), + InstallApk(InstallApkArgs), + LaunchApp(LaunchAppArgs), +} + +#[derive(Parser, Debug)] +#[command( + name = "rust-android-connection web", + about = "Serve the browser WebUSB/WebADB phone connector" +)] +struct WebArgs { + #[arg(long = "bind", default_value = DEFAULT_WEB_BIND_HOST)] + bind_host: String, + #[arg(long, default_value_t = DEFAULT_WEB_PORT, value_parser = parse_web_port)] + port: u16, + #[arg(long)] + dry_run: bool, +} + +#[derive(Parser, Debug)] +#[command( + name = "rust-android-connection phone", + about = "Send commands to an Android phone connected through the browser WebUSB bridge" +)] +struct PhoneArgs { + #[arg(long = "url", global = true, default_value = DEFAULT_PHONE_BRIDGE_URL)] + url: String, + #[command(subcommand)] + command: PhoneCommand, +} + +#[derive(Subcommand, Debug)] +enum PhoneCommand { + Adb(PhoneAdbArgs), } #[derive(Args, Clone, Debug)] -struct LifecycleArgs { - #[arg(long, default_value = DEFAULT_PROJECT_ID)] - project: String, +#[command(disable_help_flag = true)] +struct PhoneAdbArgs { + #[arg(long)] + dry_run: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +#[derive(Args, Clone, Debug)] +struct AndroidConnectionArgs { #[arg(long, default_value = "docker-git-shared")] network: String, #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] endpoint: String, #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] image: String, +} + +#[derive(Args, Clone, Debug)] +struct LifecycleArgs { + #[command(flatten)] + connection: AndroidConnectionArgs, #[arg(long = "memory", default_value = DEFAULT_ANDROID_MEMORY_LIMIT, value_parser = parse_docker_size)] memory: String, #[arg(long = "memory-swap", default_value = DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, value_parser = parse_docker_size)] memory_swap: String, #[arg(long = "cpus", default_value = DEFAULT_ANDROID_CPUS, value_parser = parse_cpus)] cpus: String, + #[arg(long = "runtime-profile", default_value = DEFAULT_ANDROID_RUNTIME_PROFILE, value_parser = parse_runtime_profile)] + runtime_profile: String, #[arg(long = "novnc-bind-host", default_value = DEFAULT_NOVNC_HOST)] novnc_bind_host: String, #[arg(long = "novnc-host", default_value = DEFAULT_NOVNC_HOST)] @@ -62,6 +143,116 @@ struct LifecycleArgs { dry_run: bool, } +#[derive(Args, Clone, Debug)] +#[command(disable_help_flag = true)] +struct AdbArgs { + #[command(flatten)] + connection: AndroidConnectionArgs, + #[arg(long = "adb-mode", default_value = DEFAULT_ADB_MODE, value_parser = parse_adb_mode)] + adb_mode: AdbMode, + #[arg(long)] + dry_run: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +#[derive(Args, Clone, Debug)] +struct InstallApkArgs { + #[command(flatten)] + connection: AndroidConnectionArgs, + #[arg(long = "adb-mode", default_value = DEFAULT_ADB_MODE, value_parser = parse_adb_mode)] + adb_mode: AdbMode, + #[arg(long)] + dry_run: bool, + path: String, +} + +#[derive(Args, Clone, Debug)] +struct LaunchAppArgs { + #[command(flatten)] + connection: AndroidConnectionArgs, + #[arg(long = "adb-mode", default_value = DEFAULT_ADB_MODE, value_parser = parse_adb_mode)] + adb_mode: AdbMode, + #[arg(long)] + dry_run: bool, + #[arg(long = "package")] + package_name: String, + #[arg(long)] + activity: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AdbMode { + Auto, + Host, + Container, +} + +impl AdbMode { + const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Host => "host", + Self::Container => "container", + } + } +} + +#[derive(Default)] +struct BridgeState { + next_id: u64, + next_client_id: u64, + active_client_id: Option, + queue: VecDeque, + results: HashMap, + files: HashMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct BridgeCommand { + id: String, + kind: String, + args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + file: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct BridgeCommandFile { + name: String, + size: u64, + url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeCommandRequest { + kind: String, + args: Vec, + #[serde(default)] + file_path: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct BridgeCommandResult { + id: String, + exit_code: Option, + stdout: Option, + stdout_base64: Option, + stderr: Option, + error: Option, +} + +#[derive(Debug)] +struct ParsedHttpUrl { + host: String, + port: u16, + path: String, +} + fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -73,22 +264,270 @@ fn main() -> ExitCode { } fn run() -> Result<(), Box> { - let cli = Cli::parse(); + let raw_args = env::args_os().collect::>(); + if raw_args + .get(1) + .is_some_and(|argument| argument == OsStr::new(WEB_COMMAND_NAME)) + { + let mut web_args = Vec::::with_capacity(raw_args.len().saturating_sub(1)); + if let Some(binary) = raw_args.first() { + web_args.push(binary.clone()); + } + web_args.extend(raw_args.into_iter().skip(2)); + return web(&WebArgs::parse_from(web_args)); + } + if raw_args + .get(1) + .is_some_and(|argument| argument == OsStr::new(PHONE_COMMAND_NAME)) + { + let mut phone_args = Vec::::with_capacity(raw_args.len().saturating_sub(1)); + if let Some(binary) = raw_args.first() { + phone_args.push(binary.clone()); + } + phone_args.extend(raw_args.into_iter().skip(2)); + return phone(&PhoneArgs::parse_from(phone_args)); + } + + let cli = Cli::parse_from(raw_args); + let project = cli.project; match cli.command { - LifecycleCommand::Start(args) => start(&args), - LifecycleCommand::Status(args) => status(&args), - LifecycleCommand::Stop(args) => stop(&args), + LifecycleCommand::Start(args) => start(&project, &args), + LifecycleCommand::Status(args) => status(&project, &args), + LifecycleCommand::Stop(args) => stop(&project, &args), + LifecycleCommand::Adb(args) => adb(&project, &args), + LifecycleCommand::InstallApk(args) => install_apk(&project, &args), + LifecycleCommand::LaunchApp(args) => launch_app(&project, &args), + } +} + +fn phone(args: &PhoneArgs) -> Result<(), Box> { + match &args.command { + PhoneCommand::Adb(adb_args) => phone_adb(&args.url, adb_args), + } +} + +fn phone_adb(url: &str, args: &PhoneAdbArgs) -> Result<(), Box> { + let file_path = phone_adb_file_attachment(&args.args)?; + if args.dry_run { + print_json(&json!({ + "url": url, + "kind": "adb", + "args": args.args, + "filePath": file_path, + "transport": "browser-webusb-bridge" + }))?; + return Ok(()); + } + + let result = run_phone_bridge_command(url, "adb", &args.args, file_path.as_deref())?; + if let Some(error) = result.error.as_deref() { + return Err(error.to_string().into()); + } + if let Some(stderr) = result.stderr.as_deref() { + io::stderr().write_all(stderr.as_bytes())?; + } + if let Some(stdout_base64) = result.stdout_base64.as_deref() { + let bytes = BASE64_STANDARD.decode(stdout_base64)?; + io::stdout().write_all(&bytes)?; + } else if let Some(stdout) = result.stdout.as_deref() { + io::stdout().write_all(stdout.as_bytes())?; + } + io::stdout().flush()?; + io::stderr().flush()?; + + let exit_code = result.exit_code.unwrap_or(0); + if exit_code == 0 { + Ok(()) + } else { + std::process::exit(exit_code); + } +} + +fn run_phone_bridge_command( + url: &str, + kind: &str, + args: &[String], + file_path: Option<&str>, +) -> Result> { + let mut payload = json!({ + "kind": kind, + "args": args, + }); + if let Some(path) = file_path { + payload["filePath"] = Value::String(path.to_string()); + } + let body = serde_json::to_string(&payload)?; + let enqueue_url = bridge_url(url, "/bridge/commands"); + let (status, response) = http_request_json("POST", &enqueue_url, Some(&body))?; + if status != 200 { + return Err(format!("bridge enqueue failed with HTTP {status}: {response}").into()); + } + let id = serde_json::from_str::(&response)? + .get("id") + .and_then(Value::as_str) + .ok_or("bridge response did not include command id")? + .to_string(); + + let started_at = Instant::now(); + let result_url = bridge_url(url, &format!("/bridge/commands/result/{id}")); + while started_at.elapsed() < PHONE_BRIDGE_TIMEOUT { + let (status, response) = http_request_json("GET", &result_url, None)?; + if status == 200 { + return Ok(serde_json::from_str::(&response)?); + } + if status != 202 { + return Err(format!("bridge result failed with HTTP {status}: {response}").into()); + } + thread::sleep(PHONE_BRIDGE_POLL_INTERVAL); + } + + Err(format!( + "timed out waiting for phone bridge command {id}; keep the browser tab open and connected" + ) + .into()) +} + +fn phone_adb_file_attachment( + args: &[String], +) -> Result, Box> { + let Some(command) = args.first() else { + return Ok(None); + }; + if command != "install" { + return Ok(None); + } + let Some(path) = args.last() else { + return Err("adb install requires an APK path".into()); + }; + if path.starts_with('-') { + return Err("adb install requires a final APK path argument".into()); + } + if !Path::new(path).is_file() { + return Err(format!("APK file not found: {path}").into()); + } + Ok(Some(path.to_string())) +} + +fn bridge_url(base_url: &str, path: &str) -> String { + format!("{}{}", base_url.trim_end_matches('/'), path) +} + +fn http_request_json( + method: &str, + url: &str, + body: Option<&str>, +) -> Result<(u16, String), Box> { + let parsed = parse_http_url(url)?; + let mut stream = TcpStream::connect((parsed.host.as_str(), parsed.port))?; + let body = body.unwrap_or(""); + let request = format!( + "{method} {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + parsed.path, + parsed.host, + body.len(), + body + ); + stream.write_all(request.as_bytes())?; + stream.flush()?; + + let mut response = Vec::new(); + stream.read_to_end(&mut response)?; + parse_http_response(&response) +} + +fn parse_http_url(url: &str) -> Result> { + let without_scheme = url + .strip_prefix("http://") + .ok_or("phone bridge only supports http:// URLs")?; + let (authority, path) = without_scheme + .split_once('/') + .map_or((without_scheme, "/"), |(authority, _)| { + (authority, &without_scheme[authority.len()..]) + }); + let (host, port) = authority.rsplit_once(':').map_or_else( + || (authority.to_string(), DEFAULT_WEB_PORT), + |(host, port)| { + let parsed_port = port.parse::().unwrap_or(DEFAULT_WEB_PORT); + (host.to_string(), parsed_port) + }, + ); + if host.is_empty() { + return Err("phone bridge URL host must not be empty".into()); + } + Ok(ParsedHttpUrl { + host, + port, + path: path.to_string(), + }) +} + +fn parse_http_response(response: &[u8]) -> Result<(u16, String), Box> { + let header_end = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or("invalid HTTP response: missing header terminator")? + + 4; + let headers = String::from_utf8_lossy(&response[..header_end]); + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or("invalid HTTP response: missing status code")? + .parse::()?; + let body = String::from_utf8_lossy(&response[header_end..]).into_owned(); + Ok((status, body)) +} + +fn web(args: &WebArgs) -> Result<(), Box> { + let url = web_url(&args.bind_host, args.port); + if args.dry_run { + print_json(&json!({ + "bindHost": args.bind_host, + "port": args.port, + "url": url, + "secureContext": "localhost-or-https", + "requiresAdb": false, + "requiresBrowser": "Chromium WebUSB", + "phoneBridge": true + }))?; + return Ok(()); + } + + let listener = TcpListener::bind((args.bind_host.as_str(), args.port))?; + let bridge = Arc::new(Mutex::new(BridgeState::default())); + eprintln!("rust-android-connection web: {url}"); + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let bridge = Arc::clone(&bridge); + thread::spawn(move || { + if let Err(error) = handle_web_client(stream, &bridge) { + eprintln!("web request failed: {error}"); + } + }); + } + Err(error) => eprintln!("web accept failed: {error}"), + } } + Ok(()) } -fn start(args: &LifecycleArgs) -> Result<(), Box> { - let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; - let no_vnc = configured_no_vnc(args, !args.dry_run)?; +fn start(project: &str, args: &LifecycleArgs) -> Result<(), Box> { + let spec = configured_spec(project, &args.connection)?; + let runtime_options = configured_runtime_options(args)?; + let no_vnc = configured_no_vnc(args, &runtime_options, !args.dry_run)?; let resource_limits = configured_resource_limits(args); - let docker_args = docker_run_args(&spec, no_vnc.as_ref(), &resource_limits); + let docker_args = docker_run_args(&spec, no_vnc.as_ref(), &resource_limits, &runtime_options); if args.dry_run { print_json(&with_docker_args( - lifecycle_output(&spec, no_vnc.as_ref(), &resource_limits, None, None), + lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + &runtime_options, + None, + None, + ), &docker_args, ))?; return Ok(()); @@ -100,36 +539,40 @@ fn start(args: &LifecycleArgs) -> Result<(), Box> { &spec, no_vnc.as_ref(), &resource_limits, + &runtime_options, Some(container_id.trim()), None, )) } -fn status(args: &LifecycleArgs) -> Result<(), Box> { - let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; +fn status(project: &str, args: &LifecycleArgs) -> Result<(), Box> { + let spec = configured_spec(project, &args.connection)?; + let runtime_options = configured_runtime_options(args)?; let resource_limits = configured_resource_limits(args); - let no_vnc = if args.no_novnc_publish { + let no_vnc = if args.no_novnc_publish || !runtime_options.web_vnc_enabled.as_bool() { None } else { docker_no_vnc_endpoint(&spec.android_container_name, &args.novnc_host) - .or(configured_no_vnc(args, false)?) + .or(configured_no_vnc(args, &runtime_options, false)?) }; print_json(&lifecycle_output( &spec, no_vnc.as_ref(), &resource_limits, + &runtime_options, None, None, )) } -fn stop(args: &LifecycleArgs) -> Result<(), Box> { - let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; +fn stop(project: &str, args: &LifecycleArgs) -> Result<(), Box> { + let spec = configured_spec(project, &args.connection)?; + let runtime_options = configured_runtime_options(args)?; let resource_limits = configured_resource_limits(args); let docker_args = docker_stop_args(&spec); if args.dry_run { print_json(&with_docker_args( - lifecycle_output(&spec, None, &resource_limits, None, None), + lifecycle_output(&spec, None, &resource_limits, &runtime_options, None, None), &docker_args, ))?; return Ok(()); @@ -140,11 +583,69 @@ fn stop(args: &LifecycleArgs) -> Result<(), Box> { &spec, None, &resource_limits, + &runtime_options, None, Some(true), )) } +fn adb(project: &str, args: &AdbArgs) -> Result<(), Box> { + let spec = configured_spec(project, &args.connection)?; + if args.dry_run { + print_json(&adb_proxy_dry_run_output(&spec, args.adb_mode, &args.args))?; + return Ok(()); + } + + let output = run_adb_proxy_command(&spec, args.adb_mode, &args.args)?; + write_process_output(&output)?; + if output.status.success() { + Ok(()) + } else { + std::process::exit(output.status.code().unwrap_or(1)); + } +} + +fn install_apk(project: &str, args: &InstallApkArgs) -> Result<(), Box> { + let spec = configured_spec(project, &args.connection)?; + if args.dry_run { + print_json(&install_apk_dry_run_output( + &spec, + args.adb_mode, + Path::new(&args.path), + ))?; + return Ok(()); + } + + let output = run_install_apk(&spec, args.adb_mode, Path::new(&args.path))?; + write_process_output(&output)?; + if output.status.success() { + Ok(()) + } else { + Err(command_error("adb install", &output).into()) + } +} + +fn launch_app(project: &str, args: &LaunchAppArgs) -> Result<(), Box> { + let spec = configured_spec(project, &args.connection)?; + let adb_args = launch_app_adb_args(&args.package_name, args.activity.as_deref()); + if args.dry_run { + print_json(&targeted_adb_dry_run_output( + &spec, + args.adb_mode, + &adb_args, + ))?; + return Ok(()); + } + + let output = run_targeted_adb_command(&spec, args.adb_mode, &adb_args)?; + write_process_output(&output)?; + if output.status.success() { + Ok(()) + } else { + Err(command_error("adb launch", &output).into()) + } +} + fn parse_docker_size(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -193,15 +694,54 @@ fn parse_no_vnc_port(value: &str) -> Result { } } +fn parse_web_port(value: &str) -> Result { + parse_no_vnc_port(value).map_err(|_| "web port must be in 1..=65535".to_string()) +} + +fn parse_runtime_profile(value: &str) -> Result { + android_runtime_options(value) + .map(|_| value.to_string()) + .ok_or_else(|| { + format!( + "runtime profile must be one of: {DEFAULT_ANDROID_RUNTIME_PROFILE}, {APP_TEST_ANDROID_RUNTIME_PROFILE}, {APP_TEST_VNC_ANDROID_RUNTIME_PROFILE}" + ) + }) +} + +fn parse_adb_mode(value: &str) -> Result { + match value { + "auto" => Ok(AdbMode::Auto), + "host" => Ok(AdbMode::Host), + "container" => Ok(AdbMode::Container), + _ => Err("ADB mode must be one of: auto, host, container".to_string()), + } +} + +fn configured_spec( + project: &str, + args: &AndroidConnectionArgs, +) -> Result +{ + android_spec(project, &args.network, &args.endpoint, &args.image) +} + fn configured_resource_limits(args: &LifecycleArgs) -> AndroidResourceLimits { android_resource_limits(&args.memory, &args.memory_swap, &args.cpus) } +fn configured_runtime_options( + args: &LifecycleArgs, +) -> Result> { + android_runtime_options(&args.runtime_profile) + .ok_or_else(|| format!("invalid runtime profile {:?}", args.runtime_profile).into()) +} + fn configured_no_vnc( args: &LifecycleArgs, + runtime_options: &AndroidRuntimeOptions, reserve_free_port: bool, ) -> Result, Box> { - if args.no_novnc_publish { + if args.no_novnc_publish || !runtime_options.web_vnc_enabled.as_bool() { return Ok(None); } @@ -247,6 +787,7 @@ fn lifecycle_output( spec: &docker_git_android_connection::AndroidSpec, no_vnc: Option<&NoVncEndpoint>, resource_limits: &AndroidResourceLimits, + runtime_options: &AndroidRuntimeOptions, container_id: Option<&str>, removed: Option, ) -> Value { @@ -259,6 +800,7 @@ fn lifecycle_output( "adbEndpoint": spec.adb_endpoint, "image": spec.image, "resourceLimits": resource_limits, + "runtime": runtime_options, "noVncPublished": no_vnc.is_some(), "noVncUrl": no_vnc.map(|endpoint| endpoint.url.as_str()), "noVncBindHost": no_vnc.map(|endpoint| endpoint.bind_host.as_str()), @@ -282,6 +824,321 @@ fn print_json(value: &Value) -> Result<(), Box> { Ok(()) } +fn adb_proxy_dry_run_output( + spec: &docker_git_android_connection::AndroidSpec, + mode: AdbMode, + adb_args: &[String], +) -> Value { + json!({ + "projectId": spec.project_id, + "androidContainerName": spec.android_container_name, + "adbEndpoint": spec.adb_endpoint, + "adbMode": mode.as_str(), + "host": host_adb_proxy_command(&spec.adb_endpoint, adb_args), + "container": container_adb_proxy_command(&spec.android_container_name, adb_args) + }) +} + +fn targeted_adb_dry_run_output( + spec: &docker_git_android_connection::AndroidSpec, + mode: AdbMode, + adb_args: &[String], +) -> Value { + json!({ + "projectId": spec.project_id, + "androidContainerName": spec.android_container_name, + "adbEndpoint": spec.adb_endpoint, + "adbMode": mode.as_str(), + "host": host_targeted_adb_command(&spec.adb_endpoint, adb_args), + "container": container_targeted_adb_command(&spec.android_container_name, adb_args) + }) +} + +fn install_apk_dry_run_output( + spec: &docker_git_android_connection::AndroidSpec, + mode: AdbMode, + apk_path: &Path, +) -> Value { + json!({ + "projectId": spec.project_id, + "androidContainerName": spec.android_container_name, + "adbEndpoint": spec.adb_endpoint, + "adbMode": mode.as_str(), + "host": host_targeted_adb_command( + &spec.adb_endpoint, + &["install".to_string(), apk_path.display().to_string()] + ), + "containerCopy": docker_cp_args(apk_path, &spec.android_container_name), + "container": container_targeted_adb_command( + &spec.android_container_name, + &["install".to_string(), CONTAINER_INSTALL_APK_PATH.to_string()] + ) + }) +} + +fn run_adb_proxy_command( + spec: &docker_git_android_connection::AndroidSpec, + mode: AdbMode, + adb_args: &[String], +) -> Result> { + match mode { + AdbMode::Host => run_host_adb_proxy(&spec.adb_endpoint, adb_args), + AdbMode::Container => run_container_adb_proxy(&spec.android_container_name, adb_args), + AdbMode::Auto => { + if host_adb_ready(&spec.adb_endpoint) { + run_host_adb_proxy(&spec.adb_endpoint, adb_args) + } else { + run_container_adb_proxy(&spec.android_container_name, adb_args) + } + } + } +} + +fn run_targeted_adb_command( + spec: &docker_git_android_connection::AndroidSpec, + mode: AdbMode, + adb_args: &[String], +) -> Result> { + match mode { + AdbMode::Host => run_host_targeted_adb(&spec.adb_endpoint, adb_args), + AdbMode::Container => run_container_targeted_adb(&spec.android_container_name, adb_args), + AdbMode::Auto => { + if host_adb_ready(&spec.adb_endpoint) { + run_host_targeted_adb(&spec.adb_endpoint, adb_args) + } else { + run_container_targeted_adb(&spec.android_container_name, adb_args) + } + } + } +} + +fn run_install_apk( + spec: &docker_git_android_connection::AndroidSpec, + mode: AdbMode, + apk_path: &Path, +) -> Result> { + match mode { + AdbMode::Host => run_host_targeted_adb( + &spec.adb_endpoint, + &["install".to_string(), apk_path.display().to_string()], + ), + AdbMode::Container => run_container_install_apk(&spec.android_container_name, apk_path), + AdbMode::Auto => { + if host_adb_ready(&spec.adb_endpoint) { + run_host_targeted_adb( + &spec.adb_endpoint, + &["install".to_string(), apk_path.display().to_string()], + ) + } else { + run_container_install_apk(&spec.android_container_name, apk_path) + } + } + } +} + +fn run_container_install_apk( + container_name: &str, + apk_path: &Path, +) -> Result> { + let copy_args = docker_cp_args(apk_path, container_name); + run_docker_status(©_args)?; + run_container_targeted_adb( + container_name, + &[ + "install".to_string(), + CONTAINER_INSTALL_APK_PATH.to_string(), + ], + ) +} + +fn run_host_adb_proxy( + endpoint: &str, + adb_args: &[String], +) -> Result> { + let connect_output = Command::new("adb").arg("connect").arg(endpoint).output()?; + if !connect_output.status.success() { + return Err(command_error("adb connect", &connect_output).into()); + } + + Command::new("adb") + .args(adb_args) + .output() + .map_err(Into::into) +} + +fn run_host_targeted_adb( + endpoint: &str, + adb_args: &[String], +) -> Result> { + let connect_output = Command::new("adb").arg("connect").arg(endpoint).output()?; + if !connect_output.status.success() { + return Err(command_error("adb connect", &connect_output).into()); + } + + Command::new("adb") + .args(host_targeted_adb_args(endpoint, adb_args)) + .output() + .map_err(Into::into) +} + +fn host_adb_ready(endpoint: &str) -> bool { + Command::new("adb") + .arg("connect") + .arg(endpoint) + .output() + .is_ok_and(|output| output.status.success()) +} + +fn run_container_adb_proxy( + container_name: &str, + adb_args: &[String], +) -> Result> { + let docker_args = container_adb_proxy_args(container_name, adb_args); + Command::new(docker_binary()) + .args(docker_args) + .output() + .map_err(Into::into) +} + +fn run_container_targeted_adb( + container_name: &str, + adb_args: &[String], +) -> Result> { + let docker_args = container_targeted_adb_args(container_name, adb_args); + Command::new(docker_binary()) + .args(docker_args) + .output() + .map_err(Into::into) +} + +fn launch_app_adb_args(package_name: &str, activity: Option<&str>) -> Vec { + match activity { + Some(activity) if !activity.is_empty() => vec![ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + format!("{package_name}/{activity}"), + ], + _ => vec![ + "shell".to_string(), + "monkey".to_string(), + "-p".to_string(), + package_name.to_string(), + "-c".to_string(), + "android.intent.category.LAUNCHER".to_string(), + "1".to_string(), + ], + } +} + +fn host_adb_proxy_command(endpoint: &str, adb_args: &[String]) -> Vec { + let adb_command = if adb_args.is_empty() { + "adb".to_string() + } else { + format!("adb {}", shell_join(adb_args)) + }; + vec![ + "sh".to_string(), + "-c".to_string(), + format!("adb connect {} && {adb_command}", shell_quote(endpoint)), + ] +} + +fn host_targeted_adb_command(endpoint: &str, adb_args: &[String]) -> Vec { + vec![ + "sh".to_string(), + "-c".to_string(), + format!( + "adb connect {} && adb {}", + shell_quote(endpoint), + shell_join(&host_targeted_adb_args(endpoint, adb_args)) + ), + ] +} + +fn host_targeted_adb_args(endpoint: &str, adb_args: &[String]) -> Vec { + let mut args = Vec::new(); + if should_add_host_serial(adb_args) { + args.extend(["-s".to_string(), endpoint.to_string()]); + } + args.extend(adb_args.iter().cloned()); + args +} + +fn container_adb_proxy_command(container_name: &str, adb_args: &[String]) -> Vec { + let mut command = vec!["docker".to_string()]; + command.extend(container_adb_proxy_args(container_name, adb_args)); + command +} + +fn container_targeted_adb_command(container_name: &str, adb_args: &[String]) -> Vec { + let mut command = vec!["docker".to_string()]; + command.extend(container_targeted_adb_args(container_name, adb_args)); + command +} + +fn container_adb_proxy_args(container_name: &str, adb_args: &[String]) -> Vec { + let mut docker_args = vec![ + "exec".to_string(), + container_name.to_string(), + "adb".to_string(), + ]; + docker_args.extend(adb_args.iter().cloned()); + docker_args +} + +fn container_targeted_adb_args(container_name: &str, adb_args: &[String]) -> Vec { + let mut docker_args = vec![ + "exec".to_string(), + container_name.to_string(), + "adb".to_string(), + ]; + if should_add_container_serial(adb_args) { + docker_args.extend(["-s".to_string(), CONTAINER_ADB_SERIAL.to_string()]); + } + docker_args.extend(adb_args.iter().cloned()); + docker_args +} + +fn docker_cp_args(apk_path: &Path, container_name: &str) -> Vec { + vec![ + "cp".to_string(), + apk_path.display().to_string(), + format!("{container_name}:{CONTAINER_INSTALL_APK_PATH}"), + ] +} + +fn should_add_container_serial(adb_args: &[String]) -> bool { + !matches!(adb_args.first().map(String::as_str), Some("devices")) + && !adb_args.iter().any(|argument| argument == "-s") +} + +fn should_add_host_serial(adb_args: &[String]) -> bool { + should_add_container_serial(adb_args) +} + +fn shell_join(values: &[String]) -> String { + values + .iter() + .map(|value| shell_quote(value)) + .collect::>() + .join(" ") +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn write_process_output(output: &Output) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + stdout.write_all(&output.stdout)?; + stdout.flush()?; + let mut stderr = io::stderr().lock(); + stderr.write_all(&output.stderr)?; + stderr.flush() +} + fn ensure_image_available(image: &str) -> Result<(), Box> { let inspect_args = vec![ "image".to_string(), @@ -361,9 +1218,12 @@ where } fn docker_error(args: &[String], output: &Output) -> String { + command_error(&format!("docker {}", args.join(" ")), output) +} + +fn command_error(label: &str, output: &Output) -> String { format!( - "docker {} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", - args.join(" "), + "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", output.status.code(), String::from_utf8_lossy(&output.stdout).trim(), String::from_utf8_lossy(&output.stderr).trim() @@ -373,3 +1233,395 @@ fn docker_error(args: &[String], output: &Output) -> String { fn docker_binary() -> String { env::var(DOCKER_BIN_ENV).unwrap_or_else(|_| "docker".to_string()) } + +fn web_url(bind_host: &str, port: u16) -> String { + let host = match bind_host { + "0.0.0.0" | "::" => DEFAULT_WEB_BIND_HOST, + host => host, + }; + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + format!("http://{host}:{port}/") +} + +fn handle_web_client(mut stream: TcpStream, bridge: &Arc>) -> io::Result<()> { + let mut buffer = [0_u8; 8192]; + let bytes_read = stream.read(&mut buffer)?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let mut parts = request + .lines() + .next() + .unwrap_or_default() + .split_whitespace(); + let method = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or("/"); + let route_path = path.split('?').next().unwrap_or("/"); + + if method == "POST" && route_path == "/client-log" { + let body = read_http_body(&mut stream, &buffer[..bytes_read], MAX_WEB_CLIENT_LOG_BYTES)?; + let is_raw_usb_trace = + body.contains(r#""message":"USB OUT "#) || body.contains(r#""message":"USB IN "#); + if !is_raw_usb_trace { + eprintln!("web client log: {body}"); + } + return write_http_response( + &mut stream, + "204 No Content", + "text/plain; charset=utf-8", + "", + false, + ); + } + + if route_path.starts_with("/bridge/") { + return handle_bridge_request( + &mut stream, + bridge, + method, + route_path, + &buffer[..bytes_read], + ); + } + + if !matches!(method, "GET" | "HEAD") { + return write_http_response( + &mut stream, + "405 Method Not Allowed", + "text/plain; charset=utf-8", + "method not allowed", + method == "HEAD", + ); + } + + let (status, content_type, body) = match route_path { + "/" | "/index.html" => ("200 OK", "text/html; charset=utf-8", WEB_INDEX_HTML), + "/app.js" => ("200 OK", "text/javascript; charset=utf-8", WEB_APP_JS), + "/styles.css" => ("200 OK", "text/css; charset=utf-8", WEB_STYLES_CSS), + "/healthz" => ("200 OK", "text/plain; charset=utf-8", "ok\n"), + _ => ("404 Not Found", "text/plain; charset=utf-8", "not found\n"), + }; + write_http_response(&mut stream, status, content_type, body, method == "HEAD") +} + +fn handle_bridge_request( + stream: &mut TcpStream, + bridge: &Arc>, + method: &str, + route_path: &str, + initial: &[u8], +) -> io::Result<()> { + match (method, route_path) { + ("POST", "/bridge/client/session") => { + let client_id = { + let mut bridge = bridge + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "bridge state poisoned"))?; + bridge.next_client_id += 1; + let client_id = format!("client-{}", bridge.next_client_id); + bridge.active_client_id = Some(client_id.clone()); + client_id + }; + let body = serde_json::to_string(&json!({ "clientId": client_id }))?; + write_http_response( + stream, + "200 OK", + "application/json; charset=utf-8", + &body, + false, + ) + } + ("POST", "/bridge/commands") => { + let body = read_http_body(stream, initial, MAX_WEB_BRIDGE_BODY_BYTES)?; + let request = serde_json::from_str::(&body).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid bridge command: {error}"), + ) + })?; + if request.file_path.is_some() + && !is_local_http_host(http_header_value(initial, "host").as_deref()) + { + return write_http_response( + stream, + "403 Forbidden", + "text/plain; charset=utf-8", + "file attachments must be enqueued from localhost\n", + false, + ); + } + let command = { + let mut bridge = bridge + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "bridge state poisoned"))?; + bridge.next_id += 1; + let id = bridge.next_id.to_string(); + let file = request + .file_path + .as_deref() + .map(|path| bridge_command_file(&id, path)) + .transpose()?; + let command = BridgeCommand { + id: id.clone(), + kind: request.kind, + args: request.args, + file, + }; + if let Some(path) = request.file_path { + bridge.files.insert(id, PathBuf::from(path)); + } + bridge.queue.push_back(command.clone()); + command + }; + let body = serde_json::to_string(&json!({ + "id": command.id, + "status": "queued" + }))?; + write_http_response( + stream, + "200 OK", + "application/json; charset=utf-8", + &body, + false, + ) + } + ("GET", "/bridge/commands/next") => { + let client_id = http_header_value(initial, BRIDGE_CLIENT_ID_HEADER); + let command = { + let mut bridge = bridge + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "bridge state poisoned"))?; + if bridge.active_client_id.as_deref() != client_id.as_deref() { + return write_stale_bridge_client_response(stream); + } + bridge.queue.pop_front() + }; + let body = serde_json::to_string(&json!({ "command": command }))?; + write_http_response( + stream, + "200 OK", + "application/json; charset=utf-8", + &body, + false, + ) + } + ("POST", "/bridge/commands/result") => { + let body = read_http_body(stream, initial, MAX_WEB_BRIDGE_BODY_BYTES)?; + let result = serde_json::from_str::(&body).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid bridge result: {error}"), + ) + })?; + let client_id = http_header_value(initial, BRIDGE_CLIENT_ID_HEADER); + { + let mut bridge = bridge + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "bridge state poisoned"))?; + if bridge.active_client_id.as_deref() != client_id.as_deref() { + return write_stale_bridge_client_response(stream); + } + bridge.files.remove(&result.id); + bridge.results.insert(result.id.clone(), result); + } + write_http_response( + stream, + "204 No Content", + "text/plain; charset=utf-8", + "", + false, + ) + } + ("GET", path) if path.starts_with("/bridge/commands/result/") => { + let id = path.trim_start_matches("/bridge/commands/result/"); + let result = { + let bridge = bridge + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "bridge state poisoned"))?; + bridge.results.get(id).cloned() + }; + if let Some(result) = result { + let body = serde_json::to_string(&result)?; + write_http_response( + stream, + "200 OK", + "application/json; charset=utf-8", + &body, + false, + ) + } else { + let body = serde_json::to_string(&json!({ + "id": id, + "pending": true + }))?; + write_http_response( + stream, + "202 Accepted", + "application/json; charset=utf-8", + &body, + false, + ) + } + } + ("GET", path) if path.starts_with("/bridge/commands/file/") => { + let id = path.trim_start_matches("/bridge/commands/file/"); + let file_path = { + let bridge = bridge + .lock() + .map_err(|_| io::Error::new(io::ErrorKind::Other, "bridge state poisoned"))?; + bridge.files.get(id).cloned() + }; + let Some(file_path) = file_path else { + return write_http_response( + stream, + "404 Not Found", + "text/plain; charset=utf-8", + "file not found\n", + false, + ); + }; + write_http_file_response( + stream, + "200 OK", + "application/vnd.android.package-archive", + &file_path, + ) + } + _ => write_http_response( + stream, + "404 Not Found", + "text/plain; charset=utf-8", + "not found\n", + false, + ), + } +} + +fn bridge_command_file(id: &str, path: &str) -> io::Result { + let path = Path::new(path); + let metadata = path.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "bridge file attachment is not a regular file", + )); + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("android-app.apk") + .to_string(); + Ok(BridgeCommandFile { + name, + size: metadata.len(), + url: format!("/bridge/commands/file/{id}"), + }) +} + +fn write_stale_bridge_client_response(stream: &mut TcpStream) -> io::Result<()> { + let body = serde_json::to_string(&json!({ + "error": "staleClient", + "message": "another browser tab owns the active bridge session" + }))?; + write_http_response( + stream, + "409 Conflict", + "application/json; charset=utf-8", + &body, + false, + ) +} + +fn http_header_value(initial: &[u8], header_name: &str) -> Option { + let header_end = initial + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(initial.len(), |position| position + 4); + let headers = String::from_utf8_lossy(&initial[..header_end]); + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case(header_name) + .then(|| value.trim().to_string()) + }) +} + +fn is_local_http_host(host: Option<&str>) -> bool { + let Some(host) = host else { + return false; + }; + let host = host + .strip_prefix('[') + .and_then(|value| value.split_once(']').map(|(host, _)| host)) + .unwrap_or_else(|| host.split(':').next().unwrap_or(host)); + matches!(host, "127.0.0.1" | "localhost" | "::1") +} + +fn read_http_body(stream: &mut TcpStream, initial: &[u8], max_bytes: usize) -> io::Result { + let header_end = initial + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(initial.len(), |position| position + 4); + let headers = String::from_utf8_lossy(&initial[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + let target_body_len = content_length.min(max_bytes); + let mut body = Vec::with_capacity(target_body_len); + let initial_body = &initial[header_end..]; + let copied_len = initial_body.len().min(target_body_len); + body.extend_from_slice(&initial_body[..copied_len]); + + while body.len() < target_body_len { + let remaining = target_body_len - body.len(); + let mut chunk = vec![0_u8; remaining.min(4096)]; + let bytes_read = stream.read(&mut chunk)?; + if bytes_read == 0 { + break; + } + body.extend_from_slice(&chunk[..bytes_read]); + } + + Ok(String::from_utf8_lossy(&body).into_owned()) +} + +fn write_http_response( + stream: &mut TcpStream, + status: &str, + content_type: &str, + body: &str, + headers_only: bool, +) -> io::Result<()> { + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes())?; + if !headers_only { + stream.write_all(body.as_bytes())?; + } + stream.flush() +} + +fn write_http_file_response( + stream: &mut TcpStream, + status: &str, + content_type: &str, + path: &Path, +) -> io::Result<()> { + let mut file = File::open(path)?; + let content_length = file.metadata()?.len(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {content_length}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n" + ); + stream.write_all(response.as_bytes())?; + io::copy(&mut file, stream)?; + stream.flush() +} diff --git a/src/mcp.rs b/src/mcp.rs deleted file mode 100644 index cf275c4..0000000 --- a/src/mcp.rs +++ /dev/null @@ -1,635 +0,0 @@ -use crate::{android_tools, AndroidSpec, SERVER_NAME}; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::fs; -use std::io::{self, BufRead, Write}; -use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Output}; - -#[derive(Clone, Debug)] -pub struct McpState { - pub spec: AndroidSpec, - pub workspace: PathBuf, - pub adb_probe: bool, - pub allow_install: bool, -} - -#[derive(Debug)] -enum McpToolError { - MissingArgument(&'static str), - InvalidArgument(String), - AdbProbeDisabled, - CommandFailed(String), - Io(String), -} - -impl std::fmt::Display for McpToolError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::MissingArgument(name) => write!(formatter, "missing required argument: {name}"), - Self::InvalidArgument(message) => write!(formatter, "invalid argument: {message}"), - Self::AdbProbeDisabled => write!(formatter, "ADB probing is disabled for this server"), - Self::CommandFailed(message) | Self::Io(message) => write!(formatter, "{message}"), - } - } -} - -#[derive(Debug, Deserialize)] -struct JsonRpcRequest { - id: Option, - method: String, - params: Option, -} - -pub fn run_stdio(reader: &mut R, writer: &mut W, state: &McpState) -> io::Result<()> -where - R: BufRead, - W: Write, -{ - while let Some(raw) = read_next_message(reader)? { - let response = match serde_json::from_str::(&raw) { - Ok(request) => handle_request(&request, state), - Err(error) => Some(json_rpc_error( - &Value::Null, - -32700, - &format!("invalid JSON-RPC request: {error}"), - )), - }; - - if let Some(value) = response { - write_json_message(writer, &value)?; - } - } - - Ok(()) -} - -fn read_next_message(reader: &mut R) -> io::Result> { - loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line)?; - if bytes_read == 0 { - return Ok(None); - } - - let first_line = line.trim_end_matches(['\r', '\n']); - if first_line.is_empty() { - continue; - } - - if let Some(length) = parse_content_length(first_line)? { - read_headers(reader)?; - let mut payload = vec![0_u8; length]; - reader.read_exact(&mut payload)?; - return String::from_utf8(payload) - .map(Some) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); - } - - return Ok(Some(first_line.to_string())); - } -} - -fn parse_content_length(header: &str) -> io::Result> { - let lowercase = header.to_ascii_lowercase(); - if !lowercase.starts_with("content-length:") { - return Ok(None); - } - - let raw_length = header - .split_once(':') - .map(|(_, value)| value.trim()) - .unwrap_or_default(); - raw_length - .parse::() - .map(Some) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) -} - -fn read_headers(reader: &mut R) -> io::Result<()> { - loop { - let mut line = String::new(); - let bytes_read = reader.read_line(&mut line)?; - if bytes_read == 0 { - return Ok(()); - } - if line.trim().is_empty() { - return Ok(()); - } - } -} - -fn write_json_message(writer: &mut W, value: &Value) -> io::Result<()> { - let body = serde_json::to_string(value) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?; - writer.flush() -} - -fn handle_request(request: &JsonRpcRequest, state: &McpState) -> Option { - if request.id.is_none() && request.method.starts_with("notifications/") { - return None; - } - - let id = request.id.clone().unwrap_or(Value::Null); - let response = match request.method.as_str() { - "initialize" => json!({ - "protocolVersion": "2024-11-05", - "capabilities": { "tools": {} }, - "serverInfo": { - "name": SERVER_NAME, - "version": env!("CARGO_PKG_VERSION") - } - }), - "tools/list" => json!({ "tools": render_tools() }), - "tools/call" => return Some(handle_tools_call(&id, request.params.as_ref(), state)), - method => { - return Some(json_rpc_error( - &id, - -32601, - &format!("method not found: {method}"), - )) - } - }; - - Some(json!({ - "jsonrpc": "2.0", - "id": id, - "result": response - })) -} - -fn json_rpc_error(id: &Value, code: i64, message: &str) -> Value { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": code, - "message": message - } - }) -} - -fn render_tools() -> Value { - Value::Array( - android_tools() - .into_iter() - .map(|tool| { - json!({ - "name": tool.name, - "description": tool.description, - "inputSchema": { - "type": "object", - "additionalProperties": true - } - }) - }) - .collect(), - ) -} - -fn handle_tools_call(id: &Value, params: Option<&Value>, state: &McpState) -> Value { - let result = call_tool_from_params(params, state); - let (text, is_error) = match result { - Ok(text) => (text, false), - Err(error) => (error.to_string(), true), - }; - - json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "content": [ - { - "type": "text", - "text": text - } - ], - "isError": is_error - } - }) -} - -fn call_tool_from_params(params: Option<&Value>, state: &McpState) -> Result { - let params = - params.ok_or_else(|| McpToolError::InvalidArgument("missing params".to_string()))?; - let name = params - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| McpToolError::InvalidArgument("missing tool name".to_string()))?; - let arguments = params.get("arguments").unwrap_or(&Value::Null); - - match name { - "android_status" => android_status(state), - "android_devices" => android_devices(state), - "android_screenshot" => android_screenshot(state, arguments), - "android_tap" => android_tap(state, arguments), - "android_swipe" => android_swipe(state, arguments), - "android_type_text" => android_type_text(state, arguments), - "android_press_key" => android_press_key(state, arguments), - "android_launch_app" => android_launch_app(state, arguments), - "android_open_url" => android_open_url(state, arguments), - "android_logcat" => android_logcat(state, arguments), - "android_install_apk" => android_install_apk(state, arguments), - unknown => Err(McpToolError::InvalidArgument(format!( - "unknown Android MCP tool: {unknown}" - ))), - } -} - -fn android_status(state: &McpState) -> Result { - if !state.adb_probe { - return serde_json::to_string_pretty(&json!({ - "server": SERVER_NAME, - "adbProbe": false, - "spec": state.spec - })) - .map_err(|error| McpToolError::Io(error.to_string())); - } - - match run_adb(state, &["devices".to_string()]) { - Ok(output) => Ok(format!( - "Android runtime: {}\nADB endpoint: {}\n\n{}", - state.spec.android_container_name, state.spec.adb_endpoint, output - )), - Err(error) => Ok(format!( - "Android runtime: {}\nADB endpoint: {}\nADB status error: {}", - state.spec.android_container_name, state.spec.adb_endpoint, error - )), - } -} - -fn android_devices(state: &McpState) -> Result { - run_adb(state, &["devices".to_string()]) -} - -fn android_tap(state: &McpState, arguments: &Value) -> Result { - let x = integer_argument(arguments, "x")?; - let y = integer_argument(arguments, "y")?; - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "tap".to_string(), - x.to_string(), - y.to_string(), - ], - ) -} - -fn android_swipe(state: &McpState, arguments: &Value) -> Result { - let start_x = integer_argument(arguments, "startX")?; - let start_y = integer_argument(arguments, "startY")?; - let end_x = integer_argument(arguments, "endX")?; - let end_y = integer_argument(arguments, "endY")?; - let duration_ms = optional_integer_argument(arguments, "durationMs")?.unwrap_or(300); - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "swipe".to_string(), - start_x.to_string(), - start_y.to_string(), - end_x.to_string(), - end_y.to_string(), - duration_ms.to_string(), - ], - ) -} - -fn android_type_text(state: &McpState, arguments: &Value) -> Result { - let text = string_argument(arguments, "text")?.replace(' ', "%s"); - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "text".to_string(), - text, - ], - ) -} - -fn android_press_key(state: &McpState, arguments: &Value) -> Result { - let keycode = string_argument(arguments, "keycode")?; - if !keycode - .bytes() - .all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) - { - return Err(McpToolError::InvalidArgument( - "keycode may contain only ASCII letters, digits, and '_'".to_string(), - )); - } - run_adb( - state, - &[ - "shell".to_string(), - "input".to_string(), - "keyevent".to_string(), - keycode, - ], - ) -} - -fn android_launch_app(state: &McpState, arguments: &Value) -> Result { - let package_name = string_argument(arguments, "package")?; - let activity = optional_string_argument(arguments, "activity")?; - match activity { - Some(activity) if !activity.is_empty() => run_adb( - state, - &[ - "shell".to_string(), - "am".to_string(), - "start".to_string(), - "-n".to_string(), - format!("{package_name}/{activity}"), - ], - ), - _ => run_adb( - state, - &[ - "shell".to_string(), - "monkey".to_string(), - "-p".to_string(), - package_name, - "-c".to_string(), - "android.intent.category.LAUNCHER".to_string(), - "1".to_string(), - ], - ), - } -} - -fn android_open_url(state: &McpState, arguments: &Value) -> Result { - let url = string_argument(arguments, "url")?; - run_adb( - state, - &[ - "shell".to_string(), - "am".to_string(), - "start".to_string(), - "-a".to_string(), - "android.intent.action.VIEW".to_string(), - "-d".to_string(), - url, - ], - ) -} - -fn android_logcat(state: &McpState, arguments: &Value) -> Result { - let lines = optional_integer_argument(arguments, "lines")? - .unwrap_or(200) - .clamp(1, 1000); - run_adb( - state, - &[ - "logcat".to_string(), - "-d".to_string(), - "-t".to_string(), - lines.to_string(), - ], - ) -} - -fn android_screenshot(state: &McpState, arguments: &Value) -> Result { - let output_path = optional_string_argument(arguments, "path")? - .unwrap_or_else(|| "android-screenshot.png".to_string()); - let target_path = workspace_path(&state.workspace, &output_path)?; - let output = run_adb_raw( - state, - &[ - "exec-out".to_string(), - "screencap".to_string(), - "-p".to_string(), - ], - )?; - if !output.status.success() { - return Err(McpToolError::CommandFailed(command_failure( - "adb screenshot", - &output, - ))); - } - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).map_err(|error| McpToolError::Io(error.to_string()))?; - } - fs::write(&target_path, output.stdout).map_err(|error| McpToolError::Io(error.to_string()))?; - Ok(format!("screenshot written to {}", target_path.display())) -} - -fn android_install_apk(state: &McpState, arguments: &Value) -> Result { - if !state.allow_install { - return Err(McpToolError::InvalidArgument( - "APK installation requires --allow-install".to_string(), - )); - } - let apk_path = string_argument(arguments, "path")?; - let target_path = workspace_path(&state.workspace, &apk_path)?; - run_adb( - state, - &["install".to_string(), target_path.display().to_string()], - ) -} - -fn run_adb(state: &McpState, args: &[String]) -> Result { - let output = run_adb_raw(state, args)?; - output_to_text("adb", &output) -} - -fn run_adb_raw(state: &McpState, args: &[String]) -> Result { - if !state.adb_probe { - return Err(McpToolError::AdbProbeDisabled); - } - - let connect_output = Command::new("adb") - .arg("connect") - .arg(&state.spec.adb_endpoint) - .output() - .map_err(|error| { - McpToolError::CommandFailed(format!("failed to execute adb connect: {error}")) - })?; - if !connect_output.status.success() { - return Err(McpToolError::CommandFailed(command_failure( - "adb connect", - &connect_output, - ))); - } - - Command::new("adb") - .args(args) - .output() - .map_err(|error| McpToolError::CommandFailed(format!("failed to execute adb: {error}"))) -} - -fn output_to_text(label: &str, output: &Output) -> Result { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Ok(match (stdout.is_empty(), stderr.is_empty()) { - (true, true) => format!("{label} completed successfully"), - (false, true) => stdout, - (true, false) => stderr, - (false, false) => format!("{stdout}\n{stderr}"), - }); - } - - Err(McpToolError::CommandFailed(command_failure(label, output))) -} - -fn command_failure(label: &str, output: &Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - format!( - "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", - output.status.code(), - stdout.trim(), - stderr.trim() - ) -} - -fn integer_argument(arguments: &Value, name: &'static str) -> Result { - arguments - .get(name) - .and_then(Value::as_i64) - .ok_or(McpToolError::MissingArgument(name)) -} - -fn optional_integer_argument( - arguments: &Value, - name: &'static str, -) -> Result, McpToolError> { - arguments.get(name).map_or(Ok(None), |value| { - value - .as_i64() - .map(Some) - .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be an integer"))) - }) -} - -fn string_argument(arguments: &Value, name: &'static str) -> Result { - arguments - .get(name) - .and_then(Value::as_str) - .map(ToString::to_string) - .ok_or(McpToolError::MissingArgument(name)) -} - -fn optional_string_argument( - arguments: &Value, - name: &'static str, -) -> Result, McpToolError> { - arguments.get(name).map_or(Ok(None), |value| { - value - .as_str() - .map(|text| Some(text.to_string())) - .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be a string"))) - }) -} - -fn workspace_path(workspace: &Path, value: &str) -> Result { - let candidate = PathBuf::from(value); - if value.is_empty() - || candidate.is_absolute() - || candidate.components().any(|component| { - matches!( - component, - Component::Prefix(_) | Component::RootDir | Component::ParentDir - ) - }) - { - return Err(McpToolError::InvalidArgument( - "path must be relative, non-empty, and must not contain '..'".to_string(), - )); - } - - Ok(workspace.join(candidate)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{android_spec, DEFAULT_ANDROID_IMAGE}; - use std::io::Cursor; - - fn test_state() -> McpState { - McpState { - spec: android_spec( - "dg-test", - "docker-git-shared", - "dg-test-android:5555", - DEFAULT_ANDROID_IMAGE, - ) - .expect("valid android spec"), - workspace: PathBuf::from("/workspace"), - adb_probe: false, - allow_install: false, - } - } - - fn frame(value: &Value) -> String { - let payload = serde_json::to_string(&value).expect("serializable request"); - format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) - } - - #[test] - fn serves_initialize_and_tools_list_over_framed_stdio() { - let input = format!( - "{}{}", - frame(&json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": {} - })), - frame(&json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} - })) - ); - let mut reader = Cursor::new(input.into_bytes()); - let mut output = Vec::new(); - - run_stdio(&mut reader, &mut output, &test_state()).expect("stdio server succeeds"); - - let output_text = String::from_utf8(output).expect("valid utf8 output"); - assert!(output_text.contains(SERVER_NAME)); - assert!(output_text.contains("android_status")); - assert!(output_text.contains("android_tap")); - } - - #[test] - fn reports_status_without_adb_when_probe_is_disabled() { - let input = frame(&json!({ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "android_status", - "arguments": {} - } - })); - let mut reader = Cursor::new(input.into_bytes()); - let mut output = Vec::new(); - - run_stdio(&mut reader, &mut output, &test_state()).expect("status succeeds"); - - let output_text = String::from_utf8(output).expect("valid utf8 output"); - assert!(output_text.contains("\"isError\":false")); - assert!(output_text.contains("adbProbe")); - assert!(output_text.contains("false")); - assert!(output_text.contains("dg-test-android")); - } - - #[test] - fn rejects_workspace_paths_outside_workspace() { - let workspace = PathBuf::from("/workspace"); - - assert!(workspace_path(&workspace, "screenshots/current.png").is_ok()); - assert!(workspace_path(&workspace, "/tmp/outside.png").is_err()); - assert!(workspace_path(&workspace, "../outside.png").is_err()); - assert!(workspace_path(&workspace, "screenshots/../outside.png").is_err()); - } -} diff --git a/src/web/app.js b/src/web/app.js new file mode 100644 index 0000000..571abbd --- /dev/null +++ b/src/web/app.js @@ -0,0 +1,1480 @@ +import { + Adb, + AdbAuthType, + AdbCommand, + AdbDaemonTransport, + AdbPublicKeyAuthenticator, + AdbSignatureAuthenticator, + AdbSubprocessService, +} from "https://esm.sh/@yume-chan/adb@2.6.0?deps=@yume-chan/stream-extra@2.5.3"; +import { AdbDaemonWebUsbDeviceManager } from "https://esm.sh/@yume-chan/adb-daemon-webusb@2.3.2?deps=@yume-chan/adb@2.6.0,@yume-chan/stream-extra@2.5.3"; +import AdbWebCredentialStore from "https://esm.sh/@yume-chan/adb-credential-web@2.1.0?deps=@yume-chan/adb@2.6.0,@yume-chan/stream-extra@2.5.3"; + +const $ = (id) => document.getElementById(id); + +const elements = { + supportPill: $("support-pill"), + contextPill: $("context-pill"), + devicePill: $("device-pill"), + refreshDevices: $("refresh-devices"), + connectDevice: $("connect-device"), + forgetDevices: $("forget-devices"), + disconnectDevice: $("disconnect-device"), + copyDiagnostics: $("copy-diagnostics"), + connectionMessage: $("connection-message"), + knownDevices: $("known-devices"), + deviceSerial: $("device-serial"), + deviceModel: $("device-model"), + deviceAndroid: $("device-android"), + shellForm: $("shell-form"), + shellCommand: $("shell-command"), + runShell: $("run-shell"), + shellOutput: $("shell-output"), + bridgeActivity: $("bridge-activity"), + captureScreen: $("capture-screen"), + screenImage: $("screen-image"), + screenFrame: $("screen-image").closest(".screenshot-frame"), + downloadScreen: $("download-screen"), + sessionLog: $("session-log"), +}; + +const state = { + manager: null, + backend: null, + connection: null, + adb: null, + subprocess: null, + screenUrl: null, + connecting: false, + authPublicKeySent: false, + authPublicKeyResolve: null, + diagnostics: [], + instrumentedUsbDevices: new WeakSet(), + bridgeClientId: null, + bridgePollingEnabled: true, + bridgeBusy: false, + bridgeActivityLimit: 8, + installCounter: 0, +}; + +const runtimeFlags = { + usbDebug: new URLSearchParams(window.location.search).get("usb_debug") === "1", +}; + +const packageVersions = { + adb: "@yume-chan/adb@2.6.0 with @yume-chan/stream-extra@2.5.3", + webusb: "@yume-chan/adb-daemon-webusb@2.3.2 with shared @yume-chan/stream-extra@2.5.3", + credential: "@yume-chan/adb-credential-web@2.1.0 with shared @yume-chan/stream-extra@2.5.3", +}; + +const setPill = (element, text, tone) => { + element.textContent = text; + element.className = `status-pill ${tone}`; +}; + +const log = (message) => { + const now = new Date(); + const entry = { + time: now.toISOString(), + message, + }; + state.diagnostics.push(entry); + postClientLog(entry); + const time = now.toLocaleTimeString(); + elements.sessionLog.textContent = `${elements.sessionLog.textContent}[${time}] ${message}\n`; + elements.sessionLog.scrollTop = elements.sessionLog.scrollHeight; +}; + +const postClientLog = (entry) => { + const payload = JSON.stringify({ + ...entry, + location: window.location.href, + clientId: state.bridgeClientId || "-", + serial: elements.deviceSerial?.textContent || "-", + state: elements.devicePill?.textContent || "-", + }); + try { + if (navigator.sendBeacon) { + navigator.sendBeacon("/client-log", new Blob([payload], { type: "application/json" })); + return; + } + fetch("/client-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: payload, + keepalive: true, + }).catch(() => {}); + } catch { + /* diagnostics must not affect WebUSB control flow */ + } +}; + +const bridgeHeaders = (headers = {}) => + state.bridgeClientId + ? { ...headers, "X-Bridge-Client-Id": state.bridgeClientId } + : headers; + +const registerBridgeClient = async () => { + const response = await fetch("/bridge/client/session", { + method: "POST", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`bridge client session failed: HTTP ${response.status}`); + } + const payload = await response.json(); + state.bridgeClientId = payload.clientId || null; + state.bridgePollingEnabled = Boolean(state.bridgeClientId); + log(`Bridge client session: ${state.bridgeClientId || "unavailable"}`); +}; + +const setMessage = (message, tone = "") => { + elements.connectionMessage.textContent = message; + elements.connectionMessage.className = `connection-message ${tone}`; +}; + +const renderDisconnected = () => { + state.backend = null; + state.connection = null; + state.adb = null; + state.subprocess = null; + state.connecting = false; + setPill(elements.devicePill, "No device", "warn"); + elements.disconnectDevice.disabled = true; + elements.runShell.disabled = true; + elements.captureScreen.disabled = true; + elements.deviceSerial.textContent = "-"; + elements.deviceModel.textContent = "-"; + elements.deviceAndroid.textContent = "-"; +}; + +const renderConnected = (serial) => { + setPill(elements.devicePill, serial || "Connected", "ok"); + elements.disconnectDevice.disabled = false; + elements.runShell.disabled = false; + elements.captureScreen.disabled = false; + elements.deviceSerial.textContent = serial || "-"; +}; + +const renderAuthorizing = (serial) => { + setPill(elements.devicePill, "Authorizing", "warn"); + elements.disconnectDevice.disabled = true; + elements.runShell.disabled = true; + elements.captureScreen.disabled = true; + elements.deviceSerial.textContent = serial || "-"; +}; + +const recordDiagnosticError = (label, error) => { + log(`${label}: ${formatError(error)}`); + if (error instanceof Error && error.stack) { + state.diagnostics.push({ + time: new Date().toISOString(), + message: `${label} stack`, + stack: error.stack, + }); + } +}; + +const assertReady = () => { + if (!state.adb) { + throw new Error("Connect a phone first"); + } + return state.adb; +}; + +const instrumentAuthenticator = (label, authenticator, onResponse) => + async function* authenticatedWithDiagnostics(credentialStore, getNextRequest) { + let requestLogged = false; + const loggedGetNextRequest = async () => { + const packet = await getNextRequest(); + if (!requestLogged) { + requestLogged = true; + log( + `${label}: auth request type=${packet.arg0}, payload=${packet.payload?.byteLength || packet.payload?.length || 0} bytes`, + ); + } + return packet; + }; + + for await (const packet of authenticator(credentialStore, loggedGetNextRequest)) { + onResponse(packet); + yield packet; + } + }; + +const instrumentedSignatureAuthenticator = instrumentAuthenticator( + "Signature auth", + AdbSignatureAuthenticator, + (packet) => { + if (packet.command === AdbCommand.Auth && packet.arg0 === AdbAuthType.Signature) { + log(`ADB signature response sent to Android`); + } + }, +); + +const instrumentedPublicKeyAuthenticator = instrumentAuthenticator( + "Public key auth", + AdbPublicKeyAuthenticator, + (packet) => { + if (packet.command === AdbCommand.Auth && packet.arg0 === AdbAuthType.PublicKey) { + state.authPublicKeySent = true; + state.authPublicKeyResolve?.(); + state.authPublicKeyResolve = null; + log(`ADB public key sent to Android: ${packet.payload?.byteLength || packet.payload?.length || 0} bytes`); + setMessage( + "ADB public key was sent. Unlock the phone and allow USB debugging.", + "warn", + ); + } + }, +); + +const closeConnection = async (connection) => { + if (!connection) { + return; + } + try { + await connection.writable?.abort?.("closing stale WebUSB ADB connection"); + } catch (error) { + log(`WebUSB writable cleanup warning: ${formatError(error)}`); + } + try { + await connection.readable?.cancel?.("closing stale WebUSB ADB connection"); + } catch (error) { + log(`WebUSB readable cleanup warning: ${formatError(error)}`); + } + try { + await connection.device?.raw?.close?.(); + } catch (error) { + log(`USB device cleanup warning: ${formatError(error)}`); + } +}; + +const closeBackendRaw = async (backend) => { + try { + await backend?.raw?.close?.(); + } catch (error) { + log(`USB raw close warning: ${formatError(error)}`); + } +}; + +const logBackendDetails = (backend) => { + const raw = backend.raw; + if (!raw) { + log("USB device details unavailable"); + return; + } + const vendorId = raw.vendorId?.toString(16).padStart(4, "0") || "unknown"; + const productId = raw.productId?.toString(16).padStart(4, "0") || "unknown"; + log( + `USB device selected: vendor=0x${vendorId}, product=0x${productId}, name=${raw.productName || "-"}, serial=${raw.serialNumber || "-"}`, + ); +}; + +const delay = (milliseconds) => + new Promise((resolve) => { + window.setTimeout(resolve, milliseconds); + }); + +const resetWebUsbConnection = async (connection) => { + const raw = connection?.device?.raw; + const endpoint = connection?.outEndpoint; + if (!raw || !endpoint) { + log("ADB reset skipped: WebUSB endpoint details unavailable"); + return; + } + + const resetPacket = new Uint8Array(endpoint.packetSize || 64); + for (let attempt = 0; attempt < 10; attempt += 1) { + await raw.transferOut(endpoint.endpointNumber, resetPacket); + } + log(`ADB reset sent on USB OUT endpoint ${endpoint.endpointNumber}`); +}; + +const asUint8Array = (data) => { + if (data instanceof Uint8Array) { + return data; + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + return new Uint8Array(); +}; + +const consumeMaybe = async (value, consumer) => { + if (value && typeof value.tryConsume === "function") { + await value.tryConsume(consumer); + return; + } + await consumer(value?.value ?? value); +}; + +const calculateChecksum = (payload) => payload.reduce((sum, byte) => (sum + byte) >>> 0, 0); + +const readUint32LittleEndian = (bytes, offset) => + ( + bytes[offset] | + (bytes[offset + 1] << 8) | + (bytes[offset + 2] << 16) | + (bytes[offset + 3] << 24) + ) >>> 0; + +const hexPreview = (bytes, limit = 48) => + Array.from(bytes.slice(0, limit), (byte) => byte.toString(16).padStart(2, "0")).join(" "); + +const asciiPreview = (bytes, limit = 32) => + Array.from(bytes.slice(0, limit), (byte) => + byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : ".", + ).join(""); + +const adbFramePreview = (bytes) => { + if (bytes.byteLength < 24) { + return ""; + } + const command = readUint32LittleEndian(bytes, 0); + const magic = readUint32LittleEndian(bytes, 20); + if (((command ^ 0xffffffff) >>> 0) !== magic) { + return ""; + } + + const name = asciiPreview(bytes.slice(0, 4), 4); + const arg0 = readUint32LittleEndian(bytes, 4); + const arg1 = readUint32LittleEndian(bytes, 8); + const payloadLength = readUint32LittleEndian(bytes, 12); + return ` adb=${name} arg0=${arg0} arg1=${arg1} payload=${payloadLength}`; +}; + +const usbBytesPreview = (data) => { + const bytes = asUint8Array(data); + if (bytes.byteLength === 0) { + return "0 bytes"; + } + return `${bytes.byteLength} bytes${adbFramePreview(bytes)} hex=[${hexPreview(bytes)}] ascii="${asciiPreview(bytes)}"`; +}; + +const instrumentRawUsbDevice = (raw, label) => { + if (!raw || state.instrumentedUsbDevices.has(raw)) { + return; + } + + const originalTransferOut = raw.transferOut.bind(raw); + const originalTransferIn = raw.transferIn.bind(raw); + raw.transferOut = async (endpointNumber, data) => { + const startedAt = performance.now(); + log(`USB OUT ${label} ep=${endpointNumber}: ${usbBytesPreview(data)}`); + try { + const result = await originalTransferOut(endpointNumber, data); + log( + `USB OUT ${label} ep=${endpointNumber} complete: status=${result.status || "ok"} bytesWritten=${result.bytesWritten ?? "?"} elapsed=${Math.round(performance.now() - startedAt)}ms`, + ); + return result; + } catch (error) { + log( + `USB OUT ${label} ep=${endpointNumber} failed after ${Math.round(performance.now() - startedAt)}ms: ${formatError(error)}`, + ); + throw error; + } + }; + raw.transferIn = async (endpointNumber, length) => { + const startedAt = performance.now(); + log(`USB IN ${label} ep=${endpointNumber}: requested ${length} bytes`); + try { + const result = await originalTransferIn(endpointNumber, length); + log( + `USB IN ${label} ep=${endpointNumber} complete: status=${result.status || "ok"} ${usbBytesPreview(result.data)} elapsed=${Math.round(performance.now() - startedAt)}ms`, + ); + return result; + } catch (error) { + log( + `USB IN ${label} ep=${endpointNumber} failed after ${Math.round(performance.now() - startedAt)}ms: ${formatError(error)}`, + ); + throw error; + } + }; + state.instrumentedUsbDevices.add(raw); + log(`USB raw diagnostics enabled for ${label}`); +}; + +const writeUint32LittleEndian = (view, offset, value) => { + view.setUint32(offset, value >>> 0, true); +}; + +const serializeAdbPacket = (packet) => { + const payload = asUint8Array(packet?.payload); + const command = packet.command >>> 0; + const checksum = packet.checksum ?? calculateChecksum(payload); + const magic = packet.magic ?? ((command ^ 0xffffffff) >>> 0); + const header = new Uint8Array(24); + const view = new DataView(header.buffer); + writeUint32LittleEndian(view, 0, command); + writeUint32LittleEndian(view, 4, packet.arg0 ?? 0); + writeUint32LittleEndian(view, 8, packet.arg1 ?? 0); + writeUint32LittleEndian(view, 12, payload.byteLength); + writeUint32LittleEndian(view, 16, checksum); + writeUint32LittleEndian(view, 20, magic); + return { header, payload }; +}; + +const writeUsbChunk = async (raw, endpoint, chunk) => { + await raw.transferOut(endpoint.endpointNumber, chunk); + const packetSize = endpoint.packetSize || 64; + if (chunk.byteLength > 0 && chunk.byteLength % packetSize === 0) { + await raw.transferOut(endpoint.endpointNumber, new Uint8Array()); + } +}; + +const createConnectionWithLocalAdbSerializer = (connection) => { + const raw = connection.device?.raw; + const endpoint = connection.outEndpoint; + if (!raw || !endpoint) { + log("Local ADB serializer skipped: WebUSB endpoint details unavailable"); + return connection; + } + + const writable = new WritableStream({ + async write(packetOrConsumable) { + await consumeMaybe(packetOrConsumable, async (packet) => { + const { header, payload } = serializeAdbPacket(packet); + await writeUsbChunk(raw, endpoint, header); + if (payload.byteLength > 0) { + await writeUsbChunk(raw, endpoint, payload); + } + }); + }, + async close() { + await raw.close(); + }, + async abort() { + await raw.close(); + }, + }); + + log("Local ADB packet serializer enabled for WebUSB writes"); + return { + device: connection.device, + readable: connection.readable, + writable, + inEndpoint: connection.inEndpoint, + outEndpoint: connection.outEndpoint, + }; +}; + +const renderKnownDevices = (devices) => { + elements.knownDevices.replaceChildren(); + if (devices.length === 0) { + const item = document.createElement("li"); + item.textContent = "No authorized WebUSB devices"; + elements.knownDevices.append(item); + return; + } + for (const device of devices) { + const item = document.createElement("li"); + item.className = "known-device"; + const name = device.name || device.serial || "Android USB device"; + const label = document.createElement("span"); + label.textContent = device.serial ? `${name} (${device.serial})` : name; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Connect"; + button.addEventListener("click", () => { + button.disabled = true; + connectBackend(device) + .catch((error) => { + renderDisconnected(); + setMessage(`Connect failed: ${formatError(error)}`, "bad"); + recordDiagnosticError("Connect failed", error); + }) + .finally(() => { + button.disabled = false; + elements.connectDevice.disabled = false; + }); + }); + item.append(label, button); + elements.knownDevices.append(item); + } +}; + +const refreshKnownDevices = async () => { + if (!state.manager) { + return; + } + try { + const devices = await state.manager.getDevices(); + renderKnownDevices(devices); + log(`Known devices: ${devices.length}`); + } catch (error) { + log(`Device refresh failed: ${formatError(error)}`); + } +}; + +const forgetKnownDevices = async () => { + if (!state.manager) { + return; + } + try { + await disconnectDevice(); + const devices = await state.manager.getDevices(); + for (const device of devices) { + await device.raw?.forget?.(); + } + await clearCredentialStore(); + renderDisconnected(); + renderKnownDevices([]); + setMessage("USB browser permission and ADB browser keys were forgotten. Replug the phone, then click Connect phone.", "ok"); + log(`Forgot USB permission for ${devices.length} device(s) and cleared ADB browser keys`); + } catch (error) { + setMessage(`Forget USB permission failed: ${formatError(error)}`, "bad"); + recordDiagnosticError("Forget USB permission failed", error); + } +}; + +const clearCredentialStore = () => + new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase("Tango"); + request.onerror = () => reject(request.error || new Error("IndexedDB delete failed")); + request.onblocked = () => { + log("ADB browser key reset is blocked by another open tab for this origin"); + resolve(); + }; + request.onsuccess = () => resolve(); + }); + +const connectDevice = async () => { + try { + if (!state.manager) { + throw new Error("WebUSB is unavailable in this browser"); + } + if (state.connecting) { + throw new Error("A connection attempt is already in progress"); + } + elements.connectDevice.disabled = true; + if (!state.bridgePollingEnabled || !state.bridgeClientId) { + await registerBridgeClient(); + } + setMessage("Waiting for browser USB permission", "warn"); + log("Requesting USB device access"); + const backend = await state.manager.requestDevice(); + if (!backend) { + throw new Error("No USB device selected"); + } + await connectBackend(backend); + } catch (error) { + renderDisconnected(); + setMessage(`Connect failed: ${formatError(error)}`, "bad"); + recordDiagnosticError("Connect failed", error); + } finally { + elements.connectDevice.disabled = false; + } +}; + +const connectBackend = async (backend) => { + if (state.connecting && state.backend !== backend) { + throw new Error("A connection attempt is already in progress"); + } + state.connecting = true; + let activeBackend = backend; + const serial = backend.serial || backend.raw?.serialNumber || "Android device"; + elements.connectDevice.disabled = true; + renderAuthorizing(serial); + setMessage("Opening USB ADB interface", "warn"); + log(`Opening ADB transport for ${serial}`); + logBackendDetails(activeBackend); + log("Step: backend.connect()"); + try { + const authResult = await authenticateBackendWithRetries(activeBackend, serial); + activeBackend = authResult.backend; + const transport = authResult.transport; + log("Step: AdbDaemonTransport.authenticate() complete"); + state.backend = activeBackend; + state.connection = null; + state.adb = new Adb(transport); + state.subprocess = new AdbSubprocessService(state.adb); + renderConnected(serial); + await hydrateDeviceFacts(); + await refreshKnownDevices(); + setMessage("ADB connected", "ok"); + log("ADB connected. Accept the RSA prompt on the phone if Android asks."); + } catch (error) { + await closeBackendRaw(activeBackend); + throw error; + } finally { + state.connecting = false; + elements.connectDevice.disabled = false; + } +}; + +const authenticateBackendWithRetries = async (initialBackend, serial) => { + let currentBackend = initialBackend; + let lastError = null; + + for (let attempt = 1; attempt <= 3; attempt += 1) { + let rawConnection = null; + let connection = null; + try { + log(`ADB handshake attempt ${attempt}`); + const openResult = await openBackendConnection(currentBackend, serial); + currentBackend = openResult.backend; + rawConnection = openResult.connection; + log("Step: backend.connect() complete"); + if (runtimeFlags.usbDebug) { + instrumentRawUsbDevice(rawConnection.device?.raw, serial); + } + connection = createConnectionWithLocalAdbSerializer(rawConnection); + state.connection = connection; + state.authPublicKeySent = false; + + const credentialStore = new AdbWebCredentialStore("rust-android-connection"); + log("Step: AdbDaemonTransport.authenticate()"); + setMessage( + "Waiting for Android RSA authorization. Unlock the phone and tap Allow USB debugging.", + "warn", + ); + + const publicKeyPromise = new Promise((resolve) => { + state.authPublicKeyResolve = resolve; + }); + const authPromise = AdbDaemonTransport.authenticate({ + serial, + connection, + credentialStore, + authenticators: [ + instrumentedSignatureAuthenticator, + instrumentedPublicKeyAuthenticator, + ], + initialDelayedAckBytes: 0, + readTimeLimit: 1000, + }); + authPromise.catch(() => {}); + + const earlyResult = await Promise.race([ + authPromise.then((transport) => ({ type: "transport", transport })), + publicKeyPromise.then(() => ({ type: "public-key" })), + delay(9000).then(() => { + if (!state.authPublicKeySent) { + throw new Error("ADB handshake produced no response before Android authorization"); + } + return { type: "public-key" }; + }), + ]); + + if (earlyResult.type === "transport") { + state.connection = null; + state.authPublicKeyResolve = null; + return { + backend: currentBackend, + transport: earlyResult.transport, + }; + } + + const transport = await withTimeout( + authPromise, + 90000, + "ADB authorization timed out. Unlock the phone, check for the Allow USB debugging prompt, or revoke USB debugging authorizations and reconnect.", + { + onTimeout: () => closeBackendRaw(currentBackend), + onLateValue: (transportAfterTimeout) => transportAfterTimeout?.close?.(), + }, + ); + state.connection = null; + state.authPublicKeyResolve = null; + return { + backend: currentBackend, + transport, + }; + } catch (error) { + lastError = error; + recordDiagnosticError(`ADB handshake attempt ${attempt} failed`, error); + state.authPublicKeyResolve = null; + if (rawConnection && !state.authPublicKeySent) { + await resetWebUsbConnection(rawConnection).catch((resetError) => { + recordDiagnosticError("ADB handshake reset failed", resetError); + }); + } + await closeConnection(connection); + await closeBackendRaw(currentBackend); + state.connection = null; + + if (state.authPublicKeySent) { + break; + } + + await delay(900); + currentBackend = await findFreshBackendBySerial(serial, currentBackend).catch((refreshError) => { + recordDiagnosticError("WebUSB re-enumeration failed", refreshError); + return currentBackend; + }); + } + } + + throw lastError || new Error("ADB handshake failed"); +}; + +const openBackendConnection = async (initialBackend, serial) => { + let lastError = null; + let currentBackend = initialBackend; + + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + if (attempt > 1) { + log(`Retrying WebUSB open for ${serial}: attempt ${attempt}`); + await delay(800); + currentBackend = await findFreshBackendBySerial(serial, currentBackend); + logBackendDetails(currentBackend); + } + + const connection = await withTimeout( + currentBackend.connect(), + 15000, + "Opening WebUSB ADB interface timed out. Close adb.exe, Android Studio, Phone Link, and other phone tools, then replug USB.", + { + onTimeout: () => closeBackendRaw(currentBackend), + onLateValue: closeConnection, + }, + ); + return { + backend: currentBackend, + connection, + }; + } catch (error) { + lastError = error; + recordDiagnosticError(`backend.connect() attempt ${attempt} failed`, error); + await closeBackendRaw(currentBackend); + if (!isDisconnectedUsbError(error)) { + break; + } + } + } + + throw lastError || new Error("Opening WebUSB ADB interface failed"); +}; + +const findFreshBackendBySerial = async (serial, fallbackBackend) => { + if (!state.manager || serial === "Android device") { + return fallbackBackend; + } + + const devices = await state.manager.getDevices({ + filters: [{ serialNumber: serial }], + }); + const [freshBackend] = devices; + if (!freshBackend) { + throw new Error(`USB device ${serial} is not available after re-enumeration`); + } + return freshBackend; +}; + +const isDisconnectedUsbError = (error) => + formatError(error).toLowerCase().includes("device was disconnected"); + +const withTimeout = (operation, timeoutMs, message, options = {}) => + new Promise((resolve, reject) => { + let settled = false; + const timeout = window.setTimeout(() => { + settled = true; + Promise.resolve(options.onTimeout?.()) + .catch((error) => { + recordDiagnosticError("Timeout cleanup failed", error); + }) + .finally(() => reject(new Error(message))); + }, timeoutMs); + operation.then( + (value) => { + if (settled) { + Promise.resolve(options.onLateValue?.(value)).catch((error) => { + recordDiagnosticError("Late timeout value cleanup failed", error); + }); + return; + } + settled = true; + window.clearTimeout(timeout); + resolve(value); + }, + (error) => { + if (settled) { + return; + } + settled = true; + window.clearTimeout(timeout); + reject(error); + }, + ); + }); + +const disconnectDevice = async () => { + try { + await state.adb?.close?.(); + await closeConnection(state.connection); + } catch (error) { + recordDiagnosticError("Disconnect warning", error); + } finally { + renderDisconnected(); + log("Disconnected"); + } +}; + +const hydrateDeviceFacts = async () => { + try { + const model = await state.adb.getProp("ro.product.model"); + const android = await state.adb.getProp("ro.build.version.release"); + elements.deviceModel.textContent = model.trim() || "-"; + elements.deviceAndroid.textContent = android.trim() || "-"; + } catch (error) { + log(`Device facts unavailable: ${formatError(error)}`); + } +}; + +const shellText = async (command) => { + assertReady(); + if (state.subprocess?.noneProtocol?.spawnWaitText) { + return state.subprocess.noneProtocol.spawnWaitText(command); + } + if (state.subprocess?.shellProtocol?.isSupported && state.subprocess.shellProtocol.spawnWaitText) { + return state.subprocess.shellProtocol.spawnWaitText(command); + } + throw new Error("Current WebADB package does not expose shell subprocess APIs"); +}; + +const shellBytes = async (command) => { + assertReady(); + if (!state.subprocess?.noneProtocol?.spawnWait) { + throw new Error("Current WebADB package does not expose binary subprocess output"); + } + return state.subprocess.noneProtocol.spawnWait(command); +}; + +const bytesToBase64 = (bytes) => { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + const chunk = bytes.slice(offset, offset + chunkSize); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); +}; + +const displayScreenshotBytes = (bytes, label) => { + const blob = new Blob([bytes], { type: "image/png" }); + if (state.screenUrl) { + URL.revokeObjectURL(state.screenUrl); + } + state.screenUrl = URL.createObjectURL(blob); + elements.screenImage.src = state.screenUrl; + elements.downloadScreen.href = state.screenUrl; + elements.downloadScreen.hidden = false; + elements.screenFrame.classList.add("has-image"); + log(`${label}: ${blob.size} bytes`); + return { size: blob.size, url: state.screenUrl }; +}; + +const adbShellCommandLine = (parts) => { + if (parts.length === 0) { + return ""; + } + if (parts.length === 1) { + return parts[0]; + } + return parts.map((part) => { + if (/^[A-Za-z0-9_./:=,@%+-]+$/.test(part)) { + return part; + } + return `'${part.replaceAll("'", "'\\''")}'`; + }).join(" "); +}; + +const normalizedBridgeAdbArgs = (args) => { + const normalized = [...args]; + if (normalized[0] === "-s" && normalized.length >= 2) { + normalized.splice(0, 2); + } + if (normalized[0] === "-d" || normalized[0] === "-e") { + normalized.shift(); + } + if (normalized[0] === "wait-for-device") { + normalized.shift(); + } + return normalized; +}; + +const isBridgeScreenshotCommand = (normalized) => + normalized[0] === "exec-out" && normalized[1] === "screencap" && normalized.includes("-p"); + +const isBridgeInstallCommand = (normalized) => normalized[0] === "install"; + +const bridgeCommandLabel = (command) => { + const args = Array.isArray(command.args) ? command.args : []; + return [command.kind, ...args].join(" ").trim(); +}; + +const previewText = (text, limit = 900) => { + const value = String(text).trim(); + if (value.length === 0) { + return ""; + } + return value.length > limit ? `${value.slice(0, limit)}\n...` : value; +}; + +const base64ByteLength = (value) => { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return Math.max(0, Math.floor((value.length * 3) / 4) - padding); +}; + +const removeBridgeActivityItem = (item) => { + item.querySelectorAll("img[data-object-url]").forEach((image) => { + URL.revokeObjectURL(image.dataset.objectUrl); + }); + item.remove(); +}; + +const addBridgeActivity = (command) => { + if (!elements.bridgeActivity) { + return null; + } + + const empty = elements.bridgeActivity.querySelector(".activity-empty"); + empty?.remove(); + + const item = document.createElement("li"); + item.className = "activity-item warn"; + + const step = document.createElement("div"); + step.className = "activity-step"; + step.textContent = command.id || "?"; + + const flow = document.createElement("div"); + flow.className = "activity-flow"; + + const callCard = document.createElement("section"); + callCard.className = "activity-card"; + const callHeader = document.createElement("div"); + callHeader.className = "activity-card-header"; + const callTitle = document.createElement("span"); + callTitle.className = "activity-card-title"; + callTitle.textContent = "Tool call"; + callHeader.append(callTitle); + const callCode = document.createElement("pre"); + callCode.className = "activity-code"; + callCode.textContent = `rust-android-connection phone ${bridgeCommandLabel(command)}`; + callCard.append(callHeader, callCode); + + const arrow = document.createElement("div"); + arrow.className = "activity-arrow"; + arrow.textContent = "->"; + + const resultCard = document.createElement("section"); + resultCard.className = "activity-card activity-result warn"; + const resultHeader = document.createElement("div"); + resultHeader.className = "activity-card-header"; + const resultTitle = document.createElement("span"); + resultTitle.className = "activity-card-title"; + resultTitle.textContent = "Result"; + const status = document.createElement("span"); + status.className = "activity-status warn"; + status.textContent = "running"; + resultHeader.append(resultTitle, status); + + const detail = document.createElement("pre"); + detail.className = "activity-detail"; + detail.textContent = `Started at ${new Date().toLocaleTimeString()}`; + resultCard.append(resultHeader, detail); + + flow.append(callCard, arrow, resultCard); + item.append(step, flow); + elements.bridgeActivity.prepend(item); + while (elements.bridgeActivity.children.length > state.bridgeActivityLimit) { + removeBridgeActivityItem(elements.bridgeActivity.lastElementChild); + } + + return { item, status, resultCard, detail }; +}; + +const updateBridgeActivity = (activity, tone, status, detail, ui = {}) => { + if (!activity) { + return; + } + activity.item.className = `activity-item ${tone}`; + activity.resultCard.className = `activity-card activity-result ${tone}`; + activity.status.className = `activity-status ${tone}`; + activity.status.textContent = status; + activity.detail.textContent = detail || "Completed with no output"; + const previousImage = activity.resultCard.querySelector(".activity-screenshot"); + if (previousImage?.dataset.objectUrl) { + URL.revokeObjectURL(previousImage.dataset.objectUrl); + } + previousImage?.remove(); + if (ui.screenshot?.url) { + const image = document.createElement("img"); + image.className = "activity-screenshot"; + image.src = ui.screenshot.url; + image.dataset.objectUrl = ui.screenshot.url; + image.alt = "Android screenshot result"; + activity.resultCard.append(image); + } +}; + +const bridgeResultDetail = (command, result) => { + if (result.error) { + return result.error; + } + if (result.stderr) { + return previewText(result.stderr); + } + if (result.stdout) { + return previewText(result.stdout) || "Completed with empty output"; + } + if (result.ui?.screenshot) { + return `PNG displayed in Screenshot panel (${result.ui.screenshot.size} bytes)`; + } + if (result.stdoutBase64) { + const normalized = command.kind === "adb" ? normalizedBridgeAdbArgs(command.args || []) : []; + const bytes = base64ByteLength(result.stdoutBase64); + if (isBridgeScreenshotCommand(normalized)) { + return `Screenshot displayed in Screenshot panel (${bytes} bytes)`; + } + return `Binary output returned to CLI (${bytes} bytes)`; + } + return "Completed with no output"; +}; + +const safeRemoteApkName = (name) => { + const sanitized = String(name || "app.apk").replace(/[^A-Za-z0-9._-]/g, "_"); + return sanitized.endsWith(".apk") ? sanitized : `${sanitized}.apk`; +}; + +const installRemotePath = (file) => { + state.installCounter += 1; + return `/data/local/tmp/rust-android-connection-${Date.now()}-${state.installCounter}-${safeRemoteApkName(file?.name)}`; +}; + +const adbInstallFlags = (normalized) => { + if (normalized.length < 2) { + throw new Error("adb install requires an APK path"); + } + return normalized.slice(1, -1); +}; + +const streamBytesToWritable = async (bytes, writable, label) => { + const writer = writable.getWriter(); + const chunkSize = 64 * 1024; + let nextProgress = 16 * 1024 * 1024; + try { + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + const end = Math.min(offset + chunkSize, bytes.byteLength); + await writer.write(bytes.subarray(offset, end)); + if (end >= nextProgress || end === bytes.byteLength) { + log(`${label}: streamed ${end}/${bytes.byteLength} bytes`); + nextProgress += 16 * 1024 * 1024; + } + } + await writer.close(); + } finally { + writer.releaseLock(); + } +}; + +const installBridgeFileViaPmStdin = async (bytes, flags) => { + if (!state.subprocess?.shellProtocol?.isSupported || !state.subprocess.shellProtocol.spawn) { + return null; + } + const command = ["pm", "install", "-S", String(bytes.byteLength), ...flags]; + log(`Install: ${adbShellCommandLine(command)} < APK`); + const process = await state.subprocess.shellProtocol.spawn(command); + const stdoutChunks = []; + const stdoutPromise = process.stdout.pipeThrough(new TextDecoderStream()).pipeTo(new WritableStream({ + write(chunk) { + stdoutChunks.push(chunk); + log(`Install stdout: ${previewText(chunk, 300)}`); + }, + })); + const stderrChunks = []; + const stderrPromise = process.stderr.pipeThrough(new TextDecoderStream()).pipeTo(new WritableStream({ + write(chunk) { + stderrChunks.push(chunk); + log(`Install stderr: ${previewText(chunk, 300)}`); + }, + })); + await streamBytesToWritable(bytes, process.stdin, "Install stdin"); + const exitCode = await process.exited; + await Promise.allSettled([stdoutPromise, stderrPromise]); + return { + exitCode, + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + }; +}; + +const pushBridgeFile = async (file, remotePath) => { + if (!file?.url) { + throw new Error("Bridge install command did not include an APK attachment"); + } + const response = await fetch(file.url, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`APK download failed: HTTP ${response.status}`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + log(`Install: downloaded APK attachment (${bytes.byteLength} bytes)`); + if (file.size && bytes.byteLength !== file.size) { + throw new Error(`APK download size mismatch: expected ${file.size}, got ${bytes.byteLength}`); + } + return bytes; +}; + +const pushBridgeFileViaSync = async (bytes, remotePath) => { + const chunkSize = 64 * 1024; + let offset = 0; + let nextProgress = 16 * 1024 * 1024; + const apkStream = new ReadableStream({ + pull(controller) { + if (offset >= bytes.byteLength) { + controller.close(); + return; + } + const end = Math.min(offset + chunkSize, bytes.byteLength); + controller.enqueue(bytes.subarray(offset, end)); + offset = end; + if (offset >= nextProgress || offset === bytes.byteLength) { + log(`Install sync: streamed ${offset}/${bytes.byteLength} bytes to ADB sync`); + nextProgress += 16 * 1024 * 1024; + } + }, + }); + const sync = await state.adb.sync(); + try { + log(`Install: pushing APK to Android sync (${bytes.byteLength} bytes)`); + await sync.write({ + filename: remotePath, + file: apkStream, + permission: 0o644, + }); + } finally { + await sync.dispose(); + } + log("Install: APK push complete"); +}; + +const executeBridgeInstallCommand = async (command, normalized) => { + assertReady(); + const remotePath = installRemotePath(command.file); + const size = command.file?.size ? `${Math.round(command.file.size / 1024 / 1024)} MiB` : "unknown size"; + log(`Install: pushing ${command.file?.name || "APK"} (${size}) to ${remotePath}`); + const flags = adbInstallFlags(normalized); + const bytes = await pushBridgeFile(command.file, remotePath); + const stdinInstall = await installBridgeFileViaPmStdin(bytes, flags); + if (stdinInstall) { + return stdinInstall.exitCode === 0 + ? { exitCode: 0, stdout: "Success\n" } + : { + exitCode: stdinInstall.exitCode, + stderr: stdinInstall.stderr || stdinInstall.stdout || "pm install failed without output", + }; + } + await pushBridgeFileViaSync(bytes, remotePath); + const installCommand = adbShellCommandLine(["pm", "install", ...flags, remotePath]); + log(`Install: ${installCommand}`); + const stdout = await shellText(installCommand); + const cleanupCommand = adbShellCommandLine(["rm", "-f", remotePath]); + try { + await shellText(cleanupCommand); + } catch (error) { + log(`Install cleanup failed: ${formatError(error)}`); + } + const success = /\bSuccess\b/.test(stdout); + return success + ? { exitCode: 0, stdout } + : { exitCode: 1, stderr: stdout || "pm install failed without output" }; +}; + +const bridgeProtocolResult = (result) => { + const { ui, ...protocolResult } = result; + return protocolResult; +}; + +const executeBridgeAdbCommand = async (command) => { + const normalized = normalizedBridgeAdbArgs(command.args || []); + const serial = elements.deviceSerial.textContent.trim(); + const model = elements.deviceModel.textContent.trim(); + if (normalized.length === 0 || normalized[0] === "devices") { + return { + exitCode: 0, + stdout: `List of devices attached\n${serial}\tdevice product:${model || "Android"} model:${model || "Android"}\n`, + }; + } + if (normalized[0] === "get-state") { + return { exitCode: 0, stdout: "device\n" }; + } + if (normalized[0] === "get-serialno") { + return { exitCode: 0, stdout: `${serial}\n` }; + } + if (isBridgeInstallCommand(normalized)) { + return executeBridgeInstallCommand(command, normalized); + } + if (normalized[0] === "shell") { + const command = adbShellCommandLine(normalized.slice(1)); + const stdout = await shellText(command); + elements.shellCommand.value = command; + elements.shellOutput.textContent = `$ ${command}\n${stdout}`; + return { exitCode: 0, stdout }; + } + if (normalized[0] === "exec-out") { + const command = adbShellCommandLine(normalized.slice(1)); + const bytes = await shellBytes(command); + const ui = {}; + if (isBridgeScreenshotCommand(normalized)) { + const display = displayScreenshotBytes(bytes, "Bridge screenshot captured"); + ui.screenshot = { + size: display.size, + url: URL.createObjectURL(new Blob([bytes], { type: "image/png" })), + }; + } + return { exitCode: 0, stdoutBase64: bytesToBase64(bytes), ui }; + } + return { + exitCode: 1, + stderr: `Unsupported browser-bridge adb command: ${normalized.join(" ")}\nSupported: devices, get-state, get-serialno, install, shell, exec-out.\n`, + }; +}; + +const executeBridgeCommand = async (command) => { + if (command.kind === "adb") { + return executeBridgeAdbCommand(command); + } + return { + exitCode: 1, + stderr: `Unsupported bridge command kind: ${command.kind}\n`, + }; +}; + +const postBridgeResult = async (result) => { + await fetch("/bridge/commands/result", { + method: "POST", + headers: bridgeHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(result), + }); +}; + +const processNextBridgeCommand = async () => { + if (!state.bridgePollingEnabled || state.bridgeBusy) { + return; + } + state.bridgeBusy = true; + try { + const response = await fetch("/bridge/commands/next", { + cache: "no-store", + headers: bridgeHeaders(), + }); + if (response.status === 409) { + state.bridgePollingEnabled = false; + state.bridgeClientId = null; + await disconnectDevice(); + setMessage("Another browser tab owns this bridge session. Click Connect phone to take over.", "warn"); + log("Bridge client session lost to a newer browser tab"); + return; + } + if (!response.ok) { + throw new Error(`bridge poll failed: HTTP ${response.status}`); + } + const payload = await response.json(); + const command = payload.command; + if (!command) { + return; + } + + const activity = addBridgeActivity(command); + log(`Bridge command ${command.id}: ${command.kind} ${(command.args || []).join(" ")}`); + if (!state.adb) { + const result = { + exitCode: 1, + stderr: "ADB is not connected. Reconnect the phone in the browser tab.\n", + error: "ADB not connected", + }; + await postBridgeResult({ id: command.id, ...result }); + updateBridgeActivity(activity, "bad", "failed", result.stderr); + log(`Bridge command ${command.id} failed: ${result.error}`); + return; + } + try { + const result = await executeBridgeCommand(command); + await postBridgeResult({ id: command.id, ...bridgeProtocolResult(result) }); + const ok = result.exitCode === 0 && !result.error; + updateBridgeActivity( + activity, + ok ? "ok" : "bad", + ok ? "done" : "failed", + bridgeResultDetail(command, result), + result.ui, + ); + log(`Bridge command ${command.id} completed`); + } catch (error) { + await postBridgeResult({ + id: command.id, + exitCode: 1, + stderr: "", + error: formatError(error), + }); + updateBridgeActivity(activity, "bad", "failed", formatError(error)); + recordDiagnosticError(`Bridge command ${command.id} failed`, error); + } + } catch (error) { + recordDiagnosticError("Bridge poll failed", error); + } finally { + state.bridgeBusy = false; + } +}; + +const runShellCommand = async (event) => { + event.preventDefault(); + const command = elements.shellCommand.value.trim(); + if (command.length === 0) { + return; + } + try { + elements.runShell.disabled = true; + elements.shellOutput.textContent = `$ ${command}\n`; + const output = await shellText(command); + elements.shellOutput.textContent += output; + log(`Shell command completed: ${command}`); + } catch (error) { + elements.shellOutput.textContent += formatError(error); + log(`Shell command failed: ${formatError(error)}`); + } finally { + elements.runShell.disabled = !state.adb; + } +}; + +const captureScreenshot = async () => { + try { + elements.captureScreen.disabled = true; + log("Capturing screen"); + const bytes = await shellBytes("screencap -p"); + displayScreenshotBytes(bytes, "Screenshot captured"); + } catch (error) { + log(`Screenshot failed: ${formatError(error)}`); + } finally { + elements.captureScreen.disabled = !state.adb; + } +}; + +const formatError = (error) => { + if (error instanceof Error) { + return error.message; + } + return String(error); +}; + +const diagnosticsPayload = () => { + const usbDevices = Array.from(elements.knownDevices.querySelectorAll("li")).map((item) => + item.textContent.trim(), + ); + return { + app: "rust-android-connection web", + location: window.location.href, + secureContext: window.isSecureContext, + webUsbSupported: "usb" in navigator, + packages: packageVersions, + device: { + serial: elements.deviceSerial.textContent, + model: elements.deviceModel.textContent, + android: elements.deviceAndroid.textContent, + pill: elements.devicePill.textContent, + }, + knownDevices: usbDevices, + message: elements.connectionMessage.textContent, + log: state.diagnostics, + }; +}; + +const copyDiagnostics = async () => { + const payload = JSON.stringify(diagnosticsPayload(), null, 2); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(payload); + } else { + const textarea = document.createElement("textarea"); + textarea.value = payload; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + setMessage("Diagnostics copied to clipboard", "ok"); + log("Diagnostics copied to clipboard"); + } catch (error) { + setMessage(`Copy diagnostics failed: ${formatError(error)}`, "bad"); + recordDiagnosticError("Copy diagnostics failed", error); + } +}; + +const initialize = async () => { + await registerBridgeClient(); + const webUsbSupported = "usb" in navigator; + setPill( + elements.supportPill, + webUsbSupported ? "WebUSB available" : "WebUSB unavailable", + webUsbSupported ? "ok" : "bad", + ); + setPill( + elements.contextPill, + window.isSecureContext ? "Secure context" : "Needs localhost or HTTPS", + window.isSecureContext ? "ok" : "bad", + ); + renderDisconnected(); + + if (!webUsbSupported || !window.isSecureContext) { + elements.refreshDevices.disabled = true; + elements.connectDevice.disabled = true; + elements.forgetDevices.disabled = true; + renderKnownDevices([]); + log("Use Chromium over localhost or HTTPS. Host ADB is not required."); + return; + } + + state.manager = AdbDaemonWebUsbDeviceManager.BROWSER; + if (!state.manager) { + elements.refreshDevices.disabled = true; + elements.connectDevice.disabled = true; + elements.forgetDevices.disabled = true; + renderKnownDevices([]); + log("WebUSB device manager is unavailable in this browser context."); + return; + } + elements.refreshDevices.addEventListener("click", refreshKnownDevices); + elements.connectDevice.addEventListener("click", connectDevice); + elements.forgetDevices.addEventListener("click", forgetKnownDevices); + elements.disconnectDevice.addEventListener("click", disconnectDevice); + elements.copyDiagnostics.addEventListener("click", copyDiagnostics); + elements.shellForm.addEventListener("submit", runShellCommand); + elements.captureScreen.addEventListener("click", captureScreenshot); + log( + `Packages: ${packageVersions.adb}, ${packageVersions.webusb}, ${packageVersions.credential}`, + ); + navigator.usb.addEventListener("connect", (event) => { + log(`USB connected: ${event.device.productName || event.device.serialNumber || "device"}`); + }); + navigator.usb.addEventListener("disconnect", (event) => { + log(`USB disconnected: ${event.device.productName || event.device.serialNumber || "device"}`); + if (state.backend?.raw === event.device) { + renderDisconnected(); + setMessage("USB device disconnected", "warn"); + } + }); + window.setInterval(processNextBridgeCommand, 750); + await refreshKnownDevices(); +}; + +initialize().catch((error) => { + setPill(elements.supportPill, "Initialization failed", "bad"); + recordDiagnosticError("Initialization failed", error); +}); + +window.addEventListener("error", (event) => { + recordDiagnosticError("Browser error", event.error || event.message); +}); + +window.addEventListener("unhandledrejection", (event) => { + recordDiagnosticError("Unhandled promise rejection", event.reason); +}); diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..7a218d5 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,107 @@ + + + + + + rust-android-connection + + + +
+
+
+
+

WebUSB Android bridge

+

rust-android-connection

+
+
+ Checking WebUSB + Checking context + No device +
+
+ +
+ + + + +
+

+ +
+
+

Device

+
+
+
Serial
+
-
+
+
+
Model
+
-
+
+
+
Android
+
-
+
+
+
+

Known USB devices

+
    +
    +
    + +
    +

    ADB shell

    +
    + + +
    +
    
    +          
    + +
    +

    Remote activity

    + +
      +
    1. No remote commands yet
    2. +
    +
    + + + +
    +
    +

    Session log

    + +
    +
    
    +          
    +
    +
    +
    + + + diff --git a/src/web/styles.css b/src/web/styles.css new file mode 100644 index 0000000..7045c2a --- /dev/null +++ b/src/web/styles.css @@ -0,0 +1,532 @@ +:root { + color-scheme: light; + --bg: #f5f7fa; + --panel: #ffffff; + --text: #17202a; + --muted: #5f6d7a; + --line: #d7dee8; + --accent: #1768ac; + --accent-strong: #0d4f86; + --ok: #1b7f4a; + --warn: #9a5b00; + --bad: #b42318; + --terminal: #111820; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +button, +input { + font: inherit; +} + +button { + min-height: 40px; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + color: var(--text); + cursor: pointer; + padding: 0 14px; +} + +button:hover:not(:disabled) { + border-color: var(--accent); +} + +button:disabled { + color: #8b98a6; + cursor: not-allowed; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +button.primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.shell { + min-height: 100vh; + padding: 24px; +} + +.workspace { + width: min(1180px, 100%); + margin: 0 auto; +} + +.masthead { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--muted); + font-size: 13px; + text-transform: uppercase; +} + +h1, +h2, +h3 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 32px; + line-height: 1.15; +} + +h2 { + font-size: 18px; +} + +h3 { + font-size: 14px; + margin-bottom: 10px; +} + +.status-strip { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.status-pill { + min-height: 28px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--muted); + display: inline-flex; + align-items: center; + padding: 0 10px; + font-size: 13px; +} + +.status-pill.ok { + border-color: #9bd4b7; + color: var(--ok); +} + +.status-pill.warn { + border-color: #e6c06f; + color: var(--warn); +} + +.status-pill.bad { + border-color: #f2aaa4; + color: var(--bad); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + +.connection-message { + min-height: 22px; + margin: -8px 0 16px; + color: var(--muted); +} + +.connection-message.ok { + color: var(--ok); +} + +.connection-message.warn { + color: var(--warn); +} + +.connection-message.bad { + color: var(--bad); +} + +.grid { + display: grid; + grid-template-columns: minmax(260px, 0.85fr) minmax(360px, 1.15fr); + gap: 14px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.panel-header button { + flex: 0 0 auto; +} + +.device-facts { + display: grid; + gap: 10px; + margin: 16px 0 20px; +} + +.device-facts div { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 10px; +} + +dt { + color: var(--muted); +} + +dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.known-devices ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.known-devices li { + min-height: 34px; + border: 1px solid var(--line); + border-radius: 6px; + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + padding: 0 10px; + color: var(--muted); + overflow-wrap: anywhere; +} + +.known-devices li span { + min-width: 0; +} + +.known-devices li button { + min-height: 30px; + padding: 0 10px; + flex: 0 0 auto; +} + +.command-form { + display: grid; + grid-template-columns: minmax(0, 1fr) 80px; + gap: 10px; + margin: 16px 0 12px; +} + +input { + width: 100%; + min-height: 40px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0 12px; + color: var(--text); +} + +input:focus { + outline: 2px solid rgba(23, 104, 172, 0.22); + border-color: var(--accent); +} + +.terminal { + min-height: 170px; + max-height: 300px; + overflow: auto; + margin: 0; + border-radius: 6px; + background: var(--terminal); + color: #eef5fb; + padding: 12px; + font: + 13px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.activity-panel { + grid-column: 1 / -1; +} + +.activity-lanes { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 58px; + margin-top: 16px; + color: var(--muted); + font-size: 13px; + font-weight: 700; + text-transform: uppercase; +} + +.activity-lanes span:last-child { + text-align: right; +} + +.activity-list { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: grid; + gap: 18px; + max-height: 520px; + overflow: auto; +} + +.activity-empty { + min-height: 48px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + display: flex; + align-items: center; + padding: 0 14px; +} + +.activity-item { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.activity-step { + width: 34px; + height: 34px; + border-radius: 50%; + background: #101820; + color: #ffffff; + display: grid; + place-items: center; + font-weight: 800; +} + +.activity-flow { + display: grid; + grid-template-columns: minmax(220px, 1fr) 46px minmax(220px, 1fr); + gap: 12px; + align-items: center; +} + +.activity-card { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 1px 3px rgba(16, 24, 32, 0.08); + padding: 12px; + display: grid; + gap: 10px; +} + +.activity-card.ok { + border-color: #9bd4b7; +} + +.activity-card.warn { + border-color: #e6c06f; +} + +.activity-card.bad { + border-color: #f2aaa4; +} + +.activity-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.activity-card-title { + font-weight: 750; +} + +.activity-status { + min-height: 22px; + border: 1px solid var(--line); + border-radius: 999px; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 0 8px; + font-size: 12px; + color: var(--muted); +} + +.activity-status.ok { + border-color: #9bd4b7; + color: var(--ok); +} + +.activity-status.warn { + border-color: #e6c06f; + color: var(--warn); +} + +.activity-status.bad { + border-color: #f2aaa4; + color: var(--bad); +} + +.activity-code, +.activity-detail { + margin: 0; + overflow: auto; + color: var(--text); + font: + 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.activity-code { + max-height: 86px; + border-radius: 6px; + background: #eef2f6; + padding: 10px; +} + +.activity-detail { + max-height: 120px; + color: var(--muted); +} + +.activity-arrow { + min-width: 0; + color: var(--muted); + font-size: 24px; + text-align: center; +} + +.activity-screenshot { + width: min(220px, 100%); + max-height: 260px; + object-fit: contain; + border: 1px solid var(--line); + border-radius: 8px; + background: #eef2f6; +} + +.screenshot-actions { + display: flex; + align-items: center; + gap: 12px; + margin: 16px 0 12px; +} + +.download-link { + color: var(--accent-strong); +} + +.screenshot-frame { + min-height: 320px; + border: 1px solid var(--line); + border-radius: 8px; + background: #eef2f6; + display: grid; + place-items: center; + overflow: hidden; +} + +.screenshot-frame img { + display: none; + max-width: 100%; + max-height: 560px; + object-fit: contain; +} + +.screenshot-frame.has-image img { + display: block; +} + +.screenshot-frame.has-image p { + display: none; +} + +#screen-empty { + color: var(--muted); +} + +.log-panel { + grid-column: 1 / -1; +} + +.log-panel .terminal { + min-height: 140px; +} + +@media (max-width: 820px) { + .shell { + padding: 16px; + } + + .masthead { + align-items: flex-start; + flex-direction: column; + } + + .status-strip { + justify-content: flex-start; + } + + .grid { + grid-template-columns: 1fr; + } + + .command-form { + grid-template-columns: 1fr; + } + + .activity-lanes { + display: none; + } + + .activity-item { + grid-template-columns: 34px minmax(0, 1fr); + } + + .activity-flow { + grid-template-columns: 1fr; + } + + .activity-arrow { + display: none; + } +} diff --git a/tests/integration/cli.rs b/tests/integration/cli.rs index 5727bb0..a70381c 100644 --- a/tests/integration/cli.rs +++ b/tests/integration/cli.rs @@ -4,9 +4,8 @@ use std::process::Command; #[test] fn lifecycle_cli_renders_status_json() { let output = lifecycle_output(&[ - "status", - "--project", "dg-test", + "status", "--endpoint", "dg-test-android:5555", "--novnc-port", @@ -26,14 +25,15 @@ fn lifecycle_cli_renders_status_json() { assert_eq!(json["resourceLimits"]["memory"], "3g"); assert_eq!(json["resourceLimits"]["memorySwap"], "3g"); assert_eq!(json["resourceLimits"]["cpus"], "1.0"); + assert_eq!(json["runtime"]["profile"], "interactive"); + assert_eq!(json["runtime"]["emulatorHeadless"], false); } #[test] fn lifecycle_cli_can_disable_no_vnc_publication() { let output = lifecycle_output(&[ - "status", - "--project", "dg-test", + "status", "--endpoint", "dg-test-android:5555", "--no-novnc-publish", @@ -50,9 +50,8 @@ fn lifecycle_cli_can_disable_no_vnc_publication() { #[test] fn start_dry_run_includes_publish_and_no_vnc_url_json() { let output = lifecycle_output(&[ - "start", - "--project", "dg-test", + "start", "--endpoint", "dg-test-android:5555", "--novnc-port", @@ -101,14 +100,14 @@ fn start_dry_run_includes_publish_and_no_vnc_url_json() { assert_eq!(json["resourceLimits"]["memory"], "3g"); assert_eq!(json["resourceLimits"]["memorySwap"], "3g"); assert_eq!(json["resourceLimits"]["cpus"], "1.0"); + assert_eq!(json["runtime"]["profile"], "interactive"); } #[test] fn start_dry_run_accepts_custom_resource_limits() { let output = lifecycle_output(&[ - "start", - "--project", "dg-test", + "start", "--endpoint", "dg-test-android:5555", "--memory", @@ -143,9 +142,102 @@ fn start_dry_run_accepts_custom_resource_limits() { assert_eq!(json["resourceLimits"]["cpus"], "2.0"); } +#[test] +fn start_dry_run_supports_app_test_runtime_profile() { + let output = lifecycle_output(&[ + "dg-test", + "start", + "--endpoint", + "dg-test-android:5555", + "--runtime-profile", + "app-test", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let docker_args = json["docker"] + .as_array() + .expect("docker args array") + .iter() + .filter_map(Value::as_str) + .collect::>(); + + assert_eq!(json["runtime"]["profile"], "app-test"); + assert_eq!(json["runtime"]["emulatorHeadless"], true); + assert_eq!(json["noVncPublished"], false); + assert!(json["noVncUrl"].is_null()); + assert!(!docker_args.contains(&"--publish")); + assert!(docker_args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=true"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--env", "APPIUM=false"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--env", "WEB_LOG=false"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=false"])); + assert!(docker_args + .last() + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); +} + +#[test] +fn start_dry_run_supports_app_test_vnc_runtime_profile() { + let output = lifecycle_output(&[ + "dg-test", + "start", + "--endpoint", + "dg-test-android:5555", + "--runtime-profile", + "app-test-vnc", + "--novnc-port", + "16080", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let docker_args = json["docker"] + .as_array() + .expect("docker args array") + .iter() + .filter_map(Value::as_str) + .collect::>(); + + assert_eq!(json["runtime"]["profile"], "app-test-vnc"); + assert_eq!(json["runtime"]["emulatorHeadless"], false); + assert_eq!(json["runtime"]["appiumEnabled"], false); + assert_eq!(json["runtime"]["webLogEnabled"], false); + assert_eq!(json["runtime"]["webVncEnabled"], true); + assert_eq!(json["noVncPublished"], true); + assert_eq!( + json["noVncUrl"], + "http://127.0.0.1:16080/?autoconnect=true&resize=remote" + ); + assert!(docker_args + .windows(2) + .any(|window| window == ["--publish", "127.0.0.1:16080:6081"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(docker_args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); + assert!(docker_args + .last() + .is_some_and(|argument| argument.contains("socat TCP-LISTEN:6081"))); + assert!(docker_args + .last() + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); +} + #[test] fn lifecycle_cli_rejects_invalid_no_vnc_port() { - let output = lifecycle_output(&["status", "--novnc-port", "0"]); + let output = lifecycle_output(&["dg-test", "status", "--novnc-port", "0"]); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -154,16 +246,73 @@ fn lifecycle_cli_rejects_invalid_no_vnc_port() { #[test] fn lifecycle_cli_rejects_invalid_cpu_limit() { - let output = lifecycle_output(&["status", "--cpus", "0"]); + let output = lifecycle_output(&["dg-test", "status", "--cpus", "0"]); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("CPU limit must be a positive finite number")); } +#[test] +fn lifecycle_cli_rejects_invalid_runtime_profile() { + let output = lifecycle_output(&["dg-test", "status", "--runtime-profile", "unknown"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("runtime profile must be one of")); +} + +#[test] +fn web_dry_run_renders_browser_connector_json() { + let output = cli_output(&["web", "--bind", "0.0.0.0", "--port", "18080", "--dry-run"]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + + assert_eq!(json["bindHost"], "0.0.0.0"); + assert_eq!(json["port"], 18080); + assert_eq!(json["url"], "http://127.0.0.1:18080/"); + assert_eq!(json["requiresAdb"], false); + assert_eq!(json["requiresBrowser"], "Chromium WebUSB"); + assert_eq!(json["secureContext"], "localhost-or-https"); + assert_eq!(json["phoneBridge"], true); +} + +#[test] +fn web_cli_rejects_invalid_port() { + let output = cli_output(&["web", "--port", "0", "--dry-run"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("web port must be in 1..=65535")); +} + +#[test] +fn phone_adb_dry_run_renders_browser_bridge_json() { + let output = cli_output(&[ + "phone", + "--url", + "http://127.0.0.1:18080", + "adb", + "--dry-run", + "shell", + "getprop", + "ro.product.model", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let args = json_array_as_strings(&json["args"]); + + assert_eq!(json["url"], "http://127.0.0.1:18080"); + assert_eq!(json["kind"], "adb"); + assert_eq!(json["transport"], "browser-webusb-bridge"); + assert_eq!(args, ["shell", "getprop", "ro.product.model"]); +} + #[test] fn lifecycle_cli_rejects_invalid_endpoint() { - let output = lifecycle_output(&["status", "--endpoint", "$(whoami):5555"]); + let output = lifecycle_output(&["dg-test", "status", "--endpoint", "$(whoami):5555"]); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -171,23 +320,243 @@ fn lifecycle_cli_rejects_invalid_endpoint() { } #[test] -fn mcp_cli_exposes_help() { - let output = Command::new(env!("CARGO_BIN_EXE_android-connection")) - .arg("--help") - .output() - .expect("failed to execute MCP binary"); +fn adb_dry_run_proxies_to_container_by_default() { + let output = lifecycle_output(&[ + "dg-test", + "adb", + "--endpoint", + "dg-test-android:5555", + "--dry-run", + "shell", + "getprop", + "sys.boot_completed", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let host = json_array_as_strings(&json["host"]); + let container = json_array_as_strings(&json["container"]); + + assert_eq!(json["adbMode"], "container"); + assert_eq!( + host, + [ + "sh", + "-c", + "adb connect 'dg-test-android:5555' && adb 'shell' 'getprop' 'sys.boot_completed'", + ] + ); + assert_eq!( + container, + [ + "docker", + "exec", + "dg-test-android", + "adb", + "shell", + "getprop", + "sys.boot_completed", + ] + ); +} + +#[test] +fn adb_dry_run_accepts_empty_adb_invocation() { + let output = lifecycle_output(&["dg-test", "adb", "--dry-run"]); assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Android MCP stdio server")); - assert!(stdout.contains("--no-adb-probe")); + let json = parse_stdout_json(&output); + let host = json_array_as_strings(&json["host"]); + let container = json_array_as_strings(&json["container"]); + + assert_eq!(host, ["sh", "-c", "adb connect 'android:5555' && adb"]); + assert_eq!(container, ["docker", "exec", "dg-test-android", "adb"]); +} + +#[test] +fn adb_dry_run_passes_help_to_container_adb() { + let output = lifecycle_output(&["dg-test", "adb", "--dry-run", "--help"]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let container = json_array_as_strings(&json["container"]); + + assert_eq!( + container, + ["docker", "exec", "dg-test-android", "adb", "--help"] + ); +} + +#[test] +fn adb_dry_run_passes_version_to_container_adb() { + let output = lifecycle_output(&["dg-test", "adb", "--dry-run", "--version"]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let container = json_array_as_strings(&json["container"]); + + assert_eq!( + container, + ["docker", "exec", "dg-test-android", "adb", "--version"] + ); +} + +#[test] +fn install_apk_dry_run_copies_apk_for_container_mode() { + let output = lifecycle_output(&["dg-test", "install-apk", "--dry-run", "app/build/app.apk"]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let container_copy = json_array_as_strings(&json["containerCopy"]); + let container = json_array_as_strings(&json["container"]); + + assert_eq!(json["adbMode"], "container"); + assert_eq!( + container_copy, + [ + "cp", + "app/build/app.apk", + "dg-test-android:/tmp/docker-git-install.apk", + ] + ); + assert_eq!( + container, + [ + "docker", + "exec", + "dg-test-android", + "adb", + "-s", + "emulator-5554", + "install", + "/tmp/docker-git-install.apk", + ] + ); +} + +#[test] +fn launch_app_dry_run_renders_monkey_command() { + let output = lifecycle_output(&[ + "dg-test", + "launch-app", + "--package", + "com.example.app", + "--dry-run", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let container = json_array_as_strings(&json["container"]); + + assert_eq!( + container, + [ + "docker", + "exec", + "dg-test-android", + "adb", + "-s", + "emulator-5554", + "shell", + "monkey", + "-p", + "com.example.app", + "-c", + "android.intent.category.LAUNCHER", + "1", + ] + ); +} + +#[test] +fn adb_dry_run_preserves_explicit_serial() { + let output = lifecycle_output(&[ + "dg-test", + "adb", + "--dry-run", + "--", + "-s", + "custom-device", + "shell", + "getprop", + "ro.product.model", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let container = json_array_as_strings(&json["container"]); + + assert_eq!( + container, + [ + "docker", + "exec", + "dg-test-android", + "adb", + "-s", + "custom-device", + "shell", + "getprop", + "ro.product.model", + ] + ); +} + +#[test] +fn adb_dry_run_preserves_hyphenated_adb_arguments() { + let output = lifecycle_output(&[ + "dg-test", + "adb", + "--dry-run", + "shell", + "monkey", + "-p", + "com.example.app", + "-c", + "android.intent.category.LAUNCHER", + "1", + ]); + + assert!(output.status.success()); + let json = parse_stdout_json(&output); + let container = json_array_as_strings(&json["container"]); + + assert_eq!( + container, + [ + "docker", + "exec", + "dg-test-android", + "adb", + "shell", + "monkey", + "-p", + "com.example.app", + "-c", + "android.intent.category.LAUNCHER", + "1", + ] + ); +} + +#[test] +fn adb_cli_rejects_invalid_adb_mode() { + let output = lifecycle_output(&["dg-test", "adb", "--adb-mode", "remote", "--", "devices"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("ADB mode must be one of: auto, host, container")); } fn lifecycle_output(args: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_docker-git-android-connection")) + cli_output(args) +} + +fn cli_output(args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rust-android-connection")) .args(args) .output() - .expect("failed to execute lifecycle binary") + .expect("failed to execute rust-android-connection binary") } fn parse_stdout_json(output: &std::process::Output) -> Value { @@ -199,3 +568,12 @@ fn parse_stdout_json(output: &std::process::Output) -> Value { ) }) } + +fn json_array_as_strings(value: &Value) -> Vec<&str> { + value + .as_array() + .expect("JSON value is an array") + .iter() + .map(|entry| entry.as_str().expect("JSON array entry is a string")) + .collect() +} diff --git a/tests/unit/android_connection.rs b/tests/unit/android_connection.rs index 1ed9c54..3960019 100644 --- a/tests/unit/android_connection.rs +++ b/tests/unit/android_connection.rs @@ -1,9 +1,10 @@ use docker_git_android_connection::{ - android_resource_limits, android_spec, android_tools, default_android_resource_limits, - docker_run_args, no_vnc_endpoint, normalize_project_id, parse_docker_no_vnc_port, - validate_adb_endpoint, DEFAULT_ANDROID_CPUS, DEFAULT_ANDROID_IMAGE, - DEFAULT_ANDROID_MEMORY_LIMIT, DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, DEFAULT_NOVNC_HOST, - DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, NOVNC_DOCKER_BRIDGE_COMMAND, + android_resource_limits, android_spec, app_test_runtime_options, app_test_vnc_runtime_options, + default_android_resource_limits, docker_run_args, interactive_runtime_options, no_vnc_endpoint, + normalize_project_id, parse_docker_no_vnc_port, validate_adb_endpoint, RuntimeSwitch, + APP_TEST_ANDROID_RUNTIME_PROFILE, APP_TEST_VNC_ANDROID_RUNTIME_PROFILE, DEFAULT_ANDROID_CPUS, + DEFAULT_ANDROID_IMAGE, DEFAULT_ANDROID_MEMORY_LIMIT, DEFAULT_ANDROID_MEMORY_SWAP_LIMIT, + DEFAULT_ANDROID_RUNTIME_PROFILE, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT, DEFAULT_PROJECT_ID, }; #[test] @@ -37,15 +38,6 @@ fn builds_deterministic_android_spec() { assert_eq!(spec.android_volume_name, "dg-test-home-android"); } -#[test] -fn advertises_android_mcp_tools() { - let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); - - assert!(names.contains(&"android_status")); - assert!(names.contains(&"android_tap")); - assert!(names.contains(&"android_install_apk")); -} - #[test] fn docker_run_args_expose_no_vnc_on_host_port() { let spec = android_spec( @@ -57,13 +49,19 @@ fn docker_run_args_expose_no_vnc_on_host_port() { .expect("valid spec"); let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); let resource_limits = default_android_resource_limits(); + let runtime_options = interactive_runtime_options(); - let args = docker_run_args(&spec, Some(&no_vnc), &resource_limits); + let args = docker_run_args(&spec, Some(&no_vnc), &resource_limits, &runtime_options); assert!(args .windows(2) .any(|window| window == ["--publish", "127.0.0.1:6080:6081"])); - assert_eq!(args.last(), Some(&NOVNC_DOCKER_BRIDGE_COMMAND.to_string())); + assert!(args + .last() + .is_some_and(|argument| argument.contains("socat TCP-LISTEN:6081"))); + assert!(args + .last() + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); assert!(args .windows(2) .any(|window| window == ["--memory", DEFAULT_ANDROID_MEMORY_LIMIT])); @@ -76,6 +74,12 @@ fn docker_run_args_expose_no_vnc_on_host_port() { assert!(args .windows(2) .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "APPIUM=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_LOG=false"])); assert!(args .windows(2) .any(|window| window == ["--env", "WEB_VNC=true"])); @@ -94,14 +98,17 @@ fn docker_run_args_can_disable_no_vnc_publication() { ) .expect("valid spec"); let resource_limits = default_android_resource_limits(); + let runtime_options = interactive_runtime_options(); - let args = docker_run_args(&spec, None, &resource_limits); + let args = docker_run_args(&spec, None, &resource_limits, &runtime_options); assert!(!args.iter().any(|argument| argument == "--publish")); - assert_eq!(args.last(), Some(&DEFAULT_ANDROID_IMAGE.to_string())); assert!(args .windows(2) - .any(|window| window == ["--env", "WEB_VNC=true"])); + .any(|window| window == ["--env", "WEB_VNC=false"])); + assert!(args + .last() + .is_some_and(|argument| argument.contains("exec ${APP_PATH}/mixins/scripts/run.sh"))); } #[test] @@ -114,8 +121,9 @@ fn docker_run_args_allow_custom_resource_limits() { ) .expect("valid spec"); let resource_limits = android_resource_limits("4g", "4g", "2.0"); + let runtime_options = interactive_runtime_options(); - let args = docker_run_args(&spec, None, &resource_limits); + let args = docker_run_args(&spec, None, &resource_limits, &runtime_options); assert!(args.windows(2).any(|window| window == ["--memory", "4g"])); assert!(args @@ -124,6 +132,102 @@ fn docker_run_args_allow_custom_resource_limits() { assert!(args.windows(2).any(|window| window == ["--cpus", "2.0"])); } +#[test] +fn docker_run_args_support_app_test_runtime_profile() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let resource_limits = default_android_resource_limits(); + let runtime_options = app_test_runtime_options(); + + let args = docker_run_args(&spec, None, &resource_limits, &runtime_options); + + assert_eq!(runtime_options.profile, APP_TEST_ANDROID_RUNTIME_PROFILE); + assert!(!args.iter().any(|argument| argument == "--publish")); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=true"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "APPIUM=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_LOG=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=false"])); + assert!(args.windows(2).any(|window| { + window == [ + "--env", + "EMULATOR_ADDITIONAL_ARGS=-no-window -no-audio -no-boot-anim -no-snapshot -lowram -memory 1536 -camera-back none -camera-front none", + ] + })); + assert!(args + .last() + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); +} + +#[test] +fn docker_run_args_support_app_test_vnc_runtime_profile() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + let no_vnc = no_vnc_endpoint(DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_HOST, DEFAULT_NOVNC_PORT); + let resource_limits = default_android_resource_limits(); + let runtime_options = app_test_vnc_runtime_options(); + + let args = docker_run_args(&spec, Some(&no_vnc), &resource_limits, &runtime_options); + + assert_eq!( + runtime_options.profile, + APP_TEST_VNC_ANDROID_RUNTIME_PROFILE + ); + assert!(args + .windows(2) + .any(|window| window == ["--publish", "127.0.0.1:6080:6081"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "EMULATOR_HEADLESS=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "APPIUM=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_LOG=false"])); + assert!(args + .windows(2) + .any(|window| window == ["--env", "WEB_VNC=true"])); + assert!(args.windows(2).any(|window| { + window == [ + "--env", + "EMULATOR_ADDITIONAL_ARGS=-no-audio -no-boot-anim -no-snapshot -lowram -memory 1536 -camera-back none -camera-front none", + ] + })); + assert!(args + .last() + .is_some_and(|argument| argument.contains("socat TCP-LISTEN:6081"))); + assert!(args + .last() + .is_some_and(|argument| argument.contains("hw.gsmModem = no"))); +} + +#[test] +fn interactive_runtime_profile_is_default() { + let runtime_options = interactive_runtime_options(); + + assert_eq!(runtime_options.profile, DEFAULT_ANDROID_RUNTIME_PROFILE); + assert_eq!(runtime_options.emulator_headless, RuntimeSwitch::Disabled); + assert_eq!(runtime_options.web_vnc_enabled, RuntimeSwitch::Enabled); +} + #[test] fn parses_docker_port_for_actual_no_vnc_url() { let endpoint =