diff --git a/Cargo.lock b/Cargo.lock index 2fd813b..4db865d 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", ] @@ -61,11 +61,17 @@ 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.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 +79,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 +91,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", @@ -107,54 +113,6 @@ version = "1.0.5" 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" -dependencies = [ - "clap", - "lino-arguments", - "regex", - "serde_json", - "walkdir", -] - [[package]] name = "heck" version = "0.5.0" @@ -173,31 +131,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 +145,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 +186,21 @@ 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 = "rust-android-connection" +version = "0.0.0" +dependencies = [ + "base64", + "clap", + "regex", + "serde", + "serde_json", + "walkdir", +] [[package]] name = "same-file" @@ -312,40 +262,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..913c002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "example-sum-package-name" -version = "0.19.2" +name = "rust-android-connection" +version = "0.0.0" edition = "2021" -description = "A Rust package template for AI-driven development" +description = "Rust Android lifecycle and ADB command 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", "emulator", "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. @@ -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", @@ -24,16 +25,18 @@ include = [ ] [lib] -name = "example_sum_package_name" +name = "docker_git_android_connection" path = "src/lib.rs" [[bin]] -name = "example-sum-package-name" +name = "rust-android-connection" path = "src/main.rs" [dependencies] -lino-arguments = "0.3" -clap = { version = "4.4", features = ["derive", "env"] } +base64 = "0.22.1" +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..4e6a896 100644 --- a/README.md +++ b/README.md @@ -1,365 +1,156 @@ -# rust-ai-driven-development-pipeline-template - -A comprehensive template for AI-driven Rust development with full CI/CD pipeline support. - -[![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 +# rust-android-connection -```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 +Rust Android lifecycle and ADB command module for docker-git. -# Run an example -cargo run --example basic_usage -``` - -### Running Tests +## Install ```bash -# Run all tests -cargo test - -# Run tests with verbose output -cargo test --verbose - -# Run doc tests -cargo test --doc +cargo install --git https://github.com/ProverCoderAI/rust-android-connection --branch main --locked --bins +``` -# Run a specific test -cargo test test_sum_positive_numbers +Installs one binary: -# Run tests with output -cargo test -- --nocapture +```text +rust-android-connection # start/status/stop Android runtime container and proxy ADB commands ``` -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 +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 ``` -## Project Structure - -``` -. -├── .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 +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 +{ + "androidContainerName": "dg-my-project-android", + "resourceLimits": { + "memory": "3g", + "memorySwap": "3g", + "cpus": "1.0" + }, + "runtime": { + "profile": "interactive", + "emulatorHeadless": false + }, + "noVncPublished": true, + "noVncUrl": "http://127.0.0.1:6080/?autoconnect=true&resize=remote" +} ``` -## 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 +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. -- **rustfmt**: Standard Rust code formatter -- **Clippy**: Rust linter with pedantic and nursery lints enabled -- **Pre-commit hooks**: Automated checks before each commit +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. -### Testing Strategy +### Runtime Profiles -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/). +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 -# Create a changelog fragment -touch changelog.d/$(date +%Y%m%d_%H%M%S)_my_change.md - -# Edit the fragment to document your changes +rust-android-connection dg-my-project start \ + --endpoint dg-my-project-android:5555 \ + --runtime-profile app-test ``` -### 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 +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. -#### Multi-Language Monorepos +It is intended for: -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. +```text +build APK -> install-apk -> launch-app --package [--activity ] +``` -If the guard fails, generate the lockfile and commit it: +Use `app-test-vnc` when the same lightweight app-test setup needs noVNC for manual debugging: ```bash -cargo generate-lockfile -git add Cargo.lock +rust-android-connection dg-my-project start \ + --endpoint dg-my-project-android:5555 \ + --runtime-profile app-test-vnc \ + --novnc-port 16080 ``` -### Optional Docker Hub Publishing +`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. -Projects that ship a Docker image can publish Docker Hub releases from the same Rust release workflow. Add a root `Dockerfile`, then configure: +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`. -| 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 | +## ADB Commands -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. +```bash +rust-android-connection dg-my-project adb \ + shell getprop sys.boot_completed -Add a visible Docker Hub badge next to the crates.io badge in repositories that enable image publishing: +rust-android-connection dg-my-project install-apk \ + app/build/outputs/apk/debug/app-debug.apk -```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) +rust-android-connection dg-my-project launch-app \ + --package com.example.app ``` -## Deploying API documentation +By default, `adb` is a container proxy: -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**. +```bash +rust-android-connection dg-my-project adb devices +rust-android-connection dg-my-project adb shell getprop sys.boot_completed +``` -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. +The command above runs ADB inside the Android container: -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. +```bash +docker exec dg-my-project-android adb shell getprop sys.boot_completed +``` -## Scripts Reference +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 ...`. -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` +For APK installation in `container` mode, the CLI copies the APK into the Android container and installs that internal path: -| 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 | +```text +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 +``` -## Example Usage +## Browser WebUSB Phone UI -```rust -use example_sum_package_name::sum; +Use the built-in browser connector when you want to attach a real Android phone from the user's computer without installing host ADB: -fn main() { - let result = sum(2, 3); - println!("2 + 3 = {result}"); -} +```bash +rust-android-connection web --port 8080 ``` -See `examples/basic_usage.rs` for more examples. - -## Contributing +Then open: -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. - -### Development Workflow +```text +http://127.0.0.1:8080/ +``` -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 +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`. -## License +Requirements for a physical phone: -[Unlicense](LICENSE) - Public Domain +- 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. -This is free and unencumbered software released into the public domain. See [LICENSE](LICENSE) for details. +## Smoke Test -## Acknowledgments +```bash +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 +``` -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) +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. -## Resources +## Development -- [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..f88e566 --- /dev/null +++ b/changelog.d/20260624_000000_bootstrap_android_connection.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added +- 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 be9a37f..ed24123 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -1,7 +1,25 @@ -use example_sum_package_name::sum; +use docker_git_android_connection::{ + 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() { - 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); + 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, &runtime_options).join(" ") + ); } diff --git a/src/lib.rs b/src/lib.rs index 490125b..9609bc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,668 @@ -pub mod sum; +use serde::Serialize; -pub use sum::sum; +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 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 { + 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)] +#[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)] +#[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 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) != "" +// 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 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 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(), + "--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(), + "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(), + 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(), + format!( + "{}:{}:{}", + no_vnc.bind_host, no_vnc.host_port, no_vnc.container_port + ), + ]); + } + + args.push(spec.image.clone()); + 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![ + "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 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 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") + .expect("publish flag"); + let image_position = args + .iter() + .position(|value| value == DEFAULT_ANDROID_IMAGE) + .expect("image argument"); + + assert!(publish_position < image_position); + 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()) + ); + 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", "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", "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 397ec09..1f03d09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,74 +1,1627 @@ -use lino_arguments::Parser; -use std::io::{self, Write}; +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_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, 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}; -use example_sum_package_name::sum; +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(name = "example-sum-package-name", about = "Sum two numbers")] -struct Args { - #[arg(long, env = "A", default_value = "0", allow_hyphen_values = true)] - a: i64, +#[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, +} + +#[derive(Subcommand, Debug)] +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)] +#[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)] + 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, +} + +#[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, +} - #[arg(long, env = "B", default_value = "0", allow_hyphen_values = true)] - b: i64, +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeCommandRequest { + kind: String, + args: Vec, + #[serde(default)] + file_path: Option, } -fn write_output(writer: &mut impl Write, output: &str) -> io::Result<()> { - match writer - .write_all(output.as_bytes()) - .and_then(|()| writer.flush()) +#[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, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let raw_args = env::args_os().collect::>(); + if raw_args + .get(1) + .is_some_and(|argument| argument == OsStr::new(WEB_COMMAND_NAME)) { - Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), - result => result, + 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(&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 write_stdout(output: &str) -> io::Result<()> { - write_output(&mut io::stdout(), output) +fn phone(args: &PhoneArgs) -> Result<(), Box> { + match &args.command { + PhoneCommand::Adb(adb_args) => phone_adb(&args.url, adb_args), + } } -fn main() -> io::Result<()> { - let args = Args::parse(); - write_stdout(&format!("{}\n", sum(args.a, args.b))) +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); + } } -#[cfg(test)] -mod tests { - use super::*; +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(); - struct BrokenPipeWriter; + 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())) +} - impl Write for BrokenPipeWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::BrokenPipe)) +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(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, &runtime_options); + if args.dry_run { + print_json(&with_docker_args( + lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + &runtime_options, + None, + None, + ), + &docker_args, + ))?; + return Ok(()); + } + + 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, + &runtime_options, + Some(container_id.trim()), + None, + )) +} + +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 || !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, &runtime_options, false)?) + }; + print_json(&lifecycle_output( + &spec, + no_vnc.as_ref(), + &resource_limits, + &runtime_options, + None, + None, + )) +} + +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, &runtime_options, None, None), + &docker_args, + ))?; + return Ok(()); + } + + run_docker_capture_stdout(&docker_args)?; + print_json(&lifecycle_output( + &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() { + 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 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 || !runtime_options.web_vnc_enabled.as_bool() { + return Ok(None); + } - fn flush(&mut self) -> io::Result<()> { - Ok(()) + 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, + runtime_options: &AndroidRuntimeOptions, + 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, + "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()), + "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 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(), + "inspect".to_string(), + image.to_string(), + ]; + if run_docker_status(&inspect_args).is_ok() { + return Ok(()); + } + + let pull_args = vec!["pull".to_string(), image.to_string()]; + run_docker_status_streaming_to_stderr(&pull_args) +} + +fn run_docker_status(args: &[String]) -> Result<(), Box> { + let output = Command::new(docker_binary()).args(args).output()?; + if output.status.success() { + return Ok(()); + } + + 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 { + command_error(&format!("docker {}", args.join(" ")), output) +} - struct OtherErrorWriter; +fn command_error(label: &str, output: &Output) -> String { + format!( + "{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() + ) +} - impl Write for OtherErrorWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::from(io::ErrorKind::PermissionDenied)) +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, + ); + } - fn flush(&mut self) -> io::Result<()> { - Ok(()) + 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, + ), } +} - #[test] - fn write_output_treats_broken_pipe_as_clean_exit() { - assert!(write_output(&mut BrokenPipeWriter, "1\n").is_ok()); +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}"), + }) +} - #[test] - fn write_output_preserves_other_io_errors() { - let err = write_output(&mut OtherErrorWriter, "1\n").unwrap_err(); +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") +} - assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); +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/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/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 new file mode 100644 index 0000000..a70381c --- /dev/null +++ b/tests/integration/cli.rs @@ -0,0 +1,579 @@ +use serde_json::Value; +use std::process::Command; + +#[test] +fn lifecycle_cli_renders_status_json() { + let output = lifecycle_output(&[ + "dg-test", + "status", + "--endpoint", + "dg-test-android:5555", + "--novnc-port", + "16080", + ]); + + assert!(output.status.success()); + 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"); + 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(&[ + "dg-test", + "status", + "--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(&[ + "dg-test", + "start", + "--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"); + assert_eq!(json["runtime"]["profile"], "interactive"); +} + +#[test] +fn start_dry_run_accepts_custom_resource_limits() { + let output = lifecycle_output(&[ + "dg-test", + "start", + "--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 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(&["dg-test", "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(&["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(&["dg-test", "status", "--endpoint", "$(whoami):5555"]); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("invalid ADB endpoint")); +} + +#[test] +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 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 { + 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 rust-android-connection 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) + ) + }) +} + +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/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..3960019 --- /dev/null +++ b/tests/unit/android_connection.rs @@ -0,0 +1,255 @@ +use docker_git_android_connection::{ + 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] +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 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 runtime_options = interactive_runtime_options(); + + 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!(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])); + 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", "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", "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 runtime_options = interactive_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", "WEB_VNC=false"])); + assert!(args + .last() + .is_some_and(|argument| argument.contains("exec ${APP_PATH}/mixins/scripts/run.sh"))); +} + +#[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 runtime_options = interactive_runtime_options(); + + let args = docker_run_args(&spec, None, &resource_limits, &runtime_options); + + 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 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 = + 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" + ); +} 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); -}