diff --git a/.github/workflows/deploy-pages-channel.yml b/.github/workflows/deploy-pages-channel.yml new file mode 100644 index 000000000..5740ac954 --- /dev/null +++ b/.github/workflows/deploy-pages-channel.yml @@ -0,0 +1,130 @@ +name: Deploy Pages Channel + +on: + workflow_dispatch: + inputs: + channel: + description: Static site channel to deploy + required: true + type: choice + options: + - beta + - demo + ref: + description: Git ref to build + required: true + default: main + dry_run: + description: Build and smoke-check without pushing to the Pages repo + required: true + type: boolean + default: false + +concurrency: + group: pages-channel-${{ inputs.channel }} + cancel-in-progress: false + +permissions: + contents: read + +env: + RUST_BACKTRACE: 1 + RISC_V_TARGET: riscv32imac-unknown-none-elf + RUSTUP_PERMIT_COPY_RENAME: 1 + +jobs: + deploy: + name: Build and deploy ${{ inputs.channel }} + runs-on: ubuntu-latest + steps: + - name: Install system deps + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-27 + components: rust-src + + - name: Install Rust targets + run: | + rustup target add wasm32-unknown-unknown + rustup target add ${{ env.RISC_V_TARGET }} + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ runner.arch }}-pages-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-pages-cargo- + + - name: Install just + uses: extractions/setup-just@v3 + + - name: Install wasm-bindgen-cli + run: cargo install wasm-bindgen-cli --version 0.2.114 --locked + + - name: Install espflash + if: inputs.channel == 'beta' + run: cargo install espflash --version 3.3.0 --locked + + - name: Build beta artifact + if: inputs.channel == 'beta' + run: just studio-web-deploy-dir beta target/pages/channel beta.lightplayer.app + + - name: Smoke-check beta artifact + if: inputs.channel == 'beta' + run: just studio-web-smoke target/pages/channel + + - name: Build demo artifact + if: inputs.channel == 'demo' + run: just web-demo-deploy-dir demo target/pages/channel demo.lightplayer.app + + - name: Smoke-check demo artifact + if: inputs.channel == 'demo' + run: just web-demo-smoke target/pages/channel + + - name: Create Pages App token + if: ${{ inputs.dry_run == false }} + id: pages-app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.LIGHTPLAYER_PAGES_APP_ID }} + private-key: ${{ secrets.LIGHTPLAYER_PAGES_PRIVATE_KEY }} + owner: light-player + repositories: | + lightplayer-beta-pages + lightplayer-demo-pages + permission-contents: write + + - name: Publish channel artifact + env: + LIGHTPLAYER_PAGES_TOKEN: ${{ steps.pages-app-token.outputs.token }} + CHANNEL: ${{ inputs.channel }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + target_repo="" + case "$CHANNEL" in + beta) target_repo="light-player/lightplayer-beta-pages" ;; + demo) target_repo="light-player/lightplayer-demo-pages" ;; + *) echo "unknown channel: $CHANNEL" >&2; exit 2 ;; + esac + + args=(--repo "$target_repo" --dir target/pages/channel --message "deploy: ${CHANNEL} ${GITHUB_SHA::12}") + if [[ "$DRY_RUN" == "true" ]]; then + args+=(--dry-run) + fi + scripts/pages/publish-static-repo.sh "${args[@]}" diff --git a/.github/workflows/deploy-studio-pages.yml b/.github/workflows/deploy-studio-pages.yml new file mode 100644 index 000000000..1e91557ec --- /dev/null +++ b/.github/workflows/deploy-studio-pages.yml @@ -0,0 +1,88 @@ +name: Deploy Studio Pages + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: pages-production + cancel-in-progress: false + +permissions: + contents: read + pages: write + id-token: write + +env: + RUST_BACKTRACE: 1 + RISC_V_TARGET: riscv32imac-unknown-none-elf + RUSTUP_PERMIT_COPY_RENAME: 1 + +jobs: + deploy: + name: Build and deploy Studio + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Install system deps + run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly-2026-04-27 + components: rust-src + + - name: Install Rust targets + run: | + rustup target add wasm32-unknown-unknown + rustup target add ${{ env.RISC_V_TARGET }} + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ runner.arch }}-pages-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-pages-cargo- + + - name: Install just + uses: extractions/setup-just@v3 + + - name: Install wasm-bindgen-cli + run: cargo install wasm-bindgen-cli --version 0.2.114 --locked + + - name: Install espflash + run: cargo install espflash --version 3.3.0 --locked + + - name: Build Pages artifact + run: just studio-web-deploy-dir production target/pages/studio lightplayer.app + + - name: Smoke-check Pages artifact + run: just studio-web-smoke target/pages/studio + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: target/pages/studio + + - name: Deploy Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6969e8294..d16d34d7f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,10 @@ perf.data* .DS_Store .builtins-source-hash traces/ -profiles/ \ No newline at end of file +profiles/ +lp-app/lpa-studio-web/public/pkg/ +lp-app/lpa-studio-web/public/fw-browser-worker.js +lp-app/lpa-studio-web/public/firmware/ +lp-app/lpa-studio-web/dist/ +lp-app/lpa-studio-web/story-images/.new/ +lp-app/lpa-studio-web/story-images/.scratch/ diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml index 27f10b7b8..8fe621d19 100644 --- a/.idea/lp2025.iml +++ b/.idea/lp2025.iml @@ -103,6 +103,13 @@ + + + + + + + diff --git a/AGENTS.md b/AGENTS.md index 8779970b5..b4405e44e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,58 @@ deprecated. Do not adopt it in new code. If a plan file you are executing asks for "tests at the top", treat that as a stale instruction and put the test module at the bottom anyway. +## Personal planning workflow + +New agent planning work uses the Photomancer personal planning workspace, not +new repo-local plan or roadmap directories. + +- Use `pm-plan` for new planning, roadmap, and investigation artifacts. +- Use `pm-implement` to execute an existing shared `plan.md`. +- Use `pm-review` for durable review artifacts. +- Resolve context from `agent-context.toml`; the repo slug is `lightplayer`. +- Resolve the workspace from `PHOTOMANCER_PLANNING_ROOT`, or from the default + `~/.photomancer/planning` link. +- Store new active artifacts under + `/lightplayer/-/`. +- Store completed artifacts under `/lightplayer/_archive/`. +- Store review artifacts under `/lightplayer/_reviews/`. + +Durable decisions belong in repo ADRs under `docs/adr/`. Intermediate plans, +phase prompts, review notes, scratch reports, and implementation logs belong in +the shared planning workspace. Existing `docs/plans`, `docs/plans-old`, +`docs/roadmaps`, and `docs/roadmaps-old` content is historical and should not +be migrated unless a separate migration plan asks for it. + +## Studio UI visual baselines + +When a change touches non-generated files under `lp-app/lp-studio-web/`, run the +Studio story baseline helper before committing: + +```bash +just studio-story-baselines-if-needed +``` + +If it updates files under `lp-app/lp-studio-web/story-images/`, include those +PNG changes in the same commit and mention the affected story baselines in the +final summary. The helper intentionally ignores generated web artifacts, +scratch PNGs, fresh check PNGs, and the baseline PNGs themselves. + +Useful related commands: + +```bash +just studio-story-pngs # ignored scratch PNGs for quick local review +just studio-story-baselines # update committed story baselines +just studio-story-check # compare fresh PNGs to committed baselines +``` + +`studio-story-baselines` and `studio-story-check` require `oxipng`; run +`scripts/dev-init.sh` or install it with `cargo install oxipng` / +`brew install oxipng`. + +Do not add an auto-mutating Git hook for this workflow unless the user asks for +one explicitly. Hooks that rewrite the working tree during commit are annoying +during rebases, merges, and partial commits. + ## Validation Commands These commands must pass for any change touching the shader pipeline: diff --git a/Cargo.lock b/Cargo.lock index 0fde21ea6..fc8ef2505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -443,6 +465,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-tungstenite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.27.0", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -506,6 +544,54 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.13.1" @@ -678,6 +764,9 @@ name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "calloop" @@ -730,6 +819,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.56" @@ -748,6 +843,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -775,6 +881,16 @@ dependencies = [ "libc", ] +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.44" @@ -788,6 +904,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.60" @@ -909,6 +1052,131 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "const-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "content_disposition" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc14a88e1463ddd193906285abe5c360c7e8564e05ccc5d501755f7fbc9ca9c" +dependencies = [ + "charset", +] + +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cordyceps" version = "0.3.4" @@ -1489,27 +1757,493 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + [[package]] name = "dialoguer" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror 1.0.69", - "zeroize", + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dioxus" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c01ecf7ddbae18a419ad3d83c486101a85ffc5740ea09cdd0f09a30dc12170d" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-logger", + "dioxus-signals", + "dioxus-stores", + "dioxus-web", + "manganis", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69387edbbc60c7cb93ad96d8cc7a22b49a76e21643380b89b1c49a78d347ff60" +dependencies = [ + "dioxus-cli-config", + "http", + "infer", + "jni 0.21.1", + "js-sys", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c000f584ddf608e2b272b3074bf11512a474eeeb2eb85a1915f276ce5c4a8615" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7637091592978fbfdb45a16b26bd99fd97fb1bd7e31c6a963530e00c022af321" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f9ed8fc1a215ad34bb8dbae42a4ea54efbcd26ca9006bbe5cca78e511bf25f" + +[[package]] +name = "dioxus-core" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45887100ff0cf89abeb8b659808294fda48cd53f3b424e36407dedffcfea830b" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370c63663dff0f24df5dfea643ca239283542c6b228a302f69b32e1d36762b7f" +dependencies = [ + "convert_case 0.8.0", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36963eab106b169737762f9cd5ee5fd97f585989dcb2d8e30a596e97a6999009" + +[[package]] +name = "dioxus-devtools" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2349cedbdf1b429df1f1bea61fdee0ad3dae7b2548eedfbeca82710122a57da0" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite 0.28.0", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab9b0f7565d1916b70915f59b89ea8054ef0a9d67a364a32bbee68ef5f3818d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e3a5bec7ffc999ff23446a487eb5cd86111d1574a23533dd3f8b3c69a53a22" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-fullstack" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37f0558edb88af5ad47275ae36a7f06317163ba482db377c26d7d8590b5cd0f6" +dependencies = [ + "anyhow", + "async-stream", + "async-tungstenite", + "axum", + "axum-core", + "base64 0.22.1", + "bytes", + "ciborium", + "const-str", + "const_format", + "content_disposition", + "derive_more", + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-core", + "dioxus-fullstack-core", + "dioxus-fullstack-macro", + "dioxus-hooks", + "dioxus-html", + "dioxus-signals", + "form_urlencoded", + "futures", + "futures-channel", + "futures-util", + "gloo-net", + "headers", + "http", + "http-body", + "http-body-util", + "js-sys", + "mime", + "pin-project", + "reqwest", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio-util", + "tracing", + "tungstenite 0.27.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "dioxus-fullstack-core" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc634b28b4b1e3eab1e8df4f98510e2d2fa39d686321467f977213155e86ed2b" +dependencies = [ + "anyhow", + "axum-core", + "base64 0.22.1", + "ciborium", + "dioxus-core", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "http", + "inventory", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "dioxus-fullstack-macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a8fe7da549859fae00c7f4bf11a2aab734ae7ef6f98f280dce9bea1f3326ec" +dependencies = [ + "const_format", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "xxhash-rust", +] + +[[package]] +name = "dioxus-history" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a15232302d1933015fcf2d6fe9e286ad36f6e9c205a546089a0f326023bb0d2" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4534f91cf6305204b948bdec130076ac9ecc7c22faab29475b76870558bf73ea" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e03d6ad4040b667f2b2eefcb678840e630938c09bf9ec39b04ea4d1d96d90d44" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584e2772127ab00f0d5e1d4d9795f39fecebc828ece0b7a02349d438bc1b1ce7" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11999d6eb5bb179a9512dad30e5de408aab66f2cb65de9098c9fbe02927e2978" +dependencies = [ + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-logger" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ccdfe36d2cb830a2784e40f7e6f7199805a2c6da99bd65b1ca308f11aed28" +dependencies = [ + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2106afda239a4c7c22ffa1ca19117011225fc1c735c139c0a5b765996aa8bb1d" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3705754f5e043deec9fc7af0d159f18e5b21c02c47d255c7e477f31368f0b6d2" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bec7b21c86b1360ec965a07a53a2c96b7caee3465049e1c299a45024e87614" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a5875e9f890f27b1cc3e5b56c1e23601211470315a1fb8627c4ca4f3b2be9a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] -name = "digest" -version = "0.10.7" +name = "dioxus-web" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "bc0a0be76b404e8242a597db0fb239d05f8dee4e7856bc1fc7144f7e244822fd" dependencies = [ - "block-buffer", - "crypto-common", + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] @@ -1610,6 +2344,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -2551,6 +3291,15 @@ dependencies = [ "vcell", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2715,6 +3464,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -2813,6 +3563,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fw-browser" +version = "40.0.0" +dependencies = [ + "fw-core", + "lpa-server", + "lpc-hardware", + "lpc-model", + "lpc-shared", + "lpc-wire", + "lpfs", + "lpvm-wasm", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "fw-checks" version = "40.0.0" @@ -2907,6 +3675,21 @@ dependencies = [ "unwinding", ] +[[package]] +name = "fw-host" +version = "40.0.0" +dependencies = [ + "fw-core", + "lpa-client", + "lpa-server", + "lpc-hardware", + "lpc-model", + "lpc-shared", + "lpc-wire", + "lpfs", + "tokio", +] + [[package]] name = "fw-tests" version = "40.0.0" @@ -2953,6 +3736,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" +[[package]] +name = "generational-box" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd0d825b8d339701ad330dbcd6399519ced4d143484954daf6e3185dace4f77" +dependencies = [ + "parking_lot", + "tracing", +] + [[package]] name = "generator" version = "0.8.8" @@ -2995,8 +3788,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -3006,9 +3801,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -3059,6 +3856,52 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.14.2" @@ -3239,6 +4082,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heapless" version = "0.8.0" @@ -3293,18 +4160,106 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humantime" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3500,6 +4455,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inotify" version = "0.9.6" @@ -3533,6 +4497,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -3543,6 +4516,12 @@ dependencies = [ "mach2", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is-terminal" version = "0.4.17" @@ -3710,6 +4689,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3727,6 +4715,21 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kqueue" version = "1.1.1" @@ -3747,6 +4750,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy-js-bundle" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccafada6c9541db44db758619236f2748f6e1bdaa84d04ded858567cd1e89321" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3877,6 +4886,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + [[package]] name = "loom" version = "0.7.2" @@ -3912,6 +4927,7 @@ dependencies = [ "lp-riscv-inst", "lp-shader", "lpa-client", + "lpa-link", "lpa-server", "lpc-engine", "lpc-hardware", @@ -3926,6 +4942,7 @@ dependencies = [ "lpvm-cranelift", "lpvm-native", "notify", + "pollster", "rustc-demangle", "serde", "serde_json", @@ -3934,7 +4951,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", - "tungstenite", + "tungstenite 0.21.0", "unicode-width 0.2.2", ] @@ -4070,6 +5087,24 @@ dependencies = [ "tokio-tungstenite", ] +[[package]] +name = "lpa-link" +version = "40.0.0" +dependencies = [ + "fw-host", + "js-sys", + "lpa-client", + "lpc-model", + "serde", + "serde-wasm-bindgen", + "serialport", + "strum", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "lpa-server" version = "40.0.0" @@ -4084,7 +5119,31 @@ dependencies = [ "lpc-shared", "lpc-view", "lpc-wire", - "lpfs", + "lpfs", +] + +[[package]] +name = "lpa-studio-ux" +version = "40.0.0" +dependencies = [ + "async-trait", + "js-sys", + "lpa-client", + "lpa-link", + "lpc-model", + "lpc-wire", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "lpa-studio-web" +version = "40.0.0" +dependencies = [ + "dioxus", + "lpa-studio-ux", + "web-sys", ] [[package]] @@ -4483,6 +5542,12 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.4.3" @@ -4492,6 +5557,17 @@ dependencies = [ "libc", ] +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4501,6 +5577,50 @@ dependencies = [ "libc", ] +[[package]] +name = "manganis" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfcf56309de35b48b8780ea097ace5c3b773a617b52edc49dfc9a63a7d9dc43" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "jni 0.21.1", + "manganis-core", + "manganis-macro", + "ndk-context", + "objc2 0.6.4", + "thiserror 2.0.18", +] + +[[package]] +name = "manganis-core" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24d6be68f594495aea60850a284029d585d7b7839b26096c1b6d758f8518648" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config", + "dioxus-core-types", + "serde", + "winnow", +] + +[[package]] +name = "manganis-macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e782a10318d707c0833e31876ded8acf91287eee0010af8392559af614c7226" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "matchers" version = "0.2.0" @@ -4510,6 +5630,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -4558,6 +5684,32 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4607,6 +5759,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "naga" version = "23.1.0" @@ -4811,6 +5980,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -5170,6 +6345,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.76" @@ -5433,6 +6614,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "pp-rs" version = "0.2.2" @@ -5478,12 +6665,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", +] + [[package]] name = "profiling" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulley-interpreter" version = "42.0.1" @@ -5532,6 +6747,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -5560,10 +6830,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -5574,6 +6854,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -5588,6 +6878,9 @@ name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "rand_core" @@ -5769,6 +7062,50 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + [[package]] name = "rgb" version = "0.8.53" @@ -5778,6 +7115,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "riscv" version = "0.15.0" @@ -5933,6 +7284,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -6076,6 +7462,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + [[package]] name = "ser-write" version = "0.3.1" @@ -6106,6 +7501,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6138,16 +7544,38 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ - "itoa", - "memchr", + "percent-encoding", "serde", - "serde_core", - "zmij", + "thiserror 2.0.18", ] [[package]] @@ -6170,6 +7598,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -6319,12 +7759,42 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + [[package]] name = "slotmap" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ + "serde", "version_check", ] @@ -6521,6 +7991,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subsecond" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc79674bd55726e6b123204403389400229a95fe4a3b2c5453dada70b06ca95" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9798bfed58797aed51c672aa99810aac30a50d3120ecfdcf28c13784e9a8f1c" +dependencies = [ + "serde", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgbobdoc" version = "0.3.0" @@ -6556,6 +8060,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -6666,6 +8179,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -6744,6 +8287,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.21.0" @@ -6753,7 +8306,22 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.21.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", ] [[package]] @@ -6816,6 +8384,51 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -6878,6 +8491,23 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6897,13 +8527,47 @@ dependencies = [ "httparse", "log", "native-tls", - "rand", + "rand 0.8.5", "sha1", "thiserror 1.0.69", "url", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "twox-hash" version = "2.1.2" @@ -6951,6 +8615,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6987,6 +8657,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unwinding" version = "0.2.8" @@ -7076,6 +8752,37 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -7159,6 +8866,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + [[package]] name = "wasm-compose" version = "0.244.0" @@ -7212,6 +8958,19 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -7736,6 +9495,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wgpu" version = "23.0.1" @@ -8565,6 +10333,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yoke" version = "0.8.1" @@ -8612,7 +10386,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand", + "rand 0.8.5", "serde", "serde_repr", "sha1", diff --git a/Cargo.toml b/Cargo.toml index 0320754ed..b61df2b65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,12 @@ members = [ "lp-core/lpc-shared", "lp-app/lpa-server", "lp-app/lpa-client", + "lp-app/lpa-link", + "lp-app/lpa-studio-ux", + "lp-app/lpa-studio-web", + "lp-fw/fw-browser", "lp-fw/fw-core", + "lp-fw/fw-host", "lp-fw/fw-checks", "lp-cli", "lp-fw/fw-esp32", @@ -68,7 +73,10 @@ default-members = [ "lp-core/lpc-shared", "lp-app/lpa-server", "lp-app/lpa-client", + "lp-app/lpa-link", + "lp-app/lpa-studio-ux", "lp-fw/fw-core", + "lp-fw/fw-host", "lp-fw/fw-checks", "lp-fw/fw-tests", "lp-cli", diff --git a/README.md b/README.md index a314ccaaa..cfd521b15 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ To get started with development: ``` This will: - - Check for required tools (Rust, Cargo, rustup, just) + - Check for required tools (Rust, Cargo, rustup, just, oxipng) - Verify Rust version meets minimum requirements (1.90.0+) - Install the RISC-V target (`riscv32imac-unknown-none-elf`) if needed - Set up git hooks (pre-commit hook runs `just check`) @@ -61,6 +61,8 @@ To get started with development: 2. **Required tools:** - Rust toolchain (1.90.0 or later) - [Install Rust](https://rustup.rs/) - `just` - Task runner: `cargo install just` or via package manager + - `oxipng` - Lossless PNG optimizer for Studio story image baselines: + `cargo install oxipng` or `brew install oxipng` 3. **Common development commands:** - `just fci` - Fix, check, build, and test the whole project. Do this before you submit a PR. diff --git a/agent-context.toml b/agent-context.toml new file mode 100644 index 000000000..3aebea282 --- /dev/null +++ b/agent-context.toml @@ -0,0 +1,5 @@ +[agent] +repo_slug = "lightplayer" +planning_root_env = "PHOTOMANCER_PLANNING_ROOT" +skills_root_env = "PHOTOMANCER_SKILLS_ROOT" +default_skills_subdir = "skills" diff --git a/docs/adr/2026-06-17-browser-firmware-runtime.md b/docs/adr/2026-06-17-browser-firmware-runtime.md new file mode 100644 index 000000000..6a3813f0e --- /dev/null +++ b/docs/adr/2026-06-17-browser-firmware-runtime.md @@ -0,0 +1,90 @@ +# ADR: Browser Firmware Runtime Boundary + +- **Status:** Accepted +- **Date:** 2026-06-17 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +The first Studio milestone needs a browser-local LightPlayer runtime that feels +like firmware, not a shader playground. The previous `fw-browser` proof exposed +direct shader/LPVM primitives, which was useful for proving browser shader +execution but too far from the real Studio/device shape. + +Studio needs to create browser-local runtimes, send normal LightPlayer protocol +messages, observe logs/status, load projects, tick deterministically in tests, +and inspect output through the same project-read resource path used by other +firmware targets. + +## Decision + +`fw-browser` is a browser/Web Worker firmware target. It owns an in-memory +`LpServer`, filesystem, virtual hardware, output provider, manual time source, +and server tick loop. JavaScript creates a module Worker and talks to it through +structured `postMessage` envelopes. + +Input envelopes include `protocol_in`, `tick`, `start`, `stop`, and `drain`. +`protocol_in` carries a whole `lpc_wire` client JSON frame. Output envelopes +include `status`, `log`, and `protocol_out`. `protocol_out` carries a whole +`lpc_wire` server JSON frame. Logs/status stay separate from protocol frames so +Studio can show connection health and raw protocol independently. + +`fw-core` owns only target-neutral runtime helpers: draining client messages and +ticking an `LpServer` frame. Browser Worker lifecycle, host process lifecycle, +and ESP32 scheduling remain target-specific. + +`lpa-link browser-worker` models endpoint/session identity and reports a +`BrowserWorker` connection with protocol `fw-browser-post-message-v1`. +The web frontend still owns the actual JavaScript `Worker` object and binds that +worker to Studio/client code. + +Output smoke coverage uses canonical project-read `OutputChannels` payloads, +not direct access to `MemoryOutputProvider` and not a bespoke output snapshot. + +## Consequences + +M1 Studio can depend on a firmware-shaped browser runtime: create a worker, +write project files via protocol messages, load a project, tick, read resources, +and surface logs/status. + +Browser and host runtimes remain distinct. `fw-browser` is for Studio +simulation and browser-local project testing; `fw-host` is for host-OS local +runtime deployments. + +The current automated Rust wasm check can compile the browser runtime tests, but +executing `wasm-bindgen-test` requires working browser/WebDriver provisioning. +The static browser smoke page is therefore part of the validation ladder until +CI browser tooling is provisioned. + +## Alternatives Considered + +- Keep direct shader/LPVM exports as the browser API. + - Rejected because it bypasses `LpServer`, filesystem, project loading, logs, + and the protocol boundary Studio must use. +- Emulate serial `M!` framing inside the Worker boundary. + - Rejected for M0a because structured worker messages are simpler and keep + logs/status/protocol clearly separated. Serial-like framing remains useful + for serial transports. +- Make `fw-core` own a full runtime factory. + - Rejected because target-specific lifecycle, logging, scheduling, and + hardware setup would make `fw-core` too broad too early. +- Verify output through a bespoke worker `outputSnapshot`. + - Rejected for the primary smoke because canonical project-read resources are + the surface Studio and agents should be able to trust. + +## Follow-ups + +- Provision stable CI/browser tooling for `wasm-bindgen-test` or a Playwright + Worker smoke. +- Add a browser-side `lpa-client` transport/binding for + `fw-browser-post-message-v1`. +- Add richer diagnostics and optional output snapshots for Studio device panels + after the canonical protocol path remains stable. + +## Later Note + +As of the provider-owned resources refactor and the `lpa-studio-ux` experiment, +`lpa-link browser-worker` owns the JavaScript Worker lifecycle. Browser UI code +should consume the Studio UX surface rather than owning the Worker directly. diff --git a/docs/adr/2026-06-17-studio-link-and-local-runtimes.md b/docs/adr/2026-06-17-studio-link-and-local-runtimes.md new file mode 100644 index 000000000..02b888384 --- /dev/null +++ b/docs/adr/2026-06-17-studio-link-and-local-runtimes.md @@ -0,0 +1,85 @@ +# ADR: Studio Link And Local Runtimes + +- **Status:** Accepted +- **Date:** 2026-06-17 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +LightPlayer Studio needs to discover and manage local and physical LightPlayer +endpoints without putting low-level device and runtime concerns directly in the +UI. The first Studio milestone also needs a browser-local runtime so the web app +can prove the end-to-end flow before real hardware flashing is available. + +The low-level layer is broader than a byte transport. Web Serial, browser-local, +host-local, and later websocket/server-owned links may all need discovery, +status, reset, flash, raw filesystem access, diagnostics, logs, and eventually a +client connection to an `lp-server`. + +Browser-local and host-local runtimes also have different purposes. A browser +runtime is for Studio project testing and simulation in a Web Worker-shaped +environment. A host runtime is for running LightPlayer on a host OS, server, or +single-board computer. + +## Decision + +Add `lpa-link` as the app-side low-level link layer below Studio capabilities +and beside `lpa-client`. + +`lpa-link` owns provider discovery, endpoint identity/status, low-level +management surfaces, raw logs/diagnostics, and opening a server/client +connection. `lpa-client` remains the typed client/RPC layer once a connection +exists. Studio owns higher-level capabilities, user/agent actions, client +sessions, project sessions, undo, and product workflows above the link layer. + +Use separate runtime targets: + +- `fw-browser` for browser/Web Worker Studio simulation and project testing. +- `fw-host` for host-OS local runtime use cases. + +Local runtime support is plural-first from the start. The type model must allow +multiple browser or host runtime instances so future multi-node and radio-style +LightPlayer systems are not forced through singleton assumptions. + +## Consequences + +Studio can be driven by both UI code and future agent harnesses through the same +domain surfaces, while `lpa-link` keeps low-level endpoint concerns out of the +UI. + +`lpa-link` can grow Web Serial and hardware-management functions without +confusing those functions with typed project/client RPCs. + +`fw-browser` and `fw-host` can evolve independently where browser and host +runtime constraints differ. This avoids pretending that browser shader execution, +host process lifecycle, and embedded firmware are the same product surface. + +Browser runtime validation needs an explicit ladder: wasm target check, +wasm-bindgen package build, a Rust-native `wasm-bindgen-test`, and browser smoke +coverage. CI-enforced headless browser execution remains dependent on browser +and WebDriver provisioning. + +## Alternatives Considered + +- Put all local behavior directly in Studio UI code. + - Rejected because it would entangle UI workflows with endpoint discovery, + hardware management, logs, and connection lifecycle. +- Treat the new layer as only a transport crate. + - Rejected because real links own more than `connect()`: discovery, status, + management, diagnostics, flashing, reset, and raw filesystem access belong + below Studio capabilities. +- Use one generic local firmware/runtime for browser and host. + - Rejected because browser and host runtimes have different purposes, + compilation constraints, output surfaces, and lifecycle models. +- Start singleton-shaped and generalize later. + - Rejected because multi-instance runtime support is foundational for future + multi-node LightPlayer systems. + +## Follow-ups + +- Provision CI/browser tooling for `fw-browser` `wasm-bindgen-test` execution. +- Add Web Serial and real hardware flashing as a later link provider. +- Keep embedded runtime shader compilation intact; browser and host runtimes are + Studio/local surfaces, not replacements for on-device GLSL JIT compilation. diff --git a/docs/adr/2026-06-18-browser-serial-shim.md b/docs/adr/2026-06-18-browser-serial-shim.md new file mode 100644 index 000000000..3db9127c9 --- /dev/null +++ b/docs/adr/2026-06-18-browser-serial-shim.md @@ -0,0 +1,57 @@ +# ADR 2026-06-18: Browser Serial Shim + +## Status + +Superseded by [2026-06-21 LPA Link Provider-Owned Resources](./2026-06-21-lpa-link-provider-owned-resources.md) +and [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md). + +## Context + +LightPlayer Studio needs a static web path that can connect to already-flashed +ESP32 hardware over Web Serial. The rest of the Studio stack should stay in +Rust: `lp-studio-core` owns actions/state, `lp-studio-runtime` owns protocol +flow, and `lpa-link` models device/link/session concepts. + +The browser Web Serial API is available to JavaScript, but the `web-sys` +bindings for `Serial` and `SerialPort` are currently gated behind +`web_sys_unstable_apis`. Requiring that cfg for normal Studio wasm builds would +make the build/deploy path more fragile and would leak a browser-platform detail +into unrelated Rust validation. + +## Decision + +Use a tiny JavaScript shim for direct Web Serial stream ownership. + +- `lp-app/lp-studio-web/public/browser-serial.js` owns + `navigator.serial.requestPort()`, `SerialPort.open()`, stream readers, + stream writers, line buffering, and close/cancel behavior. +- The shim installs a narrow global function surface before the Rust wasm module + starts. +- `lp-studio-runtime` calls that function surface through + `browser_serial_shim.rs`. +- Rust still owns Studio actions/effects/events, endpoint/session modeling, + `M!` protocol framing, JSON request/response parsing, server-event handling, + diagnostics, and demo project upload semantics. +- `lpa-link` models `browser-serial-esp32` as a provider/session/connection + kind, but it does not own browser stream objects. + +## Consequences + +Studio can build with ordinary wasm settings while still using Web Serial in +supported browsers. + +The boundary is intentionally narrow and replaceable. If stable `web-sys` +bindings become practical later, the shim can be collapsed into Rust without +changing the Studio action model or the `browser-serial-esp32` provider +vocabulary. + +The cost is one small JavaScript file in the static web shell. Browser stream +edge cases such as reader cancellation, disconnects, and permission errors must +be handled and tested at that boundary. + +## Update 2026-06-22 + +Browser serial ownership moved below Studio into `lpa-link`'s +`browser-serial-esp32` provider. `lpa-studio-ux` now adapts the connected link +session into `lpa-client`; `lpa-studio-web` does not own Web Serial streams or a +runtime shim. diff --git a/docs/adr/2026-06-18-link-provider-id-convention.md b/docs/adr/2026-06-18-link-provider-id-convention.md new file mode 100644 index 000000000..6348b6deb --- /dev/null +++ b/docs/adr/2026-06-18-link-provider-id-convention.md @@ -0,0 +1,76 @@ +# ADR: Link Provider ID Convention + +- **Status:** Accepted +- **Date:** 2026-06-18 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +`lpa-link` provider IDs are becoming durable application vocabulary. Studio, the +CLI, tests, and future agent harnesses will use these IDs to describe how an app +discovers, manages, and connects to LightPlayer runtimes or devices. + +The initial provider names used `local-host` and `local-browser`, and early +serial planning used names such as `serial-esp32-host`. That ordering did not +scale cleanly once the same mechanism needed different host and browser +capabilities, such as host serial versus browser Web Serial, or host websocket +discovery versus browser websocket constraints. + +## Decision + +Use kebab-case provider IDs with this grammar: + +```text +{environment}-{mechanism}-{target?} +``` + +The environment identifies where the provider runs, such as `host` or +`browser`. The mechanism identifies how the provider reaches or owns the runtime +or device, such as `process`, `worker`, `serial`, or `websocket`. The target is +optional and is included when management behavior is target-specific. + +Canonical examples: + +- `host-process` +- `browser-worker` +- `host-serial-esp32` +- `browser-serial-esp32` +- `host-websocket` +- `browser-websocket` + +Use Rust module/type names that match the provider ID in Rust style, such as +`providers::host_serial_esp32::HostSerialEsp32Provider`. + +## Consequences + +Provider IDs now expose capability differences before the caller inspects the +provider. For example, `host-websocket` and `browser-websocket` can differ in +discovery, permissions, and network constraints without overloading a generic +`websocket` provider name. + +ESP32 serial providers remain target-specific because flashing, reset, +boot-mode handling, and raw filesystem access are device-family behavior, not +generic serial behavior. + +The early `local-host` and `local-browser` names are renamed to `host-process` +and `browser-worker` instead of being carried forward as permanent aliases. + +## Alternatives Considered + +- `{mechanism}-{target}-{environment}`, such as `serial-esp32-host`. + - Rejected because it groups host and browser variants apart even though the + environment determines discovery, permissions, and management capabilities. +- Generic provider IDs such as `websocket` or `serial`. + - Rejected for providers whose behavior differs materially by environment or + target family. +- Keep `local-host` and `local-browser`. + - Rejected because "local" hides the containment model that matters to the + link layer: host process versus browser worker. + +## Follow-ups + +- Add `browser-serial-esp32` when Web Serial hardware support is implemented. +- Add `host-websocket` and `browser-websocket` separately when websocket + discovery and connection behavior is ready. diff --git a/docs/adr/2026-06-18-portable-lpa-client.md b/docs/adr/2026-06-18-portable-lpa-client.md new file mode 100644 index 000000000..02ec0f07d --- /dev/null +++ b/docs/adr/2026-06-18-portable-lpa-client.md @@ -0,0 +1,96 @@ +# ADR: Portable lpa-client And Link Server Connections + +- **Status:** Accepted +- **Date:** 2026-06-18 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +LightPlayer now has multiple app-side ways to reach a running `lp-server`: +host-process, host serial ESP32, browser worker, browser serial ESP32, and +future websocket/server-owned variants. The CLI grew first and its `LpClient` +was shaped around Tokio, shared mutexes, request timeouts, host transports, and +CLI heartbeat rendering. + +Studio web is now the primary app direction. It needs the same server protocol +semantics in browser runtimes and future agent harnesses without forcing Tokio, +`Send`, or host transport types into the core client model. At the same time, +the CLI and native host paths should keep their current ergonomics. + +`lpa-link` also needs a crisp boundary. It owns endpoint discovery, status, +logs, diagnostics, reset, flashing, raw filesystem access, and connection +lifecycle. It should not grow typed project operations. Once a link reaches a +running server, `lpa-client` should own the server protocol. + +## Decision + +Make `lpa-client::LpClient` the portable, runtime-neutral client for the +LightPlayer server protocol. + +- `ClientIo` is the small portable send/receive/close contract over + `lpc-wire` messages. It does not require Tokio or `Send`. +- `LpClient` owns request id allocation, response correlation, server error + handling, typed filesystem/project/overlay operations, and protocol events. +- Heartbeats, server logs, and uncorrelated responses are returned as + `ClientEvent`s instead of being printed or mapped to UI state in the core. +- Project deploy ordering lives in `lpa-client` helpers so Studio, CLI, and + agents can share stop/write/load semantics. +- Host/native behavior lives in `TokioLpClient` and host transport modules: + shared Tokio mutexes, timeouts, current CLI heartbeat display, local + transports, serial transports, and websocket transports. +- Host support remains enabled by default for existing native callers, while + browser/core consumers can compile with `default-features = false`. +- `lpa-link` exposes server connections from connected sessions. Host links + return a `LinkServerConnection` that can become a `TokioLpClient`; browser + links model endpoint/session/protocol identity and should adapt their browser + streams into `ClientIo`. +- The dependency direction is `lpa-link -> lpa-client` only for connection + types/adapters. `lpa-client` must not depend on `lpa-link`. + +## Consequences + +Studio browser work can reuse server protocol semantics without copying request +id, response correlation, heartbeat/log, server error, or project deploy logic. + +The CLI keeps a cloneable Tokio client wrapper and current command behavior, +but that wrapper is now a host adapter rather than the definition of the client +model. + +`lpa-link` stays a low-level device/runtime link layer. It can grow ESP32 +flashing, reset, raw filesystem, and diagnostics without confusing those +management operations with typed project/client protocol operations. + +Browser worker and Web Serial Studio paths now adapt connected `lpa-link` +sessions into `lpa-client::ClientIo` from `lpa-studio-ux`, so Studio does not +need duplicated browser-local protocol clients. + +The feature model is slightly more explicit. Host adapters and host transports +sit behind `host`; the portable core can be checked for wasm with +`cargo check -p lpa-client --target wasm32-unknown-unknown --no-default-features`. + +## Alternatives Considered + +- Keep the Tokio `LpClient` as the core model. + - Rejected because it would make Studio web and agent harnesses inherit host + runtime choices. +- Split out a new `lpa-client-core` crate immediately. + - Rejected because the dependency cycle did not require it; feature-gated + modules keep the boundary clear with less crate churn. +- Keep browser serial and worker protocol logic inside `lp-studio-runtime`. + - Rejected as the final architecture because it would duplicate foundational + protocol semantics. Accepted only as temporary M2 bring-up code until M2c + cuts browser streams over to `ClientIo`. +- Put typed project operations in `lpa-link`. + - Rejected because links should own discovery/management/connection, while + `lpa-client` owns server protocol semantics once connected. + +## Follow-ups + +- Continue hardening the browser `ClientIo` adapters for disconnects, + resynchronization, logs, and heartbeat edge cases. +- Consider moving host serial framing/resynchronization helpers below + `ClientTransport` if host and browser serial hardening converge. +- Keep ESP32 multi-project behavior conservative by using stop/write/load + deploy helpers until firmware intentionally supports multiple live projects. diff --git a/docs/adr/2026-06-18-studio-action-session-architecture.md b/docs/adr/2026-06-18-studio-action-session-architecture.md new file mode 100644 index 000000000..8169b01d5 --- /dev/null +++ b/docs/adr/2026-06-18-studio-action-session-architecture.md @@ -0,0 +1,81 @@ +# ADR: Studio Action And Session Architecture + +Date: 2026-06-18 + +## Status + +Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +LightPlayer Studio needs a real UI while preserving the product's core embedded +GLSL JIT architecture. The first Studio milestone also needs to stay honest for +future non-UI consumers: host harnesses, tests, and agents should be able to +drive the same product surface as the web UI. + +The first implementation slice uses: + +- `browser-worker` for static web/GitHub Pages-style simulation. +- `host-process` for host-side non-UI harness validation. +- `lpa-link` below Studio for endpoint discovery, status, management, logs, + diagnostics, and opening server/client connections. + +At the same time, Studio actions need to look forward to generic UI help, +agent-discoverable tools, and undo/redo, without implementing editing or undo in +M1. + +## Decision + +Studio is split into three application-facing crates: + +- `lp-studio-core` owns state, documented actions, action metadata, effects, + events, diagnostics, capabilities, and session records. +- `lp-studio-runtime` executes effects and translates link/client/runtime facts + back into Studio events. +- `lp-studio-web` renders `StudioState` with Dioxus and dispatches + `StudioAction` values. + +The core loop is: + +```text +StudioAction -> StudioState + StudioEffect -> StudioEvent -> StudioState +``` + +The core crate is synchronous and UI-free. It does not perform I/O, spawn +runtimes, own browser workers, open serial ports, or render components. + +Runtime code owns I/O. It consumes `StudioEffect` values and emits +`StudioEvent` values. The host path validates this with `host-process` and +`fw-host`; the browser path validates it with the `fw-browser` Web Worker +envelope. + +Actions are documented program objects. Each action has metadata and a +descriptor surface for labels, summaries, categories, origin, correlation, and +history policy. This gives UI controls and future agents a common way to explain +and inspect available operations. + +M1 does not implement undo/redo. It only classifies history behavior. Most M1 +actions are operational, read-only, or navigation actions, so they are +non-undoable or ephemeral. Future global undo should attach to successful domain +edit transactions, not to every `StudioAction`. + +## Consequences + +- Dioxus is a thin consumer rather than the owner of Studio behavior. +- A non-UI harness can validate the same action/effect/event shape as the web + UI. +- The first deployed web path can use `browser-worker` without implying that + browser simulation replaces ESP32 runtime compilation. +- `lpa-link` remains below Studio capabilities; Studio does not branch UI code + directly on serial/process/worker mechanics. +- Future agents can discover documented actions and capabilities without + scraping UI components. +- Undo remains deliberately deferred, but the action model will not need to be + reclassified later to separate operational actions from edit history. + +## Deferred + +- Real ESP32 hardware UX and Web Serial belong to M2. +- Agent execution/harness depth belongs to M3. +- Editing, overlay conflicts, commit/discard, undo/redo, and file/body edit + sessions belong to later authoring milestones. diff --git a/docs/adr/2026-06-18-studio-native-storybook.md b/docs/adr/2026-06-18-studio-native-storybook.md new file mode 100644 index 000000000..52edd407c --- /dev/null +++ b/docs/adr/2026-06-18-studio-native-storybook.md @@ -0,0 +1,58 @@ +# ADR 2026-06-18: Studio-Native Component Storybook + +## Status + +Accepted. + +## Context + +LightPlayer Studio is a Rust-first Dioxus web application. We want the component +development ergonomics of Storybook: isolated examples, meaningful states near +the component source, direct links, and fast visual review. At the same time, we +do not want to introduce a JavaScript Storybook toolchain before the Studio UI +surface is large enough to justify it. + +The current Studio components render product-shaped Studio state and simple UI +props. That makes them well suited to local fixture-driven stories. + +## Decision + +Studio component stories will be native Dioxus code in `lp-studio-web`. + +- Story files live next to components as sibling `*_stories.rs` modules. +- A small explicit Rust registry collects story descriptors and render functions. +- Stories render the real Studio components against fake, domain-shaped + `StudioView` / `UxPaneView` fixtures. +- `just studio-dev` builds the web app with the `stories` feature so the local + storybook is available at `/#/stories`. +- Production/static `studio-web-build` does not enable the storybook feature. +- `just studio-story-pngs` generates local PNGs into gitignored + `lp-app/lpa-studio-web/story-images/.scratch/`. + +## Consequences + +The component workflow stays close to the Rust code and avoids a second UI +runtime. Stories can evolve with the same types the production app uses, which +keeps UI fixtures honest as the Studio domain model grows. + +The tradeoff is that we do not get Storybook's ecosystem features for free: +add-ons, controls, automatic discovery, and hosted visual-regression workflows +would need to be built or adopted later. + +PNG generation is intentionally local-only for now. Before PNGs become committed +baselines or CI gates, we need a stable rendering environment, a curated story +set, and rules for volatile content such as logs, animation, timestamps, and +browser/font differences. + +## Update 2026-06-18 + +The PNG baseline policy was amended by +[ADR 2026-06-18: Studio Story PNG Baselines](./2026-06-18-studio-story-png-baselines.md). +The native Dioxus storybook decision remains accepted. + +## Update 2026-06-22 + +Studio stories now render `StudioView` and `UxPaneView` fixtures from +`lpa-studio-ux`. The storybook remains native Dioxus code, but it should follow +the active view/action model rather than the deleted `lpa-studio-core` +`StudioState` model. diff --git a/docs/adr/2026-06-18-studio-provisioning-core-model.md b/docs/adr/2026-06-18-studio-provisioning-core-model.md new file mode 100644 index 000000000..3e1cc5c8e --- /dev/null +++ b/docs/adr/2026-06-18-studio-provisioning-core-model.md @@ -0,0 +1,100 @@ +# ADR: Studio Provisioning Core Model + +- **Status:** Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) +- **Date:** 2026-06-18 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +The first Studio UI proved that LightPlayer can run in the browser, connect to +the browser worker simulator, connect to ESP32 hardware over Web Serial, upload +a demo project, and show basic runtime state. That UI was intentionally +demo-shaped: a pair of buttons drove scripted helper flows and the app inferred +device state from low-level session fields. + +The real Studio needs a product provisioning flow. A user should be able to +open Studio, choose a simulator or hardware provider, handle browser/runtime +availability, grant permission, open a link, probe the target, recover or flash +when needed, deploy/load a project, and arrive at a live project session. The +same flow also needs to be understandable by future agents and deterministic +fake runtimes. + +`lpa-link` already owns lower-level provider, endpoint, session, connection, and +management vocabulary. It should not grow product UX concepts such as "show a +compatible browser help action" or "this provisioning issue can be retried." + +## Decision + +Add a first-class provisioning/device-manager model to `lp-studio-core`. + +- `DeviceManagerState` is part of `StudioState`. +- `ProviderCatalog` owns provider cards, selected provider, provider + availability, and discovered/granted endpoints. +- `DeviceFlowState` models the user journey through choosing a provider, + requesting access, opening a link, probing, provisioning, flashing, deploying + a project, readiness, degradation, and disconnection. +- `DeviceIssue` and `RecoveryAction` provide structured failures and suggested + next steps for UI and future agents. +- `ProgressState`, `ProvisioningReason`, and `TargetProbeResult` model + long-running operations and target classification even before real flashing + is implemented. +- Existing live records such as `DeviceSession`, `ConnectionSession`, + `ClientSession`, and `ProjectSession` remain canonical for the connected + runtime and loaded project. +- `LinkSelection` is removed as canonical Studio state. Provider selection and + endpoints now live in `ProviderCatalog`. +- Actions, effects, and events gain provisioning vocabulary for provider catalog + refresh, starting/canceling/retrying provisioning, target probing, progress, + and typed issues. + +## Consequences + +The web UI can render the provisioning journey directly from core state instead +of keeping hidden wizard state in Dioxus. + +Runtime implementations and future fake scenarios can emit incremental events +for intermediate states rather than returning a fully scripted `StudioApp`. + +Future agents can inspect the same documented actions, issues, and recovery +actions as the UI. + +`lpa-link` stays focused on low-level link/device/runtime concerns. Product +availability and recovery guidance stay in Studio core. + +There is temporary scaffold churn in current demo helpers and stories because +they must read provider selection/endpoints from `ProviderCatalog` instead of +`LinkSelection`. That churn is accepted because the demo UI is not the product +architecture. + +## Alternatives Considered + +- Keep deriving provisioning state from optional session fields. + - Rejected because states such as unsupported browser, permission canceled, + blank device, flash needed, or project deploy failed cannot be represented + cleanly by `Option` and `Option`. +- Keep `LinkSelection` for compatibility with demo helpers. + - Rejected because the references were only scaffolding pressure. In active + development, preserving stale state would make the final model less clear. +- Move product availability and recovery guidance into `lpa-link`. + - Rejected because `lpa-link` should remain below Studio product semantics. +- Build the final Dioxus provisioning UI first. + - Rejected because the UI should render a tested core flow instead of + inventing local wizard state. + +## Follow-ups + +- M2 provisioning work should add deterministic scenario fakes and flow tests. +- M3 should replace the demo UI with the real onboarding/device-manager UX and + journey stories. +- Real browser/host ESP32 flashing and recovery should plug into the same flow + model instead of creating a separate provisioning path. + +## Update 2026-06-22 + +The `lp-studio-core` provisioning model was removed with the old +core/runtime split. Active Studio state now lives in `lpa-studio-ux` as +resource-owning Link, Server, and Project UX nodes. Flashing, recovery, and +provisioning concepts should be reintroduced there deliberately when the +hardware UX is ported. diff --git a/docs/adr/2026-06-18-studio-runtime-scenarios.md b/docs/adr/2026-06-18-studio-runtime-scenarios.md new file mode 100644 index 000000000..ba3564e06 --- /dev/null +++ b/docs/adr/2026-06-18-studio-runtime-scenarios.md @@ -0,0 +1,90 @@ +# ADR: Studio Runtime Scenarios + +- **Status:** Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) +- **Date:** 2026-06-18 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +The Studio provisioning core model gives the UI, runtimes, and future agents a +shared vocabulary for provider choice, permissions, link opening, target +probing, flashing, project loading, readiness, degradation, and recovery. + +That model needs deterministic coverage before the real Dioxus provisioning UI +is built. Hardware and browser APIs are useful integration targets, but they are +too manual and environment-dependent to cover the full matrix of product states: +unsupported browser, permission cancellation, blank device, endpoint-open +failure, firmware incompatibility, flash failure, project deploy/load failure, +and connection loss. + +`lpa-link` already has lower-level fake providers, but those fakes model link +provider mechanics. They should not become the owner of Studio product outcomes +or UI journey states. + +## Decision + +Add a public, I/O-free `lp-studio-runtime::scenario` module for deterministic +Studio provisioning scenarios. + +- Scenario definitions live in `lp-studio-runtime`, not `lp-studio-core` and + not `lpa-link`. +- `ProvisioningScenario` describes product-level runtime outcomes. +- `ScenarioRuntime` implements `EffectExecutor` and maps scripted outcomes into + real `StudioEvent` values. +- `ScenarioHarness` drives a real `StudioApp` through real + `StudioActionKind -> StudioEffect -> StudioEvent -> StudioApp::apply_event` + loops. +- `ScenarioSnapshot` records lightweight journey checkpoints for tests and + future UI stories. +- Scenario data has no browser, host-process, hardware, timing, or filesystem + dependency. + +## Consequences + +Provisioning tests can cover happy and failure paths without Web Serial, +firmware, device availability, or manual interaction. + +Future Dioxus journey stories can reuse the same scenario vocabulary instead of +inventing separate UI fixture states. + +The scenario layer pressures the real core reducer and event contract. When a +user-visible path cannot be expressed, the core model must gain a narrow typed +event or status instead of hiding the path behind a generic runtime error. + +`lpa-link` remains focused on provider/endpoint/session/management mechanics. +Studio product outcomes such as permission canceled, flash failed, or project +deploy failed stay above that layer. + +The public runtime test fixture surface is now part of Studio architecture. It +should stay small, serializable where useful, and I/O-free. + +## Alternatives Considered + +- Keep scenario fakes as private test helpers. + - Rejected because M3 UI journey stories and future agent harnesses need the + same vocabulary. +- Put the fakes in `lp-studio-core`. + - Rejected because scenarios execute effects and model runtime outcomes; core + should own state transitions, not fake effect execution. +- Put the fakes in `lpa-link`. + - Rejected because the important states are Studio product outcomes rather + than link-layer mechanics. +- Rely on browser and hardware integration tests only. + - Rejected because they cannot cheaply or deterministically cover permission, + flashing, project, and connection failure combinations. + +## Follow-ups + +- M3 should render provisioning journey stories from `ProvisioningScenario` or + `ScenarioSnapshot` rather than duplicating fixture vocabulary in the web app. +- Real browser/host flashing work should emit the same typed events and issues + exercised by scenario tests. + +## Update 2026-06-22 + +The `lpa-studio-runtime` crate was deleted when Studio moved to the +resource-owning `lpa-studio-ux` model. Future deterministic Studio stories and +tests should be built from `StudioView`, typed `UxAction` values, and focused +UX-node fixtures rather than the deleted runtime scenario module. diff --git a/docs/adr/2026-06-18-studio-story-png-baselines.md b/docs/adr/2026-06-18-studio-story-png-baselines.md new file mode 100644 index 000000000..46dbc981d --- /dev/null +++ b/docs/adr/2026-06-18-studio-story-png-baselines.md @@ -0,0 +1,53 @@ +# ADR 2026-06-18: Studio Story PNG Baselines + +## Status + +Accepted. + +## Context + +The native Studio storybook can generate PNGs for each component story. The +initial decision kept those PNGs local-only to avoid repository bloat and +browser-dependent screenshot churn. + +During early Studio UI work, the most valuable developer experience is being +able to see which component stories changed in the same commit as the source +change. LightPlayer is currently a small, solo-developed project, and the +initial story PNG set is modest enough that the visibility is worth trying +before investing in CI visual-regression infrastructure. + +## Decision + +Commit a curated baseline PNG set for `lp-studio-web` stories. + +- Committed baselines live under `lp-app/lp-studio-web/story-images/`. +- Scratch review PNGs stay gitignored under + `lp-app/lp-studio-web/story-images/.scratch/`. +- Fresh check output lives under gitignored + `lp-app/lp-studio-web/story-images/.new/`. +- `just studio-story-baselines` regenerates the committed baseline set. +- `just studio-story-check` compares fresh story PNGs to committed baselines + without updating them. +- `just studio-story-baselines-if-needed` runs baseline generation only when + non-generated files under `lp-app/lp-studio-web/` changed since `HEAD`. +- Story captures are clipped to the marked story canvas content rather than the + full browser viewport. +- Baseline and check commands require `oxipng` so fresh captures are normalized + the same way as committed images. +- Agents should run the helper before committing Studio UI work and include + changed baseline PNGs in the same commit. +- Do not use an auto-mutating Git hook for now. + +## Consequences + +Studio UI commits can show source changes and visual story changes together, +which makes review much easier while the UI foundation is still moving quickly. + +The tradeoff is that binary files will enter the repo and may churn when +browser rendering, fonts, or story fixtures change. To keep that acceptable, the +baseline set should stay curated, volatile content should be avoided in stories, +and baseline updates should remain intentional. + +CI can later run `just studio-story-check`, but CI should not commit updated +PNGs. If the image set grows substantially or churn becomes painful, revisit +this decision before adding Git LFS or hard visual gates. diff --git a/docs/adr/2026-06-18-studio-web-provisioning-controller.md b/docs/adr/2026-06-18-studio-web-provisioning-controller.md new file mode 100644 index 000000000..4a104c613 --- /dev/null +++ b/docs/adr/2026-06-18-studio-web-provisioning-controller.md @@ -0,0 +1,87 @@ +# ADR: Studio Web Provisioning Controller + +- **Status:** Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) +- **Date:** 2026-06-18 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +The first Studio web UI proved the end-to-end browser and hardware paths, but +it did so through demo helper functions that returned a fully completed +`StudioApp`. That was useful for proof-of-concept work, but it hid the product +journey from the UI: provider catalog, permission/access, endpoint discovery, +link opening, server readiness, project-state inspection, project selection, +recovery, deploying, and readiness. + +Studio now has a core provisioning model and deterministic scenario runtime. +The web app needs to drive that model from day one without moving domain state +into Dioxus-local wizard code. It also needs to keep simulator and Web Serial +hardware paths shaped the same way, because future host/web providers should +plug into the same user journey. + +## Decision + +Use a thin browser-side provisioning controller in `lp-studio-web`. + +- The controller dispatches real `StudioActionKind` values into `StudioApp`. +- It drains returned `StudioEffect` values through the active browser runtime. +- It applies returned `StudioEvent` values back into `StudioApp`. +- It owns browser-provider routing, including a merged provider catalog for + browser-worker simulator and browser-serial ESP32. +- It auto-advances only obvious steps, such as endpoint granted -> connect and + server ready -> read project state. +- It does not own Studio domain transitions, project semantics, provider + availability, or recovery decisions. +- Browser-worker is a real `EffectExecutor`, like browser serial and host + process, rather than a bespoke helper path. +- After server readiness, the default path is `ReadProjectState`: attach to one + loaded project, ask for user/project intent when zero or many are loaded, and + render recovery when recovery is reported. Loading the starter project is an + explicit user action. + +## Consequences + +The main web app renders the provisioning journey from shared Studio state +instead of swapping in a finished demo app. + +Simulator and browser-serial hardware now pressure the same +action/effect/event/reducer contract. Future browser flashing, host serial, and +remote providers can reuse the same controller shape with provider-specific +capabilities. + +The controller is intentionally thin and browser-specific. It is not a new +domain layer, and it should not grow product policy that belongs in +`lp-studio-core` or protocol/runtime behavior that belongs in +`lp-studio-runtime`. + +Story fixtures and PNG baselines can show the user journey state by state. The +visual story set becomes a lightweight review surface for this foundational UX. + +## Alternatives Considered + +- Keep using demo helpers that return completed `StudioApp` values. + - Rejected because the real onboarding/provisioning journey would remain + invisible to the UI and hard to review. +- Put the provisioning controller in `lp-studio-core`. + - Rejected because core must stay UI- and runtime-independent. +- Put the controller in `lp-studio-runtime`. + - Rejected because provider routing and Dioxus signal/update concerns are web + presentation concerns; runtime adapters should execute effects. +- Upload/load the starter project automatically after connecting. + - Rejected because real hardware usually already has a project loaded, and + overwriting/loading should be explicit user intent. + +## Follow-ups + +- Wire real browser-side ESP32 flashing into the same provider/controller flow. +- Add real recovery/safe-mode protocol once firmware/server support exists. +- Consider extracting shared controller test helpers if desktop Studio needs + the same auto-advance policy outside Dioxus. + +## Update 2026-06-22 + +The active Studio web app no longer owns a separate provisioning controller. +`lpa-studio-ux` owns Link, Server, and Project controller logic directly and +`lpa-studio-web` renders `StudioView` panes plus `UxAction` controls. diff --git a/docs/adr/2026-06-21-lpa-link-provider-owned-resources.md b/docs/adr/2026-06-21-lpa-link-provider-owned-resources.md new file mode 100644 index 000000000..5d2488c82 --- /dev/null +++ b/docs/adr/2026-06-21-lpa-link-provider-owned-resources.md @@ -0,0 +1,50 @@ +# ADR 2026-06-21: LPA Link Provider-Owned Resources + +## Status + +Accepted + +## Context + +`lpa-link` had a split ownership model. It exposed shared endpoint records, but +each provider returned a provider-specific session type, while browser serial +and browser worker resource ownership mostly lived in the old Studio runtime +crate and `lpa-studio-web` glue. + +That made the boundary hard to reason about: the link provider looked like the +owner of endpoint/session identity, but Web Serial ports, worker lifecycles, +ESP32 probe/flash, and browser-side JavaScript were owned elsewhere. + +## Decision + +`lpa-link` providers own their concrete resources. `LinkEndpoint` remains a +provider-neutral endpoint snapshot. `LinkSession` is now a provider-neutral +session snapshot/handle. Provider-private endpoint/session state owns concrete +resources, and public provider operations accept endpoint/session ids. + +Browser provider JavaScript that implements provider mechanics is owned by +`lpa-link`: + +- `browser_worker` owns the worker wrapper and worker lifecycle. +- `browser_serial_esp32` owns Web Serial request/open/release/close. +- `browser_serial_esp32` owns ESP32 bootloader probe and firmware flash + bindings. + +Application-owned browser artifacts are passed to provider constructors as +same-origin paths, not as remote URLs or a general locator model. + +`lpa-studio-ux` owns the active Studio controller layer above `lpa-link`. It may +adapt connected link sessions into `lpa-client`, but it does not own provider +resource identity or lifecycle. + +## Consequences + +- Provider behavior is discoverable in one crate. +- Browser serial endpoint-to-port state is provider-private. +- Browser worker lifecycle is provider-private. +- `lpa-client` continues to own request ids, response correlation, protocol + errors, heartbeat/log events, and project deploy helpers. +- Web apps must provide same-origin sidecar paths for generated assets such as + `fw_browser.js`, `fw_browser_bg.wasm`, firmware manifests, and esptool modules. +- Future providers should follow the same pattern: shared endpoint/session + records, provider-private concrete state, and provider methods by id. diff --git a/docs/adr/2026-06-21-studio-manager-action-model.md b/docs/adr/2026-06-21-studio-manager-action-model.md new file mode 100644 index 000000000..5f82eede3 --- /dev/null +++ b/docs/adr/2026-06-21-studio-manager-action-model.md @@ -0,0 +1,91 @@ +# ADR: Studio Manager Action Model + +Date: 2026-06-21 + +## Status + +Superseded by [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +Studio has outgrown a single provisioning-flow model. The UI needs a device/link +surface now, but the same product model also needs to support future server, +project, CLI, and agent consumers. A flat `StudioActionKind` made every action +look like an app-global operation and did not clearly say which manager owned +the state or decision. + +At the same time, the current web UI still renders one device-oriented journey. +The architecture needs to move toward link/server/project managers without +forcing a full presentation rewrite in the same step. + +## Decision + +Studio core exposes three manager-owned action vocabularies: + +- `LinkActionRequest` for provider choice, endpoint access, link open/close, + target probing, flashing, reset, diagnostics, and link issues. +- `ServerActionRequest` for `lp-server` protocol status and server project-state + reads. +- `ProjectActionRequest` for project attachment, project loading/deployment, + inventory reads, and project-local navigation. + +`StudioActionKind` is now an app-wide wrapper around those manager-local +requests. Generic consumers dispatch `StudioActionKind`, while manager code owns +its local request vocabulary. + +Each manager read model exposes `available_actions()`, returning +`AvailableAction`. `AvailableAction` combines: + +- a dispatchable action payload; +- an `ActionDescriptor` for label, summary, category, and history policy; +- enablement; +- presentation priority; +- optional confirmation metadata for risky actions. + +`StudioState::available_actions()` combines link, server, and project actions +into app-wide `StudioActionKind` values for UI and agent consumers. + +The visible link vocabulary no longer uses `ProviderSelected` or +`EndpointGranted` as flow states. Provider selection is catalog state, and +endpoint discovery proceeds directly into `LinkState::Opening` with a connect +effect. This keeps the happy path friction-free and leaves user-visible states +for moments where the user or system has meaningful work. + +## Consequences + +- UI and future agents can ask core for the current state and valid actions + without hard-coding flow-specific buttons. +- Action descriptors remain shared program documentation rather than duplicated + component copy. +- The runtime boundary stays effect/event based; manager actions do not perform + I/O directly. +- The current web UI can keep rendering transitional server/project milestones + through `LinkState` until the presentation is split into link/server/project + panes. +- Endpoint discovery now auto-continues into link opening instead of exposing a + separate endpoint-granted stop. + +## Alternatives Considered + +- Keep a flat `StudioActionKind`: simpler in the short term, but it obscures + ownership and makes agent/tool documentation harder to organize. +- Move all server/project states out of `LinkState` immediately: cleaner final + model, but it would couple this core action pass to a larger web UI rewrite. +- Let UI components derive available actions directly from state: fast for the + current UI, but it would duplicate policy and make non-UI consumers less + honest. + +## Follow-ups + +- Split the web presentation around link/server/project read models. +- Move remaining transitional server/project journey states out of `LinkState` + once the UI consumes the new manager states directly. +- Use `AvailableAction` for visible controls instead of component-local button + decisions. + +## Update 2026-06-22 + +The manager action idea became the active `lpa-studio-ux` model: `LinkUx`, +`ServerUx`, and `ProjectUx` own typed operations and expose contextual +`UxAction` values. `AvailableAction` and the effect/event runtime boundary were +removed with the old core/runtime crates. diff --git a/docs/adr/2026-06-21-studio-ux-layer.md b/docs/adr/2026-06-21-studio-ux-layer.md new file mode 100644 index 000000000..931851008 --- /dev/null +++ b/docs/adr/2026-06-21-studio-ux-layer.md @@ -0,0 +1,142 @@ +# ADR: Studio UX Layer + +- **Status:** Accepted +- **Date:** 2026-06-21 +- **Deciders:** Photomancer +- **Supersedes:** [2026-06-18 Studio Action And Session Architecture](./2026-06-18-studio-action-session-architecture.md) + +## Context + +The first Studio prototype split UI-independent state from runtime execution: + +```text +StudioAction -> StudioState + StudioEffect -> StudioEvent -> StudioState +``` + +That proved the browser-worker simulator and hardware provisioning paths, but it +made ownership hard to reason about. Effects such as opening a link, creating a +client, loading a project, and reading inventory were not merely UI effects; +they were the actual application logic. The web app also still composed browser +runtime routing, which meant the split did not fully hide implementation +mechanics from presentation code. + +Studio needs a middle layer that is UI-independent and product-shaped, but that +actually owns the services below it. The same layer should be useful to web UI, +future CLI/desktop shells, tests, and agents. + +## Decision + +Add `lpa-studio-ux` as the Studio UX layer: + +```text +lpa-link / lpa-client / protocol services + owned by +lpa-studio-ux + consumed by +lpa-studio-web, future CLI, desktop, and agents +``` + +`Ux` means a resource-owning product surface. It owns lower-level services such +as `lpa-link` and `lpa-client`, then exposes user-shaped state, semantic views, +snapshots, typed operations, contextual actions, progress, issues, logs, and +project summaries. + +The first implementation slice uses: + +- `StudioUx` as the top-level surface; +- `LinkUx`, `ServerUx`, and `ProjectUx` as domain sub-surfaces; +- `LinkUx` owns `lpa-link::LinkProviderRegistry` and opens provider sessions + through the registry; +- `ServerUx` owns the connected `lpa-client` protocol client after a link + connection exposes server I/O; +- `StudioSnapshot` and related snapshots as cloneable read models; +- `StudioView`, `UxPaneView`, and `UxBody` as small UI-independent view + primitives for panes, status, body content, metrics, issues, and actions; +- typed operations such as `LinkOp` and `ProjectOp`; +- `UxNodeId` to address resource-owning UX nodes such as `studio.link` and + `studio.project`; +- `UxAction` as the in-process user-facing action offering: target node id, + boxed concrete operation, and contextual labels, summaries, priorities, + enablement, and confirmation data; +- `UxNode` helpers so each UX node can create actions with its own node id; +- `UxContext` dispatch so `StudioUx` can route a `UxAction` to the owning node + and downcast to the concrete operation at the boundary; +- async dispatch methods that perform the real operation and update the UX + state directly. + +Studio does not maintain a separate string `ActionKind` identity in the core +model. Operation identity is the concrete enum type and variant. If tooling +later needs string identities, those should be derived from the operation type +instead of maintained as parallel tags. + +The first proof path is browser-worker simulation through the same provider +registry entry point that future hardware and host providers use. The simulator +provider is represented as an initial action; executing it auto-discovers and +connects the single browser-worker endpoint, then attaches the server protocol. +`lpa-link` owns the browser-worker provider/session. `lpa-studio-ux` owns the +registry and adapts the connected link session into `lpa-client::LpClient` +as an internal server transport detail. + +Browser Web Serial is also represented as an initial provider action when the +web build enables that provider. Browser port selection and permission remain +browser-owned behavior; Studio UX starts the access request and then models the +resulting provider endpoint/session state. The web app renders `StudioView` +panes and generic `UxAction` values; it does not route runtime providers, drain +service effects, correlate protocol responses, or implement browser port +selection itself. + +A fully dynamic `UxRegistry` is intentionally deferred. `StudioUx` manually owns +and dispatches to its current nodes for now, while the `UxNodeId`/`UxContext` +shape leaves room for a future UX tree such as `studio.project.node_tree`. + +These UX models are in-process client-side objects. They are meant for web UI, +future CLI/desktop shells, tests, and agent-facing textual descriptions; they +are not a new client/server serialization boundary. + +The older `lpa-studio-core` and `lpa-studio-runtime` crates were deleted after +the vertical slice proved the new model could own link, server, and project +resources directly. + +## Consequences + +- Studio behavior becomes easier to inspect through states, node ids, and + available actions. +- Web UI, future CLI, tests, and agents can share the same view/action/snapshot + language. +- Initial provider choices are renderable by generic action components instead + of special-cased web UI. +- Service operations move out of the UI and out of an abstract effect/event + loop. +- The first slice is smaller than the old provisioning UI; ESP32 flashing, + provisioning, and rich recovery states must be ported intentionally later. +- Historical plan files and old ADRs may mention the deleted core/runtime split, + but the active workspace uses `lpa-studio-ux` directly. + +## Alternatives Considered + +- Keep the `lpa-studio-core` / `lpa-studio-runtime` split. + - Rejected for the active direction because it made real application + ownership look like external effects and still leaked runtime composition + into the web app. +- Rename the old crates to backup directories immediately. + - Deferred during the experiment because keeping them compiling as references + made the slice easier to compare. They were later deleted instead of + renamed once the new slice was viable. +- Start with a generic UX component tree. + - Deferred. Domain-specific `LinkUx`, `ServerUx`, and `ProjectUx` states are + clearer for this stage. Generic view concepts can emerge from repeated + needs. +- Keep `ActionKind` as a parallel string identity for operations. + - Rejected because operation identity already exists in concrete operation + enum types and variants. Future string identities can be derived when + tooling needs them. + +## Follow-Ups + +- Port browser serial ESP32 and firmware flashing into the UX model. +- Add a CLI or test harness that drives `StudioUx` directly. +- Rebuild richer Studio visual stories on the new view/action model. +- Add a concrete `UxRegistry` when dynamic UX nodes such as + `studio.project.node_tree` need registration and dispatch. +- Add derive macros for operation metadata after the manual `UxOp` model has + more usage pressure. diff --git a/docs/adr/2026-06-22-browser-esp32-device-controller.md b/docs/adr/2026-06-22-browser-esp32-device-controller.md new file mode 100644 index 000000000..9a9e8d317 --- /dev/null +++ b/docs/adr/2026-06-22-browser-esp32-device-controller.md @@ -0,0 +1,83 @@ +# ADR 2026-06-22: Browser ESP32 Device Controller Boundary + +## Status + +Accepted + +## Context + +Studio's browser ESP32 path needs to do more than open a serial transport. The +same user workflow may need to select a Web Serial port, open the normal +LightPlayer JSON-lines protocol, reset the device, stream raw boot logs, detect +blank flash or ROM downloader mode, flash firmware through `esptool-js`, wipe +the device, and reconnect afterward. + +Earlier code split normal protocol attach into separate browser operations: +open for reset, close, reopen for protocol, then wait for readiness in Rust. +That made boot output easy to miss, made reset signal failures look like +connection failures, and forced the standalone serial debug page to duplicate +Web Serial lifecycle code. + +Web Serial lifecycle details are browser-native: `SerialPort` ownership, +reader/writer locks, `setSignals`, close races, and `esptool-js` handoff are +all JavaScript/browser concerns. `lpa-link` still needs to own the provider +semantics, while Studio UX should only see link capabilities, logs, progress, +and connection state. + +## Decision + +Use a browser-side `BrowserEsp32DeviceController` as the durable owner of the +Web Serial ESP32 device session. + +The controller owns: + +- the selected `SerialPort`; +- reader and writer locks; +- raw serial log pumping; +- best-effort normal reset signaling; +- explicit debug reset sequences; +- line buffering for the LightPlayer protocol adapter; +- safe close/release behavior; +- event/log/progress emission for browser consumers. + +The controller is served by the Studio web app at: + +```text +/lpa-link/browser_esp32_device_controller.js +``` + +The wasm-bound `browser-serial-esp32` provider adapter imports that module and +maps it into `lpa-link` sessions, logs, and protocol I/O. The standalone +`serial-debug.html` page imports the same module directly, so hardware debug +flows exercise the same Web Serial lifecycle as Studio. + +Normal protocol attach now opens the port once, starts reading immediately, and +then attempts normal reset while serial output is already being captured. Reset +signal failures are diagnostic, not the source of connection truth. Readiness +is determined from raw serial output and LightPlayer protocol frames above this +controller boundary. + +`esptool-js` remains the implementation mechanism for bootloader operations. +Provider management operations release normal protocol ownership before +flash/wipe takes bootloader ownership, then reconnect through the same +controller-backed protocol attach path afterward. + +## Consequences + +There is one browser-native place to fix Web Serial lock handling, reset timing, +close races, raw log capture, and small retry policy. + +Studio UX remains independent of Web Serial details. It consumes link/provider +capabilities, logs, progress, and readiness results rather than owning browser +ports or signal sequences. + +The debug page is more trustworthy because its normal connect/reset/read path +uses the same primitive as the app. + +Apps using the browser serial ESP32 provider must serve the controller module at +the expected same-origin path, or configure an equivalent packaging strategy in +a later provider option. + +The current controller is intentionally browser/client-side. It is not a new +client/server protocol boundary and is not meant to be serialized beyond logs +or textual agent/debug views. diff --git a/docs/adr/2026-06-22-studio-device-ux-workflow.md b/docs/adr/2026-06-22-studio-device-ux-workflow.md new file mode 100644 index 000000000..e00811ba2 --- /dev/null +++ b/docs/adr/2026-06-22-studio-device-ux-workflow.md @@ -0,0 +1,94 @@ +# ADR: Studio Device UX Workflow + +- **Status:** Accepted +- **Date:** 2026-06-22 +- **Deciders:** Photomancer +- **Builds On:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md), + [2026-06-22 Studio Link Management Workflow](./2026-06-22-studio-link-management-workflow.md) + +## Context + +The first Studio UX slice exposed separate Link, Server, and Project panes. That +matched lower-level service boundaries, but it did not match the user's mental +model. A user chooses how to connect to a device, waits for that device to open, +waits for LightPlayer firmware/server readiness, and then opens a project. Link +and server are meaningful implementation layers, but they are not usually the +primary product concepts. + +Studio also needs to handle blank ESP32 devices, firmware provisioning, reset to +blank, boot logs, progress, and project attach in one discoverable flow. Keeping +Link and Server as adjacent primary panes made those recovery states feel split: +the action lived in one pane while the failure or boot output often lived in +another. + +## Decision + +Make `DeviceUx` the user-facing controller for the connection workflow. +`StudioUx` owns `DeviceUx` and `ProjectUx`. + +`DeviceUx` owns the lower-level `LinkUx` and `ServerUx` controllers internally. +It exposes one semantic `UiStackView` with step sections for: + +- select connection; +- connect device; +- connect LightPlayer; +- open project. + +Actions are attached to the view section that offers them. A UX node does not +have a separate global action list; actions are part of the presented view. +`StudioUx::actions()` derives the currently available actions from the current +`StudioView`. + +Project remains separate from Device, but only after a project is loaded. +Project attach, demo loading, and loaded-project selection are part of getting +the device into a usable state, so those actions live in the Device +open-project step. Project is expected to grow into the node tree, file tree, +and project editing surface, so folding the loaded project surface into Device +would mix two product concepts that will evolve differently. + +`UiStackView`, `UiStackSection`, and `UiStepState` are reusable UI-independent +view primitives. They are intentionally small and client-side only. They are +used for web rendering, textual agent rendering, tests, and future CLI/desktop +surfaces, not as a serialized client/server protocol. + +## Consequences + +- The active Studio shell now presents a Device pane first, with Project shown + only once a project is loaded. +- Link and Server remain real implementation controllers, but they are no + longer top-level user-facing panes. +- Blank-device detection, provisioning, reset-to-blank, reconnect, boot logs, + LightPlayer server attach, and project opening appear in one continuous Device + workflow. +- Web UI rendering becomes more generic: it renders stack sections, section + bodies, section-local actions, and terminal output without knowing link/server + policy. +- Agents and future CLI shells can inspect the same tree and describe both the + state and the available requests in a product-shaped language. +- Some transitional code still maps Link/Server progress updates into the + Device pane while lower-level progress emitters are migrated. + +## Alternatives Considered + +- Keep Link and Server as separate primary panes. + - Rejected. It preserves implementation boundaries at the cost of a fractured + user workflow. +- Merge Project into Device. + - Rejected. Project will become a larger editing/navigation surface, and it + should remain its own Studio concept. +- Build a highly generic component schema immediately. + - Rejected. A small stack/body/action vocabulary covers the current workflow + while leaving room to grow from real use. +- Serialize the UX tree as a protocol. + - Rejected. This is an in-process client-side model. Textual rendering for + agents is useful, but Studio already has a real client/server boundary in + `lpa-client` and `lp-server`. + +## Follow-Ups + +- Migrate lower-level progress emission so Device actions publish section-aware + activity directly instead of relying on Link/Server node-id fallback mapping. +- Continue shaping the loaded Project pane around the future project node tree + and file tree. +- Add more focused tests for Device stack state/action placement as hardware + workflows settle. diff --git a/docs/adr/2026-06-22-studio-link-management-workflow.md b/docs/adr/2026-06-22-studio-link-management-workflow.md new file mode 100644 index 000000000..061a6f87e --- /dev/null +++ b/docs/adr/2026-06-22-studio-link-management-workflow.md @@ -0,0 +1,135 @@ +# ADR: Studio Link Management Workflow + +- **Status:** Accepted +- **Date:** 2026-06-22 +- **Deciders:** Photomancer +- **Builds On:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +Studio now has a UI-independent UX layer that owns `lpa-link`, `lpa-client`, +server state, project state, and action dispatch. The next missing hardware +workflow is the blank-device lifecycle: + +- provision a blank ESP32-C6 with packaged LightPlayer firmware; +- reset an existing ESP32-C6 back to blank. + +These operations are below the `lp-server` protocol. A blank device may not be +running a server at all, and a full-device erase intentionally destroys the +firmware and server filesystem. Modeling those operations as `lpa-client` +requests or Dioxus component logic would put ownership in the wrong layer. + +## Decision + +Add a provider-neutral link management API to `lpa-link`: + +- providers advertise low-level support through `LinkCapabilities`; +- callers execute session-scoped requests with `LinkProvider::manage`; +- requests use `LinkManagementRequest`; +- results use `LinkManagementResult` plus compact management progress/log data; +- long-running providers can additionally publish `LinkManagementEvent` values + through `manage_with_events`; +- unsupported providers return `LinkError::OperationUnsupported`. + +The initial implemented requests are: + +- `FlashFirmware`: write the provider-configured firmware image set to the + connected target; +- `EraseDeviceFlash`: erase the whole device flash so the ESP32 returns to an + unprovisioned blank state. + +Do not overload reset/reboot vocabulary for destructive blanking. +`LinkOperation::Reset` remains non-destructive runtime/device reset. +`LinkOperation::EraseDeviceFlash` is the destructive full-device erase +capability. + +Browser Web Serial ESP32 is the first concrete management provider. It owns Web +Serial permission, port ownership, ESP32 bootloader access, firmware manifest +loading, `esptool-js` integration, and protocol release/reopen behavior. Before +probe, firmware flash, or full erase, it releases normal server/protocol serial +ownership so bootloader tooling can take the port exclusively. + +Browser serial server protocol open/reopen also performs a hard reset before +opening the JSON-lines protocol stream. The browser serial client then waits for +the first valid protocol frame before sending the first request, so the initial +project probe is not lost while firmware is still booting. If the readiness wait +or probe still fails, Studio marks the server failed instead of leaving the +server pane in a misleading connected state. + +Use the packaged Studio firmware manifest at +`./firmware/esp32c6/manifest.json`. Use a pinned browser esptool module, +`https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm`, as the default development +path. A browser ESM CDN endpoint is required because the raw package ESM imports +dependencies such as `pako` by bare specifier, which browsers cannot resolve +directly. The selected endpoint has been checked against the ESP32-C6 stub +decode path used by reset/provisioning. Applications can override the module +path through `BrowserSerialEsp32Options` when they want to serve the dependency +themselves. + +Expose the workflow through `lpa-studio-ux` actions: + +- `Provision firmware` is a primary link action when a connected link supports + firmware flashing and Studio is not attached to a server; +- `Reset to blank` is a tertiary destructive link action when a connected link + supports whole-device erase; +- both actions carry `ActionConfirmation` metadata and are rendered generically + by the web UI; +- `StudioUx` clears server/project state before provisioning or erasing because + either operation invalidates the old server connection; +- after provisioning, Studio attempts to reopen the server protocol and resume + the normal server/project workflow; +- after reset-to-blank, Studio leaves server/project detached and keeps the + link context provisionable when the browser still holds the serial permission. +- live management progress is surfaced as pane-scoped `UxActivity`, including + progress bars and raw esptool terminal output for browser serial flash/erase. + +Zip upload/download is out of this slice. If raw filesystem backup/restore is +added later, it should read or write direct device/LittleFS image bytes through +link-level management, not route through the running server filesystem API. + +## Consequences + +- Blank-device provisioning and reset-to-blank are now part of the same + `UxAction` language as simulator start, server attach, and project actions. +- Web UI remains presentation-only: it renders action metadata, confirms + destructive actions, and dispatches the selected `UxAction`. +- Agents and future CLI/desktop shells can inspect the same UX tree and see + link management actions without learning browser serial or esptool details. +- `lpa-link` becomes the durable home for low-level device management, while + `lpa-client` remains the durable home for server protocol/project operations. +- Flash/erase no longer need to leave the UI opaque while awaiting a single + final result; the UX layer can publish live activity updates without moving + provider ownership into the web UI. +- The default esptool path depends on a pinned remote module. This is acceptable + for the current development slice and is explicitly overridable for packaged + deployments. + +## Alternatives Considered + +- Implement provisioning directly in Dioxus components. + - Rejected. The web app should not own Web Serial/esptool resource lifecycle or + decide server/project invalidation policy. +- Treat reset-to-blank as a server filesystem operation. + - Rejected. A full blank reset destroys firmware and server state; it must be + below the server protocol. +- Hide provisioning behind server connect failure handling. + - Rejected. Provisioning is a first-class capability of a connected management + link, not an error recovery side effect. +- Implement zip filesystem backup/restore now. + - Deferred. The immediate product need is provisioning a blank device and + resetting an existing device to blank. Future filesystem backup/restore + should use raw LittleFS/device image bytes. +- Self-host `esptool-js` immediately. + - Deferred. The pinned remote module keeps this slice small while preserving + an explicit override path for deployments. + +## Follow-Ups + +- Validate provision/reset on real ESP32-C6 hardware in the browser and record + any timing or reconnect refinements. +- Self-host or vendor the browser esptool module for offline/deployed Studio + builds. +- Add host-serial ESP32 management support using the same request/result model. +- Add direct raw LittleFS image read/write if backup/restore becomes a priority. +- Add cancellation/retry affordances for long-running management activity if + flash/erase failures need more recovery control. diff --git a/docs/adr/2026-06-22-studio-pages-deployment.md b/docs/adr/2026-06-22-studio-pages-deployment.md new file mode 100644 index 000000000..84c635087 --- /dev/null +++ b/docs/adr/2026-06-22-studio-pages-deployment.md @@ -0,0 +1,71 @@ +# ADR 2026-06-22: Studio Pages Deployment + +## Status + +Accepted + +## Context + +`lpa-studio-web` is now a static browser shell for LightPlayer Studio. It +packages Dioxus wasm, the browser firmware runtime wasm, browser Web Serial +support files, and an ESP32-C6 firmware image for provisioning. + +The project owns `lightplayer.app`. The repo already had a GitHub Pages-oriented +web demo deploy path, so GitHub Pages is the lowest-friction first public +hosting option. + +Studio browser assets currently assume root-hosted paths. The browser serial +provider's generated JavaScript imports the app-served controller at +`/lpa-link/browser_esp32_device_controller.js`. Path-hosted deployments such as +`lightplayer.app/beta/` would require base-path-aware asset resolution first. + +GitHub Pages also associates custom domains with a Pages site. Root-hosted +production, beta, and demo subdomains therefore need separate Pages sites or a +more complex router/fronting layer. + +## Decision + +Use GitHub Pages for the first public static deployment: + +- `light-player/lightplayer` hosts production Studio at `lightplayer.app` using + GitHub Pages from Actions. +- `light-player/lightplayer-beta-pages` hosts manually deployed beta Studio at + `beta.lightplayer.app`. +- `light-player/lightplayer-demo-pages` hosts the GLSL web demo at + `demo.lightplayer.app`. + +The source repo remains the build authority. Production deploys from `main` via +GitHub's Pages artifact flow. Beta and demo are built in the source repo by a +manual workflow, then published as static commits to the corresponding Pages +repository. + +Deployment tooling stages clean release-only artifacts under `target/pages/` +and generates `version.json`, `.nojekyll`, and `CNAME` files. This avoids +uploading stale debug wasm from `lp-app/lpa-studio-web/public/pkg`. + +## Consequences + +- Production deploys are automatic after `main` updates. +- Beta and demo deploys are explicit and cannot overwrite production. +- Each public hostname is root-hosted, which matches current browser asset path + assumptions. +- The source repo needs GitHub App credentials for workflows that push to + beta/demo Pages repositories. The workflow mints a short-lived installation + token with `contents:write`. +- DNS, GitHub Pages custom-domain checks, and HTTPS certificate provisioning + remain human-confirmed operational steps. +- The app still depends on the pinned jsDelivr ESM endpoint for `esptool-js` + until a later self-hosting pass. +- Path-hosted channels remain future work until Studio and `lpa-link` browser + assets are base-path aware. + +## Alternatives Considered + +- **Single Pages site with `/beta/` and `/demo/` paths.** Rejected for the first + pass because root-anchored browser serial assets would need additional work. +- **Keep only `gh-pages` branch deployment.** Rejected for production because + GitHub Pages from Actions provides a cleaner artifact boundary and deployment + environment. +- **Use a different static host immediately.** Deferred because GitHub Pages is + already close to the existing demo workflow and is sufficient for the current + static bundle size. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 000000000..497b2efd7 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,43 @@ +# Architecture Decision Records + +Architecture Decision Records, or ADRs, capture durable architecture and process +decisions for this repo. + +Use ADRs for decisions that choose a direction among plausible alternatives and +have lasting architectural, operational, security, data-model, API, workflow, +product, embedded, or cross-repo/process consequences. + +Do not create ADRs for ordinary feature work, bug fixes, UI copy/layout +changes, mechanical refactors, tests, scripts, helpers, or phase sequencing +unless they set a broader precedent. + +## Filename + +Use date-based filenames: + +```text +YYYY-MM-DD-short-title.md +``` + +Date-based names keep files sortable and reduce conflicts between parallel +branches. + +## Status + +Use one of: + +- `Proposed` +- `Accepted` +- `Superseded` +- `Rejected` + +Treat ADRs as durable history. If a decision changes, create a new ADR that +supersedes the old one instead of rewriting old context heavily. + +## Relationship To Shared Planning + +Plans, roadmap-level plans, reviews, reports, scratch notes, and phase prompts +live in the personal planning workspace configured by `PHOTOMANCER_PLANNING_ROOT` +or `~/.photomancer/planning`. + +Only durable decisions graduate into `docs/adr/`. diff --git a/docs/adr/_template.md b/docs/adr/_template.md new file mode 100644 index 000000000..f02b7364c --- /dev/null +++ b/docs/adr/_template.md @@ -0,0 +1,17 @@ +# ADR: Title + +- **Status:** Proposed +- **Date:** YYYY-MM-DD +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +## Decision + +## Consequences + +## Alternatives Considered + +## Follow-ups diff --git a/docs/deploy/studio-pages.md b/docs/deploy/studio-pages.md new file mode 100644 index 000000000..8736c78c7 --- /dev/null +++ b/docs/deploy/studio-pages.md @@ -0,0 +1,136 @@ +# LightPlayer Pages Deployment + +LightPlayer Studio deploys as a static GitHub Pages site. The first deployment +topology uses root-hosted domains rather than path-hosted channels because +browser serial assets are currently resolved from root paths such as +`/lpa-link/browser_esp32_device_controller.js`. + +## Sites + +| Channel | URL | Source | +|---|---|---| +| Production Studio | `https://lightplayer.app/` | `light-player/lightplayer`, GitHub Pages from Actions | +| Beta Studio | `https://beta.lightplayer.app/` | `light-player/lightplayer-beta-pages`, branch Pages | +| Web demo | `https://demo.lightplayer.app/` | `light-player/lightplayer-demo-pages`, branch Pages | + +## Automated Setup + +Create and configure the beta/demo Pages repositories with: + +```bash +scripts/pages/setup-pages-repos.sh --dry-run +scripts/pages/setup-pages-repos.sh --apply +``` + +The helper uses `gh repo create` and `gh api` where permissions allow. It does +not change DNS and it may still leave GitHub Pages confirmation steps for a +human when token scopes or organization settings block automation. + +The source repo needs GitHub App credentials for manual beta/demo deployments. +The app should be installed only on: + +- `light-player/lightplayer-beta-pages` +- `light-player/lightplayer-demo-pages` + +The app needs only repository **Contents: Read and write** permission. Store the +credentials in `light-player/lightplayer` as Actions secrets: + +```text +LIGHTPLAYER_PAGES_APP_ID +LIGHTPLAYER_PAGES_PRIVATE_KEY +``` + +Production uses GitHub's native Pages deployment token and does not need these +secrets. + +## DNS + +Use GitHub Pages records for the apex: + +```text +A @ 185.199.108.153 +A @ 185.199.109.153 +A @ 185.199.110.153 +A @ 185.199.111.153 +``` + +Optional IPv6 records: + +```text +AAAA @ 2606:50c0:8000::153 +AAAA @ 2606:50c0:8001::153 +AAAA @ 2606:50c0:8002::153 +AAAA @ 2606:50c0:8003::153 +``` + +Subdomain records: + +```text +CNAME www light-player.github.io +CNAME beta light-player.github.io +CNAME demo light-player.github.io +``` + +Remove parking, forwarding, or old apex `A`/`AAAA`/`ALIAS`/`ANAME` records. +Extra apex records can prevent GitHub from provisioning the Pages certificate. + +## GitHub Pages Settings + +Production: + +1. Open `https://github.com/light-player/lightplayer/settings/pages`. +2. Set the Pages source to GitHub Actions. +3. Set the custom domain to `lightplayer.app`. +4. Wait for the DNS check and TLS certificate. +5. Enable Enforce HTTPS. + +Beta and demo: + +1. Open each Pages repo's Pages settings. +2. Confirm branch source is `main` and `/`. +3. Confirm custom domains: + - `beta.lightplayer.app` + - `demo.lightplayer.app` +4. Wait for DNS checks and TLS certificates. +5. Enable Enforce HTTPS. + +## Build And Smoke Locally + +Studio production artifact: + +```bash +just studio-web-deploy-dir production target/pages/studio lightplayer.app +just studio-web-smoke target/pages/studio +``` + +Beta Studio artifact: + +```bash +just studio-web-deploy-dir beta target/pages/studio beta.lightplayer.app +just studio-web-smoke target/pages/studio +``` + +Web demo artifact: + +```bash +just web-demo-deploy-dir demo target/pages/web-demo demo.lightplayer.app +just web-demo-smoke target/pages/web-demo +``` + +The deploy-directory recipes generate `version.json`, `.nojekyll`, and `CNAME` +when a domain is supplied. They also print total artifact size and the largest +files so debug wasm artifacts are easy to spot. + +## Deploy + +Production deploys automatically from `main` through the `Deploy Studio Pages` +workflow. + +Beta and demo deploy manually through the `Deploy Pages Channel` workflow: + +- `channel`: `beta` or `demo` +- `ref`: branch, tag, or commit to build +- `dry_run`: build and smoke-check without pushing + +The older `just web-demo-deploy` path remains available until +`demo.lightplayer.app` is verified. diff --git a/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md b/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md index df9d25d01..7a9205743 100644 --- a/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md +++ b/docs/plans/2026-04-19-cpu-profile-m2-cpu-collector/00-design.md @@ -140,12 +140,12 @@ proposal — local placement chosen). #### `src/emu/executor/compressed.rs` — compressed jump classification -| Mnemonic | Encoding | New class | -| ------------ | ---------- | ---------------------------------------------- | -| `c.j` | rd = x0 | `JalTail` | -| `c.jal` | rd = x1 | `JalCall` | -| `c.jr rs1` | rd = x0 | `JalrReturn` if `rs1 == 1` else `JalrIndirect` | -| `c.jalr rs1` | rd = x1 | `JalrCall` | +| Mnemonic | Encoding | New class | +|--------------|----------|------------------------------------------------| +| `c.j` | rd = x0 | `JalTail` | +| `c.jal` | rd = x1 | `JalCall` | +| `c.jr rs1` | rd = x0 | `JalrReturn` if `rs1 == 1` else `JalrIndirect` | +| `c.jalr rs1` | rd = x1 | `JalrCall` | #### `src/emu/executor/mod.rs` — `ExecutionResult` @@ -204,12 +204,15 @@ impl Riscv32Emulator { #### `src/profile/mod.rs` — trait + session updates `InstClass` re-export from `cycle_model`: + ```rust pub use crate::emu::cycle_model::InstClass; ``` + (Replaces the m1-era stub `pub struct InstClass {}`.) `Collector` trait: + ```rust pub trait Collector: Send { fn on_syscall(&mut self, _ctx: &mut EmuCtx, _id: u32, _args: &[u32; 8]) -> SyscallAction { SyscallAction::Forward } @@ -223,6 +226,7 @@ pub trait Collector: Send { ``` `ProfileSession`: + ```rust impl ProfileSession { pub fn dispatch_instruction(&mut self, pc: u32, target_pc: u32, @@ -233,7 +237,7 @@ impl ProfileSession { } /// Extended on_perf_event: fans out to collectors, runs gate, - /// fans out gate action, propagates Stop. + /// fans out gate ux, propagates Stop. pub fn on_perf_event(&mut self, event: PerfEvent) { for c in &mut self.collectors { c.on_perf_event(&event); } let action = self.gate.as_mut() @@ -478,6 +482,7 @@ impl CycleModelArg { #### `src/commands/profile/handler.rs` — wiring Two new chunks: + 1. **Cycle model plumbing**: `emu = emu.with_cycle_model(args.cycle_model.to_emu());` 2. **`cpu` collector validation + auto-include events**: @@ -568,14 +573,15 @@ impl<'a> Symbolizer<'a> { ``` Algorithm: + 1. Symbolize every PC in `func_stats` and assemble a deduplicated `frames: Vec<{ name }>` with a `pc -> frame_idx` map. 2. DFS over `call_edges` keyed by caller, maintaining a running `cursor: u64`. For each (caller, callee) in DFS order: - - Emit `O` event for callee at `cursor`. - - Recurse into callee's outgoing edges. - - `cursor += call_edges[(caller, callee)].inclusive_cycles - ` - - Emit `C` event for callee at `cursor`. + - Emit `O` event for callee at `cursor`. + - Recurse into callee's outgoing edges. + - `cursor += call_edges[(caller, callee)].inclusive_cycles - ` + - Emit `C` event for callee at `cursor`. 3. Wrap in the Speedscope envelope: ```json { @@ -680,13 +686,13 @@ lp-cli profile [--dir PROFILES_DIR] EXAMPLE_DIR `profiles/2026-04-19T15-22-01--basic--steady-render/` -| File | Source | Notes | -| ------------------------------- | ----------------- | ---------------------------------- | -| `meta.json` | handler | gains `cycle_model` field | -| `events.jsonl` | EventsCollector | auto-included with cpu | -| `cpu-profile.json` | output_cpu_json | canonical, m3's diff target | -| `cpu-profile.speedscope.json` | output_speedscope | browser flame chart | -| `report.txt` | output | gains `=== CPU summary ===` section| +| File | Source | Notes | +|-------------------------------|-------------------|-------------------------------------| +| `meta.json` | handler | gains `cycle_model` field | +| `events.jsonl` | EventsCollector | auto-included with cpu | +| `cpu-profile.json` | output_cpu_json | canonical, m3's diff target | +| `cpu-profile.speedscope.json` | output_speedscope | browser flame chart | +| `report.txt` | output | gains `=== CPU summary ===` section | `heap-trace.jsonl` shows up only when `alloc` is in `--collect`. @@ -704,60 +710,60 @@ lp-cli profile [--dir PROFILES_DIR] EXAMPLE_DIR values where it matters). - `lp-riscv-emu/src/profile/cpu.rs#tests` — eight scenarios: - 1. `gate_disabled_no_attribution`: events ignored when active=false. - 2. `simple_call_return`: push, attribute, pop. Inclusive cycles - match wall-clock between push and pop. - 3. `nested_three_deep`: A→B→C→C-return→B-return→A-return. - Inclusive cycles bubble correctly. - 4. `tail_call_swaps_top`: A→B(tail)→C(tail)→return-from-A. Stack - never deeper than 1 frame. - 5. `orphaned_return_at_root`: extra return when stack is empty. - No-op, no panic. - 6. `root_self_cycles`: instructions before any call land under - `` (PC=0). - 7. `enable_disable_toggle`: gate flips on/off mid-run; cycles only - attributed during `active=true` windows. - 8. `call_edge_aggregation`: same (caller, callee) called 3 times; - `call_edges[(caller, callee)].count == 3`. + 1. `gate_disabled_no_attribution`: events ignored when active=false. + 2. `simple_call_return`: push, attribute, pop. Inclusive cycles + match wall-clock between push and pop. + 3. `nested_three_deep`: A→B→C→C-return→B-return→A-return. + Inclusive cycles bubble correctly. + 4. `tail_call_swaps_top`: A→B(tail)→C(tail)→return-from-A. Stack + never deeper than 1 frame. + 5. `orphaned_return_at_root`: extra return when stack is empty. + No-op, no panic. + 6. `root_self_cycles`: instructions before any call land under + `` (PC=0). + 7. `enable_disable_toggle`: gate flips on/off mid-run; cycles only + attributed during `active=true` windows. + 8. `call_edge_aggregation`: same (caller, callee) called 3 times; + `call_edges[(caller, callee)].count == 3`. - `lp-cli/src/commands/profile/symbolize.rs#tests`: - - hit (PC inside known symbol) - - miss in RAM → `` - - miss elsewhere → `` - - PC == 0 → `` - - boundary: `pc == addr` (hit), `pc == addr + size - 1` (hit), - `pc == addr + size` (miss). + - hit (PC inside known symbol) + - miss in RAM → `` + - miss elsewhere → `` + - PC == 0 → `` + - boundary: `pc == addr` (hit), `pc == addr + size - 1` (hit), + `pc == addr + size` (miss). - `lp-cli/src/commands/profile/output_speedscope.rs#tests`: - - 3-frame call graph fixture → output parses as JSON, has correct - Speedscope shape (`$schema`, `profiles[0].type == "evented"`, - `events.len() == call_edges.len() * 2`). - - Sum of all event delta cycles equals `total_cycles_attributed`. + - 3-frame call graph fixture → output parses as JSON, has correct + Speedscope shape (`$schema`, `profiles[0].type == "evented"`, + `events.len() == call_edges.len() * 2`). + - Sum of all event delta cycles equals `total_cycles_attributed`. - `lp-cli/src/commands/profile/output_cpu_json.rs#tests`: - - Round-trip through `serde_json::from_str` / `to_string`. - - `schema_version == 1` present. - - Hex format matches `^0x[0-9a-f]{8}$` for all PC keys. + - Round-trip through `serde_json::from_str` / `to_string`. + - `schema_version == 1` present. + - Hex format matches `^0x[0-9a-f]{8}$` for all PC keys. - `lp-cli/src/commands/profile/mode/{compile,startup,all}.rs#tests`: - - New case: each gate returns `Enable` on `profile:start`. - - (Existing m1 cases unchanged.) + - New case: each gate returns `Enable` on `profile:start`. + - (Existing m1 cases unchanged.) ### Integration tests - `lp-cli/tests/profile_cpu.rs` (new): - - `cpu_default_smoke`: `lp-cli profile examples/basic` produces - `meta.json`, `events.jsonl`, `cpu-profile.json`, - `cpu-profile.speedscope.json`, `report.txt`. `cpu-profile.json` - parses; `total_cycles_attributed > 0`; `report.txt` contains - "CPU summary". - - `cpu_with_alloc`: `--collect cpu,alloc` produces both - `cpu-profile.json` and `heap-trace.jsonl`. - - `cpu_uniform_model`: `--cycle-model uniform` → - `meta.json.cycle_model == "uniform"` and all instruction costs - are 1 (sanity check on a tiny example). - - `cpu_compile_mode`: `--mode compile` produces a non-empty - flame chart (smoke test that gate Enable-on-profile-start works). + - `cpu_default_smoke`: `lp-cli profile examples/basic` produces + `meta.json`, `events.jsonl`, `cpu-profile.json`, + `cpu-profile.speedscope.json`, `report.txt`. `cpu-profile.json` + parses; `total_cycles_attributed > 0`; `report.txt` contains + "CPU summary". + - `cpu_with_alloc`: `--collect cpu,alloc` produces both + `cpu-profile.json` and `heap-trace.jsonl`. + - `cpu_uniform_model`: `--cycle-model uniform` → + `meta.json.cycle_model == "uniform"` and all instruction costs + are 1 (sanity check on a tiny example). + - `cpu_compile_mode`: `--mode compile` produces a non-empty + flame chart (smoke test that gate Enable-on-profile-start works). ## Out of scope diff --git a/examples/basic3/project.json b/examples/basic3/project.json new file mode 100644 index 000000000..83f8de292 --- /dev/null +++ b/examples/basic3/project.json @@ -0,0 +1,4 @@ +{ + "uid": "2026.05.03-11.37.00-basic3", + "name": "basic3" +} \ No newline at end of file diff --git a/examples/basic3/src/fixture.fixture/node.json b/examples/basic3/src/fixture.fixture/node.json new file mode 100644 index 000000000..cdb7076c4 --- /dev/null +++ b/examples/basic3/src/fixture.fixture/node.json @@ -0,0 +1,31 @@ +{ + "output_spec": "/src/strip.output", + "texture_spec": "/src/main.texture", + "mapping": { + "PathPoints": { + "paths": [ + { + "RingArray": { + "center": [0.5, 0.5], + "diameter": 1.0, + "start_ring_inclusive": 0, + "end_ring_exclusive": 9, + "ring_lamp_counts": [1, 8, 12, 16, 24, 32, 40, 48, 60], + "offset_angle": 0.0, + "order": "InnerFirst" + } + } + ], + "sample_diameter": 2 + } + }, + "color_order": "Rgb", + "transform": [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ], + "brightness": 255, + "gamma_correction": false +} diff --git a/examples/basic3/src/main.texture/node.json b/examples/basic3/src/main.texture/node.json new file mode 100644 index 000000000..428db3e46 --- /dev/null +++ b/examples/basic3/src/main.texture/node.json @@ -0,0 +1,4 @@ +{ + "width": 16, + "height": 16 +} diff --git a/examples/basic3/src/rainbow.shader/main.glsl b/examples/basic3/src/rainbow.shader/main.glsl new file mode 100644 index 000000000..31ae8fdef --- /dev/null +++ b/examples/basic3/src/rainbow.shader/main.glsl @@ -0,0 +1,33 @@ +const int ITERS = 10; +const float TAU = 6.28318; + +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec4 friendPattern(vec2 uv) { + vec2 v = vec2(1.0, 1.0); + vec2 p = (uv + uv - v) / 0.3; + vec4 color = vec4(0.0); + float phase = mod(time * 0.05 * TAU, TAU); + + for (int i = 1; i < ITERS; i++) { + v = p; + for (int f = 1; f < ITERS; f++) { + float ff = float(f); + v += sin(v.yx * ff + float(i) + phase) / ff; + } + + vec4 ramp = cos(float(i) + vec4(0.0, 1.0, 2.0, 3.0)) + 1.0; + color += ramp / 6.0 / max(length(v), 0.001); + } + + vec4 mapped = color * color; + color = mapped / (1.0 + mapped); + color.a = 1.0; + return color; +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + return friendPattern(uv); +} \ No newline at end of file diff --git a/examples/basic3/src/rainbow.shader/node.json b/examples/basic3/src/rainbow.shader/node.json new file mode 100644 index 000000000..22b838e89 --- /dev/null +++ b/examples/basic3/src/rainbow.shader/node.json @@ -0,0 +1,5 @@ +{ + "glsl_path": "main.glsl", + "texture_spec": "/src/main.texture", + "render_order": 0 +} \ No newline at end of file diff --git a/examples/basic3/src/strip.output/node.json b/examples/basic3/src/strip.output/node.json new file mode 100644 index 000000000..2c0646168 --- /dev/null +++ b/examples/basic3/src/strip.output/node.json @@ -0,0 +1,11 @@ +{ + "GpioStrip": { + "pin": 4, + "options": { + "interpolation_enabled": true, + "dithering_enabled": false, + "lut_enabled": true, + "brightness": 0.125 + } + } +} diff --git a/justfile b/justfile index cf4b59fb4..6786687f3 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,7 @@ rv32_firmware_packages := "fw-esp32" # fw-esp32 uses release-esp32 (panic=unwind, nightly) for panic recovery fw_esp32_profile := "release-esp32" +fw_esp32_elf := "target/" + rv32_target + "/" + fw_esp32_profile + "/fw-esp32" lps_dir := "lp-shader" # Default recipe - show available commands @@ -66,7 +67,7 @@ web-demo-build: install-wasm32-target wasm-bindgen target/wasm32-unknown-unknown/release/web_demo.wasm \ --out-dir lp-app/web-demo/www/pkg --target web mkdir -p lp-app/web-demo/www - cp examples/basic/src/rainbow.shader/main.glsl lp-app/web-demo/www/rainbow-default.glsl + cp examples/basic/shader.glsl lp-app/web-demo/www/rainbow-default.glsl echo "Artifacts: lp-app/web-demo/www/ (index.html, pkg/)" # Build and serve the web demo (installs miniserve via cargo if missing) @@ -80,6 +81,27 @@ web-demo: web-demo-build echo "Serving http://127.0.0.1:2812 (Ctrl+C to stop)" miniserve --index index.html -p 2812 lp-app/web-demo/www/ +# Build a clean GitHub Pages artifact for the web demo. +web-demo-deploy-dir channel="local" out_dir="target/pages/web-demo" domain="": + #!/usr/bin/env bash + set -euo pipefail + just web-demo-build + args=(--kind web-demo --channel "{{ channel }}" --out "{{ out_dir }}") + if [[ -n "{{ domain }}" ]]; then + args+=(--domain "{{ domain }}") + fi + node scripts/pages/prepare-pages-artifact.mjs "${args[@]}" + +# Smoke-check the staged web demo Pages artifact. +web-demo-smoke out_dir="target/pages/web-demo": + #!/usr/bin/env bash + set -euo pipefail + node scripts/pages/static-site-smoke.mjs \ + --kind web-demo \ + --dir "{{ out_dir }}" \ + --port "${WEB_DEMO_SMOKE_PORT:-2831}" \ + --server "${PAGES_SMOKE_SERVER:-required}" + # Deploy web demo to gh-pages branch web-demo-deploy: web-demo-build #!/usr/bin/env bash @@ -121,6 +143,216 @@ web-demo-deploy: web-demo-build cd - git worktree remove --force "$tmp_dir/wt" +# ============================================================================ +# fw-browser (browser/Web Worker runtime proof) +# ============================================================================ + +fw-browser-build: install-wasm32-target + #!/usr/bin/env bash + set -euo pipefail + echo "Building fw-browser for wasm32..." + cargo build -p fw-browser --target wasm32-unknown-unknown --release + if ! command -v wasm-bindgen >/dev/null 2>&1; then + echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114" + exit 1 + fi + echo "Generating fw-browser JS glue..." + wasm-bindgen target/wasm32-unknown-unknown/release/fw_browser.wasm \ + --out-dir lp-fw/fw-browser/www/pkg --target web + echo "Artifacts: lp-fw/fw-browser/www/ (smoke.html, pkg/)" + +fw-browser-test: install-wasm32-target + #!/usr/bin/env bash + set -euo pipefail + if ! command -v wasm-bindgen-test-runner >/dev/null 2>&1; then + echo "wasm-bindgen-test-runner not found. Install: cargo install wasm-bindgen-cli --version 0.2.114" + exit 1 + fi + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ + cargo test -p fw-browser --target wasm32-unknown-unknown + +fw-browser-smoke: fw-browser-build + #!/usr/bin/env bash + set -euo pipefail + port="${FW_BROWSER_SMOKE_PORT:-2819}" + echo "Serving fw-browser smoke page at http://127.0.0.1:${port}/smoke.html" + echo "Success: page shows ok and documentElement.dataset.smoke is 'ok'." + cd lp-fw/fw-browser/www + python3 -m http.server "${port}" --bind 127.0.0.1 + +# ============================================================================ +# Studio web app +# ============================================================================ + +studio-web-dev-build: install-wasm32-target + #!/usr/bin/env bash + set -euo pipefail + echo "Building fw-browser for wasm32 debug..." + cargo build -p fw-browser --target wasm32-unknown-unknown + echo "Building lpa-studio-web for wasm32 debug with stories..." + cargo build -p lpa-studio-web --target wasm32-unknown-unknown --features stories + if ! command -v wasm-bindgen >/dev/null 2>&1; then + echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114" + exit 1 + fi + echo "Generating fw-browser debug JS glue..." + wasm-bindgen target/wasm32-unknown-unknown/debug/fw_browser.wasm \ + --out-dir lp-fw/fw-browser/www/pkg --target web + echo "Generating Studio web debug JS glue..." + mkdir -p lp-app/lpa-studio-web/public/pkg + wasm-bindgen target/wasm32-unknown-unknown/debug/lpa-studio-web.wasm \ + --out-dir lp-app/lpa-studio-web/public/pkg --target web + echo "Copying fw-browser wasm artifacts..." + cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lpa-studio-web/public/pkg/fw_browser.js + cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lpa-studio-web/public/pkg/fw_browser_bg.wasm + echo "Artifacts: lp-app/lpa-studio-web/public/ (debug build)" + +studio-story-pngs: studio-web-dev-build + #!/usr/bin/env bash + set -euo pipefail + node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs + +studio-story-baselines: studio-web-dev-build + #!/usr/bin/env bash + set -euo pipefail + node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs baselines + +studio-story-check: studio-web-dev-build + #!/usr/bin/env bash + set -euo pipefail + node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs check + +studio-story-baselines-if-needed: + #!/usr/bin/env bash + set -euo pipefail + tracked="$(git diff --name-only HEAD -- \ + lp-app/lpa-studio-web \ + ':!lp-app/lpa-studio-web/public/**' \ + ':!lp-app/lpa-studio-web/story-images/**')" + untracked="$(git ls-files --others --exclude-standard -- lp-app/lpa-studio-web \ + | grep -v '^lp-app/lpa-studio-web/public/' \ + | grep -v '^lp-app/lpa-studio-web/story-images/' \ + || true)" + changed="$(printf '%s\n%s\n' "$tracked" "$untracked" | sed '/^$/d' | sort -u)" + if [[ -z "$changed" ]]; then + echo "No Studio UI source changes; skipping story baseline generation." + exit 0 + fi + echo "Studio UI source changed; updating story baselines:" + printf '%s\n' "$changed" | sed 's/^/ /' + just studio-story-baselines + +studio-dev: studio-web-dev-build + #!/usr/bin/env bash + set -euo pipefail + port="${STUDIO_WEB_PORT:-2820}" + echo "Serving LightPlayer Studio dev build at http://127.0.0.1:${port}/" + echo "Storybook: http://127.0.0.1:${port}/#/stories" + echo "Re-run just studio-dev after Rust changes; generated artifacts are ignored." + cd lp-app/lpa-studio-web/public + python3 -m http.server "${port}" --bind 127.0.0.1 + +studio-firmware-package-esp32c6: install-rv32-target + #!/usr/bin/env bash + set -euo pipefail + if ! command -v espflash >/dev/null 2>&1; then + echo "espflash not found. Install it before packaging Studio firmware assets." + exit 1 + fi + + firmware_id="lightplayer-esp32c6-server" + display_name="LightPlayer ESP32-C6 server firmware" + features="esp32c6,server" + out_dir="lp-app/lpa-studio-web/public/firmware/esp32c6" + image_name="fw-esp32c6-server-merged.bin" + image_file="${out_dir}/${image_name}" + manifest_file="${out_dir}/manifest.json" + + echo "Building ${display_name}..." + (cd lp-fw/fw-esp32 && cargo build --target {{ rv32_target }} --profile {{ fw_esp32_profile }} --features "${features}") + + mkdir -p "${out_dir}" + rm -f "${out_dir}"/*.bin "${manifest_file}" + + echo "Generating browser-flashable merged ESP32-C6 image..." + espflash save-image \ + --chip esp32c6 \ + --partition-table lp-fw/fw-esp32/partitions.csv \ + --merge \ + --skip-padding \ + {{ fw_esp32_elf }} \ + "${image_file}" + + size_bytes="$(wc -c < "${image_file}" | tr -d ' ')" + sha256="$(shasum -a 256 "${image_file}" | awk '{print $1}')" + generated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + source_commit="$(git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)" + source_dirty=false + if ! git diff --quiet --ignore-submodules -- || ! git diff --cached --quiet --ignore-submodules --; then + source_dirty=true + fi + + MANIFEST_FIRMWARE_ID="${firmware_id}" \ + MANIFEST_DISPLAY_NAME="${display_name}" \ + MANIFEST_TARGET="{{ rv32_target }}" \ + MANIFEST_PROFILE="{{ fw_esp32_profile }}" \ + MANIFEST_SOURCE_COMMIT="${source_commit}" \ + MANIFEST_SOURCE_DIRTY="${source_dirty}" \ + MANIFEST_GENERATED_AT="${generated_at}" \ + MANIFEST_IMAGE_PATH="${image_name}" \ + MANIFEST_IMAGE_SIZE="${size_bytes}" \ + MANIFEST_IMAGE_SHA256="${sha256}" \ + node lp-app/lpa-studio-web/scripts/studio-firmware-manifest.mjs "${manifest_file}" + echo "Firmware manifest: ${manifest_file}" + echo "Firmware image: ${image_file} (${size_bytes} bytes, sha256=${sha256})" + +studio-web-build: install-wasm32-target fw-browser-build studio-firmware-package-esp32c6 + #!/usr/bin/env bash + set -euo pipefail + echo "Building lpa-studio-web for wasm32..." + cargo build -p lpa-studio-web --target wasm32-unknown-unknown --release + if ! command -v wasm-bindgen >/dev/null 2>&1; then + echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114" + exit 1 + fi + echo "Generating Studio web JS glue..." + mkdir -p lp-app/lpa-studio-web/public/pkg + wasm-bindgen target/wasm32-unknown-unknown/release/lpa-studio-web.wasm \ + --out-dir lp-app/lpa-studio-web/public/pkg --target web + echo "Copying fw-browser wasm artifacts..." + cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lpa-studio-web/public/pkg/fw_browser.js + cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lpa-studio-web/public/pkg/fw_browser_bg.wasm + echo "Artifacts: lp-app/lpa-studio-web/public/ (index.html, pkg/)" + +# Build a clean GitHub Pages artifact for Studio. +studio-web-deploy-dir channel="local" out_dir="target/pages/studio" domain="": + #!/usr/bin/env bash + set -euo pipefail + just studio-web-build + args=(--kind studio --channel "{{ channel }}" --out "{{ out_dir }}") + if [[ -n "{{ domain }}" ]]; then + args+=(--domain "{{ domain }}") + fi + node scripts/pages/prepare-pages-artifact.mjs "${args[@]}" + +# Smoke-check the staged Studio Pages artifact. +studio-web-smoke out_dir="target/pages/studio": + #!/usr/bin/env bash + set -euo pipefail + node scripts/pages/static-site-smoke.mjs \ + --kind studio \ + --dir "{{ out_dir }}" \ + --port "${STUDIO_WEB_SMOKE_PORT:-2830}" \ + --server "${PAGES_SMOKE_SERVER:-required}" + +studio-web: studio-web-build + #!/usr/bin/env bash + set -euo pipefail + port="${STUDIO_WEB_PORT:-2820}" + echo "Serving LightPlayer Studio at http://127.0.0.1:${port}/" + cd lp-app/lpa-studio-web/public + python3 -m http.server "${port}" --bind 127.0.0.1 + # ============================================================================ # Build commands - Workspace-wide # ============================================================================ @@ -405,11 +637,6 @@ merge: check demo example="basic": cd lp-cli && cargo run -- dev ../examples/{{ example }} -# Run firmware on ESP32-C6 device -# Path to fw-esp32 ELF (release-esp32 profile) - -fw_esp32_elf := "target/" + rv32_target + "/" + fw_esp32_profile + "/fw-esp32" - # Requires: ESP32-C6 device connected via USB. Builds the default lps-glsl frontend path. # Usage: just demo-esp32c6-host [example-name] demo-esp32c6-host example="basic": install-rv32-target diff --git a/lp-app/README.md b/lp-app/README.md index e6b811f17..76a3a6aae 100644 --- a/lp-app/README.md +++ b/lp-app/README.md @@ -17,6 +17,12 @@ logic. projects, and serving the `lpc-wire` API over app-provided transports. - `lpa-client` — client-side transport/API layer for talking to a LightPlayer server or firmware target. +- `lpa-link` — low-level endpoint/link layer for discovery, status, + management, diagnostics, logs, and opening server/client connections. +- `lpa-studio-ux` — UI-independent Studio UX/controller layer. It owns + lower-level services and exposes Studio views, node states, typed actions, + logs, and project summaries to UI shells. +- `lpa-studio-web` — static Dioxus browser shell for the first Studio UI slice. - `web-demo` — browser demo and tooling for the shader pipeline. ## Boundary diff --git a/lp-app/lpa-client/Cargo.toml b/lp-app/lpa-client/Cargo.toml index f4bf97156..3d3253171 100644 --- a/lp-app/lpa-client/Cargo.toml +++ b/lp-app/lpa-client/Cargo.toml @@ -9,24 +9,32 @@ rust-version.workspace = true [dependencies] lpc-model = { path = "../../lp-core/lpc-model" } lpc-wire = { path = "../../lp-core/lpc-wire" } -lpc-shared = { path = "../../lp-core/lpc-shared" } -tokio = { version = "1", features = ["full"] } -anyhow = "1" +lpc-shared = { path = "../../lp-core/lpc-shared", optional = true } +tokio = { version = "1", features = ["full"], optional = true } +anyhow = { version = "1", optional = true } tokio-tungstenite = { version = "0.21", optional = true } -futures-util = "0.3" +futures-util = { version = "0.3", optional = true } async-trait = "0.1" serde = { version = "1", features = ["derive"] } lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", optional = true, features = ["std"] } lp-riscv-emu = { path = "../../lp-riscv/lp-riscv-emu", optional = true, default-features = false } -log = { workspace = true, features = ["std"] } +log = { workspace = true, features = ["std"], optional = true } serialport = { version = "4.8", optional = true } hashbrown = { workspace = true, optional = true } [features] -default = [] -serial = ["lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "hashbrown", "serialport"] -emu = ["lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "hashbrown"] -ws = ["tokio-tungstenite"] +default = ["host"] +host = ["dep:anyhow", "dep:futures-util", "dep:log", "dep:lpc-shared", "dep:tokio"] +serial = [ + "host", + "lp-riscv-elf", + "lp-riscv-emu", + "lp-riscv-emu/std", + "hashbrown", + "serialport", +] +emu = ["host", "lp-riscv-elf", "lp-riscv-emu", "lp-riscv-emu/std", "hashbrown"] +ws = ["host", "tokio-tungstenite"] [dev-dependencies] lp-riscv-elf = { path = "../../lp-riscv/lp-riscv-elf", features = ["std"] } diff --git a/lp-app/lpa-client/README.md b/lp-app/lpa-client/README.md index d010a34a7..708bed202 100644 --- a/lp-app/lpa-client/README.md +++ b/lp-app/lpa-client/README.md @@ -1,11 +1,88 @@ # lpa-client -Application client layer for LightPlayer. +`lpa-client` owns the typed client protocol for talking to a running +LightPlayer `lp-server`. -This crate owns client-side transports and request helpers for talking to a -LightPlayer server or firmware target. It is app-facing: websocket, serial, -emulator, and local-server client plumbing belong here. +The crate is split into two layers: -It uses `lpc-wire` for messages and may use `lpc-view` in tests or callers to -maintain a local view of one engine, but it should not own core engine state or -source/wire type definitions. +- `LpClient` is the portable protocol client. It owns request ids, + response correlation, server errors, heartbeats/log events, and typed + filesystem/project/overlay operations. It depends on a small `ClientIo` trait + and does not require Tokio or `Send`. +- Host adapters provide the current native ergonomics: cloneable shared + transports, Tokio timeouts, serial/websocket/local transports, and CLI-style + heartbeat/log rendering. + +This keeps Studio web, CLI, host runtimes, and future agents on one protocol +model while allowing each runtime to bind its own I/O. + +## Feature Model + +| Feature | Purpose | +|---|---| +| `default` | Enables `host` for existing native callers. | +| `host` | Tokio/shared transport adapter, local in-memory transport, host specifier parsing, logging, and `TokioLpClient`. | +| `serial` | Host serial transport for ESP32/emulator-style JSON-lines links. Implies `host`. | +| `emu` | Emulator serial transport support. Implies `host`. | +| `ws` | Host websocket transport. Implies `host`. | + +Portable/browser-oriented consumers should depend on the core without defaults: + +```toml +lpa-client = { path = "../lpa-client", default-features = false } +``` + +The core compile check is: + +```bash +cargo check -p lpa-client --target wasm32-unknown-unknown --no-default-features +``` + +## Important Types + +- `ClientIo`: runtime-neutral send/receive/close trait for `lpc-wire` messages. +- `LpClient`: typed protocol client over any `ClientIo`. +- `ClientOutcome`: operation result plus protocol events observed while + waiting for the correlated response. +- `ClientEvent`: heartbeat/log/uncorrelated-response events surfaced to the + caller. +- `ProjectDeployFile`: one project file for shared stop/write/load deploy + helpers. +- `TokioLpClient`: host wrapper that preserves the CLI/native shared-client API. +- `ClientTransport`: host-only Tokio transport trait used by native providers. + +## Project Deploy Semantics + +Server-protocol project deploys should use this crate rather than open-coding +request sequences. The shared deploy flow is currently: + +1. `StopAllProjects` +2. write files under `/projects/{project_id}/...` +3. `LoadProject { path: "projects/{project_id}" }` + +That ordering avoids the ESP32 trying to run multiple loaded projects during a +replace-in-place upload. Direct bootloader/raw filesystem image access is not a +server-protocol deploy; it belongs below this layer in `lpa-link` management. + +Use `deploy_project_files` for initial upload/load flows such as CLI upload, +CLI dev startup, firmware demo checks, and browser hardware demo loading. Use +`push_project_files` only when the caller intentionally wants write-only sync, +such as an already-loaded file-watch update. + +## Relationship To lpa-link + +`lpa-link` owns device/runtime discovery, endpoint status, raw logs, +diagnostics, reset, flashing, and raw filesystem access. When a link is +connected to a running `lp-server`, it exposes a server connection that callers +can wrap with this crate. + +Keep server protocol semantics here. Keep low-level device management in +`lpa-link`. + +## Validation + +```bash +cargo check -p lpa-client +cargo test -p lpa-client +cargo check -p lpa-client --target wasm32-unknown-unknown --no-default-features +``` diff --git a/lp-app/lpa-client/src/client.rs b/lp-app/lpa-client/src/client.rs index 4ac0bc95e..21624214f 100644 --- a/lp-app/lpa-client/src/client.rs +++ b/lp-app/lpa-client/src/client.rs @@ -1,603 +1,434 @@ -//! Standalone LpClient for communicating with LpServer -//! -//! Provides async methods for filesystem and project operations. +//! Portable LightPlayer server protocol client. -use anyhow::{Error, Result}; use lpc_model::{LpPath, LpPathBuf}; use lpc_wire::{ - ProjectReadRequest, ProjectReadResponse, WireOverlayCommitRequest, WireOverlayCommitResponse, - WireOverlayMutationRequest, WireOverlayMutationResponse, WireOverlayReadRequest, - WireOverlayReadResponse, WireProjectCommand, WireProjectCommandResponse, - WireProjectHandle as ProjectHandle, WireProjectInventoryReadRequest, - WireProjectInventoryReadResponse, WireServerMessage, - messages::{ClientMessage, ClientRequest}, - server::{AvailableProject, FsResponse, LoadedProject, ServerMsgBody}, + ClientMessage, ClientRequest, FsRequest, ProjectReadRequest, ProjectReadResponse, + WireOverlayCommitRequest, WireOverlayCommitResponse, WireOverlayMutationRequest, + WireOverlayMutationResponse, WireOverlayReadRequest, WireOverlayReadResponse, + WireProjectCommand, WireProjectCommandResponse, WireProjectHandle, + WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, WireServerMessage, + WireServerMsgBody, + server::{AvailableProject, FsResponse, LoadedProject}, }; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; -use tokio::time::timeout; -use crate::transport::ClientTransport; +use crate::client_error::{ClientError, ClientResult}; +use crate::client_event::ClientEvent; +use crate::client_io::ClientIo; +use crate::project_deploy::{ + ProjectDeployFile, project_deploy_requests, project_write_requests, + validate_project_deploy_response, +}; +use crate::protocol_session::{ProtocolSession, ResponseDisposition}; -/// Standalone client for communicating with LpServer -/// -/// Provides typed async methods for filesystem and project operations. -/// Uses an async `ClientTransport` for communication. -pub struct LpClient { - /// Transport wrapped in Arc for sharing across async tasks - transport: Arc>>, - /// Next request ID to use - next_request_id: Arc, +/// Result value plus protocol events observed while waiting for it. +#[derive(Debug)] +pub struct ClientOutcome { + pub value: T, + pub events: Vec, } -impl Clone for LpClient { - fn clone(&self) -> Self { - Self { - transport: Arc::clone(&self.transport), - next_request_id: Arc::clone(&self.next_request_id), +impl ClientOutcome { + pub fn new(value: T, events: Vec) -> Self { + Self { value, events } + } + + pub fn map(self, f: impl FnOnce(T) -> U) -> ClientOutcome { + ClientOutcome { + value: f(self.value), + events: self.events, } } + + pub fn into_value(self) -> T { + self.value + } +} + +/// Runtime-neutral client for communicating with `LpServer`. +/// +/// The core client owns request ids, response correlation, server errors, and +/// typed server operations. It does not require Tokio or `Send`; host/native +/// code should use `TokioLpClient` when it wants sharing, timeouts, and current +/// CLI ergonomics. +pub struct LpClient { + io: Io, + protocol: ProtocolSession, } -impl LpClient { - /// Create a new LpClient with the given transport - /// - /// # Arguments - /// - /// * `transport` - The client transport (will be wrapped in Arc) - /// - /// # Returns - /// - /// * `Self` - The client - #[allow(dead_code, reason = "Will be used in tests and other contexts")] - pub fn new(transport: Box) -> Self { +impl LpClient +where + Io: ClientIo, +{ + pub fn new(io: Io) -> Self { Self { - transport: Arc::new(tokio::sync::Mutex::new(transport)), - next_request_id: Arc::new(AtomicU64::new(1)), + io, + protocol: ProtocolSession::new(), } } - /// Create a new LpClient with a shared transport - /// - /// # Arguments - /// - /// * `transport` - Shared transport (Arc>>) - /// - /// # Returns - /// - /// * `Self` - The client - pub fn new_shared(transport: Arc>>) -> Self { - Self { - transport, - next_request_id: Arc::new(AtomicU64::new(1)), - } + pub fn into_io(self) -> Io { + self.io } - /// Send a request and wait for the response - /// - /// Helper method that generates a request ID, sends the request, and waits for the response. - /// Correlates messages by ID to handle heartbeats and other interstitial messages. - /// If the server returns an Error response, converts it to an Err. - async fn send_request(&self, request: ClientRequest) -> Result { - let id = self.next_request_id.fetch_add(1, Ordering::Relaxed); - let msg = ClientMessage { id, msg: request }; + pub async fn close(&mut self) -> ClientResult> { + self.io.close().await.map_err(ClientError::from)?; + Ok(ClientOutcome::new((), Vec::new())) + } - // Lock transport and send - let mut transport = self.transport.lock().await; - transport - .send(msg) + pub async fn send_request( + &mut self, + request: ClientRequest, + ) -> ClientResult> { + let request_id = self.protocol.next_request_id(); + self.io + .send(ClientMessage { + id: request_id, + msg: request, + }) .await - .map_err(|e| Error::msg(format!("Transport error: {e}")))?; - - // Wait for response with matching ID (with timeout to avoid deadlock if server - // never receives our request, e.g. host->device serial direction broken) - // ESP32 compile and other heavy ops can take 30s+, so allow 60s - const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); - - let wait_response = async { - loop { - let response = transport - .receive() - .await - .map_err(|e| Error::msg(format!("Transport error: {e}")))?; - - if response.id == id { - if let ServerMsgBody::Error { error } = &response.msg { - return Err(Error::msg(error.clone())); + .map_err(ClientError::from)?; + + let mut events = Vec::new(); + loop { + let response = self.io.receive().await.map_err(ClientError::from)?; + match self.protocol.response_disposition(&response, request_id) { + ResponseDisposition::Matched => { + if let WireServerMsgBody::Error { error } = &response.msg { + return Err(ClientError::Server(error.clone())); } - return Ok(response); + return Ok(ClientOutcome::new(response, events)); } - - if response.id == 0 { - if let ServerMsgBody::Heartbeat { - fps, - frame_count, - loaded_projects, - uptime_ms, - memory, - } = &response.msg - { - Self::display_heartbeat( - fps, - *frame_count, - loaded_projects.as_slice(), - *uptime_ms, - memory, - ); + ResponseDisposition::Unsolicited => { + if let Some(event) = ClientEvent::from_unsolicited_message(response) { + events.push(event); } - continue; } - - log::warn!( - "Received non-correlated message (id: {}, expected: {})", - response.id, - id - ); + ResponseDisposition::Uncorrelated { + response_id, + expected_id, + } => events.push(ClientEvent::UncorrelatedResponse { + response_id, + expected_id, + }), } - }; - - match timeout(REQUEST_TIMEOUT, wait_response).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(e)) => Err(e), - Err(_) => Err(Error::msg( - "Request timed out - server may not be receiving messages (check host->device serial)", - )), } } - /// Display heartbeat with colors and memory bar chart - fn display_heartbeat( - fps: &lpc_wire::server::SampleStats, - _frame_count: u64, - loaded_projects: &[lpc_wire::server::LoadedProject], - uptime_ms: u64, - memory: &Option, - ) { - const BOLD: &str = "\x1b[1m"; - const DIM: &str = "\x1b[90m"; - const CYAN: &str = "\x1b[36m"; - const GREEN: &str = "\x1b[32m"; - const YELLOW: &str = "\x1b[33m"; - const RED: &str = "\x1b[31m"; - const RESET: &str = "\x1b[0m"; - - let uptime_secs = uptime_ms as f64 / 1000.0; - let _projects_str = if loaded_projects.is_empty() { - format!("{DIM}none{RESET}") - } else { - loaded_projects - .iter() - .map(|p| { - p.path - .file_name() - .map(|n| n.to_string()) - .unwrap_or_else(|| p.path.as_str().to_string()) - }) - .collect::>() - .join(", ") - }; - - let fps_color = if fps.avg >= 50.0 { - GREEN - } else if fps.avg >= 20.0 { - YELLOW - } else { - RED - }; - - let mut line = format!( - "{BOLD}{CYAN}[server]{RESET} {fps_color}FPS {:.0}{RESET} avg (σ{:.1} {:.0}-{:.0}) {DIM}|{RESET} \ - {DIM}Uptime {uptime_secs:.1}s{RESET}", - fps.avg, fps.sdev, fps.min, fps.max - ); - - if let Some(mem) = memory { - let total = mem.total_bytes as f32; - let used_pct = if total > 0.0 { - (mem.used_bytes as f32 / total) * 100.0 - } else { - 0.0 - }; - let free_pct = 100.0 - used_pct; - - const BAR_WIDTH: usize = 16; - let filled = if total > 0.0 { - ((mem.used_bytes as f32 / total) * BAR_WIDTH as f32).round() as usize - } else { - 0 - }; - let filled = filled.min(BAR_WIDTH); - - let (bar_fill_color, bar_empty_color) = if free_pct >= 40.0 { - (GREEN, DIM) - } else if free_pct >= 15.0 { - (YELLOW, DIM) - } else { - (RED, DIM) - }; - - let bar: String = (0..BAR_WIDTH) - .map(|i| { - if i < filled { - format!("{bar_fill_color}█{RESET}") - } else { - format!("{bar_empty_color}░{RESET}") - } - }) - .collect(); - - let free_kb = mem.free_bytes / 1024; - let total_kb = mem.total_bytes / 1024; - - line.push_str(&format!( - " {DIM}|{RESET} [{bar}] {bar_fill_color}{used_pct:.0}%{RESET} ({free_kb}k free / {total_kb}k total)" - )); - } - - eprintln!("{line}"); - } - - /// Read a file from the server filesystem - /// - /// # Arguments - /// - /// * `path` - Path to the file (relative to server root) - /// - /// # Returns - /// - /// * `Ok(Vec)` if the file was read successfully - /// * `Err` if reading failed or transport error occurred - pub async fn fs_read(&self, path: &LpPath) -> Result> { - let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::Read { - path: path.to_path_buf(), - }); - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::Filesystem(FsResponse::Read { data, error, .. }) => { - if let Some(err) = error { - return Err(Error::msg(format!("Server error: {err}"))); + pub async fn fs_read(&mut self, path: &LpPath) -> ClientResult>> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::Read { + path: path.to_path_buf(), + })) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::Read { data, error, .. }) => { + if let Some(error) = error { + return Err(ClientError::Server(error)); } - data.ok_or_else(|| Error::msg("No data in read response")) + data.map(|data| ClientOutcome::new(data, events)) + .ok_or_else(|| ClientError::Protocol("no data in read response".to_string())) } - _ => Err(Error::msg(format!( - "Unexpected response type for fs_read: {:?}", - response.msg - ))), + other => Err(ClientError::unexpected_response("fs.read", other)), } } - /// Write a file to the server filesystem - /// - /// # Arguments - /// - /// * `path` - Path to the file (relative to server root) - /// * `data` - File contents to write - /// - /// # Returns - /// - /// * `Ok(())` if the file was written successfully - /// * `Err` if writing failed or transport error occurred - pub async fn fs_write(&self, path: &LpPath, data: Vec) -> Result<()> { - let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::Write { - path: path.to_path_buf(), - data, - }); - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::Filesystem(FsResponse::Write { error, .. }) => { - if let Some(err) = error { - return Err(Error::msg(format!("Server error: {err}"))); + pub async fn fs_write( + &mut self, + path: &LpPath, + data: Vec, + ) -> ClientResult> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::Write { + path: path.to_path_buf(), + data, + })) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::Write { error, .. }) => { + if let Some(error) = error { + return Err(ClientError::Server(error)); } - Ok(()) + Ok(ClientOutcome::new((), events)) } - _ => Err(Error::msg(format!( - "Unexpected response type for fs_write: {:?}", - response.msg - ))), + other => Err(ClientError::unexpected_response("fs.write", other)), } } - /// Delete a file from the server filesystem - /// - /// # Arguments - /// - /// * `path` - Path to the file (relative to server root) - /// - /// # Returns - /// - /// * `Ok(())` if the file was deleted successfully - /// * `Err` if deletion failed or transport error occurred - pub async fn fs_delete_file(&self, path: &LpPath) -> Result<()> { - let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::DeleteFile { - path: path.to_path_buf(), - }); - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::Filesystem(FsResponse::DeleteFile { error, .. }) => { - if let Some(err) = error { - return Err(Error::msg(format!("Server error: {err}"))); + pub async fn fs_delete_file(&mut self, path: &LpPath) -> ClientResult> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::DeleteFile { + path: path.to_path_buf(), + })) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::DeleteFile { error, .. }) => { + if let Some(error) = error { + return Err(ClientError::Server(error)); } - Ok(()) + Ok(ClientOutcome::new((), events)) } - _ => Err(Error::msg(format!( - "Unexpected response type for fs_delete_file: {:?}", - response.msg - ))), + other => Err(ClientError::unexpected_response("fs.delete_file", other)), } } - /// List directory contents from the server filesystem - /// - /// # Arguments - /// - /// * `path` - Path to the directory (relative to server root) - /// * `recursive` - Whether to list recursively - /// - /// # Returns - /// - /// * `Ok(Vec)` - List of file/directory paths - /// * `Err` if listing failed or transport error occurred - pub async fn fs_list_dir(&self, path: &LpPath, recursive: bool) -> Result> { - let request = ClientRequest::Filesystem(lpc_wire::server::FsRequest::ListDir { - path: path.to_path_buf(), - recursive, - }); - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::Filesystem(FsResponse::ListDir { entries, error, .. }) => { - if let Some(err) = error { - return Err(Error::msg(format!("Server error: {err}"))); + pub async fn fs_list_dir( + &mut self, + path: &LpPath, + recursive: bool, + ) -> ClientResult>> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::ListDir { + path: path.to_path_buf(), + recursive, + })) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::ListDir { entries, error, .. }) => { + if let Some(error) = error { + return Err(ClientError::Server(error)); } - Ok(entries) + Ok(ClientOutcome::new(entries, events)) } - _ => Err(Error::msg(format!( - "Unexpected response type for fs_list_dir: {:?}", - response.msg - ))), + other => Err(ClientError::unexpected_response("fs.list_dir", other)), } } - /// Load a project on the server - /// - /// # Arguments - /// - /// * `path` - Path to the project file (relative to server root) - /// - /// # Returns - /// - /// * `Ok(ProjectHandle)` if the project was loaded successfully - /// * `Err` if loading failed or transport error occurred - pub async fn project_load(&self, path: &str) -> Result { - let request = ClientRequest::LoadProject { - path: path.to_string(), - }; - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::LoadProject { handle } => Ok(handle), - _ => Err(Error::msg(format!( - "Unexpected response type for project_load: {:?}", - response.msg - ))), + pub async fn project_load( + &mut self, + path: &str, + ) -> ClientResult> { + let response = self + .send_request(ClientRequest::LoadProject { + path: path.to_string(), + }) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::LoadProject { handle } => Ok(ClientOutcome::new(handle, events)), + other => Err(ClientError::unexpected_response("project.load", other)), } } - /// Unload a project on the server - /// - /// # Arguments - /// - /// * `handle` - Project handle to unload - /// - /// # Returns - /// - /// * `Ok(())` if the project was unloaded successfully - /// * `Err` if unloading failed or transport error occurred - #[allow(dead_code, reason = "Will be used in future commands")] - pub async fn project_unload(&self, handle: ProjectHandle) -> Result<()> { - let request = ClientRequest::UnloadProject { handle }; - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::UnloadProject => Ok(()), - _ => Err(Error::msg(format!( - "Unexpected response type for project_unload: {:?}", - response.msg - ))), + pub async fn project_unload( + &mut self, + handle: WireProjectHandle, + ) -> ClientResult> { + let response = self + .send_request(ClientRequest::UnloadProject { handle }) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::UnloadProject => Ok(ClientOutcome::new((), events)), + other => Err(ClientError::unexpected_response("project.unload", other)), } } - /// Read the current project state using the stateless project read API. pub async fn project_read( - &self, - handle: ProjectHandle, + &mut self, + handle: WireProjectHandle, read: ProjectReadRequest, - ) -> Result { - let request = ClientRequest::ProjectRequest { - handle, - request: read, - }; - - let response = self.send_request(request).await?; - match response.msg { - ServerMsgBody::ProjectRequest { response } => Ok(response), - _ => Err(Error::msg(format!( - "Unexpected response type for project_read: {:?}", - response.msg - ))), + ) -> ClientResult> { + let response = self + .send_request(ClientRequest::ProjectRequest { + handle, + request: read, + }) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::ProjectRequest { response } => { + Ok(ClientOutcome::new(response, events)) + } + other => Err(ClientError::unexpected_response("project.read", other)), } } - /// Read the standard developer/debug project view. pub async fn project_read_default_debug( - &self, - handle: ProjectHandle, - ) -> Result { + &mut self, + handle: WireProjectHandle, + ) -> ClientResult> { self.project_read(handle, ProjectReadRequest::default_debug(None)) .await } pub async fn project_command( - &self, - handle: ProjectHandle, + &mut self, + handle: WireProjectHandle, command: WireProjectCommand, - ) -> Result { - let request = ClientRequest::ProjectCommand { handle, command }; - let response = self.send_request(request).await?; - match response.msg { - ServerMsgBody::ProjectCommand { response } => Ok(response), - _ => Err(Error::msg(format!( - "Unexpected response type for project_command: {:?}", - response.msg - ))), + ) -> ClientResult> { + let response = self + .send_request(ClientRequest::ProjectCommand { handle, command }) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::ProjectCommand { response } => { + Ok(ClientOutcome::new(response, events)) + } + other => Err(ClientError::unexpected_response("project.command", other)), } } pub async fn project_overlay_read( - &self, - handle: ProjectHandle, - ) -> Result { - match self + &mut self, + handle: WireProjectHandle, + ) -> ClientResult> { + let response = self .project_command( handle, WireProjectCommand::ReadOverlay { request: WireOverlayReadRequest, }, ) - .await? - { - WireProjectCommandResponse::ReadOverlay { response } => Ok(response), - other => Err(Error::msg(format!( - "Unexpected project command response for overlay read: {other:?}" - ))), + .await?; + match response.value { + WireProjectCommandResponse::ReadOverlay { response: value } => { + Ok(ClientOutcome::new(value, response.events)) + } + other => Err(ClientError::unexpected_response( + "project.overlay_read", + other, + )), } } pub async fn project_overlay_mutate( - &self, - handle: ProjectHandle, + &mut self, + handle: WireProjectHandle, request: WireOverlayMutationRequest, - ) -> Result { - match self + ) -> ClientResult> { + let response = self .project_command(handle, WireProjectCommand::MutateOverlay { request }) - .await? - { - WireProjectCommandResponse::MutateOverlay { response } => Ok(response), - other => Err(Error::msg(format!( - "Unexpected project command response for overlay mutate: {other:?}" - ))), + .await?; + match response.value { + WireProjectCommandResponse::MutateOverlay { response: value } => { + Ok(ClientOutcome::new(value, response.events)) + } + other => Err(ClientError::unexpected_response( + "project.overlay_mutate", + other, + )), } } pub async fn project_overlay_commit( - &self, - handle: ProjectHandle, - ) -> Result { - match self + &mut self, + handle: WireProjectHandle, + ) -> ClientResult> { + let response = self .project_command( handle, WireProjectCommand::CommitOverlay { request: WireOverlayCommitRequest, }, ) - .await? - { - WireProjectCommandResponse::CommitOverlay { response } => Ok(response), - other => Err(Error::msg(format!( - "Unexpected project command response for overlay commit: {other:?}" - ))), + .await?; + match response.value { + WireProjectCommandResponse::CommitOverlay { response: value } => { + Ok(ClientOutcome::new(value, response.events)) + } + other => Err(ClientError::unexpected_response( + "project.overlay_commit", + other, + )), } } pub async fn project_inventory_read( - &self, - handle: ProjectHandle, - ) -> Result { - match self + &mut self, + handle: WireProjectHandle, + ) -> ClientResult> { + let response = self .project_command( handle, WireProjectCommand::ReadInventory { request: WireProjectInventoryReadRequest, }, ) - .await? - { - WireProjectCommandResponse::ReadInventory { response } => Ok(response), - other => Err(Error::msg(format!( - "Unexpected project command response for inventory read: {other:?}" - ))), + .await?; + match response.value { + WireProjectCommandResponse::ReadInventory { response: value } => { + Ok(ClientOutcome::new(value, response.events)) + } + other => Err(ClientError::unexpected_response( + "project.inventory_read", + other, + )), } } - /// List available projects on the server filesystem - /// - /// # Returns - /// - /// * `Ok(Vec)` - List of available projects - /// * `Err` if listing failed or transport error occurred - #[allow(dead_code, reason = "Will be used in future commands")] - pub async fn project_list_available(&self) -> Result> { - let request = ClientRequest::ListAvailableProjects; - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::ListAvailableProjects { projects } => Ok(projects), - _ => Err(Error::msg(format!( - "Unexpected response type for project_list_available: {:?}", - response.msg - ))), + pub async fn project_list_available( + &mut self, + ) -> ClientResult>> { + let response = self + .send_request(ClientRequest::ListAvailableProjects) + .await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::ListAvailableProjects { projects } => { + Ok(ClientOutcome::new(projects, events)) + } + other => Err(ClientError::unexpected_response( + "project.list_available", + other, + )), } } - /// List loaded projects on the server - /// - /// # Returns - /// - /// * `Ok(Vec)` - List of loaded projects - /// * `Err` if listing failed or transport error occurred - #[allow(dead_code, reason = "Will be used in future commands")] - pub async fn project_list_loaded(&self) -> Result> { - let request = ClientRequest::ListLoadedProjects; - - let response = self.send_request(request).await?; - - match response.msg { - ServerMsgBody::ListLoadedProjects { projects } => Ok(projects), - _ => Err(Error::msg(format!( - "Unexpected response type for project_list_loaded: {:?}", - response.msg - ))), + pub async fn project_list_loaded(&mut self) -> ClientResult>> { + let response = self.send_request(ClientRequest::ListLoadedProjects).await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::ListLoadedProjects { projects } => { + Ok(ClientOutcome::new(projects, events)) + } + other => Err(ClientError::unexpected_response( + "project.list_loaded", + other, + )), } } - /// Stop all loaded projects on the server - /// - /// # Returns - /// - /// * `Ok(())` if all projects were stopped successfully - /// * `Err` if the request failed or transport error occurred - pub async fn stop_all_projects(&self) -> Result<()> { - let request = ClientRequest::StopAllProjects; + pub async fn stop_all_projects(&mut self) -> ClientResult> { + let response = self.send_request(ClientRequest::StopAllProjects).await?; + let events = response.events; + match response.value.msg { + WireServerMsgBody::StopAllProjects => Ok(ClientOutcome::new((), events)), + other => Err(ClientError::unexpected_response("project.stop_all", other)), + } + } - let response = self.send_request(request).await?; + pub async fn push_project_files( + &mut self, + project_id: &str, + files: impl IntoIterator, + ) -> ClientResult> { + let mut events = Vec::new(); + for request in project_write_requests(project_id, files) { + let outcome = self.send_request(request.clone()).await?; + events.extend(outcome.events); + validate_project_deploy_response(&request, &outcome.value.msg)?; + } + Ok(ClientOutcome::new((), events)) + } - match response.msg { - ServerMsgBody::StopAllProjects => Ok(()), - _ => Err(Error::msg(format!( - "Unexpected response type for stop_all_projects: {:?}", - response.msg - ))), + pub async fn deploy_project_files( + &mut self, + project_id: &str, + files: impl IntoIterator, + ) -> ClientResult> { + let mut events = Vec::new(); + let mut handle = None; + for request in project_deploy_requests(project_id, files) { + let outcome = self.send_request(request.clone()).await?; + events.extend(outcome.events); + handle = validate_project_deploy_response(&request, &outcome.value.msg)?.or(handle); } + handle + .map(|handle| ClientOutcome::new(handle, events)) + .ok_or_else(|| ClientError::Protocol("project deploy did not load project".into())) } } diff --git a/lp-app/lpa-client/src/client_error.rs b/lp-app/lpa-client/src/client_error.rs new file mode 100644 index 000000000..d11a65603 --- /dev/null +++ b/lp-app/lpa-client/src/client_error.rs @@ -0,0 +1,59 @@ +//! Portable errors for the LightPlayer server client. +//! +//! The core client avoids `anyhow` so browser and other non-Tokio runtimes can +//! preserve structured protocol failures. Host adapters may convert these into +//! application-local error types at their boundary. + +use std::error::Error; +use std::fmt; + +use lpc_wire::TransportError; + +pub type ClientResult = Result; + +/// Error surfaced by the runtime-neutral `LpClient` core. +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ClientError { + /// The underlying I/O channel failed. + Transport(String), + /// The server returned an explicit protocol error response. + Server(String), + /// The received stream violated the expected client protocol. + Protocol(String), + /// A valid response arrived, but not the one required for the operation. + UnexpectedResponse { + operation: &'static str, + response: String, + }, +} + +impl ClientError { + pub fn unexpected_response(operation: &'static str, response: impl fmt::Debug) -> Self { + Self::UnexpectedResponse { + operation, + response: format!("{response:?}"), + } + } +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Transport(message) => write!(f, "transport error: {message}"), + Self::Server(message) => write!(f, "server error: {message}"), + Self::Protocol(message) => write!(f, "protocol error: {message}"), + Self::UnexpectedResponse { + operation, + response, + } => write!(f, "unexpected response for {operation}: {response}"), + } + } +} + +impl Error for ClientError {} + +impl From for ClientError { + fn from(error: TransportError) -> Self { + Self::Transport(error.to_string()) + } +} diff --git a/lp-app/lpa-client/src/client_event.rs b/lp-app/lpa-client/src/client_event.rs new file mode 100644 index 000000000..c13589948 --- /dev/null +++ b/lp-app/lpa-client/src/client_event.rs @@ -0,0 +1,48 @@ +//! Protocol events observed while waiting for a request response. +//! +//! `lp-server` can emit heartbeats and logs between correlated responses. The +//! portable client returns those events to callers instead of printing or +//! mutating UI state directly. + +use lpc_wire::server::api::LogLevel; +use lpc_wire::server::{LoadedProject, MemoryStats, SampleStats}; +use lpc_wire::{WireServerMessage, WireServerMsgBody}; + +/// Side-channel protocol event surfaced by `LpClient`. +#[derive(Debug)] +pub enum ClientEvent { + /// Periodic server health and performance sample. + Heartbeat { + fps: SampleStats, + frame_count: u64, + loaded_projects: Vec, + uptime_ms: u64, + memory: Option, + }, + /// Firmware/server log line carried by the protocol. + Log { level: LogLevel, message: String }, + /// A response id arrived while another request id was expected. + UncorrelatedResponse { response_id: u64, expected_id: u64 }, +} + +impl ClientEvent { + pub fn from_unsolicited_message(message: WireServerMessage) -> Option { + match message.msg { + WireServerMsgBody::Heartbeat { + fps, + frame_count, + loaded_projects, + uptime_ms, + memory, + } => Some(Self::Heartbeat { + fps, + frame_count, + loaded_projects, + uptime_ms, + memory, + }), + WireServerMsgBody::Log { level, message } => Some(Self::Log { level, message }), + _ => None, + } + } +} diff --git a/lp-app/lpa-client/src/client_io.rs b/lp-app/lpa-client/src/client_io.rs new file mode 100644 index 000000000..87c5b864d --- /dev/null +++ b/lp-app/lpa-client/src/client_io.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage}; + +/// Runtime-neutral I/O for the LightPlayer server protocol. +/// +/// Core `LpClient` code uses this trait instead of depending on Tokio or +/// requiring `Send`. Host/native adapters can add those requirements at their +/// boundary. +#[async_trait(?Send)] +pub trait ClientIo { + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError>; + + async fn receive(&mut self) -> Result; + + async fn close(&mut self) -> Result<(), TransportError>; +} + +#[async_trait(?Send)] +impl ClientIo for Box +where + T: ClientIo + ?Sized, +{ + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { + (**self).send(msg).await + } + + async fn receive(&mut self) -> Result { + (**self).receive().await + } + + async fn close(&mut self) -> Result<(), TransportError> { + (**self).close().await + } +} diff --git a/lp-app/lpa-client/src/lib.rs b/lp-app/lpa-client/src/lib.rs index 281e2cd73..a35f263f4 100644 --- a/lp-app/lpa-client/src/lib.rs +++ b/lp-app/lpa-client/src/lib.rs @@ -1,24 +1,45 @@ //! LightPlayer client library. //! -//! Provides client-side functionality for communicating with LpServer. -//! Includes transport implementations and the main LpClient struct. +//! Provides the typed client-side protocol for communicating with `LpServer`. +//! The core `LpClient` is runtime-neutral; host transports and the +//! cloneable Tokio wrapper live behind host-oriented feature flags. pub mod client; +pub mod client_error; +pub mod client_event; +pub mod client_io; +#[cfg(feature = "host")] pub mod local; +pub mod project_deploy; +pub mod protocol_session; +#[cfg(feature = "host")] pub mod specifier; +#[cfg(feature = "host")] +pub mod tokio_client; +#[cfg(feature = "host")] pub mod transport; #[cfg(feature = "emu")] pub mod transport_emu_serial; +#[cfg(feature = "serial")] pub mod transport_serial; #[cfg(feature = "ws")] pub mod transport_ws; // Re-export main types -pub use client::LpClient; +pub use client::{ClientOutcome, LpClient}; +pub use client_error::{ClientError, ClientResult}; +pub use client_event::ClientEvent; +pub use client_io::ClientIo; +#[cfg(feature = "host")] pub use local::{ AsyncLocalClientTransport, AsyncLocalServerTransport, create_local_transport_pair, }; +pub use project_deploy::ProjectDeployFile; +#[cfg(feature = "host")] pub use specifier::HostSpecifier; +#[cfg(feature = "host")] +pub use tokio_client::{SharedClientTransport, TokioClientIo, TokioLpClient}; +#[cfg(feature = "host")] pub use transport::ClientTransport; #[cfg(feature = "ws")] pub use transport_ws::WebSocketClientTransport; diff --git a/lp-app/lpa-client/src/project_deploy.rs b/lp-app/lpa-client/src/project_deploy.rs new file mode 100644 index 000000000..9b2770dfb --- /dev/null +++ b/lp-app/lpa-client/src/project_deploy.rs @@ -0,0 +1,161 @@ +//! Helpers for planning project uploads over the server protocol. +//! +//! This module owns the common stop/write/load request order that Studio, CLI, +//! and future agents should share when they deploy a project through a running +//! `lp-server`. + +use lpc_model::AsLpPathBuf; +use lpc_wire::{ClientRequest, FsRequest, WireProjectHandle, WireServerMsgBody}; + +use crate::client_error::{ClientError, ClientResult}; + +/// One file to write under `/projects/{project_id}`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectDeployFile { + relative_path: String, + bytes: Vec, +} + +impl ProjectDeployFile { + pub fn new(relative_path: impl Into, bytes: impl Into>) -> Self { + Self { + relative_path: normalize_relative_path(&relative_path.into()), + bytes: bytes.into(), + } + } + + pub fn relative_path(&self) -> &str { + &self.relative_path + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + fn into_write_request(self, project_id: &str) -> ClientRequest { + ClientRequest::Filesystem(FsRequest::Write { + path: project_file_path(project_id, &self.relative_path).as_path_buf(), + data: self.bytes, + }) + } +} + +pub fn project_load_path(project_id: &str) -> String { + format!("projects/{project_id}") +} + +/// Build the absolute server filesystem path for a project file. +pub fn project_file_path(project_id: &str, relative_path: &str) -> String { + format!( + "/projects/{project_id}/{}", + normalize_relative_path(relative_path) + ) +} + +/// Build write requests without changing project lifecycle. +pub fn project_write_requests( + project_id: &str, + files: impl IntoIterator, +) -> Vec { + files + .into_iter() + .map(|file| file.into_write_request(project_id)) + .collect() +} + +/// Build the current deploy flow: stop loaded projects, write files, load. +pub fn project_deploy_requests( + project_id: &str, + files: impl IntoIterator, +) -> Vec { + let mut requests = Vec::new(); + requests.push(ClientRequest::StopAllProjects); + requests.extend(project_write_requests(project_id, files)); + requests.push(ClientRequest::LoadProject { + path: project_load_path(project_id), + }); + requests +} + +/// Validate one deploy response and return the loaded project handle if present. +pub fn validate_project_deploy_response( + request: &ClientRequest, + response: &WireServerMsgBody, +) -> ClientResult> { + match (request, response) { + (ClientRequest::StopAllProjects, WireServerMsgBody::StopAllProjects) => Ok(None), + ( + ClientRequest::Filesystem(FsRequest::Write { path, .. }), + WireServerMsgBody::Filesystem(lpc_wire::FsResponse::Write { error, .. }), + ) => { + if let Some(error) = error { + Err(ClientError::Server(format!( + "failed to write {}: {error}", + path.as_str() + ))) + } else { + Ok(None) + } + } + (ClientRequest::LoadProject { .. }, WireServerMsgBody::LoadProject { handle }) => { + Ok(Some(*handle)) + } + _ => Err(ClientError::unexpected_response( + request_label(request), + response, + )), + } +} + +pub fn request_label(request: &ClientRequest) -> &'static str { + match request { + ClientRequest::Filesystem(FsRequest::Read { .. }) => "fs.read", + ClientRequest::Filesystem(FsRequest::Write { .. }) => "fs.write", + ClientRequest::Filesystem(FsRequest::DeleteFile { .. }) => "fs.delete_file", + ClientRequest::Filesystem(FsRequest::DeleteDir { .. }) => "fs.delete_dir", + ClientRequest::Filesystem(FsRequest::ListDir { .. }) => "fs.list_dir", + ClientRequest::LoadProject { .. } => "project.load", + ClientRequest::UnloadProject { .. } => "project.unload", + ClientRequest::ProjectRequest { .. } => "project.read", + ClientRequest::ProjectCommand { .. } => "project.command", + ClientRequest::ListAvailableProjects => "project.list_available", + ClientRequest::ListLoadedProjects => "project.list_loaded", + ClientRequest::StopAllProjects => "project.stop_all", + } +} + +fn normalize_relative_path(path: &str) -> String { + path.trim_start_matches('/').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deploy_requests_stop_write_then_load() { + let requests = project_deploy_requests( + "demo", + [ + ProjectDeployFile::new("project.toml", b"project".to_vec()), + ProjectDeployFile::new("/shader.glsl", b"shader".to_vec()), + ], + ); + + assert!(matches!(requests[0], ClientRequest::StopAllProjects)); + assert!(matches!( + &requests[1], + ClientRequest::Filesystem(FsRequest::Write { path, .. }) + if path.as_str() == "/projects/demo/project.toml" + )); + assert!(matches!( + &requests[2], + ClientRequest::Filesystem(FsRequest::Write { path, .. }) + if path.as_str() == "/projects/demo/shader.glsl" + )); + assert!(matches!( + &requests[3], + ClientRequest::LoadProject { path } if path == "projects/demo" + )); + } +} diff --git a/lp-app/lpa-client/src/protocol_session.rs b/lp-app/lpa-client/src/protocol_session.rs new file mode 100644 index 000000000..428688229 --- /dev/null +++ b/lp-app/lpa-client/src/protocol_session.rs @@ -0,0 +1,102 @@ +//! Request id allocation and response classification for `lp-server`. +//! +//! Keeping this separate lets host and browser adapters share correlation +//! behavior even when their I/O mechanics differ. + +use lpc_wire::WireServerMessage; + +/// Per-connection protocol state. +#[derive(Debug, Clone)] +pub struct ProtocolSession { + next_request_id: u64, +} + +impl ProtocolSession { + pub fn new() -> Self { + Self { next_request_id: 1 } + } + + pub fn next_request_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id += 1; + id + } + + pub fn response_disposition( + &self, + response: &WireServerMessage, + expected_id: u64, + ) -> ResponseDisposition { + if response.id == expected_id { + ResponseDisposition::Matched + } else if response.id == 0 { + ResponseDisposition::Unsolicited + } else { + ResponseDisposition::Uncorrelated { + response_id: response.id, + expected_id, + } + } + } +} + +impl Default for ProtocolSession { + fn default() -> Self { + Self::new() + } +} + +/// How an incoming server message relates to the request currently in flight. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ResponseDisposition { + /// The response id matches the request id we are waiting for. + Matched, + /// Server-originated event such as heartbeat/log. + Unsolicited, + /// A response for a different request arrived. + Uncorrelated { response_id: u64, expected_id: u64 }, +} + +#[cfg(test)] +mod tests { + use lpc_wire::WireServerMessage; + use lpc_wire::server::ServerMsgBody; + + use super::*; + + #[test] + fn request_ids_start_at_one_and_increment() { + let mut session = ProtocolSession::new(); + + assert_eq!(session.next_request_id(), 1); + assert_eq!(session.next_request_id(), 2); + } + + #[test] + fn classifies_response_ids() { + let session = ProtocolSession::new(); + + assert_eq!( + session.response_disposition(&message(7), 7), + ResponseDisposition::Matched + ); + assert_eq!( + session.response_disposition(&message(0), 7), + ResponseDisposition::Unsolicited + ); + assert_eq!( + session.response_disposition(&message(9), 7), + ResponseDisposition::Uncorrelated { + response_id: 9, + expected_id: 7 + } + ); + } + + fn message(id: u64) -> WireServerMessage { + WireServerMessage { + id, + msg: ServerMsgBody::StopAllProjects, + } + } +} diff --git a/lp-app/lpa-client/src/tokio_client.rs b/lp-app/lpa-client/src/tokio_client.rs new file mode 100644 index 000000000..1cd2de8a6 --- /dev/null +++ b/lp-app/lpa-client/src/tokio_client.rs @@ -0,0 +1,576 @@ +//! Tokio host adapter for the portable LightPlayer client protocol. +//! +//! `TokioLpClient` preserves the existing CLI/native ergonomics: cloneable +//! shared transport, request timeout, and heartbeat/log rendering. Protocol +//! state and deploy ordering still come from the portable modules so host and +//! browser paths do not diverge semantically. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Error, Result}; +use lpc_model::{LpPath, LpPathBuf}; +use lpc_wire::server::api::LogLevel; +use lpc_wire::{ + ClientMessage, ClientRequest, FsRequest, ProjectReadRequest, ProjectReadResponse, + WireOverlayCommitRequest, WireOverlayCommitResponse, WireOverlayMutationRequest, + WireOverlayMutationResponse, WireOverlayReadRequest, WireOverlayReadResponse, + WireProjectCommand, WireProjectCommandResponse, WireProjectHandle, + WireProjectInventoryReadRequest, WireProjectInventoryReadResponse, WireServerMessage, + WireServerMsgBody, + server::{AvailableProject, FsResponse, LoadedProject}, +}; +use tokio::sync::Mutex; +use tokio::time::timeout; + +use crate::client::ClientOutcome; +use crate::client_event::ClientEvent; +use crate::client_io::ClientIo; +use crate::project_deploy::{ + ProjectDeployFile, project_deploy_requests, project_write_requests, + validate_project_deploy_response, +}; +use crate::protocol_session::{ProtocolSession, ResponseDisposition}; +use crate::transport::ClientTransport; + +pub type SharedClientTransport = Arc>>; + +/// `ClientIo` implementation backed by a shared Tokio transport. +#[derive(Clone)] +pub struct TokioClientIo { + transport: SharedClientTransport, +} + +impl TokioClientIo { + pub fn new(transport: Box) -> Self { + Self { + transport: Arc::new(Mutex::new(transport)), + } + } + + pub fn new_shared(transport: SharedClientTransport) -> Self { + Self { transport } + } + + pub fn shared_transport(&self) -> SharedClientTransport { + Arc::clone(&self.transport) + } +} + +#[async_trait::async_trait(?Send)] +impl ClientIo for TokioClientIo { + async fn send(&mut self, msg: lpc_wire::ClientMessage) -> Result<(), lpc_wire::TransportError> { + self.transport.lock().await.send(msg).await + } + + async fn receive(&mut self) -> Result { + self.transport.lock().await.receive().await + } + + async fn close(&mut self) -> Result<(), lpc_wire::TransportError> { + self.transport.lock().await.close().await + } +} + +/// Cloneable host client wrapper with timeouts and optional heartbeat display. +#[derive(Clone)] +pub struct TokioLpClient { + state: Arc>, + request_timeout: Duration, + display_heartbeats: bool, +} + +struct TokioLpClientState { + transport: SharedClientTransport, + protocol: ProtocolSession, +} + +impl TokioLpClient { + pub fn new(transport: Box) -> Self { + Self::new_shared(Arc::new(Mutex::new(transport))) + } + + pub fn new_shared(transport: SharedClientTransport) -> Self { + Self { + state: Arc::new(Mutex::new(TokioLpClientState { + transport, + protocol: ProtocolSession::new(), + })), + request_timeout: Duration::from_secs(60), + display_heartbeats: true, + } + } + + pub fn from_io(io: TokioClientIo) -> Self { + Self::new_shared(io.shared_transport()) + } + + pub fn with_heartbeat_display(mut self, display_heartbeats: bool) -> Self { + self.display_heartbeats = display_heartbeats; + self + } + + pub async fn send_request( + &self, + request: ClientRequest, + ) -> Result> { + let run = self.send_request_inner(request); + let outcome = match timeout(self.request_timeout, run).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(error)) => return Err(error), + Err(_) => { + return Err(Error::msg( + "Request timed out - server may not be receiving messages (check host->device serial)", + )); + } + }; + self.handle_events(&outcome.events); + Ok(outcome) + } + + async fn send_request_inner( + &self, + request: ClientRequest, + ) -> Result> { + let mut state = self.state.lock().await; + let request_id = state.protocol.next_request_id(); + let mut transport = state.transport.lock().await; + transport + .send(ClientMessage { + id: request_id, + msg: request, + }) + .await + .map_err(|error| Error::msg(format!("Transport error: {error}")))?; + + let mut events = Vec::new(); + loop { + let response = transport + .receive() + .await + .map_err(|error| Error::msg(format!("Transport error: {error}")))?; + match state.protocol.response_disposition(&response, request_id) { + ResponseDisposition::Matched => { + if let WireServerMsgBody::Error { error } = &response.msg { + return Err(Error::msg(error.clone())); + } + return Ok(ClientOutcome::new(response, events)); + } + ResponseDisposition::Unsolicited => { + if let Some(event) = ClientEvent::from_unsolicited_message(response) { + events.push(event); + } + } + ResponseDisposition::Uncorrelated { + response_id, + expected_id, + } => events.push(ClientEvent::UncorrelatedResponse { + response_id, + expected_id, + }), + } + } + } + + pub async fn fs_read(&self, path: &LpPath) -> Result> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::Read { + path: path.to_path_buf(), + })) + .await?; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::Read { data, error, .. }) => { + if let Some(error) = error { + return Err(Error::msg(format!("Server error: {error}"))); + } + data.ok_or_else(|| Error::msg("No data in read response")) + } + other => Err(unexpected_response("fs_read", other)), + } + } + + pub async fn fs_write(&self, path: &LpPath, data: Vec) -> Result<()> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::Write { + path: path.to_path_buf(), + data, + })) + .await?; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::Write { error, .. }) => { + if let Some(error) = error { + return Err(Error::msg(format!("Server error: {error}"))); + } + Ok(()) + } + other => Err(unexpected_response("fs_write", other)), + } + } + + pub async fn fs_delete_file(&self, path: &LpPath) -> Result<()> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::DeleteFile { + path: path.to_path_buf(), + })) + .await?; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::DeleteFile { error, .. }) => { + if let Some(error) = error { + return Err(Error::msg(format!("Server error: {error}"))); + } + Ok(()) + } + other => Err(unexpected_response("fs_delete_file", other)), + } + } + + pub async fn fs_list_dir(&self, path: &LpPath, recursive: bool) -> Result> { + let response = self + .send_request(ClientRequest::Filesystem(FsRequest::ListDir { + path: path.to_path_buf(), + recursive, + })) + .await?; + match response.value.msg { + WireServerMsgBody::Filesystem(FsResponse::ListDir { entries, error, .. }) => { + if let Some(error) = error { + return Err(Error::msg(format!("Server error: {error}"))); + } + Ok(entries) + } + other => Err(unexpected_response("fs_list_dir", other)), + } + } + + pub async fn project_load(&self, path: &str) -> Result { + let response = self + .send_request(ClientRequest::LoadProject { + path: path.to_string(), + }) + .await?; + match response.value.msg { + WireServerMsgBody::LoadProject { handle } => Ok(handle), + other => Err(unexpected_response("project_load", other)), + } + } + + pub async fn project_unload(&self, handle: WireProjectHandle) -> Result<()> { + let response = self + .send_request(ClientRequest::UnloadProject { handle }) + .await?; + match response.value.msg { + WireServerMsgBody::UnloadProject => Ok(()), + other => Err(unexpected_response("project_unload", other)), + } + } + + pub async fn project_read( + &self, + handle: WireProjectHandle, + read: ProjectReadRequest, + ) -> Result { + let response = self + .send_request(ClientRequest::ProjectRequest { + handle, + request: read, + }) + .await?; + match response.value.msg { + WireServerMsgBody::ProjectRequest { response } => Ok(response), + other => Err(unexpected_response("project_read", other)), + } + } + + pub async fn project_read_default_debug( + &self, + handle: WireProjectHandle, + ) -> Result { + self.project_read(handle, ProjectReadRequest::default_debug(None)) + .await + } + + pub async fn project_command( + &self, + handle: WireProjectHandle, + command: WireProjectCommand, + ) -> Result { + let response = self + .send_request(ClientRequest::ProjectCommand { handle, command }) + .await?; + match response.value.msg { + WireServerMsgBody::ProjectCommand { response } => Ok(response), + other => Err(unexpected_response("project_command", other)), + } + } + + pub async fn project_overlay_read( + &self, + handle: WireProjectHandle, + ) -> Result { + match self + .project_command( + handle, + WireProjectCommand::ReadOverlay { + request: WireOverlayReadRequest, + }, + ) + .await? + { + WireProjectCommandResponse::ReadOverlay { response } => Ok(response), + other => Err(unexpected_response("project_overlay_read", other)), + } + } + + pub async fn project_overlay_mutate( + &self, + handle: WireProjectHandle, + request: WireOverlayMutationRequest, + ) -> Result { + match self + .project_command(handle, WireProjectCommand::MutateOverlay { request }) + .await? + { + WireProjectCommandResponse::MutateOverlay { response } => Ok(response), + other => Err(unexpected_response("project_overlay_mutate", other)), + } + } + + pub async fn project_overlay_commit( + &self, + handle: WireProjectHandle, + ) -> Result { + match self + .project_command( + handle, + WireProjectCommand::CommitOverlay { + request: WireOverlayCommitRequest, + }, + ) + .await? + { + WireProjectCommandResponse::CommitOverlay { response } => Ok(response), + other => Err(unexpected_response("project_overlay_commit", other)), + } + } + + pub async fn project_inventory_read( + &self, + handle: WireProjectHandle, + ) -> Result { + match self + .project_command( + handle, + WireProjectCommand::ReadInventory { + request: WireProjectInventoryReadRequest, + }, + ) + .await? + { + WireProjectCommandResponse::ReadInventory { response } => Ok(response), + other => Err(unexpected_response("project_inventory_read", other)), + } + } + + pub async fn project_list_available(&self) -> Result> { + let response = self + .send_request(ClientRequest::ListAvailableProjects) + .await?; + match response.value.msg { + WireServerMsgBody::ListAvailableProjects { projects } => Ok(projects), + other => Err(unexpected_response("project_list_available", other)), + } + } + + pub async fn project_list_loaded(&self) -> Result> { + let response = self.send_request(ClientRequest::ListLoadedProjects).await?; + match response.value.msg { + WireServerMsgBody::ListLoadedProjects { projects } => Ok(projects), + other => Err(unexpected_response("project_list_loaded", other)), + } + } + + pub async fn stop_all_projects(&self) -> Result<()> { + let response = self.send_request(ClientRequest::StopAllProjects).await?; + match response.value.msg { + WireServerMsgBody::StopAllProjects => Ok(()), + other => Err(unexpected_response("stop_all_projects", other)), + } + } + + pub async fn push_project_files( + &self, + project_id: &str, + files: impl IntoIterator, + ) -> Result<()> { + for request in project_write_requests(project_id, files) { + let response = self.send_request(request.clone()).await?; + validate_project_deploy_response(&request, &response.value.msg) + .map_err(|error| Error::msg(error.to_string()))?; + } + Ok(()) + } + + pub async fn deploy_project_files( + &self, + project_id: &str, + files: impl IntoIterator, + ) -> Result { + let mut handle = None; + for request in project_deploy_requests(project_id, files) { + let response = self.send_request(request.clone()).await?; + handle = validate_project_deploy_response(&request, &response.value.msg) + .map_err(|error| Error::msg(error.to_string()))? + .or(handle); + } + handle.ok_or_else(|| Error::msg("project deploy did not return a project handle")) + } + + pub async fn close(&self) -> Result<()> { + let state = self.state.lock().await; + let mut transport = state.transport.lock().await; + transport + .close() + .await + .map_err(|error| Error::msg(error.to_string())) + } + + fn handle_events(&self, events: &[ClientEvent]) { + for event in events { + match event { + ClientEvent::Heartbeat { + fps, + frame_count, + loaded_projects, + uptime_ms, + memory, + } if self.display_heartbeats => { + display_heartbeat( + fps, + *frame_count, + loaded_projects.as_slice(), + *uptime_ms, + memory, + ); + } + ClientEvent::Log { level, message } => { + log::log!(server_log_level(level), "[server] {message}"); + } + ClientEvent::UncorrelatedResponse { + response_id, + expected_id, + } => { + log::warn!( + "Received non-correlated message (id: {response_id}, expected: {expected_id})" + ); + } + _ => {} + } + } + } +} + +fn unexpected_response(operation: &'static str, response: impl std::fmt::Debug) -> Error { + Error::msg(format!( + "Unexpected response type for {operation}: {response:?}" + )) +} + +fn server_log_level(level: &LogLevel) -> log::Level { + match level { + LogLevel::Debug => log::Level::Debug, + LogLevel::Info => log::Level::Info, + LogLevel::Warn => log::Level::Warn, + LogLevel::Error => log::Level::Error, + } +} + +fn display_heartbeat( + fps: &lpc_wire::server::SampleStats, + _frame_count: u64, + loaded_projects: &[lpc_wire::server::LoadedProject], + uptime_ms: u64, + memory: &Option, +) { + const BOLD: &str = "\x1b[1m"; + const DIM: &str = "\x1b[90m"; + const CYAN: &str = "\x1b[36m"; + const GREEN: &str = "\x1b[32m"; + const YELLOW: &str = "\x1b[33m"; + const RED: &str = "\x1b[31m"; + const RESET: &str = "\x1b[0m"; + + let uptime_secs = uptime_ms as f64 / 1000.0; + let _projects_str = if loaded_projects.is_empty() { + format!("{DIM}none{RESET}") + } else { + loaded_projects + .iter() + .map(|p| { + p.path + .file_name() + .map(|n| n.to_string()) + .unwrap_or_else(|| p.path.as_str().to_string()) + }) + .collect::>() + .join(", ") + }; + + let fps_color = if fps.avg >= 50.0 { + GREEN + } else if fps.avg >= 20.0 { + YELLOW + } else { + RED + }; + + let mut line = format!( + "{BOLD}{CYAN}[server]{RESET} {fps_color}FPS {:.0}{RESET} avg (σ{:.1} {:.0}-{:.0}) {DIM}|{RESET} \ + {DIM}Uptime {uptime_secs:.1}s{RESET}", + fps.avg, fps.sdev, fps.min, fps.max + ); + + if let Some(mem) = memory { + let total = mem.total_bytes as f32; + let used_pct = if total > 0.0 { + (mem.used_bytes as f32 / total) * 100.0 + } else { + 0.0 + }; + let free_pct = 100.0 - used_pct; + + const BAR_WIDTH: usize = 16; + let filled = if total > 0.0 { + ((mem.used_bytes as f32 / total) * BAR_WIDTH as f32).round() as usize + } else { + 0 + }; + let filled = filled.min(BAR_WIDTH); + + let (bar_fill_color, bar_empty_color) = if free_pct >= 40.0 { + (GREEN, DIM) + } else if free_pct >= 15.0 { + (YELLOW, DIM) + } else { + (RED, DIM) + }; + + let bar: String = (0..BAR_WIDTH) + .map(|i| { + if i < filled { + format!("{bar_fill_color}█{RESET}") + } else { + format!("{bar_empty_color}░{RESET}") + } + }) + .collect(); + + let free_kb = mem.free_bytes / 1024; + let total_kb = mem.total_bytes / 1024; + + line.push_str(&format!( + " {DIM}|{RESET} [{bar}] {bar_fill_color}{used_pct:.0}%{RESET} ({free_kb}k free / {total_kb}k total)" + )); + } + + eprintln!("{line}"); +} diff --git a/lp-app/lpa-client/src/transport_serial/emulator.rs b/lp-app/lpa-client/src/transport_serial/emulator.rs index 0a5441db0..86b52a883 100644 --- a/lp-app/lpa-client/src/transport_serial/emulator.rs +++ b/lp-app/lpa-client/src/transport_serial/emulator.rs @@ -195,6 +195,9 @@ fn emulator_thread_loop( continue; } }; + if message_str.is_empty() { + continue; + } // Check for M! prefix - protocol messages; non-M! lines are server logs if !message_str.starts_with("M!") { diff --git a/lp-app/lpa-client/src/transport_serial/hardware.rs b/lp-app/lpa-client/src/transport_serial/hardware.rs index bea11ec82..d8bb4a653 100644 --- a/lp-app/lpa-client/src/transport_serial/hardware.rs +++ b/lp-app/lpa-client/src/transport_serial/hardware.rs @@ -141,6 +141,9 @@ fn serial_thread_loop( } }; let line_str = line_str.trim_end_matches('\r'); + if line_str.is_empty() { + continue; + } if let Some(observer) = &options.line_observer { observer.observe_line(line_str); diff --git a/lp-app/lpa-link/Cargo.toml b/lp-app/lpa-link/Cargo.toml new file mode 100644 index 000000000..ecf7428a9 --- /dev/null +++ b/lp-app/lpa-link/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "lpa-link" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +fw-host = { path = "../../lp-fw/fw-host", optional = true } +lpa-client = { path = "../lpa-client", optional = true } +lpc-model = { path = "../../lp-core/lpc-model", optional = true } +js-sys = { version = "0.3", optional = true } +serde = { workspace = true, features = ["derive"] } +serde-wasm-bindgen = { version = "0.6", optional = true } +serialport = { version = "4.8", optional = true } +strum = { version = "0.27", features = ["derive"] } +tokio = { version = "1", features = ["sync"], optional = true } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +web-sys = { version = "0.3", optional = true, features = [ + "Document", + "ErrorEvent", + "Location", + "MessageEvent", + "Url", + "Window", + "Worker", + "WorkerOptions", + "WorkerType", +] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + +[features] +default = [] +browser-serial-esp32 = [ + "dep:js-sys", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", +] +browser-worker = [ + "dep:js-sys", + "dep:serde-wasm-bindgen", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] +host-process = ["dep:fw-host", "dep:lpa-client", "dep:tokio"] +host-serial-esp32 = [ + "dep:lpa-client", + "lpa-client/serial", + "dep:lpc-model", + "dep:serialport", + "dep:tokio", +] + +[lints] +workspace = true diff --git a/lp-app/lpa-link/README.md b/lp-app/lpa-link/README.md new file mode 100644 index 000000000..2e025aaa0 --- /dev/null +++ b/lp-app/lpa-link/README.md @@ -0,0 +1,209 @@ +# lpa-link + +`lpa-link` provides the mechanism by which an application like Studio or the CLI +connects to an `lp-server`. + +Link providers allow discovery and management of their transports and underlying +hardware. A serial link for ESP32 provides firmware flashing, resetting, raw +filesystem access, diagnostics, and a client connection to the running +LightPlayer server. + +This crate sits below Studio capabilities and beside `lpa-client`. A link +provider owns discovery, endpoint identity, endpoint status, low-level +management, raw logs, diagnostics, and opening a server/client connection. Once +a connection exists, `lpa-client` remains the typed client API for talking to +`lp-server`. + +## Why This Is Not Just Transport + +Real LightPlayer links need more than `connect()`. Depending on the provider, +the same low-level surface may need to discover ports or workers, report what is +connected, reset a device, flash firmware, inspect raw filesystem state, read +diagnostics, stream logs, and then open a server connection. + +Studio should build product capabilities above this crate. It should not embed +Web Serial, browser-worker, host-process, flashing, or endpoint-management +details directly in UI code. + +## Endpoint, Session, Connection + +The central lifecycle is: + +```text +LinkProvider::discover() -> LinkEndpoint +LinkProvider::connect(endpoint_id) -> LinkSession +LinkProvider::connection(session_id) -> LinkConnection +LinkConnection::server_client() -> lpa-client +``` + +- **Endpoint:** a provider-visible target that can be opened. It is a + discoverable candidate, not a live resource. Examples include an ESP32 serial + port, a browser worker runtime target, a spawnable `fw-host` runtime, or a + future websocket target. +- **Session:** provider-neutral snapshot/handle for an opened endpoint. The + provider owns the concrete resources behind the session, such as an open + serial port, a spawned `fw-host` runtime, or a browser worker identity. +- **Connection:** the handoff from a live session to the `lp-server` protocol + layer. A connection is not a project session and does not replace the owning + link session. + +## Management API + +Some link operations happen below the running `lp-server`: firmware +provisioning, full-device erase, raw filesystem image access, bootloader +probing, and low-level reset/recovery. Providers advertise the operations they +can perform through `LinkCapabilities`, then execute supported operations +through the session-scoped management API: + +```rust +provider + .manage(session.id(), LinkManagementRequest::FlashFirmware) + .await?; +``` + +Callers that need live UI feedback can use `manage_with_events` with a +`LinkManagementEventSink`. Providers that can observe progress stream terminal +log lines and compact progress entries while the operation runs; providers that +only have final results fall back to replaying the result logs/progress through +the same event vocabulary. + +`LinkManagementRequest` is provider-neutral, while each provider owns the +target-specific work needed to satisfy it. For browser Web Serial ESP32, this +means releasing normal server/protocol ownership of the serial port before +taking exclusive bootloader ownership for flash or erase. + +The current implemented management operations are: + +- `FlashFirmware`: write the provider-configured LightPlayer ESP32-C6 firmware + manifest/images to the device. +- `EraseDeviceFlash`: erase the whole device flash so the ESP32 returns to a + blank, unprovisioned state. + +Raw filesystem image erase/read/write are modeled as link-level operations but +are future work. They should operate on direct device/LittleFS image bytes below +the server, not on the server filesystem API used for normal project upload. + +## Server Connections + +`LinkConnection` is the handoff point from an open link session to the server +protocol. Host providers currently expose a `LinkServerConnection`, which is a +shared host `lpa-client` transport and can be wrapped as a `TokioLpClient` with +`server_client()`. + +Browser providers own their browser resource bindings: + +- `browser-worker` owns the JavaScript module Worker wrapper and lifecycle. +- `browser-serial-esp32` owns Web Serial permission/open/release/close and ESP32 + probe/flash bindings. + +`lpa-studio-ux` adapts provider send/receive streams into +`lpa-client::ClientIo`; UI shells should not reimplement provider resource +ownership, request ids, response correlation, server error handling, +heartbeat/log handling, or project deploy ordering. + +## Providers + +Applications can inspect the providers compiled into `lpa-link` without +duplicating the feature/target matrix: + +```rust +let registry = + lpa_link::providers::LinkProviderRegistry::from_env(lpa_link::providers::LinkEnv::default()); +let providers = registry.descriptors(); +``` + +The returned `LinkProviderDescriptor` values contain provider kinds, build +availability, labels, and low-level `LinkCapabilities`. `LinkProviderKind` +owns the stable kebab-case key used at app boundaries. Product surfaces such as +Studio should map these descriptors into their own UX-facing provider cards, +intents, ordering, and recovery actions. + +| Provider key | Rust module/type | Runtime or device | Endpoint kind | Management intent | Status | +|---|---|---|---|---|---| +| `fake` | `providers::fake::FakeProvider` | none | test endpoint | diagnostics only | implemented | +| `host-process` | `providers::host_process::HostProcessProvider` | host process running `fw-host` | spawnable host runtime | logs, diagnostics, future local filesystem/runtime controls | implemented; returns host `LinkServerConnection` | +| `browser-worker` | `providers::browser_worker::BrowserWorkerProvider` | `fw-browser` Web Worker | browser worker runtime | logs, diagnostics, worker lifecycle | implemented; owns Worker wrapper/lifecycle | +| `host-serial-esp32` | `providers::host_serial_esp32::HostSerialEsp32Provider` | ESP32 over host serial | physical serial device | connect, reset-after-open, logs, diagnostics; future flash/raw filesystem | implemented for discovery/connect; returns host `LinkServerConnection` | +| `browser-serial-esp32` | `providers::browser_serial_esp32::BrowserSerialEsp32Provider` | ESP32 over Web Serial | physical serial device | connect, provision firmware, erase to blank, reset, logs, diagnostics; future raw filesystem | implemented for browser Web Serial/probe/flash/erase ownership | +| `host-websocket` | future `providers::host_websocket::HostWebsocketProvider` | already-running server over host networking | remote endpoint | host-side discovery/connect/status; limited management | future | +| `browser-websocket` | future `providers::browser_websocket::BrowserWebsocketProvider` | already-running server over browser networking | remote endpoint | browser permission/discovery/connect/status; limited management | future | +| `host-webserver` | future `providers::host_webserver::HostWebserverProvider` | host service owning `fw-host` runtimes | service-managed runtime endpoint | create/stop runtimes, logs, diagnostics | future | + +The ESP32 serial providers are intentionally ESP32-specific. Flashing, +resetting, boot-mode handling, and raw filesystem access are target-family +details; a generic serial abstraction can come later if another target earns it. + +Provider support is feature-gated: + +```bash +cargo check -p lpa-link +cargo test -p lpa-link +cargo check -p lpa-link --features host-process +cargo test -p lpa-link --features host-process +cargo check -p lpa-link --features host-serial-esp32 +cargo test -p lpa-link --features host-serial-esp32 +cargo check -p lpa-link --features browser-serial-esp32 --target wasm32-unknown-unknown +cargo check -p lpa-link --features browser-worker --target wasm32-unknown-unknown +``` + +## Design Notes + +- **Provider:** source of endpoints and management behavior, such as + `host-process`, `browser-worker`, or ESP32 serial providers. +- **Endpoint:** discoverable candidate target. It has identity, status, and + `LinkCapabilities`, but no live resource ownership. +- **Session:** provider-neutral snapshot/handle for a connected endpoint or + launched runtime. Provider-private session state owns concrete resources. +- **Connection:** server protocol handoff to `lp-server`, consumed by + `lpa-client`. +- **Capabilities:** low-level operations below Studio product actions: reset, + flash, raw filesystem image access, logs, diagnostics, and similar + device/runtime controls. +- Public domain types use `Link*` names where they cross crate boundaries: + `LinkProvider`, `LinkEndpoint`, `LinkSession`, `LinkConnection`, and related + IDs/status types. +- Provider modules and methods use natural names such as `host_process`, + `browser_worker`, `discover`, `status`, `connect`, and `logs`. +- Public provider IDs use kebab-case and generally follow + `{environment}-{mechanism}-{target?}`, such as `host-process`, + `browser-worker`, `host-serial-esp32`, `browser-serial-esp32`, + `host-websocket`, and `browser-websocket`. The target segment is optional when + the mechanism already carries the whole contract. Include it when management + details are target-specific. Rust modules/types use Rust naming, such as + `providers::host_serial_esp32::HostSerialEsp32Provider`. +- The model is plural-first. Multiple host or browser runtime instances should + be natural, even if the first Studio UI exposes only one session. +- `host-process` endpoints are spawnable. Calling `connect()` creates a new + in-process `fw-host` runtime instance and records provider-private session + state that owns its lifecycle. +- A `LinkConnection` is a server/client connection, not a project session. + Project sessions belong above this layer. +- `browser-worker` owns the worker wrapper source under + `src/providers/browser_worker`. Apps pass same-origin + `fw_browser_module_path` and `fw_browser_wasm_path` options for the generated + `fw-browser` sidecar artifacts. +- `browser-serial-esp32` owns Web Serial access and ESP32 probe/flash/erase + bindings under `src/providers/browser_serial_esp32`. In the browser, its + wasm-bound session adapter delegates Web Serial lifecycle to the app-served + `BrowserEsp32DeviceController` at + `/lpa-link/browser_esp32_device_controller.js`. The controller owns the + selected `SerialPort`, reader/writer locks, raw serial log pump, best-effort + reset signaling, and the handoff between normal protocol reading and + `esptool-js` bootloader operations. Flash and erase stream esptool + terminal/progress events through `LinkManagementEventSink`. Apps pass + same-origin `firmware_manifest_path` and optional `esptool_module_path` + options for app-owned assets. The default esptool module is pinned to the + browser ESM endpoint `https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm` for + development. The ESM CDN rewrite is important because the raw package imports + dependencies such as `pako` by bare specifier, which browsers cannot resolve + without a bundler or import map. This endpoint has also been checked against + the ESP32-C6 stub decoding path used by reset/provisioning. A deployed app can + override the default with a hosted module path. The provider releases normal + protocol ownership before probe/flash/erase takes exclusive bootloader access. + Opening the normal serial server protocol opens the port once, starts reading + immediately, then attempts a best-effort hard reset while boot output is being + captured. Reset signal failures are diagnostic; readiness is classified from + serial output and protocol frames. +- Direct filesystem access means raw/full filesystem image management below the + running `lp-server`. Normal project upload should use `lpa-client` and the + server filesystem/project protocol once firmware is running. diff --git a/lp-app/lpa-link/src/lib.rs b/lp-app/lpa-link/src/lib.rs new file mode 100644 index 000000000..8fc726d5b --- /dev/null +++ b/lp-app/lpa-link/src/lib.rs @@ -0,0 +1,30 @@ +//! App-side links to LightPlayer runtimes and devices. + +pub mod provider; +pub mod providers; +pub mod registry; + +#[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] +pub use provider::connection::{LinkClientTransport, LinkServerConnection}; +pub use provider::connection::{LinkConnection, LinkConnectionKind}; +pub use provider::diagnostic::{LinkDiagnostic, LinkDiagnosticSeverity}; +pub use provider::endpoint::LinkEndpoint; +pub use provider::endpoint::LinkEndpointId; +pub use provider::endpoint::LinkEndpointStatus; +pub use provider::error::LinkError; +pub use provider::log::{LinkLogEntry, LinkLogLevel}; +pub use provider::management_event::{ + LinkManagementEvent, LinkManagementEventSink, emit_management_result_events, +}; +pub use provider::management_progress::LinkManagementProgress; +pub use provider::management_request::LinkManagementRequest; +pub use provider::management_result::{ + LinkEraseDeviceResult, LinkFirmwareFlashResult, LinkFirmwareManifest, LinkManagementResult, + LinkRawFilesystemEraseResult, +}; +pub use provider::operation::{LinkCapabilities, LinkOperation}; +pub use provider::provider::LinkProvider; +pub use provider::session::LinkSession; +pub use provider::session::LinkSessionId; +pub use provider::session::LinkSessionStatus; +pub use providers::LinkProviderKind; diff --git a/lp-app/lpa-link/src/provider/connection.rs b/lp-app/lpa-link/src/provider/connection.rs new file mode 100644 index 000000000..c07366831 --- /dev/null +++ b/lp-app/lpa-link/src/provider/connection.rs @@ -0,0 +1,157 @@ +use serde::{Deserialize, Serialize}; + +use crate::provider::endpoint::LinkEndpointId; +use crate::provider::session::LinkSessionId; + +/// Handoff from a live link session to the `lp-server` protocol layer. +/// +/// `LinkConnection` is created by `LinkProvider::connection()`. It identifies +/// which endpoint/session produced the server protocol connection and describes +/// the provider/runtime flavor used to reach that server. +/// +/// It is not an endpoint and it does not own the whole session lifecycle. Keep +/// the provider-owned session open while using the connection. +#[derive(Clone, Deserialize, Serialize)] +pub struct LinkConnection { + /// Endpoint that the owning session was opened from. + pub endpoint_id: LinkEndpointId, + /// Live session that produced this connection. + pub session_id: LinkSessionId, + /// Provider/runtime flavor for this protocol connection. + pub kind: LinkConnectionKind, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + /// Host-side protocol channel for links that can expose one directly. + #[serde(skip)] + pub server_connection: Option, +} + +/// Host-side server protocol connection opened by a link session. +/// +/// Browser links currently expose protocol identity in `LinkConnectionKind`; +/// their actual streams are owned by the web runtime and should be adapted into +/// `lpa_client::ClientIo`. +#[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] +pub type LinkServerConnection = lpa_client::SharedClientTransport; +/// Compatibility alias for the previous host transport name. +#[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] +pub type LinkClientTransport = LinkServerConnection; + +/// Transport/runtime flavor for a server protocol connection. +/// +/// Browser variants include protocol identity, but browser-owned streams still +/// live in the web runtime. Host variants may carry a `LinkServerConnection`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkConnectionKind { + Fake, + HostProcess, + BrowserWorker { protocol: String }, + HostSerialEsp32, + BrowserSerialEsp32 { protocol: String }, + PendingImplementation { kind: String }, +} + +impl LinkConnection { + pub fn fake( + endpoint_id: impl Into, + session_id: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::Fake, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + server_connection: None, + } + } + + pub fn pending( + endpoint_id: impl Into, + session_id: impl Into, + kind: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::PendingImplementation { kind: kind.into() }, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + server_connection: None, + } + } + + pub fn browser_worker( + endpoint_id: impl Into, + session_id: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::BrowserWorker { + protocol: "fw-browser-post-message-v1".to_string(), + }, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + server_connection: None, + } + } + + pub fn browser_serial_esp32( + endpoint_id: impl Into, + session_id: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::BrowserSerialEsp32 { + protocol: "lp-serial-json-lines-v1".to_string(), + }, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + server_connection: None, + } + } + + #[cfg(feature = "host-process")] + pub fn host_process( + endpoint_id: impl Into, + session_id: impl Into, + server_connection: LinkServerConnection, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::HostProcess, + server_connection: Some(server_connection), + } + } + + #[cfg(feature = "host-serial-esp32")] + pub fn host_serial_esp32( + endpoint_id: impl Into, + session_id: impl Into, + server_connection: LinkServerConnection, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::HostSerialEsp32, + server_connection: Some(server_connection), + } + } + + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + /// Return the host protocol channel opened by this connection. + pub fn server_connection(&self) -> Option { + self.server_connection.clone() + } + + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + /// Wrap the host protocol channel with the Tokio client adapter. + pub fn server_client(&self) -> Option { + self.server_connection() + .map(lpa_client::TokioLpClient::new_shared) + } + + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + /// Deprecated compatibility shim for callers still using transport wording. + pub fn client_transport(&self) -> Option { + self.server_connection() + } +} diff --git a/lp-app/lpa-link/src/provider/diagnostic.rs b/lp-app/lpa-link/src/provider/diagnostic.rs new file mode 100644 index 000000000..0c2acdc13 --- /dev/null +++ b/lp-app/lpa-link/src/provider/diagnostic.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +use crate::provider::endpoint::LinkEndpointId; +use crate::provider::session::LinkSessionId; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkDiagnostic { + pub endpoint_id: LinkEndpointId, + pub session_id: Option, + pub severity: LinkDiagnosticSeverity, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkDiagnosticSeverity { + Info, + Warning, + Error, +} + +impl LinkDiagnostic { + pub fn new( + endpoint_id: impl Into, + session_id: Option, + severity: LinkDiagnosticSeverity, + message: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id, + severity, + message: message.into(), + } + } +} diff --git a/lp-app/lpa-link/src/provider/endpoint.rs b/lp-app/lpa-link/src/provider/endpoint.rs new file mode 100644 index 000000000..389740afe --- /dev/null +++ b/lp-app/lpa-link/src/provider/endpoint.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +use crate::LinkCapabilities; +use crate::providers::LinkProviderKind; + +/// A provider-visible target that can be connected to. +/// +/// An endpoint is a candidate target, not a live connection. It is returned by +/// `LinkProvider::discover()` and describes what can be opened: a serial port, +/// a browser worker runtime, a host process runtime template, or a future +/// websocket target. +/// +/// Endpoints are not always physical devices. `host-process`, for example, +/// exposes spawnable host runtime endpoints: connecting to one creates a new +/// in-process `fw-host` runtime session. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkEndpoint { + /// Provider-local endpoint id used for status/connect operations. + pub id: LinkEndpointId, + /// Built-in provider kind that discovered and owns this endpoint. + pub provider_kind: LinkProviderKind, + /// Human-facing endpoint label, such as a serial port name. + pub label: String, + /// Last known endpoint availability state. + pub status: LinkEndpointStatus, + /// Link operations supported when this endpoint is connected. + pub capabilities: LinkCapabilities, +} + +impl LinkEndpoint { + pub fn new( + id: impl Into, + provider_kind: impl Into, + label: impl Into, + ) -> Self { + Self { + id: id.into(), + provider_kind: provider_kind.into(), + label: label.into(), + status: LinkEndpointStatus::Available, + capabilities: LinkCapabilities::default(), + } + } + + pub fn with_status(mut self, status: LinkEndpointStatus) -> Self { + self.status = status; + self + } + + pub fn with_capabilities(mut self, capabilities: LinkCapabilities) -> Self { + self.capabilities = capabilities; + self + } +} + +/// Opaque provider-scoped endpoint identity. +/// +/// Endpoint ids only need to be stable enough for the provider that returned +/// them to recognize later `status` and `connect` calls. They are not provider +/// identities; use `LinkEndpoint::provider_kind` for the provider class. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub struct LinkEndpointId(String); + +impl LinkEndpointId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for LinkEndpointId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for LinkEndpointId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +/// Provider-reported endpoint lifecycle state. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkEndpointStatus { + Available, + Launching, + Connected, + InUse, + Unavailable { reason: String }, + Error { message: String }, +} diff --git a/lp-app/lpa-link/src/provider/error.rs b/lp-app/lpa-link/src/provider/error.rs new file mode 100644 index 000000000..a15001e72 --- /dev/null +++ b/lp-app/lpa-link/src/provider/error.rs @@ -0,0 +1,66 @@ +use std::fmt::{self, Display}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LinkError { + EndpointNotFound { endpoint: String }, + SessionNotFound { session: String }, + OperationUnsupported { operation: String }, + ConnectionFailed { message: String }, + Cancelled { message: String }, + Closed, + Other { message: String }, +} + +impl LinkError { + pub fn endpoint_not_found(endpoint: impl Into) -> Self { + Self::EndpointNotFound { + endpoint: endpoint.into(), + } + } + + pub fn session_not_found(session: impl Into) -> Self { + Self::SessionNotFound { + session: session.into(), + } + } + + pub fn unsupported(operation: impl Into) -> Self { + Self::OperationUnsupported { + operation: operation.into(), + } + } + + pub fn cancelled(message: impl Into) -> Self { + Self::Cancelled { + message: message.into(), + } + } + + pub fn other(message: impl Into) -> Self { + Self::Other { + message: message.into(), + } + } +} + +impl Display for LinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EndpointNotFound { endpoint } => { + write!(f, "link endpoint not found: {endpoint}") + } + Self::SessionNotFound { session } => { + write!(f, "link session not found: {session}") + } + Self::OperationUnsupported { operation } => { + write!(f, "link operation unsupported: {operation}") + } + Self::ConnectionFailed { message } => write!(f, "link connection failed: {message}"), + Self::Cancelled { message } => f.write_str(message), + Self::Closed => write!(f, "link session is closed"), + Self::Other { message } => f.write_str(message), + } + } +} + +impl std::error::Error for LinkError {} diff --git a/lp-app/lpa-link/src/provider/log.rs b/lp-app/lpa-link/src/provider/log.rs new file mode 100644 index 000000000..56f0a8134 --- /dev/null +++ b/lp-app/lpa-link/src/provider/log.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +use crate::provider::endpoint::LinkEndpointId; +use crate::provider::session::LinkSessionId; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkLogEntry { + pub endpoint_id: LinkEndpointId, + pub session_id: Option, + pub level: LinkLogLevel, + pub message: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl LinkLogEntry { + pub fn new( + endpoint_id: impl Into, + session_id: Option, + level: LinkLogLevel, + message: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id, + level, + message: message.into(), + } + } +} diff --git a/lp-app/lpa-link/src/provider/management_event.rs b/lp-app/lpa-link/src/provider/management_event.rs new file mode 100644 index 000000000..cfe79d15c --- /dev/null +++ b/lp-app/lpa-link/src/provider/management_event.rs @@ -0,0 +1,116 @@ +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; + +use crate::{LinkManagementProgress, LinkManagementResult}; + +/// Live event emitted while a link management operation is running. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkManagementEvent { + Log { message: String }, + Progress(LinkManagementProgress), +} + +impl LinkManagementEvent { + pub fn log(message: impl Into) -> Self { + Self::Log { + message: message.into(), + } + } + + pub fn progress(progress: LinkManagementProgress) -> Self { + Self::Progress(progress) + } +} + +/// Cloneable in-process sink for live management events. +#[derive(Clone)] +pub struct LinkManagementEventSink { + on_event: Rc, +} + +impl LinkManagementEventSink { + pub fn new(on_event: impl Fn(LinkManagementEvent) + 'static) -> Self { + Self { + on_event: Rc::new(on_event), + } + } + + pub fn noop() -> Self { + Self::new(|_| {}) + } + + pub fn emit(&self, event: LinkManagementEvent) { + (self.on_event)(event); + } +} + +pub fn emit_management_result_events( + result: &LinkManagementResult, + sink: &LinkManagementEventSink, +) { + for message in result.logs() { + sink.emit(LinkManagementEvent::log(message.clone())); + } + for progress in result.progress() { + sink.emit(LinkManagementEvent::progress(progress.clone())); + } +} + +impl LinkManagementResult { + fn logs(&self) -> &[String] { + match self { + Self::ResetRuntime => &[], + Self::FlashFirmware(result) => &result.logs, + Self::EraseDeviceFlash(result) => &result.logs, + Self::EraseRawFilesystem(result) => &result.logs, + } + } + + fn progress(&self) -> &[LinkManagementProgress] { + match self { + Self::ResetRuntime => &[], + Self::FlashFirmware(result) => &result.progress, + Self::EraseDeviceFlash(result) => &result.progress, + Self::EraseRawFilesystem(result) => &result.progress, + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use crate::{LinkEraseDeviceResult, LinkManagementEvent, LinkManagementProgress}; + + use super::*; + + #[test] + fn result_events_replay_logs_then_progress() { + let events = Rc::new(RefCell::new(Vec::new())); + let sink = LinkManagementEventSink::new({ + let events = Rc::clone(&events); + move |event| { + events.borrow_mut().push(event); + } + }); + let result = LinkManagementResult::EraseDeviceFlash(LinkEraseDeviceResult { + chip_name: Some("ESP32-C6".to_string()), + logs: vec!["bootloader connected".to_string()], + progress: vec![LinkManagementProgress::new("Erasing").with_percent(50)], + }); + + emit_management_result_events(&result, &sink); + + assert_eq!( + *events.borrow(), + vec![ + LinkManagementEvent::log("bootloader connected"), + LinkManagementEvent::progress( + LinkManagementProgress::new("Erasing").with_percent(50) + ) + ] + ); + } +} diff --git a/lp-app/lpa-link/src/provider/management_progress.rs b/lp-app/lpa-link/src/provider/management_progress.rs new file mode 100644 index 000000000..d858c6a0b --- /dev/null +++ b/lp-app/lpa-link/src/provider/management_progress.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// One progress entry produced by a link management operation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkManagementProgress { + pub label: String, + pub completed_steps: u32, + pub total_steps: Option, + pub percent: Option, +} + +impl LinkManagementProgress { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + completed_steps: 0, + total_steps: None, + percent: None, + } + } + + pub fn with_steps(mut self, completed_steps: u32, total_steps: impl Into>) -> Self { + self.completed_steps = completed_steps; + self.total_steps = total_steps.into(); + self + } + + pub fn with_percent(mut self, percent: u32) -> Self { + self.percent = Some(percent); + self + } +} diff --git a/lp-app/lpa-link/src/provider/management_request.rs b/lp-app/lpa-link/src/provider/management_request.rs new file mode 100644 index 000000000..5d8ab1cd5 --- /dev/null +++ b/lp-app/lpa-link/src/provider/management_request.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +use crate::LinkOperation; + +/// Provider-neutral request for a low-level link management operation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkManagementRequest { + /// Reset or reboot the endpoint/runtime without erasing user data. + ResetRuntime, + /// Flash the provider's configured firmware image. + FlashFirmware, + /// Erase device flash so the endpoint returns to a blank state. + EraseDeviceFlash, + /// Erase the raw device filesystem partition below the running server. + EraseRawFilesystem, +} + +impl LinkManagementRequest { + pub fn operation(&self) -> LinkOperation { + match self { + Self::ResetRuntime => LinkOperation::Reset, + Self::FlashFirmware => LinkOperation::FlashFirmware, + Self::EraseDeviceFlash => LinkOperation::EraseDeviceFlash, + Self::EraseRawFilesystem => LinkOperation::WriteRawFilesystem, + } + } +} diff --git a/lp-app/lpa-link/src/provider/management_result.rs b/lp-app/lpa-link/src/provider/management_result.rs new file mode 100644 index 000000000..45695af11 --- /dev/null +++ b/lp-app/lpa-link/src/provider/management_result.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::LinkManagementProgress; + +/// Firmware image summary reported by a provider management operation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkFirmwareManifest { + pub firmware_id: String, + pub display_name: String, + pub target_chip: String, + pub image_count: u32, + pub total_bytes: u32, + pub manifest_path: Option, +} + +/// Result of flashing firmware through a link provider. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkFirmwareFlashResult { + pub manifest: LinkFirmwareManifest, + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + +/// Result of erasing an endpoint back to a blank state. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkEraseDeviceResult { + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + +/// Result of erasing a raw device filesystem partition. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkRawFilesystemEraseResult { + pub logs: Vec, + pub progress: Vec, +} + +/// Provider-neutral result from a link management operation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkManagementResult { + ResetRuntime, + FlashFirmware(LinkFirmwareFlashResult), + EraseDeviceFlash(LinkEraseDeviceResult), + EraseRawFilesystem(LinkRawFilesystemEraseResult), +} diff --git a/lp-app/lpa-link/src/provider/mod.rs b/lp-app/lpa-link/src/provider/mod.rs new file mode 100644 index 000000000..934231c1b --- /dev/null +++ b/lp-app/lpa-link/src/provider/mod.rs @@ -0,0 +1,23 @@ +//! Provider-facing link model and controller trait. +//! +//! This module defines the provider-neutral vocabulary used by every concrete +//! link implementation: endpoints that can be discovered, sessions that are +//! opened from endpoints, protocol connections handed to higher layers, logs, +//! diagnostics, capabilities, and the `LinkProvider` controller trait. +//! +//! The types here are lightweight records and ids. Concrete resources such as +//! serial ports, browser workers, spawned host runtimes, and protocol handles +//! stay owned by the provider implementation that created them. + +pub mod connection; +pub mod diagnostic; +pub mod endpoint; +pub mod error; +pub mod log; +pub mod management_event; +pub mod management_progress; +pub mod management_request; +pub mod management_result; +pub mod operation; +pub mod provider; +pub mod session; diff --git a/lp-app/lpa-link/src/provider/operation.rs b/lp-app/lpa-link/src/provider/operation.rs new file mode 100644 index 000000000..dc1e46581 --- /dev/null +++ b/lp-app/lpa-link/src/provider/operation.rs @@ -0,0 +1,84 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +/// Low-level operation a link endpoint/session may be able to perform. +/// +/// These operations are below Studio product actions and below the `lp-server` +/// protocol. For example, project upload is a server filesystem operation, while +/// raw filesystem image access is a link operation. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub enum LinkOperation { + /// Reset or reboot the endpoint/runtime. + Reset, + /// Flash firmware onto the endpoint. + FlashFirmware, + /// Erase the endpoint flash so the device returns to a blank state. + EraseDeviceFlash, + /// Read the raw filesystem image below the running server. + ReadRawFilesystem, + /// Write the raw filesystem image below the running server. + WriteRawFilesystem, + /// Read low-level logs from the endpoint/link. + ReadLogs, + /// Read low-level diagnostics from the endpoint/link. + ReadDiagnostics, +} + +/// Set of low-level link operations advertised by an endpoint. +/// +/// This is intentionally a set of `LinkOperation` values rather than one bool +/// per operation so Studio UX, UI shells, and future agents can inspect and +/// present the operation surface generically. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkCapabilities { + operations: BTreeSet, +} + +impl LinkCapabilities { + pub fn new(operations: impl IntoIterator) -> Self { + Self { + operations: operations.into_iter().collect(), + } + } + + pub fn operations(&self) -> impl Iterator + '_ { + self.operations.iter().copied() + } + + pub fn diagnostics_only() -> Self { + Self::default().with(LinkOperation::ReadDiagnostics) + } + + pub fn supports(&self, operation: LinkOperation) -> bool { + self.operations.contains(&operation) + } + + pub fn with(mut self, operation: LinkOperation) -> Self { + self.operations.insert(operation); + self + } + + pub fn esp32_serial_base() -> Self { + Self::default() + .with(LinkOperation::Reset) + .with(LinkOperation::ReadLogs) + .with(LinkOperation::ReadDiagnostics) + } + + pub fn with_flash(mut self) -> Self { + self.operations.insert(LinkOperation::FlashFirmware); + self + } + + pub fn with_device_erase(mut self) -> Self { + self.operations.insert(LinkOperation::EraseDeviceFlash); + self + } + + pub fn with_raw_filesystem(mut self) -> Self { + self.operations.insert(LinkOperation::ReadRawFilesystem); + self.operations.insert(LinkOperation::WriteRawFilesystem); + self + } +} diff --git a/lp-app/lpa-link/src/provider/provider.rs b/lp-app/lpa-link/src/provider/provider.rs new file mode 100644 index 000000000..091ca5546 --- /dev/null +++ b/lp-app/lpa-link/src/provider/provider.rs @@ -0,0 +1,85 @@ +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::management_event::{LinkManagementEventSink, emit_management_result_events}; +use crate::provider::management_request::LinkManagementRequest; +use crate::provider::management_result::LinkManagementResult; +use crate::provider::session::LinkSessionId; +use crate::providers::LinkProviderKind; +use crate::{LinkConnection, LinkDiagnostic, LinkEndpoint, LinkError, LinkLogEntry, LinkSession}; + +/// Controller interface for one built-in link provider. +/// +/// A provider owns the resources for a single `LinkProviderKind`: discovered +/// endpoints, live sessions, serial ports, workers, spawned runtimes, and any +/// provider-specific management state. Callers hold lightweight endpoint and +/// session records and pass their ids back into the provider for follow-up +/// operations. +/// +/// The trait is intentionally not used as a trait object today because async +/// trait methods are not object-safe. `LinkProviderRegistry` stores concrete +/// providers through `LinkProviderInstance`, an enum-dispatched wrapper that +/// still exposes this same controller interface. +#[allow(async_fn_in_trait, reason = "Link providers are not object-safe yet")] +pub trait LinkProvider { + /// Stable built-in provider kind, such as `host-process` or `browser-serial-esp32`. + fn kind(&self) -> LinkProviderKind; + + /// Discover endpoints currently offered by this provider. + /// + /// Providers may return physical endpoints, such as an ESP32 serial port, + /// or spawnable endpoints, such as `host-process` memory runtimes. + async fn discover(&mut self) -> Result, LinkError>; + + /// Return the current status for a previously discovered endpoint. + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result; + + /// Open a live session from a discovered endpoint. + /// + /// The provider owns the concrete resources behind the returned session + /// record. Use the session id with `connection`, `logs`, `diagnostics`, and + /// `close` for provider-owned follow-up operations. + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result; + + /// Open or return the client connection associated with a live session. + async fn connection(&mut self, session_id: &LinkSessionId) + -> Result; + + /// Link-level logs available through the provider-owned session. + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError>; + + /// Link-level diagnostics available through the provider-owned session. + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError>; + + /// Execute a low-level management operation through a live session. + /// + /// Providers that do not support the requested operation should return + /// `LinkError::OperationUnsupported`. Management operations are below the + /// `lp-server` protocol and may invalidate any server connection opened from + /// the same session. + async fn manage( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + ) -> Result { + let _ = session_id; + Err(LinkError::unsupported(format!("{:?}", request.operation()))) + } + + /// Execute a low-level management operation and publish live progress where + /// the provider can observe it. + async fn manage_with_events( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + events: LinkManagementEventSink, + ) -> Result { + let result = self.manage(session_id, request).await?; + emit_management_result_events(&result, &events); + Ok(result) + } + + /// Close provider-owned resources for a live session. + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError>; +} diff --git a/lp-app/lpa-link/src/provider/session.rs b/lp-app/lpa-link/src/provider/session.rs new file mode 100644 index 000000000..f1f971e7d --- /dev/null +++ b/lp-app/lpa-link/src/provider/session.rs @@ -0,0 +1,102 @@ +use crate::LinkCapabilities; +use crate::provider::connection::LinkConnectionKind; +use crate::provider::endpoint::LinkEndpointId; +use crate::providers::LinkProviderKind; +use serde::{Deserialize, Serialize}; + +/// Provider-neutral snapshot of a live link session. +/// +/// A session begins when a provider successfully connects to a `LinkEndpoint`. +/// The concrete resources below the session, such as browser serial ports, +/// workers, spawned host runtimes, and protocol streams, remain owned by the +/// provider that created the session. +/// +/// A `LinkSession` is not itself the `lp-server` client protocol and does not +/// own resources directly. Call `LinkProvider::connection()` with the session id +/// when the caller needs the protocol handoff. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkSession { + /// Provider-scoped session id used for connection/log/close operations. + pub id: LinkSessionId, + /// Built-in provider kind that owns the concrete resources for this session. + pub provider_kind: LinkProviderKind, + /// Endpoint that was connected to create this session. + pub endpoint_id: LinkEndpointId, + /// Protocol/transport shape exposed by the session. + pub connection_kind: LinkConnectionKind, + /// Operations available through this live session. + pub capabilities: LinkCapabilities, + /// Provider-neutral session lifecycle state. + pub status: LinkSessionStatus, +} + +impl LinkSession { + pub fn new( + id: impl Into, + provider_kind: impl Into, + endpoint_id: impl Into, + connection_kind: LinkConnectionKind, + capabilities: LinkCapabilities, + ) -> Self { + Self { + id: id.into(), + provider_kind: provider_kind.into(), + endpoint_id: endpoint_id.into(), + connection_kind, + capabilities, + status: LinkSessionStatus::Open, + } + } + + pub fn id(&self) -> &LinkSessionId { + &self.id + } + + pub fn endpoint_id(&self) -> &LinkEndpointId { + &self.endpoint_id + } + + pub fn with_status(mut self, status: LinkSessionStatus) -> Self { + self.status = status; + self + } +} + +/// Opaque provider-scoped live session identity. +/// +/// The provider that created a session owns the underlying resources. Pass this +/// id back to that provider to request a protocol connection, logs, +/// diagnostics, or resource cleanup. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub struct LinkSessionId(String); + +impl LinkSessionId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for LinkSessionId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for LinkSessionId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +/// Provider-neutral live session lifecycle state. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkSessionStatus { + Open, + Closing, + Closed, + Error { message: String }, +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js new file mode 100644 index 000000000..92766875a --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.js @@ -0,0 +1,313 @@ +import { getPort, releasePort } from "./browser_serial.js"; + +export function isSupported() { + return Boolean(globalThis.navigator?.serial && globalThis.fetch); +} + +export async function loadManifest(manifestPath) { + const manifest = await loadFullManifest(manifestPath); + return summarizeManifest(manifest, manifestPath); +} + +export async function probeTarget(portId, esptoolModulePath) { + if (!isSupported()) { + throw new Error("Web Serial ESP32 probing is not supported in this browser."); + } + + const port = getPort(portId); + await releasePort(portId); + + const { ESPLoader, Transport } = await loadEsptoolModule(esptoolModulePath); + const logs = []; + const terminal = terminalFor(logs, "esp32-probe"); + const transport = new Transport(port, true); + const loader = new ESPLoader({ + transport, + baudrate: 115200, + terminal, + debugLogging: false, + }); + + try { + const chipName = await loader.main(); + await loader.after("hard_reset"); + return { + chipName: chipName ? String(chipName) : null, + logs, + }; + } finally { + try { + await transport.disconnect(); + } catch (error) { + console.warn("[esp32-probe] transport disconnect failed", error); + } + } +} + +export async function flashFirmware(portId, manifestPath, esptoolModulePath, onEvent) { + if (!isSupported()) { + throw new Error("Web Serial firmware flashing is not supported in this browser."); + } + + const port = getPort(portId); + await releasePort(portId); + + const manifest = await loadFullManifest(manifestPath); + const imageFiles = await loadImageFiles(manifest, manifestPath); + const { ESPLoader, Transport } = await loadEsptoolModule(esptoolModulePath); + const logs = []; + const progress = []; + const terminal = terminalFor(logs, "esp32-flash", onEvent); + const transport = new Transport(port, true); + const loader = new ESPLoader({ + transport, + baudrate: manifest.flash?.baudRate ?? 115200, + terminal, + debugLogging: false, + }); + + try { + const chipName = await loader.main(); + pushProgress(progress, onEvent, { + label: "Connected to ESP32 bootloader", + completedSteps: 1, + totalSteps: 3, + percent: 10, + }); + await loader.writeFlash({ + fileArray: imageFiles.map((image) => ({ + data: image.data, + address: image.address, + })), + flashSize: "keep", + flashMode: "keep", + flashFreq: "keep", + eraseAll: false, + compress: true, + reportProgress: (fileIndex, written, total) => { + const percent = total > 0 ? Math.round((written / total) * 100) : 0; + pushProgress(progress, onEvent, { + label: `Writing firmware image ${fileIndex + 1}/${imageFiles.length}`, + completedSteps: 2, + totalSteps: 3, + percent, + }); + }, + }); + pushProgress(progress, onEvent, { + label: "Resetting flashed device", + completedSteps: 3, + totalSteps: 3, + percent: 100, + }); + await loader.after("hard_reset"); + return { + manifest: summarizeManifest(manifest, manifestPath), + chipName: chipName ? String(chipName) : null, + logs, + progress: compactProgress(progress), + }; + } finally { + try { + await transport.disconnect(); + } catch (error) { + console.warn("[esp32-flash] transport disconnect failed", error); + } + } +} + +export async function eraseDeviceFlash(portId, esptoolModulePath, onEvent) { + if (!isSupported()) { + throw new Error("Web Serial device erase is not supported in this browser."); + } + + const port = getPort(portId); + await releasePort(portId); + + const { ESPLoader, Transport } = await loadEsptoolModule(esptoolModulePath); + const logs = []; + const progress = []; + const terminal = terminalFor(logs, "esp32-erase", onEvent); + const transport = new Transport(port, true); + const loader = new ESPLoader({ + transport, + baudrate: 115200, + terminal, + debugLogging: false, + }); + + try { + const chipName = await loader.main(); + pushProgress(progress, onEvent, { + label: "Connected to ESP32 bootloader", + completedSteps: 1, + totalSteps: 3, + percent: 10, + }); + pushProgress(progress, onEvent, { + label: "Erasing device flash", + completedSteps: 2, + totalSteps: 3, + percent: 50, + }); + await loader.eraseFlash(); + assertNoFlashCommunicationWarning(logs, "Device erase"); + pushProgress(progress, onEvent, { + label: "Device flash erased", + completedSteps: 3, + totalSteps: 3, + percent: 100, + }); + return { + chipName: chipName ? String(chipName) : null, + logs, + progress: compactProgress(progress), + }; + } finally { + try { + await transport.disconnect(); + } catch (error) { + console.warn("[esp32-erase] transport disconnect failed", error); + } + } +} + +function assertNoFlashCommunicationWarning(logs, context) { + const warning = logs.find((line) => + line.includes("Failed to communicate with the flash chip") || + line.includes("Flash ID: 0") + ); + if (warning) { + throw new Error(`${context} failed: ${warning}`); + } +} + +function terminalFor(logs, target, onEvent) { + return { + clean() {}, + writeLine(line) { + const message = String(line ?? ""); + logs.push(message); + emitEvent(onEvent, { kind: "log", message }); + console.info(`[${target}] ${message}`); + }, + write(text) { + const message = String(text ?? "").trimEnd(); + if (message.length > 0) { + logs.push(message); + emitEvent(onEvent, { kind: "log", message }); + console.info(`[${target}] ${message}`); + } + }, + }; +} + +function pushProgress(progress, onEvent, entry) { + const normalized = { + label: String(entry.label ?? ""), + completedSteps: Number(entry.completedSteps ?? 0), + totalSteps: entry.totalSteps == null ? null : Number(entry.totalSteps), + percent: entry.percent == null ? null : Number(entry.percent), + }; + const previous = progress.at(-1); + if ( + previous && + previous.label === normalized.label && + previous.completedSteps === normalized.completedSteps && + previous.totalSteps === normalized.totalSteps && + previous.percent === normalized.percent + ) { + return; + } + progress.push(normalized); + emitEvent(onEvent, { kind: "progress", ...normalized }); +} + +function emitEvent(onEvent, event) { + if (typeof onEvent === "function") { + onEvent(event); + } +} + +async function loadFullManifest(manifestPath) { + const response = await fetch(manifestPath, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Firmware manifest is unavailable (${response.status} ${response.statusText}).`); + } + const manifest = await response.json(); + validateManifest(manifest); + return manifest; +} + +async function loadImageFiles(manifest, manifestPath) { + const basePath = new URL(manifestPath, globalThis.location?.href ?? "http://localhost/"); + return Promise.all( + manifest.images.map(async (image) => { + const response = await fetch(new URL(image.path, basePath), { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Firmware image ${image.path} is unavailable (${response.status} ${response.statusText}).`); + } + return { + address: parseAddress(image.address), + data: new Uint8Array(await response.arrayBuffer()), + }; + }), + ); +} + +async function loadEsptoolModule(esptoolModulePath) { + if (!esptoolModulePath) { + throw new Error("Missing esptool_module_path."); + } + return import(esptoolModulePath); +} + +function summarizeManifest(manifest, manifestPath) { + return { + firmwareId: String(manifest.firmwareId), + displayName: String(manifest.displayName ?? manifest.firmwareId), + targetChip: String(manifest.target?.chip ?? "esp32c6"), + imageCount: manifest.images.length, + totalBytes: manifest.images.reduce((total, image) => total + Number(image.sizeBytes ?? 0), 0), + manifestPath, + }; +} + +function compactProgress(progress) { + const compacted = []; + let previousKey = null; + for (const entry of progress) { + const key = `${entry.label}:${entry.percent}`; + if (key === previousKey) { + continue; + } + previousKey = key; + compacted.push(entry); + } + return compacted; +} + +function validateManifest(manifest) { + if (!manifest || typeof manifest !== "object") { + throw new Error("Firmware manifest is not a JSON object."); + } + if (typeof manifest.firmwareId !== "string") { + throw new Error("Firmware manifest is missing firmwareId."); + } + if (!Array.isArray(manifest.images) || manifest.images.length === 0) { + throw new Error("Firmware manifest does not list any flash images."); + } + for (const image of manifest.images) { + if (typeof image.path !== "string" || typeof image.address !== "string") { + throw new Error("Firmware manifest image entries must include path and address."); + } + } +} + +function parseAddress(address) { + const value = Number(address); + if (!Number.isInteger(value)) { + throw new Error(`Firmware image address is invalid: ${address}`); + } + return value; +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs new file mode 100644 index 000000000..c2479fdfd --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs @@ -0,0 +1,254 @@ +use js_sys::{Array, Function, Promise, Reflect}; +use wasm_bindgen::{JsCast, closure::Closure, prelude::*}; +use wasm_bindgen_futures::JsFuture; + +use crate::{LinkError, LinkManagementEvent, LinkManagementEventSink, LinkManagementProgress}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserEsp32FirmwareManifest { + pub firmware_id: String, + pub display_name: String, + pub target_chip: String, + pub image_count: u32, + pub total_bytes: u32, + pub manifest_path: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BrowserEsp32FlashResult { + pub manifest: BrowserEsp32FirmwareManifest, + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BrowserEsp32EraseResult { + pub chip_name: Option, + pub logs: Vec, + pub progress: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserEsp32FlashProgress { + pub label: String, + pub completed_steps: u32, + pub total_steps: Option, + pub percent: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserEsp32ProbeResult { + pub chip_name: Option, + pub logs: Vec, +} + +#[wasm_bindgen(module = "/src/providers/browser_serial_esp32/browser_esp32_flash.js")] +extern "C" { + #[wasm_bindgen(js_name = isSupported)] + fn js_is_supported() -> bool; + + #[wasm_bindgen(js_name = loadManifest)] + fn js_load_manifest(manifest_path: &str) -> Promise; + + #[wasm_bindgen(js_name = probeTarget)] + fn js_probe_target(port_id: u32, esptool_module_path: &str) -> Promise; + + #[wasm_bindgen(js_name = flashFirmware)] + fn js_flash_firmware( + port_id: u32, + manifest_path: &str, + esptool_module_path: &str, + on_event: &Function, + ) -> Promise; + + #[wasm_bindgen(js_name = eraseDeviceFlash)] + fn js_erase_device_flash( + port_id: u32, + esptool_module_path: &str, + on_event: &Function, + ) -> Promise; +} + +pub fn is_supported() -> bool { + js_is_supported() +} + +pub async fn load_manifest(manifest_path: &str) -> Result { + let value = JsFuture::from(js_load_manifest(manifest_path)) + .await + .map_err(js_error)?; + parse_manifest(&value) +} + +pub async fn flash_firmware_with_events( + port_id: u32, + manifest_path: &str, + esptool_module_path: &str, + events: LinkManagementEventSink, +) -> Result { + let on_event = management_event_callback(events); + let value = JsFuture::from(js_flash_firmware( + port_id, + manifest_path, + esptool_module_path, + on_event.as_ref().unchecked_ref(), + )) + .await + .map_err(js_error)?; + let manifest_value = reflect_value(&value, "manifest")?; + Ok(BrowserEsp32FlashResult { + manifest: parse_manifest(&manifest_value)?, + chip_name: reflect_optional_string(&value, "chipName")?, + logs: reflect_string_array(&value, "logs")?, + progress: reflect_progress_array(&value, "progress")?, + }) +} + +pub async fn erase_device_flash_with_events( + port_id: u32, + esptool_module_path: &str, + events: LinkManagementEventSink, +) -> Result { + let on_event = management_event_callback(events); + let value = JsFuture::from(js_erase_device_flash( + port_id, + esptool_module_path, + on_event.as_ref().unchecked_ref(), + )) + .await + .map_err(js_error)?; + Ok(BrowserEsp32EraseResult { + chip_name: reflect_optional_string(&value, "chipName")?, + logs: reflect_string_array(&value, "logs")?, + progress: reflect_progress_array(&value, "progress")?, + }) +} + +pub async fn probe_target( + port_id: u32, + esptool_module_path: &str, +) -> Result { + let value = JsFuture::from(js_probe_target(port_id, esptool_module_path)) + .await + .map_err(js_error)?; + Ok(BrowserEsp32ProbeResult { + chip_name: reflect_optional_string(&value, "chipName")?, + logs: reflect_string_array(&value, "logs")?, + }) +} + +fn management_event_callback(events: LinkManagementEventSink) -> Closure { + Closure::wrap(Box::new(move |value: JsValue| match parse_event(&value) { + Ok(event) => events.emit(event), + Err(error) => events.emit(LinkManagementEvent::log(format!( + "failed to parse browser ESP32 progress event: {error}" + ))), + }) as Box) +} + +fn parse_event(value: &JsValue) -> Result { + match reflect_string(value, "kind")?.as_str() { + "log" => Ok(LinkManagementEvent::log(reflect_string(value, "message")?)), + "progress" => Ok(LinkManagementEvent::progress(LinkManagementProgress { + label: reflect_string(value, "label")?, + completed_steps: reflect_optional_u32(value, "completedSteps")?.unwrap_or(0), + total_steps: reflect_optional_u32(value, "totalSteps")?, + percent: reflect_optional_u32(value, "percent")?, + })), + kind => Err(LinkError::other(format!( + "unknown browser ESP32 progress event kind `{kind}`" + ))), + } +} + +fn parse_manifest(value: &JsValue) -> Result { + Ok(BrowserEsp32FirmwareManifest { + firmware_id: reflect_string(value, "firmwareId")?, + display_name: reflect_string(value, "displayName")?, + target_chip: reflect_string(value, "targetChip")?, + image_count: reflect_u32(value, "imageCount")?, + total_bytes: reflect_u32(value, "totalBytes")?, + manifest_path: reflect_optional_string(value, "manifestPath")?, + }) +} + +fn reflect_progress_array( + value: &JsValue, + key: &str, +) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(Vec::new()); + } + let array = Array::from(&value); + let mut progress = Vec::with_capacity(array.length() as usize); + for entry in array.iter() { + progress.push(BrowserEsp32FlashProgress { + label: reflect_string(&entry, "label")?, + completed_steps: reflect_optional_u32(&entry, "completedSteps")?.unwrap_or(0), + total_steps: reflect_optional_u32(&entry, "totalSteps")?, + percent: reflect_optional_u32(&entry, "percent")?, + }); + } + Ok(progress) +} + +fn reflect_string_array(value: &JsValue, key: &str) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(Vec::new()); + } + Ok(Array::from(&value) + .iter() + .filter_map(|value| value.as_string()) + .collect()) +} + +fn reflect_value(value: &JsValue, key: &str) -> Result { + Reflect::get(value, &JsValue::from_str(key)).map_err(js_error) +} + +fn reflect_u32(value: &JsValue, key: &str) -> Result { + reflect_optional_u32(value, key)? + .ok_or_else(|| LinkError::other(format!("browser ESP32 response missing numeric `{key}`"))) +} + +fn reflect_optional_u32(value: &JsValue, key: &str) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + let Some(value) = value.as_f64() else { + return Err(LinkError::other(format!( + "browser ESP32 response `{key}` is not numeric" + ))); + }; + Ok(Some(value as u32)) +} + +fn reflect_string(value: &JsValue, key: &str) -> Result { + reflect_optional_string(value, key)? + .ok_or_else(|| LinkError::other(format!("browser ESP32 response missing string `{key}`"))) +} + +fn reflect_optional_string(value: &JsValue, key: &str) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .as_string() + .map(Some) + .ok_or_else(|| LinkError::other(format!("browser ESP32 response `{key}` is not a string"))) +} + +fn js_error(value: JsValue) -> LinkError { + if let Some(error) = value.dyn_ref::() { + LinkError::other(error.message()) + } else if let Some(message) = value.as_string() { + LinkError::other(message) + } else { + LinkError::other(format!("{value:?}")) + } +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js new file mode 100644 index 000000000..6b33c2a8f --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js @@ -0,0 +1,60 @@ +import { BrowserEsp32DeviceController } from "/lpa-link/browser_esp32_device_controller.js"; + +const sessions = new Map(); +let nextSessionId = 1; + +export function isSupported() { + return BrowserEsp32DeviceController.isSupported(); +} + +export async function requestPort() { + const { port, label } = await BrowserEsp32DeviceController.requestPort(); + const id = nextSessionId++; + sessions.set(id, new BrowserEsp32DeviceController({ port, label })); + return { id, label }; +} + +export async function openPort(id, baudRate) { + return requireSession(id).openProtocol({ baudRate }); +} + +export async function writeLine(id, line) { + await requireSession(id).writeLine(line); +} + +export function takeLines(id) { + return requireSession(id).takeLines(); +} + +export function takeErrors(id) { + return requireSession(id).takeErrors(); +} + +export async function closePort(id) { + const session = sessions.get(id); + if (!session) { + return; + } + await session.close(); + sessions.delete(id); +} + +export async function releasePort(id) { + const session = sessions.get(id); + if (!session) { + return; + } + await session.releaseProtocol(); +} + +export function getPort(id) { + return requireSession(id).port; +} + +function requireSession(id) { + const session = sessions.get(id); + if (!session) { + throw new Error(`Unknown browser serial session: ${id}`); + } + return session; +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs new file mode 100644 index 000000000..dbc286380 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs @@ -0,0 +1,216 @@ +use js_sys::{Array, Promise, Reflect}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use crate::LinkError; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserSerialPortHandle { + pub id: u32, + pub label: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserSerialProtocolOpenResult { + pub logs: Vec, + pub progress: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserSerialProtocolProgress { + pub label: String, + pub completed_steps: u32, + pub total_steps: Option, + pub percent: Option, +} + +#[wasm_bindgen(module = "/src/providers/browser_serial_esp32/browser_serial.js")] +extern "C" { + #[wasm_bindgen(js_name = isSupported)] + fn js_is_supported() -> bool; + + #[wasm_bindgen(js_name = requestPort)] + fn js_request_port() -> Promise; + + #[wasm_bindgen(js_name = openPort)] + fn js_open(id: u32, baud_rate: u32) -> Promise; + + #[wasm_bindgen(js_name = writeLine)] + fn js_write_line(id: u32, line: &str) -> Promise; + + #[wasm_bindgen(js_name = takeLines)] + fn js_take_lines(id: u32) -> Array; + + #[wasm_bindgen(js_name = takeErrors)] + fn js_take_errors(id: u32) -> Array; + + #[wasm_bindgen(js_name = releasePort)] + fn js_release(id: u32) -> Promise; + + #[wasm_bindgen(js_name = closePort)] + fn js_close(id: u32) -> Promise; +} + +pub fn is_supported() -> bool { + js_is_supported() +} + +pub async fn request_port() -> Result { + let value = JsFuture::from(js_request_port()) + .await + .map_err(js_request_port_error)?; + let id = reflect_u32(&value, "id")?; + let label = reflect_string(&value, "label")?; + Ok(BrowserSerialPortHandle { id, label }) +} + +pub async fn open(id: u32, baud_rate: u32) -> Result { + let value = JsFuture::from(js_open(id, baud_rate)) + .await + .map_err(js_error)?; + Ok(BrowserSerialProtocolOpenResult { + logs: reflect_string_array(&value, "logs")?, + progress: reflect_progress_array(&value, "progress")?, + }) +} + +pub async fn write_line(id: u32, line: &str) -> Result<(), LinkError> { + JsFuture::from(js_write_line(id, line)) + .await + .map(|_| ()) + .map_err(js_error) +} + +pub fn take_lines(id: u32) -> Vec { + js_array_to_strings(js_take_lines(id)) +} + +pub fn take_errors(id: u32) -> Vec { + js_array_to_strings(js_take_errors(id)) +} + +pub async fn release(id: u32) -> Result<(), LinkError> { + JsFuture::from(js_release(id)) + .await + .map(|_| ()) + .map_err(js_error) +} + +pub async fn close(id: u32) -> Result<(), LinkError> { + JsFuture::from(js_close(id)) + .await + .map(|_| ()) + .map_err(js_error) +} + +fn js_array_to_strings(array: Array) -> Vec { + array.iter().filter_map(|value| value.as_string()).collect() +} + +fn reflect_progress_array( + value: &JsValue, + key: &str, +) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(Vec::new()); + } + let array = Array::from(&value); + let mut progress = Vec::with_capacity(array.length() as usize); + for entry in array.iter() { + progress.push(BrowserSerialProtocolProgress { + label: reflect_string(&entry, "label")?, + completed_steps: reflect_optional_u32(&entry, "completedSteps")?.unwrap_or(0), + total_steps: reflect_optional_u32(&entry, "totalSteps")?, + percent: reflect_optional_u32(&entry, "percent")?, + }); + } + Ok(progress) +} + +fn reflect_string_array(value: &JsValue, key: &str) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(Vec::new()); + } + Ok(Array::from(&value) + .iter() + .filter_map(|value| value.as_string()) + .collect()) +} + +fn reflect_value(value: &JsValue, key: &str) -> Result { + Reflect::get(value, &JsValue::from_str(key)).map_err(js_error) +} + +fn reflect_u32(value: &JsValue, key: &str) -> Result { + let value = Reflect::get(value, &JsValue::from_str(key)).map_err(js_error)?; + let Some(value) = value.as_f64() else { + return Err(LinkError::other(format!( + "browser serial response missing numeric `{key}`" + ))); + }; + Ok(value as u32) +} + +fn reflect_string(value: &JsValue, key: &str) -> Result { + reflect_optional_string(value, key)? + .ok_or_else(|| LinkError::other(format!("browser serial response missing string `{key}`"))) +} + +fn reflect_optional_u32(value: &JsValue, key: &str) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + let Some(value) = value.as_f64() else { + return Err(LinkError::other(format!( + "browser serial response `{key}` is not numeric" + ))); + }; + Ok(Some(value as u32)) +} + +fn reflect_optional_string(value: &JsValue, key: &str) -> Result, LinkError> { + let value = reflect_value(value, key)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .as_string() + .map(Some) + .ok_or_else(|| LinkError::other(format!("browser serial response `{key}` is not a string"))) +} + +fn js_request_port_error(value: JsValue) -> LinkError { + let message = js_error_message(&value); + if is_request_port_cancel(js_error_name(&value).as_deref(), &message) { + LinkError::cancelled("Port selection canceled") + } else { + LinkError::other(message) + } +} + +fn js_error(value: JsValue) -> LinkError { + LinkError::other(js_error_message(&value)) +} + +fn js_error_message(value: &JsValue) -> String { + if let Some(error) = value.dyn_ref::() { + error.message().into() + } else if let Some(message) = value.as_string() { + message + } else { + format!("{value:?}") + } +} + +fn js_error_name(value: &JsValue) -> Option { + Reflect::get(value, &JsValue::from_str("name")) + .ok() + .and_then(|name| name.as_string()) +} + +fn is_request_port_cancel(name: Option<&str>, message: &str) -> bool { + matches!(name, Some("NotFoundError")) || message.contains("No port selected by the user") +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial_esp32_options.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial_esp32_options.rs new file mode 100644 index 000000000..539a3673e --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial_esp32_options.rs @@ -0,0 +1,33 @@ +pub const DEFAULT_ESP32C6_FIRMWARE_MANIFEST_PATH: &str = "./firmware/esp32c6/manifest.json"; +pub const DEFAULT_ESPTOOL_MODULE_PATH: &str = "https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserSerialEsp32Options { + pub firmware_manifest_path: String, + pub esptool_module_path: Option, +} + +impl BrowserSerialEsp32Options { + pub fn new(firmware_manifest_path: impl Into) -> Self { + Self { + firmware_manifest_path: firmware_manifest_path.into(), + esptool_module_path: None, + } + } + + pub fn with_esptool_module_path(mut self, esptool_module_path: impl Into) -> Self { + self.esptool_module_path = Some(esptool_module_path.into()); + self + } + + pub(crate) fn esptool_module_path(&self) -> &str { + self.esptool_module_path.as_deref().unwrap_or("") + } +} + +impl Default for BrowserSerialEsp32Options { + fn default() -> Self { + Self::new(DEFAULT_ESP32C6_FIRMWARE_MANIFEST_PATH) + .with_esptool_module_path(DEFAULT_ESPTOOL_MODULE_PATH) + } +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs new file mode 100644 index 000000000..8968a1562 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/mod.rs @@ -0,0 +1,17 @@ +mod browser_esp32_flash; +mod browser_serial; +mod browser_serial_esp32_options; +mod provider; + +pub use browser_esp32_flash::{ + BrowserEsp32EraseResult, BrowserEsp32FirmwareManifest, BrowserEsp32FlashProgress, + BrowserEsp32FlashResult, BrowserEsp32ProbeResult, +}; +pub use browser_serial::BrowserSerialPortHandle; +pub use browser_serial_esp32_options::{ + BrowserSerialEsp32Options, DEFAULT_ESP32C6_FIRMWARE_MANIFEST_PATH, DEFAULT_ESPTOOL_MODULE_PATH, +}; +pub use provider::{BrowserSerialEsp32Provider, descriptor}; + +#[cfg(test)] +mod tests; diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs new file mode 100644 index 000000000..596d5ebd9 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs @@ -0,0 +1,514 @@ +use std::collections::BTreeMap; + +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::management_request::LinkManagementRequest; +use crate::provider::management_result::{ + LinkEraseDeviceResult, LinkFirmwareFlashResult, LinkFirmwareManifest, LinkManagementResult, +}; +use crate::provider::session::LinkSessionId; +use crate::providers::browser_serial_esp32::BrowserSerialEsp32Options; +use crate::providers::browser_serial_esp32::{ + BrowserEsp32EraseResult, BrowserEsp32FirmwareManifest, BrowserEsp32FlashProgress, + BrowserEsp32FlashResult, BrowserEsp32ProbeResult, browser_esp32_flash, browser_serial, +}; +use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; +use crate::{ + LinkCapabilities, LinkConnection, LinkConnectionKind, LinkDiagnostic, LinkDiagnosticSeverity, + LinkEndpoint, LinkError, LinkLogEntry, LinkLogLevel, LinkManagementEventSink, + LinkManagementProgress, LinkProvider, LinkSession, LinkSessionStatus, +}; + +pub fn descriptor() -> LinkProviderDescriptor { + LinkProviderKind::BrowserSerialEsp32.descriptor() +} + +pub struct BrowserSerialEsp32Provider { + endpoints: BTreeMap, + sessions: BTreeMap, + options: BrowserSerialEsp32Options, + next_endpoint_index: u64, + next_session_index: u64, +} + +impl BrowserSerialEsp32Provider { + pub fn new() -> Self { + Self::with_options(BrowserSerialEsp32Options::default()) + } + + pub fn with_options(options: BrowserSerialEsp32Options) -> Self { + Self { + endpoints: BTreeMap::new(), + sessions: BTreeMap::new(), + options, + next_endpoint_index: 1, + next_session_index: 1, + } + } + + pub fn options(&self) -> &BrowserSerialEsp32Options { + &self.options + } + + pub fn create_granted_endpoint( + &mut self, + label: impl Into, + port_id: u32, + ) -> LinkEndpointId { + let endpoint_id = LinkEndpointId::new(format!( + "{}-port-{}", + self.kind().key(), + self.next_endpoint_index + )); + self.next_endpoint_index += 1; + + let mut capabilities = LinkCapabilities::esp32_serial_base(); + if self.is_flash_supported() { + capabilities = capabilities.with_flash().with_device_erase(); + } + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.kind(), label) + .with_capabilities(capabilities); + self.endpoints.insert( + endpoint_id.clone(), + BrowserSerialEndpointState { endpoint, port_id }, + ); + endpoint_id + } + + pub fn is_serial_supported(&self) -> bool { + browser_serial::is_supported() + } + + pub fn is_flash_supported(&self) -> bool { + browser_esp32_flash::is_supported() + } + + pub async fn request_access(&mut self) -> Result { + let port = browser_serial::request_port().await?; + let endpoint_id = self.create_granted_endpoint(port.label, port.id); + Ok(self.endpoint(&endpoint_id)?.clone()) + } + + pub async fn open_protocol( + &mut self, + session_id: &LinkSessionId, + baud_rate: u32, + ) -> Result<(), LinkError> { + let (endpoint_id, port_id) = { + let state = self.session(session_id)?; + (state.session.endpoint_id.clone(), state.port_id) + }; + let result = browser_serial::open(port_id, baud_rate).await?; + let logs = protocol_open_result_logs(endpoint_id, session_id.clone(), result); + let state = self.session_mut(session_id)?; + state.logs.extend(logs); + state.protocol_open = true; + Ok(()) + } + + pub async fn write_line( + &self, + session_id: &LinkSessionId, + line: &str, + ) -> Result<(), LinkError> { + let state = self.session(session_id)?; + browser_serial::write_line(state.port_id, line).await + } + + pub fn take_lines(&self, session_id: &LinkSessionId) -> Result, LinkError> { + let state = self.session(session_id)?; + Ok(browser_serial::take_lines(state.port_id)) + } + + pub fn take_errors(&self, session_id: &LinkSessionId) -> Result, LinkError> { + let state = self.session(session_id)?; + Ok(browser_serial::take_errors(state.port_id)) + } + + pub async fn release_protocol(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + let state = self.session_mut(session_id)?; + browser_serial::release(state.port_id).await?; + state.protocol_open = false; + Ok(()) + } + + pub async fn release_session_for_management( + &mut self, + session_id: &LinkSessionId, + ) -> Result<(), LinkError> { + self.release_protocol(session_id).await?; + self.sessions.remove(session_id); + Ok(()) + } + + pub async fn load_firmware_manifest(&self) -> Result { + browser_esp32_flash::load_manifest(&self.options.firmware_manifest_path).await + } + + pub async fn probe_target( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + let port_id = self.endpoint_state(endpoint_id)?.port_id; + browser_esp32_flash::probe_target(port_id, self.options.esptool_module_path()).await + } + + pub async fn flash_firmware( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + self.flash_firmware_with_events(endpoint_id, LinkManagementEventSink::noop()) + .await + } + + pub async fn flash_firmware_with_events( + &mut self, + endpoint_id: &LinkEndpointId, + events: LinkManagementEventSink, + ) -> Result { + let port_id = self.endpoint_state(endpoint_id)?.port_id; + browser_esp32_flash::flash_firmware_with_events( + port_id, + &self.options.firmware_manifest_path, + self.options.esptool_module_path(), + events, + ) + .await + } + + pub async fn erase_device_flash( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + self.erase_device_flash_with_events(endpoint_id, LinkManagementEventSink::noop()) + .await + } + + pub async fn erase_device_flash_with_events( + &mut self, + endpoint_id: &LinkEndpointId, + events: LinkManagementEventSink, + ) -> Result { + let port_id = self.endpoint_state(endpoint_id)?.port_id; + browser_esp32_flash::erase_device_flash_with_events( + port_id, + self.options.esptool_module_path(), + events, + ) + .await + } + + async fn manage_inner( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + events: LinkManagementEventSink, + ) -> Result { + self.session_capabilities_support(session_id, &request)?; + let endpoint_id = self.session(session_id)?.session.endpoint_id.clone(); + self.release_protocol_if_open(session_id).await?; + match request { + LinkManagementRequest::FlashFirmware => { + let result = self + .flash_firmware_with_events(&endpoint_id, events.clone()) + .await?; + let logs = result + .logs + .iter() + .map(|message| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + message.clone(), + ) + }) + .collect::>(); + self.session_mut(session_id)?.logs.extend(logs); + Ok(LinkManagementResult::FlashFirmware( + map_firmware_flash_result(result), + )) + } + LinkManagementRequest::EraseDeviceFlash => { + let result = self + .erase_device_flash_with_events(&endpoint_id, events.clone()) + .await?; + let logs = result + .logs + .iter() + .map(|message| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + message.clone(), + ) + }) + .collect::>(); + self.session_mut(session_id)?.logs.extend(logs); + Ok(LinkManagementResult::EraseDeviceFlash( + map_erase_device_result(result), + )) + } + LinkManagementRequest::ResetRuntime | LinkManagementRequest::EraseRawFilesystem => { + Err(LinkError::unsupported(format!("{:?}", request.operation()))) + } + } + } + + async fn release_protocol_if_open( + &mut self, + session_id: &LinkSessionId, + ) -> Result<(), LinkError> { + if self.session(session_id)?.protocol_open { + self.release_protocol(session_id).await?; + } + Ok(()) + } + + fn session_capabilities_support( + &self, + session_id: &LinkSessionId, + request: &LinkManagementRequest, + ) -> Result<(), LinkError> { + let session = &self.session(session_id)?.session; + let operation = request.operation(); + if session.capabilities.supports(operation) { + Ok(()) + } else { + Err(LinkError::unsupported(format!("{operation:?}"))) + } + } + + fn endpoint(&self, endpoint_id: &LinkEndpointId) -> Result<&LinkEndpoint, LinkError> { + Ok(&self.endpoint_state(endpoint_id)?.endpoint) + } + + fn endpoint_state( + &self, + endpoint_id: &LinkEndpointId, + ) -> Result<&BrowserSerialEndpointState, LinkError> { + self.endpoints + .get(endpoint_id) + .ok_or_else(|| LinkError::endpoint_not_found(endpoint_id.as_str())) + } + + fn session(&self, session_id: &LinkSessionId) -> Result<&BrowserSerialSessionState, LinkError> { + self.sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } + + fn session_mut( + &mut self, + session_id: &LinkSessionId, + ) -> Result<&mut BrowserSerialSessionState, LinkError> { + self.sessions + .get_mut(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } +} + +impl LinkProvider for BrowserSerialEsp32Provider { + fn kind(&self) -> LinkProviderKind { + LinkProviderKind::BrowserSerialEsp32 + } + + async fn discover(&mut self) -> Result, LinkError> { + Ok(self + .endpoints + .values() + .map(|state| state.endpoint.clone()) + .collect()) + } + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + Ok(self.endpoint(endpoint_id)?.status.clone()) + } + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result { + let endpoint_state = self.endpoint_state(endpoint_id)?.clone(); + let session_id = LinkSessionId::new(format!( + "{}:{}", + endpoint_id.as_str(), + self.next_session_index + )); + self.next_session_index += 1; + let session = LinkSession::new( + session_id.clone(), + self.kind(), + endpoint_state.endpoint.id.clone(), + LinkConnectionKind::BrowserSerialEsp32 { + protocol: "lp-serial-json-lines-v1".to_string(), + }, + endpoint_state.endpoint.capabilities.clone(), + ); + self.sessions.insert( + session_id, + BrowserSerialSessionState::new(session.clone(), endpoint_state.port_id), + ); + Ok(session) + } + + async fn connection( + &mut self, + session_id: &LinkSessionId, + ) -> Result { + let state = self.session(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Err(LinkError::Closed); + } + Ok(LinkConnection::browser_serial_esp32( + state.session.endpoint_id.clone(), + state.session.id.clone(), + )) + } + + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.logs.clone()) + } + + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.diagnostics.clone()) + } + + async fn manage( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + ) -> Result { + self.manage_inner(session_id, request, LinkManagementEventSink::noop()) + .await + } + + async fn manage_with_events( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + events: LinkManagementEventSink, + ) -> Result { + self.manage_inner(session_id, request, events).await + } + + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + let state = self.session_mut(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Ok(()); + } + state.session.status = LinkSessionStatus::Closed; + browser_serial::close(state.port_id).await?; + state.protocol_open = false; + state.logs.push(LinkLogEntry::new( + state.session.endpoint_id.clone(), + Some(state.session.id.clone()), + LinkLogLevel::Info, + "browser serial ESP32 session closed", + )); + Ok(()) + } +} + +fn map_firmware_flash_result(result: BrowserEsp32FlashResult) -> LinkFirmwareFlashResult { + LinkFirmwareFlashResult { + manifest: LinkFirmwareManifest { + firmware_id: result.manifest.firmware_id, + display_name: result.manifest.display_name, + target_chip: result.manifest.target_chip, + image_count: result.manifest.image_count, + total_bytes: result.manifest.total_bytes, + manifest_path: result.manifest.manifest_path, + }, + chip_name: result.chip_name, + logs: result.logs, + progress: map_progress(result.progress), + } +} + +fn map_erase_device_result(result: BrowserEsp32EraseResult) -> LinkEraseDeviceResult { + LinkEraseDeviceResult { + chip_name: result.chip_name, + logs: result.logs, + progress: map_progress(result.progress), + } +} + +fn protocol_open_result_logs( + endpoint_id: LinkEndpointId, + session_id: LinkSessionId, + result: browser_serial::BrowserSerialProtocolOpenResult, +) -> Vec { + let mut logs = result + .logs + .into_iter() + .map(|message| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + message, + ) + }) + .collect::>(); + logs.extend(result.progress.into_iter().map(|progress| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + progress.label, + ) + })); + logs +} + +fn map_progress(progress: Vec) -> Vec { + progress + .into_iter() + .map(|entry| LinkManagementProgress { + label: entry.label, + completed_steps: entry.completed_steps, + total_steps: entry.total_steps, + percent: entry.percent, + }) + .collect() +} + +#[derive(Clone, Debug)] +struct BrowserSerialEndpointState { + endpoint: LinkEndpoint, + port_id: u32, +} + +#[derive(Clone, Debug)] +struct BrowserSerialSessionState { + session: LinkSession, + port_id: u32, + protocol_open: bool, + logs: Vec, + diagnostics: Vec, +} + +impl BrowserSerialSessionState { + fn new(session: LinkSession, port_id: u32) -> Self { + let logs = vec![LinkLogEntry::new( + session.endpoint_id.clone(), + Some(session.id.clone()), + LinkLogLevel::Info, + "browser serial ESP32 session created", + )]; + let diagnostics = vec![LinkDiagnostic::new( + session.endpoint_id.clone(), + Some(session.id.clone()), + LinkDiagnosticSeverity::Info, + "browser serial session owns Web Serial resources in lpa-link", + )]; + Self { + session, + port_id, + protocol_open: false, + logs, + diagnostics, + } + } +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/tests.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/tests.rs new file mode 100644 index 000000000..143b483f0 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/tests.rs @@ -0,0 +1,55 @@ +use crate::providers::browser_serial_esp32::{ + BrowserSerialEsp32Provider, DEFAULT_ESPTOOL_MODULE_PATH, +}; +use crate::{LinkConnectionKind, LinkOperation, LinkProvider}; + +#[tokio::test] +async fn browser_serial_provider_models_granted_ports() { + let mut provider = BrowserSerialEsp32Provider::new(); + let endpoint_id = provider.create_granted_endpoint("ESP32-C6", 7); + + let endpoints = provider.discover().await.unwrap(); + + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].id, endpoint_id); + assert!(endpoints[0].capabilities.supports(LinkOperation::Reset)); + assert!(endpoints[0].capabilities.supports(LinkOperation::ReadLogs)); + assert!( + endpoints[0] + .capabilities + .supports(LinkOperation::FlashFirmware) + ); + assert!( + endpoints[0] + .capabilities + .supports(LinkOperation::EraseDeviceFlash) + ); +} + +#[tokio::test] +async fn browser_serial_connection_reports_protocol() { + let mut provider = BrowserSerialEsp32Provider::new(); + let endpoint_id = provider.create_granted_endpoint("ESP32-C6", 7); + let session = provider.connect(&endpoint_id).await.unwrap(); + + let connection = provider.connection(session.id()).await.unwrap(); + + assert!(matches!( + connection.kind, + LinkConnectionKind::BrowserSerialEsp32 { ref protocol } + if protocol == "lp-serial-json-lines-v1" + )); +} + +#[test] +fn default_esptool_module_path_uses_bundled_browser_esm() { + assert_eq!( + BrowserSerialEsp32Provider::new() + .options() + .esptool_module_path + .as_deref(), + Some(DEFAULT_ESPTOOL_MODULE_PATH) + ); + assert!(DEFAULT_ESPTOOL_MODULE_PATH.contains("cdn.jsdelivr.net/")); + assert!(DEFAULT_ESPTOOL_MODULE_PATH.ends_with("/+esm")); +} diff --git a/lp-app/lpa-link/src/providers/browser_worker/browser_worker_options.rs b/lp-app/lpa-link/src/providers/browser_worker/browser_worker_options.rs new file mode 100644 index 000000000..cf4e8bedb --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/browser_worker_options.rs @@ -0,0 +1,27 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserWorkerOptions { + pub fw_browser_module_path: String, + pub fw_browser_wasm_path: String, +} + +impl BrowserWorkerOptions { + pub fn new( + fw_browser_module_path: impl Into, + fw_browser_wasm_path: impl Into, + ) -> Self { + Self { + fw_browser_module_path: fw_browser_module_path.into(), + fw_browser_wasm_path: fw_browser_wasm_path.into(), + } + } + + pub fn worker_script_path(&self) -> String { + wasm_bindgen::link_to!(module = "/src/providers/browser_worker/fw_browser_worker.js") + } +} + +impl Default for BrowserWorkerOptions { + fn default() -> Self { + Self::new("/pkg/fw_browser.js", "/pkg/fw_browser_bg.wasm") + } +} diff --git a/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js b/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js new file mode 100644 index 000000000..f2fd89140 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js @@ -0,0 +1,72 @@ +let runtimeId = null; +let booted = false; +let fwBrowser = null; + +self.onmessage = async (event) => { + try { + const message = event.data || {}; + switch (message.kind) { + case "boot": + await boot( + message.label || "browser-worker", + message.fw_browser_module_path, + message.fw_browser_wasm_path, + ); + break; + case "protocol_in": + requireBooted(); + postMany(fwBrowser.handle_envelope_json(runtimeId, JSON.stringify(message))); + break; + case "tick": + requireBooted(); + postMany(fwBrowser.tick_runtime(runtimeId, message.delta_ms || 16)); + break; + case "drain": + requireBooted(); + postMany(fwBrowser.drain_output_json(runtimeId)); + break; + case "start": + case "stop": + requireBooted(); + postMany(fwBrowser.handle_envelope_json(runtimeId, JSON.stringify(message))); + break; + default: + throw new Error(`unknown worker message kind: ${message.kind}`); + } + } catch (error) { + console.error("[fw-browser-worker]", error); + self.postMessage({ + kind: "status", + status: "error", + message: String(error?.stack || error), + }); + } +}; + +async function boot(label, modulePath, wasmPath) { + if (!booted) { + if (!modulePath) { + throw new Error("missing fw_browser_module_path"); + } + self.postMessage({ kind: "status", status: "booting" }); + fwBrowser = await import(modulePath); + const exports = await fwBrowser.default(wasmPath || undefined); + fwBrowser.fw_browser_init_exports(exports); + runtimeId = fwBrowser.create_runtime(label); + booted = true; + postMany(fwBrowser.drain_output_json(runtimeId)); + self.postMessage({ kind: "status", status: "ready" }); + } +} + +function requireBooted() { + if (!booted || runtimeId == null || fwBrowser == null) { + throw new Error("worker runtime has not booted"); + } +} + +function postMany(envelopesJson) { + for (const envelope of JSON.parse(envelopesJson)) { + self.postMessage(envelope); + } +} diff --git a/lp-app/lpa-link/src/providers/browser_worker/mod.rs b/lp-app/lpa-link/src/providers/browser_worker/mod.rs new file mode 100644 index 000000000..ef64f2c92 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/mod.rs @@ -0,0 +1,12 @@ +mod browser_worker_options; +mod provider; +mod worker_envelope; +mod worker_handle; + +pub use browser_worker_options::BrowserWorkerOptions; +pub use provider::{BrowserWorkerProvider, descriptor}; +pub use worker_envelope::{BrowserInputEnvelope, BrowserOutputEnvelope}; +pub use worker_handle::BrowserWorkerHandle; + +#[cfg(test)] +mod tests; diff --git a/lp-app/lpa-link/src/providers/browser_worker/provider.rs b/lp-app/lpa-link/src/providers/browser_worker/provider.rs new file mode 100644 index 000000000..9dbca924d --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/provider.rs @@ -0,0 +1,235 @@ +use std::collections::BTreeMap; + +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::session::LinkSessionId; +use crate::providers::browser_worker::BrowserWorkerOptions; +use crate::providers::browser_worker::{ + BrowserInputEnvelope, BrowserOutputEnvelope, BrowserWorkerHandle, +}; +use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; +use crate::{ + LinkCapabilities, LinkConnection, LinkConnectionKind, LinkDiagnostic, LinkDiagnosticSeverity, + LinkEndpoint, LinkError, LinkLogEntry, LinkLogLevel, LinkOperation, LinkProvider, LinkSession, + LinkSessionStatus, +}; + +pub fn descriptor() -> LinkProviderDescriptor { + LinkProviderKind::BrowserWorker.descriptor() +} + +pub struct BrowserWorkerProvider { + endpoints: Vec, + sessions: BTreeMap, + options: BrowserWorkerOptions, + next_endpoint_index: u64, + next_session_index: u64, +} + +impl BrowserWorkerProvider { + pub fn new() -> Self { + Self::with_options(BrowserWorkerOptions::default()) + } + + pub fn with_options(options: BrowserWorkerOptions) -> Self { + Self { + endpoints: Vec::new(), + sessions: BTreeMap::new(), + options, + next_endpoint_index: 1, + next_session_index: 1, + } + } + + pub fn options(&self) -> &BrowserWorkerOptions { + &self.options + } + + pub fn create_worker_endpoint(&mut self, label: impl Into) -> LinkEndpointId { + let endpoint_id = LinkEndpointId::new(format!( + "{}-worker-{}", + self.kind().key(), + self.next_endpoint_index + )); + self.next_endpoint_index += 1; + + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.kind(), label) + .with_capabilities( + LinkCapabilities::default() + .with(LinkOperation::ReadLogs) + .with(LinkOperation::ReadDiagnostics), + ); + self.endpoints.push(endpoint); + endpoint_id + } + + fn endpoint(&self, endpoint_id: &LinkEndpointId) -> Result<&LinkEndpoint, LinkError> { + self.endpoints + .iter() + .find(|endpoint| endpoint.id == *endpoint_id) + .ok_or_else(|| LinkError::endpoint_not_found(endpoint_id.as_str())) + } + + pub fn post( + &self, + session_id: &LinkSessionId, + envelope: &BrowserInputEnvelope, + ) -> Result<(), LinkError> { + self.session(session_id)?.handle()?.post(envelope) + } + + pub fn take_outputs( + &mut self, + session_id: &LinkSessionId, + ) -> Result, LinkError> { + let state = self.session_mut(session_id)?; + let mut outputs = state.pending_outputs.split_off(0); + outputs.extend(state.handle_mut()?.take_outputs()); + Ok(outputs) + } + + fn session(&self, session_id: &LinkSessionId) -> Result<&BrowserWorkerSessionState, LinkError> { + self.sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } + + fn session_mut( + &mut self, + session_id: &LinkSessionId, + ) -> Result<&mut BrowserWorkerSessionState, LinkError> { + self.sessions + .get_mut(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } +} + +impl LinkProvider for BrowserWorkerProvider { + fn kind(&self) -> LinkProviderKind { + LinkProviderKind::BrowserWorker + } + + async fn discover(&mut self) -> Result, LinkError> { + Ok(self.endpoints.clone()) + } + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + Ok(self.endpoint(endpoint_id)?.status.clone()) + } + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result { + let endpoint = self.endpoint(endpoint_id)?.clone(); + let session_id = LinkSessionId::new(format!( + "{}:{}", + endpoint_id.as_str(), + self.next_session_index + )); + self.next_session_index += 1; + + let session = LinkSession::new( + session_id.clone(), + self.kind(), + endpoint.id.clone(), + LinkConnectionKind::BrowserWorker { + protocol: "fw-browser-post-message-v1".to_string(), + }, + endpoint.capabilities.clone(), + ); + let mut state = BrowserWorkerSessionState::new(endpoint.id, session.clone()); + let mut handle = BrowserWorkerHandle::new(&self.options.worker_script_path())?; + state + .pending_outputs + .extend(handle.boot("Studio browser runtime", &self.options).await?); + state.handle = Some(handle); + self.sessions.insert(session_id, state); + Ok(session) + } + + async fn connection( + &mut self, + session_id: &LinkSessionId, + ) -> Result { + let state = self.session(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Err(LinkError::Closed); + } + Ok(LinkConnection::browser_worker( + state.session.endpoint_id.clone(), + state.session.id.clone(), + )) + } + + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.logs.clone()) + } + + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.diagnostics.clone()) + } + + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + let state = self.session_mut(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Ok(()); + } + state.session.status = LinkSessionStatus::Closed; + if let Some(handle) = &state.handle { + handle.terminate(); + } + state.logs.push(LinkLogEntry::new( + state.endpoint_id.clone(), + Some(state.session.id.clone()), + LinkLogLevel::Info, + "browser worker session closed", + )); + Ok(()) + } +} + +struct BrowserWorkerSessionState { + endpoint_id: LinkEndpointId, + session: LinkSession, + logs: Vec, + diagnostics: Vec, + pending_outputs: Vec, + handle: Option, +} + +impl BrowserWorkerSessionState { + fn new(endpoint_id: LinkEndpointId, session: LinkSession) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(session.id.clone()), + LinkLogLevel::Info, + "browser worker session created", + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(session.id.clone()), + LinkDiagnosticSeverity::Info, + "browser worker session owns Worker lifecycle in lpa-link", + )]; + Self { + endpoint_id, + session, + logs, + diagnostics, + pending_outputs: Vec::new(), + handle: None, + } + } + + fn handle(&self) -> Result<&BrowserWorkerHandle, LinkError> { + self.handle + .as_ref() + .ok_or_else(|| LinkError::other("browser worker session has no worker handle")) + } + + fn handle_mut(&mut self) -> Result<&mut BrowserWorkerHandle, LinkError> { + self.handle + .as_mut() + .ok_or_else(|| LinkError::other("browser worker session has no worker handle")) + } +} diff --git a/lp-app/lpa-link/src/providers/browser_worker/tests.rs b/lp-app/lpa-link/src/providers/browser_worker/tests.rs new file mode 100644 index 000000000..82316e620 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/tests.rs @@ -0,0 +1,34 @@ +use crate::providers::browser_worker::BrowserWorkerProvider; +use crate::{LinkConnectionKind, LinkProvider}; + +#[tokio::test] +async fn browser_worker_provider_supports_multiple_worker_endpoints() { + let mut provider = BrowserWorkerProvider::new(); + provider.create_worker_endpoint("Browser A"); + provider.create_worker_endpoint("Browser B"); + + let endpoints = provider.discover().await.unwrap(); + assert_eq!(endpoints.len(), 2); + + let session_a = provider.connect(&endpoints[0].id).await.unwrap(); + let session_b = provider.connect(&endpoints[1].id).await.unwrap(); + + assert_ne!(session_a.id(), session_b.id()); + assert_ne!(session_a.endpoint_id(), session_b.endpoint_id()); +} + +#[tokio::test] +async fn browser_worker_connection_reports_worker_protocol() { + let mut provider = BrowserWorkerProvider::new(); + let endpoint_id = provider.create_worker_endpoint("Browser A"); + let session = provider.connect(&endpoint_id).await.unwrap(); + + let connection = provider.connection(session.id()).await.unwrap(); + + assert_eq!(connection.endpoint_id, endpoint_id); + assert!(matches!( + connection.kind, + LinkConnectionKind::BrowserWorker { ref protocol } + if protocol == "fw-browser-post-message-v1" + )); +} diff --git a/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs b/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs new file mode 100644 index 000000000..644b5d149 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BrowserInputEnvelope { + Boot { + label: String, + fw_browser_module_path: String, + fw_browser_wasm_path: String, + }, + ProtocolIn { + frame: String, + }, + Tick { + delta_ms: Option, + }, + Start, + Stop, + Drain, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BrowserOutputEnvelope { + Status { + #[serde(default)] + runtime_id: Option, + status: String, + message: Option, + }, + Log { + runtime_id: u32, + level: String, + target: String, + message: String, + }, + ProtocolOut { + frame: String, + }, +} diff --git a/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs b/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs new file mode 100644 index 000000000..44b0571f2 --- /dev/null +++ b/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs @@ -0,0 +1,215 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use js_sys::Promise; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ErrorEvent, MessageEvent, Url, Worker, WorkerOptions, WorkerType}; + +use crate::LinkError; +use crate::providers::browser_worker::{ + BrowserInputEnvelope, BrowserOutputEnvelope, BrowserWorkerOptions, +}; + +pub struct BrowserWorkerHandle { + worker: Worker, + outputs: Rc>>, +} + +impl BrowserWorkerHandle { + pub fn new(worker_script_path: &str) -> Result { + let options = WorkerOptions::new(); + options.set_type(WorkerType::Module); + let worker = Worker::new_with_options(worker_script_path, &options) + .map_err(|error| LinkError::other(format!("{error:?}")))?; + let outputs = Rc::new(RefCell::new(Vec::new())); + let output_ref = Rc::clone(&outputs); + let on_message = Closure::::new(move |event: MessageEvent| { + match serde_wasm_bindgen::from_value::(event.data()) { + Ok(envelope) => output_ref.borrow_mut().push(envelope), + Err(error) => output_ref.borrow_mut().push(BrowserOutputEnvelope::Log { + runtime_id: 0, + level: "error".to_string(), + target: "lpa-link".to_string(), + message: format!("failed to parse worker message: {error}"), + }), + } + }); + worker.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + on_message.forget(); + + let output_ref = Rc::clone(&outputs); + let on_error = Closure::::new(move |event: ErrorEvent| { + output_ref.borrow_mut().push(BrowserOutputEnvelope::Status { + runtime_id: None, + status: "error".to_string(), + message: Some(format!( + "worker error: {} at {}:{}:{}", + event.message(), + event.filename(), + event.lineno(), + event.colno() + )), + }); + }); + worker.set_onerror(Some(on_error.as_ref().unchecked_ref())); + on_error.forget(); + + let output_ref = Rc::clone(&outputs); + let on_message_error = + Closure::::new(move |_event: MessageEvent| { + output_ref.borrow_mut().push(BrowserOutputEnvelope::Status { + runtime_id: None, + status: "error".to_string(), + message: Some("worker message could not be deserialized".to_string()), + }); + }); + worker.set_onmessageerror(Some(on_message_error.as_ref().unchecked_ref())); + on_message_error.forget(); + Ok(Self { worker, outputs }) + } + + pub async fn boot( + &mut self, + label: &str, + options: &BrowserWorkerOptions, + ) -> Result, LinkError> { + let fw_browser_module_path = resolve_page_url(&options.fw_browser_module_path)?; + let fw_browser_wasm_path = resolve_page_url(&options.fw_browser_wasm_path)?; + self.post(&BrowserInputEnvelope::Boot { + label: label.to_string(), + fw_browser_module_path, + fw_browser_wasm_path, + })?; + let mut outputs = Vec::new(); + for _ in 0..200 { + sleep_ms(25).await?; + for output in self.take_outputs() { + let ready = matches!( + &output, + BrowserOutputEnvelope::Status { status, .. } if status == "ready" + ); + let error = boot_error_message(&output); + outputs.push(output); + if let Some(message) = error { + return Err(LinkError::other(format!( + "browser worker boot failed: {message}" + ))); + } + if ready { + return Ok(outputs); + } + } + } + Err(LinkError::other(format!( + "timed out waiting for browser worker boot{}", + boot_output_summary(&outputs) + ))) + } + + pub fn post(&self, envelope: &BrowserInputEnvelope) -> Result<(), LinkError> { + let value = serde_wasm_bindgen::to_value(envelope) + .map_err(|error| LinkError::other(error.to_string()))?; + self.worker + .post_message(&value) + .map_err(|error| LinkError::other(format!("{error:?}"))) + } + + pub fn take_outputs(&mut self) -> Vec { + core::mem::take(&mut *self.outputs.borrow_mut()) + } + + pub fn terminate(&self) { + self.worker.terminate(); + } +} + +fn resolve_page_url(path: &str) -> Result { + if path.is_empty() { + return Ok(String::new()); + } + if Url::new(path).is_ok() { + return Ok(path.to_string()); + } + + let Some(window) = web_sys::window() else { + return Err(LinkError::other(format!( + "cannot resolve browser worker asset path {path:?}: missing window" + ))); + }; + let base = if let Some(document) = window.document() { + document + .url() + .map_err(|error| LinkError::other(format!("{error:?}")))? + } else { + window + .location() + .href() + .map_err(|error| LinkError::other(format!("{error:?}")))? + }; + Url::new_with_base(path, &base) + .map(|url| url.href()) + .map_err(|error| { + LinkError::other(format!( + "cannot resolve browser worker asset path {path:?} from {base:?}: {error:?}" + )) + }) +} + +fn boot_error_message(output: &BrowserOutputEnvelope) -> Option { + match output { + BrowserOutputEnvelope::Status { + status, message, .. + } if status == "error" => Some( + message + .clone() + .unwrap_or_else(|| "worker reported error status".to_string()), + ), + BrowserOutputEnvelope::Log { level, message, .. } if level == "error" => { + Some(message.clone()) + } + _ => None, + } +} + +fn boot_output_summary(outputs: &[BrowserOutputEnvelope]) -> String { + let Some(last) = outputs.last() else { + return "; no worker output was received".to_string(); + }; + match last { + BrowserOutputEnvelope::Status { + status, message, .. + } => match message { + Some(message) => format!("; last worker status was {status}: {message}"), + None => format!("; last worker status was {status}"), + }, + BrowserOutputEnvelope::Log { + level, + target, + message, + .. + } => format!("; last worker log was {level} from {target}: {message}"), + BrowserOutputEnvelope::ProtocolOut { .. } => { + "; last worker output was a protocol frame".to_string() + } + } +} + +async fn sleep_ms(ms: i32) -> Result<(), LinkError> { + let promise = Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| { + let Some(window) = web_sys::window() else { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("missing window")); + return; + }; + if let Err(error) = + window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms) + { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + JsFuture::from(promise) + .await + .map(|_| ()) + .map_err(|error| LinkError::other(format!("{error:?}"))) +} diff --git a/lp-app/lpa-link/src/providers/fake/mod.rs b/lp-app/lpa-link/src/providers/fake/mod.rs new file mode 100644 index 000000000..869218f93 --- /dev/null +++ b/lp-app/lpa-link/src/providers/fake/mod.rs @@ -0,0 +1,6 @@ +mod provider; + +pub use provider::{FakeProvider, descriptor}; + +#[cfg(test)] +mod tests; diff --git a/lp-app/lpa-link/src/providers/fake/provider.rs b/lp-app/lpa-link/src/providers/fake/provider.rs new file mode 100644 index 000000000..a4adf3bb9 --- /dev/null +++ b/lp-app/lpa-link/src/providers/fake/provider.rs @@ -0,0 +1,198 @@ +use std::collections::BTreeMap; + +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::session::LinkSessionId; +use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; +use crate::{ + LinkConnection, LinkConnectionKind, LinkDiagnostic, LinkDiagnosticSeverity, LinkEndpoint, + LinkError, LinkLogEntry, LinkLogLevel, LinkProvider, LinkSession, LinkSessionStatus, +}; + +pub fn descriptor() -> LinkProviderDescriptor { + LinkProviderKind::Fake.descriptor() +} + +#[derive(Clone, Debug)] +pub struct FakeProvider { + endpoints: Vec, + sessions: BTreeMap, + next_session_index: u64, + discover_error: Option, + connect_error: Option, + connection_error: Option, +} + +impl FakeProvider { + pub fn new() -> Self { + Self { + endpoints: Vec::new(), + sessions: BTreeMap::new(), + next_session_index: 1, + discover_error: None, + connect_error: None, + connection_error: None, + } + } + + pub fn with_endpoint(mut self, endpoint: LinkEndpoint) -> Self { + self.endpoints.push(endpoint); + self + } + + pub fn with_discover_error(mut self, message: impl Into) -> Self { + self.discover_error = Some(message.into()); + self + } + + pub fn with_connect_error(mut self, message: impl Into) -> Self { + self.connect_error = Some(message.into()); + self + } + + pub fn with_connection_error(mut self, message: impl Into) -> Self { + self.connection_error = Some(message.into()); + self + } + + fn endpoint(&self, endpoint_id: &LinkEndpointId) -> Result<&LinkEndpoint, LinkError> { + self.endpoints + .iter() + .find(|endpoint| endpoint.id == *endpoint_id) + .ok_or_else(|| LinkError::endpoint_not_found(endpoint_id.as_str())) + } + + fn session(&self, session_id: &LinkSessionId) -> Result<&FakeSessionState, LinkError> { + self.sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } + + fn session_mut( + &mut self, + session_id: &LinkSessionId, + ) -> Result<&mut FakeSessionState, LinkError> { + self.sessions + .get_mut(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } +} + +impl LinkProvider for FakeProvider { + fn kind(&self) -> LinkProviderKind { + LinkProviderKind::Fake + } + + async fn discover(&mut self) -> Result, LinkError> { + if let Some(message) = &self.discover_error { + return Err(LinkError::ConnectionFailed { + message: message.clone(), + }); + } + Ok(self.endpoints.clone()) + } + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + Ok(self.endpoint(endpoint_id)?.status.clone()) + } + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result { + if let Some(message) = &self.connect_error { + return Err(LinkError::ConnectionFailed { + message: message.clone(), + }); + } + let endpoint = self.endpoint(endpoint_id)?.clone(); + let session_id = LinkSessionId::new(format!( + "{}:{}", + endpoint_id.as_str(), + self.next_session_index + )); + self.next_session_index += 1; + + let session = LinkSession::new( + session_id.clone(), + self.kind(), + endpoint.id.clone(), + LinkConnectionKind::Fake, + endpoint.capabilities.clone(), + ); + self.sessions.insert( + session_id, + FakeSessionState::new(endpoint.id, session.clone()), + ); + Ok(session) + } + + async fn connection( + &mut self, + session_id: &LinkSessionId, + ) -> Result { + if let Some(message) = &self.connection_error { + return Err(LinkError::ConnectionFailed { + message: message.clone(), + }); + } + let state = self.session(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Err(LinkError::Closed); + } + Ok(LinkConnection::fake( + state.session.endpoint_id.clone(), + state.session.id.clone(), + )) + } + + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.logs.clone()) + } + + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.diagnostics.clone()) + } + + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + let state = self.session_mut(session_id)?; + state.session.status = LinkSessionStatus::Closed; + state.logs.push(LinkLogEntry::new( + state.endpoint_id.clone(), + Some(state.session.id.clone()), + LinkLogLevel::Info, + "fake link session closed", + )); + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct FakeSessionState { + endpoint_id: LinkEndpointId, + session: LinkSession, + logs: Vec, + diagnostics: Vec, +} + +impl FakeSessionState { + fn new(endpoint_id: LinkEndpointId, session: LinkSession) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(session.id.clone()), + LinkLogLevel::Info, + "fake link session opened", + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(session.id.clone()), + LinkDiagnosticSeverity::Info, + "fake link session ready", + )]; + Self { + endpoint_id, + session, + logs, + diagnostics, + } + } +} diff --git a/lp-app/lpa-link/src/providers/fake/tests.rs b/lp-app/lpa-link/src/providers/fake/tests.rs new file mode 100644 index 000000000..1df451ba4 --- /dev/null +++ b/lp-app/lpa-link/src/providers/fake/tests.rs @@ -0,0 +1,82 @@ +use crate::provider::endpoint::LinkEndpointId; +use crate::providers::LinkProviderKind; +use crate::providers::fake::FakeProvider; +use crate::{LinkCapabilities, LinkEndpoint, LinkError, LinkManagementRequest, LinkProvider}; + +#[tokio::test] +async fn discover_returns_all_fake_endpoints() { + let mut provider = fake_provider(); + + let endpoints = provider.discover().await.unwrap(); + + assert_eq!(endpoints.len(), 2); + assert_eq!(endpoints[0].id.as_str(), "fake-a"); + assert_eq!(endpoints[1].id.as_str(), "fake-b"); +} + +#[tokio::test] +async fn sessions_are_scoped_to_endpoint_and_have_stable_ids() { + let mut provider = fake_provider(); + let endpoint_a = LinkEndpointId::new("fake-a"); + let endpoint_b = LinkEndpointId::new("fake-b"); + + let session_a = provider.connect(&endpoint_a).await.unwrap(); + let session_b = provider.connect(&endpoint_b).await.unwrap(); + + assert_eq!(session_a.endpoint_id().as_str(), "fake-a"); + assert_eq!(session_b.endpoint_id().as_str(), "fake-b"); + assert_ne!(session_a.id(), session_b.id()); + + let connection = provider.connection(session_a.id()).await.unwrap(); + assert_eq!(connection.endpoint_id.as_str(), "fake-a"); + assert_eq!(connection.session_id, session_a.id().clone()); +} + +#[tokio::test] +async fn logs_and_diagnostics_are_scoped_to_session() { + let mut provider = fake_provider(); + let session = provider + .connect(&LinkEndpointId::new("fake-a")) + .await + .unwrap(); + + let logs = provider.logs(session.id()).unwrap(); + let diagnostics = provider.diagnostics(session.id()).unwrap(); + + assert_eq!(logs[0].endpoint_id.as_str(), "fake-a"); + assert_eq!(logs[0].session_id, Some(session.id().clone())); + assert_eq!(diagnostics[0].endpoint_id.as_str(), "fake-a"); + assert_eq!(diagnostics[0].session_id, Some(session.id().clone())); + + provider.close(session.id()).await.unwrap(); + assert!(provider.connection(session.id()).await.is_err()); +} + +#[tokio::test] +async fn unsupported_management_request_returns_link_error() { + let mut provider = fake_provider(); + let session = provider + .connect(&LinkEndpointId::new("fake-a")) + .await + .unwrap(); + + let error = provider + .manage(session.id(), LinkManagementRequest::FlashFirmware) + .await + .unwrap_err(); + + assert!(matches!(error, LinkError::OperationUnsupported { .. })); +} + +fn fake_provider() -> FakeProvider { + FakeProvider::new() + .with_endpoint( + LinkEndpoint::new("fake-a", LinkProviderKind::Fake, "Fake A") + .with_capabilities(LinkCapabilities::diagnostics_only()), + ) + .with_endpoint(LinkEndpoint::new( + "fake-b", + LinkProviderKind::Fake, + "Fake B", + )) +} diff --git a/lp-app/lpa-link/src/providers/host_process/mod.rs b/lp-app/lpa-link/src/providers/host_process/mod.rs new file mode 100644 index 000000000..3f9048aa6 --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_process/mod.rs @@ -0,0 +1,6 @@ +mod provider; + +pub use provider::{HostProcessProvider, descriptor}; + +#[cfg(test)] +mod tests; diff --git a/lp-app/lpa-link/src/providers/host_process/provider.rs b/lp-app/lpa-link/src/providers/host_process/provider.rs new file mode 100644 index 000000000..8a3972e73 --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_process/provider.rs @@ -0,0 +1,197 @@ +use std::collections::BTreeMap; + +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::session::LinkSessionId; +use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; +use crate::{ + LinkCapabilities, LinkConnection, LinkConnectionKind, LinkDiagnostic, LinkDiagnosticSeverity, + LinkEndpoint, LinkError, LinkLogEntry, LinkLogLevel, LinkOperation, LinkProvider, LinkSession, + LinkSessionStatus, +}; +use fw_host::HostRuntime; + +pub fn descriptor() -> LinkProviderDescriptor { + LinkProviderKind::HostProcess.descriptor() +} + +pub struct HostProcessProvider { + endpoints: Vec, + sessions: BTreeMap, + next_endpoint_index: u64, + next_session_index: u64, +} + +impl HostProcessProvider { + pub fn new() -> Self { + Self { + endpoints: Vec::new(), + sessions: BTreeMap::new(), + next_endpoint_index: 1, + next_session_index: 1, + } + } + + /// Create a spawnable in-process `fw-host` memory runtime endpoint. + /// + /// The endpoint is not a physical device. Each successful `connect()` call + /// starts a new `fw-host` runtime instance and returns a session that owns + /// that runtime lifecycle. + pub fn create_memory_endpoint(&mut self, label: impl Into) -> LinkEndpointId { + let endpoint_id = LinkEndpointId::new(format!( + "{}-memory-{}", + self.kind().key(), + self.next_endpoint_index + )); + self.next_endpoint_index += 1; + + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.kind(), label) + .with_capabilities( + LinkCapabilities::default() + .with(LinkOperation::ReadLogs) + .with(LinkOperation::ReadDiagnostics), + ); + self.endpoints.push(endpoint); + endpoint_id + } + + fn endpoint(&self, endpoint_id: &LinkEndpointId) -> Result<&LinkEndpoint, LinkError> { + self.endpoints + .iter() + .find(|endpoint| endpoint.id == *endpoint_id) + .ok_or_else(|| LinkError::endpoint_not_found(endpoint_id.as_str())) + } + + fn session(&self, session_id: &LinkSessionId) -> Result<&HostProcessSessionState, LinkError> { + self.sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } + + fn session_mut( + &mut self, + session_id: &LinkSessionId, + ) -> Result<&mut HostProcessSessionState, LinkError> { + self.sessions + .get_mut(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } +} + +impl LinkProvider for HostProcessProvider { + fn kind(&self) -> LinkProviderKind { + LinkProviderKind::HostProcess + } + + async fn discover(&mut self) -> Result, LinkError> { + Ok(self.endpoints.clone()) + } + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + Ok(self.endpoint(endpoint_id)?.status.clone()) + } + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result { + let endpoint = self.endpoint(endpoint_id)?.clone(); + let runtime = HostRuntime::start_memory().map_err(|error| LinkError::ConnectionFailed { + message: error.to_string(), + })?; + let session_id = LinkSessionId::new(format!( + "{}:{}", + endpoint_id.as_str(), + self.next_session_index + )); + self.next_session_index += 1; + + let session = LinkSession::new( + session_id.clone(), + self.kind(), + endpoint.id.clone(), + LinkConnectionKind::HostProcess, + endpoint.capabilities.clone(), + ); + self.sessions.insert( + session_id, + HostProcessSessionState::new(endpoint.id, session.clone(), runtime), + ); + Ok(session) + } + + async fn connection( + &mut self, + session_id: &LinkSessionId, + ) -> Result { + let state = self.session(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Err(LinkError::Closed); + } + Ok(LinkConnection::host_process( + state.session.endpoint_id.clone(), + state.session.id.clone(), + state.runtime.client_transport(), + )) + } + + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.logs.clone()) + } + + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.diagnostics.clone()) + } + + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + let state = self.session_mut(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Ok(()); + } + state.session.status = LinkSessionStatus::Closed; + state + .runtime + .close() + .await + .map_err(|error| LinkError::other(error.to_string()))?; + state.logs.push(LinkLogEntry::new( + state.endpoint_id.clone(), + Some(state.session.id.clone()), + LinkLogLevel::Info, + "host process runtime stopped", + )); + Ok(()) + } +} + +struct HostProcessSessionState { + endpoint_id: LinkEndpointId, + session: LinkSession, + runtime: HostRuntime, + logs: Vec, + diagnostics: Vec, +} + +impl HostProcessSessionState { + fn new(endpoint_id: LinkEndpointId, session: LinkSession, runtime: HostRuntime) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(session.id.clone()), + LinkLogLevel::Info, + "host process runtime started", + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(session.id.clone()), + LinkDiagnosticSeverity::Info, + "host process runtime ready", + )]; + + Self { + endpoint_id, + session, + runtime, + logs, + diagnostics, + } + } +} diff --git a/lp-app/lpa-link/src/providers/host_process/tests.rs b/lp-app/lpa-link/src/providers/host_process/tests.rs new file mode 100644 index 000000000..bb67903f7 --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_process/tests.rs @@ -0,0 +1,45 @@ +use crate::LinkProvider; +use crate::provider::endpoint::LinkEndpointId; +use crate::providers::host_process::HostProcessProvider; + +#[tokio::test] +async fn host_process_connection_serves_client_requests() { + let mut provider = provider_with_two_endpoints(); + let endpoint_id = LinkEndpointId::new("host-process-memory-1"); + let session = provider.connect(&endpoint_id).await.unwrap(); + + let connection = provider.connection(session.id()).await.unwrap(); + assert!(matches!( + connection.kind, + crate::LinkConnectionKind::HostProcess + )); + let client = connection.server_client().unwrap(); + let projects = client.project_list_available().await.unwrap(); + + assert!(projects.is_empty()); + provider.close(session.id()).await.unwrap(); +} + +#[tokio::test] +async fn host_process_provider_supports_multiple_endpoints() { + let mut provider = provider_with_two_endpoints(); + let endpoints = provider.discover().await.unwrap(); + + assert_eq!(endpoints.len(), 2); + + let session_a = provider.connect(&endpoints[0].id).await.unwrap(); + let session_b = provider.connect(&endpoints[1].id).await.unwrap(); + + assert_ne!(session_a.id(), session_b.id()); + assert_ne!(session_a.endpoint_id(), session_b.endpoint_id()); + + provider.close(session_a.id()).await.unwrap(); + provider.close(session_b.id()).await.unwrap(); +} + +fn provider_with_two_endpoints() -> HostProcessProvider { + let mut provider = HostProcessProvider::new(); + provider.create_memory_endpoint("Host Process A"); + provider.create_memory_endpoint("Host Process B"); + provider +} diff --git a/lp-app/lpa-link/src/providers/host_serial_esp32/mod.rs b/lp-app/lpa-link/src/providers/host_serial_esp32/mod.rs new file mode 100644 index 000000000..d0af8a0a2 --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_serial_esp32/mod.rs @@ -0,0 +1,9 @@ +mod provider; + +pub use provider::{ + HostSerialEsp32Options, HostSerialEsp32Provider, descriptor, is_likely_esp32_serial_port, + label_for_port, +}; + +#[cfg(test)] +mod tests; diff --git a/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs b/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs new file mode 100644 index 000000000..0e316b54e --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_serial_esp32/provider.rs @@ -0,0 +1,350 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::session::LinkSessionId; +use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; +use crate::{ + LinkCapabilities, LinkConnection, LinkConnectionKind, LinkDiagnostic, LinkDiagnosticSeverity, + LinkEndpoint, LinkError, LinkLogEntry, LinkLogLevel, LinkProvider, LinkServerConnection, + LinkSession, LinkSessionStatus, +}; +use lpa_client::transport_serial::{ + HardwareSerialOptions, SerialLineObserver, create_hardware_serial_transport_pair_with_options, +}; +use tokio::sync::Mutex; + +pub fn descriptor() -> LinkProviderDescriptor { + LinkProviderKind::HostSerialEsp32.descriptor() +} + +pub struct HostSerialEsp32Provider { + endpoints: Vec, + sessions: BTreeMap, + options: HostSerialEsp32Options, + next_session_index: u64, +} + +#[derive(Clone, Default)] +pub struct HostSerialEsp32Options { + pub baud_rate: Option, + pub reset_after_open: bool, + pub line_observer: Option>, +} + +impl HostSerialEsp32Provider { + pub fn new() -> Self { + Self::with_options(HostSerialEsp32Options::default()) + } + + pub fn with_options(options: HostSerialEsp32Options) -> Self { + Self { + endpoints: Vec::new(), + sessions: BTreeMap::new(), + options, + next_session_index: 1, + } + } + + pub fn set_options(&mut self, options: HostSerialEsp32Options) { + self.options = options; + } + + pub fn options(&self) -> &HostSerialEsp32Options { + &self.options + } + + pub fn create_endpoint_for_port( + &mut self, + port_name: impl Into, + label: impl Into, + ) -> LinkEndpointId { + let port_name = port_name.into(); + let endpoint_id = endpoint_id_for_port(&port_name); + self.upsert_port_endpoint(endpoint_id.clone(), port_name, label.into()); + endpoint_id + } + + pub fn port_name_for_endpoint(&self, endpoint_id: &LinkEndpointId) -> Option<&str> { + self.endpoints + .iter() + .find(|entry| entry.endpoint.id == *endpoint_id) + .map(|entry| entry.port_name.as_str()) + } + + fn refresh_discovered_endpoints(&mut self) -> Result<(), LinkError> { + let mut ports = serialport::available_ports() + .map_err(|error| LinkError::other(format!("failed to list serial ports: {error}")))? + .into_iter() + .map(|info| info.port_name) + .collect::>(); + ports.sort(); + + self.endpoints.clear(); + for port_name in ports { + let label = label_for_port(&port_name); + self.create_endpoint_for_port(port_name, label); + } + Ok(()) + } + + pub fn endpoint(&self, endpoint_id: &LinkEndpointId) -> Result<&LinkEndpoint, LinkError> { + Ok(&self.endpoint_entry(endpoint_id)?.endpoint) + } + + fn endpoint_entry( + &self, + endpoint_id: &LinkEndpointId, + ) -> Result<&HostSerialEsp32Endpoint, LinkError> { + self.endpoints + .iter() + .find(|entry| entry.endpoint.id == *endpoint_id) + .ok_or_else(|| LinkError::endpoint_not_found(endpoint_id.as_str())) + } + + fn session( + &self, + session_id: &LinkSessionId, + ) -> Result<&HostSerialEsp32SessionState, LinkError> { + self.sessions + .get(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } + + fn session_mut( + &mut self, + session_id: &LinkSessionId, + ) -> Result<&mut HostSerialEsp32SessionState, LinkError> { + self.sessions + .get_mut(session_id) + .ok_or_else(|| LinkError::session_not_found(session_id.as_str())) + } + + fn upsert_port_endpoint( + &mut self, + endpoint_id: LinkEndpointId, + port_name: String, + label: String, + ) { + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.kind(), label) + .with_capabilities(LinkCapabilities::esp32_serial_base()); + + if let Some(existing) = self + .endpoints + .iter_mut() + .find(|entry| entry.endpoint.id == endpoint_id) + { + *existing = HostSerialEsp32Endpoint { + endpoint, + port_name, + }; + } else { + self.endpoints.push(HostSerialEsp32Endpoint { + endpoint, + port_name, + }); + } + } +} + +impl LinkProvider for HostSerialEsp32Provider { + fn kind(&self) -> LinkProviderKind { + LinkProviderKind::HostSerialEsp32 + } + + async fn discover(&mut self) -> Result, LinkError> { + self.refresh_discovered_endpoints()?; + Ok(self + .endpoints + .iter() + .map(|entry| entry.endpoint.clone()) + .collect()) + } + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + Ok(self.endpoint(endpoint_id)?.status.clone()) + } + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result { + let endpoint = self.endpoint_entry(endpoint_id)?.clone(); + let session_id = LinkSessionId::new(format!( + "{}:{}", + endpoint_id.as_str(), + self.next_session_index + )); + self.next_session_index += 1; + + let baud_rate = self + .options + .baud_rate + .unwrap_or(lpc_model::DEFAULT_SERIAL_BAUD_RATE); + let serial_options = HardwareSerialOptions { + reset_after_open: self.options.reset_after_open, + line_observer: self.options.line_observer.clone(), + }; + let transport = create_hardware_serial_transport_pair_with_options( + &endpoint.port_name, + baud_rate, + serial_options, + ) + .map_err(|error| LinkError::ConnectionFailed { + message: error.to_string(), + })?; + let transport: Box = Box::new(transport); + let server_connection: LinkServerConnection = Arc::new(Mutex::new(transport)); + + let session = LinkSession::new( + session_id.clone(), + self.kind(), + endpoint.endpoint.id.clone(), + LinkConnectionKind::HostSerialEsp32, + endpoint.endpoint.capabilities.clone(), + ); + self.sessions.insert( + session_id, + HostSerialEsp32SessionState::new( + session.clone(), + endpoint.port_name, + baud_rate, + server_connection, + ), + ); + Ok(session) + } + + async fn connection( + &mut self, + session_id: &LinkSessionId, + ) -> Result { + let state = self.session(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Err(LinkError::Closed); + } + let Some(server_connection) = &state.server_connection else { + return Err(LinkError::Closed); + }; + Ok(LinkConnection::host_serial_esp32( + state.session.endpoint_id.clone(), + state.session.id.clone(), + server_connection.clone(), + )) + } + + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.logs.clone()) + } + + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError> { + Ok(self.session(session_id)?.diagnostics.clone()) + } + + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + let state = self.session_mut(session_id)?; + if state.session.status == LinkSessionStatus::Closed { + return Ok(()); + } + state.session.status = LinkSessionStatus::Closed; + if let Some(server_connection) = state.server_connection.take() { + let mut transport = server_connection.lock().await; + lpa_client::ClientTransport::close(&mut **transport) + .await + .map_err(|error| LinkError::other(error.to_string()))?; + } + state.logs.push(LinkLogEntry::new( + state.session.endpoint_id.clone(), + Some(state.session.id.clone()), + LinkLogLevel::Info, + format!( + "host serial ESP32 session closed on {} at {} baud", + state.port_name, state.baud_rate + ), + )); + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct HostSerialEsp32Endpoint { + endpoint: LinkEndpoint, + port_name: String, +} + +struct HostSerialEsp32SessionState { + session: LinkSession, + port_name: String, + baud_rate: u32, + server_connection: Option, + logs: Vec, + diagnostics: Vec, +} + +impl HostSerialEsp32SessionState { + fn new( + session: LinkSession, + port_name: String, + baud_rate: u32, + server_connection: LinkServerConnection, + ) -> Self { + let logs = vec![LinkLogEntry::new( + session.endpoint_id.clone(), + Some(session.id.clone()), + LinkLogLevel::Info, + format!("host serial ESP32 session opened on {port_name}"), + )]; + let diagnostics = vec![LinkDiagnostic::new( + session.endpoint_id.clone(), + Some(session.id.clone()), + LinkDiagnosticSeverity::Info, + format!("host serial ESP32 transport ready at {baud_rate} baud"), + )]; + Self { + session, + port_name, + baud_rate, + server_connection: Some(server_connection), + logs, + diagnostics, + } + } +} + +pub fn label_for_port(port_name: &str) -> String { + if is_likely_esp32_serial_port(port_name) { + format!("ESP32 Serial ({port_name})") + } else { + format!("Serial ({port_name})") + } +} + +fn endpoint_id_for_port(port_name: &str) -> LinkEndpointId { + LinkEndpointId::new(format!( + "{}:{}", + LinkProviderKind::HostSerialEsp32.key(), + sanitize_endpoint_part(port_name) + )) +} + +fn sanitize_endpoint_part(value: &str) -> String { + let mut out = String::new(); + let mut previous_dash = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + previous_dash = false; + } else if !previous_dash { + out.push('-'); + previous_dash = true; + } + } + out.trim_matches('-').to_string() +} + +pub fn is_likely_esp32_serial_port(port_name: &str) -> bool { + port_name.contains("usbmodem") + || port_name.contains("ttyUSB") + || port_name.contains("ttyACM") + || port_name.contains("tty.usbserial") +} diff --git a/lp-app/lpa-link/src/providers/host_serial_esp32/tests.rs b/lp-app/lpa-link/src/providers/host_serial_esp32/tests.rs new file mode 100644 index 000000000..2782ee562 --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_serial_esp32/tests.rs @@ -0,0 +1,54 @@ +use crate::LinkOperation; +use crate::providers::host_serial_esp32::{HostSerialEsp32Provider, label_for_port}; +use lpc_model::DEFAULT_SERIAL_BAUD_RATE; +#[test] +fn explicit_port_endpoint_records_metadata() { + let mut provider = HostSerialEsp32Provider::new(); + + let endpoint_id = provider.create_endpoint_for_port("/dev/cu.usbmodem2101", "Board"); + + assert_eq!( + endpoint_id.as_str(), + "host-serial-esp32:dev-cu-usbmodem2101" + ); + assert_eq!( + provider.port_name_for_endpoint(&endpoint_id), + Some("/dev/cu.usbmodem2101") + ); + let endpoint = provider.endpoint(&endpoint_id).unwrap(); + assert!(endpoint.capabilities.supports(LinkOperation::Reset)); + assert!(endpoint.capabilities.supports(LinkOperation::ReadLogs)); + assert!( + endpoint + .capabilities + .supports(LinkOperation::ReadDiagnostics) + ); + assert!(!endpoint.capabilities.supports(LinkOperation::FlashFirmware)); +} + +#[test] +fn labels_likely_esp32_ports() { + assert_eq!( + label_for_port("/dev/cu.usbmodem2101"), + "ESP32 Serial (/dev/cu.usbmodem2101)" + ); + assert_eq!( + label_for_port("/dev/cu.Bluetooth"), + "Serial (/dev/cu.Bluetooth)" + ); +} + +#[test] +fn default_options_do_not_reset_after_open() { + let provider = HostSerialEsp32Provider::new(); + + assert_eq!(provider.options().baud_rate, None); + assert!(!provider.options().reset_after_open); + assert_eq!( + provider + .options() + .baud_rate + .unwrap_or(DEFAULT_SERIAL_BAUD_RATE), + DEFAULT_SERIAL_BAUD_RATE + ); +} diff --git a/lp-app/lpa-link/src/providers/mod.rs b/lp-app/lpa-link/src/providers/mod.rs new file mode 100644 index 000000000..3053fdaf2 --- /dev/null +++ b/lp-app/lpa-link/src/providers/mod.rs @@ -0,0 +1,37 @@ +//! Concrete link provider implementations. +//! +//! Each submodule owns one runtime/device integration and its provider-specific +//! resources. Public callers usually enter through `crate::registry` to obtain +//! the enabled provider set; these modules are useful when an application or +//! test wants to construct a specific provider manually. +//! +//! Provider keys use kebab-case and generally follow +//! `{environment}-{mechanism}-{target?}`: +//! +//! - `host-process`: host process runtime backed by `fw-host` +//! - `browser-worker`: browser worker runtime backed by `fw-browser` +//! - `host-serial-esp32`: ESP32 hardware over host OS serial +//! - `browser-serial-esp32`: ESP32 hardware over browser Web Serial +//! - `host-websocket`: host-side websocket connection to an existing server +//! - `browser-websocket`: browser-side websocket connection to an existing server +//! +//! The target segment is optional when the mechanism already carries the whole +//! contract. Include it when management details are target-specific, such as +//! ESP32 flashing, reset, and filesystem behavior. + +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +pub mod browser_serial_esp32; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +pub mod browser_worker; +pub mod fake; +#[cfg(feature = "host-process")] +pub mod host_process; +#[cfg(feature = "host-serial-esp32")] +pub mod host_serial_esp32; + +pub use crate::registry::availability::LinkProviderAvailability; +pub use crate::registry::descriptor::LinkProviderDescriptor; +pub use crate::registry::env::LinkEnv; +pub use crate::registry::instance::LinkProviderInstance; +pub use crate::registry::kind::LinkProviderKind; +pub use crate::registry::registry::{LinkProviderRegistry, available_provider_descriptors}; diff --git a/lp-app/lpa-link/src/registry/availability.rs b/lp-app/lpa-link/src/registry/availability.rs new file mode 100644 index 000000000..fe227186b --- /dev/null +++ b/lp-app/lpa-link/src/registry/availability.rs @@ -0,0 +1,12 @@ +/// Build/runtime availability for a provider descriptor. +/// +/// The registry only constructs providers compiled for the current feature and +/// target matrix. This enum leaves room for future descriptors that are known +/// to the crate but not usable in the current runtime. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LinkProviderAvailability { + /// Provider can be constructed and used in the current build/runtime. + Available, + /// Provider is known but cannot currently be used. + Unavailable { reason: &'static str }, +} diff --git a/lp-app/lpa-link/src/registry/descriptor.rs b/lp-app/lpa-link/src/registry/descriptor.rs new file mode 100644 index 000000000..cc6bf66fa --- /dev/null +++ b/lp-app/lpa-link/src/registry/descriptor.rs @@ -0,0 +1,35 @@ +use crate::LinkCapabilities; +use crate::providers::{LinkProviderAvailability, LinkProviderKind}; + +/// Static metadata describing a provider kind available to an application. +/// +/// Descriptors are intentionally lower-level than Studio provider cards. They +/// describe what `lpa-link` can construct and what operations the provider can +/// perform; product layers can add UX intent, ordering, copy, and recovery +/// affordances on top. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LinkProviderDescriptor { + /// Built-in provider kind and stable app-boundary key. + pub kind: LinkProviderKind, + /// Short technical label supplied by `lpa-link`. + pub label: &'static str, + /// Whether the provider is usable in the current build/runtime. + pub availability: LinkProviderAvailability, + /// Low-level operations supported by the provider class. + pub capabilities: LinkCapabilities, +} + +impl LinkProviderDescriptor { + pub fn available( + kind: LinkProviderKind, + label: &'static str, + capabilities: LinkCapabilities, + ) -> Self { + Self { + kind, + label, + availability: LinkProviderAvailability::Available, + capabilities, + } + } +} diff --git a/lp-app/lpa-link/src/registry/env.rs b/lp-app/lpa-link/src/registry/env.rs new file mode 100644 index 000000000..5d75a708f --- /dev/null +++ b/lp-app/lpa-link/src/registry/env.rs @@ -0,0 +1,23 @@ +/// Application-supplied resources needed to construct compiled-in providers. +/// +/// `lpa-link` owns provider implementations, but some browser and host +/// providers need paths or options that belong to the embedding application: +/// bundled wasm modules, firmware manifests, esptool paths, serial options, +/// and similar deployment details. `LinkEnv` is the single feature-gated input +/// surface for those resources. +/// +/// Fields appear only when their provider feature and target are enabled. A +/// host build with browser features, for example, does not expose browser env +/// fields because the browser providers themselves are not compiled on host. +#[derive(Clone, Default)] +pub struct LinkEnv { + /// Browser Web Serial ESP32 provider options. + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + pub browser_serial_esp32: crate::providers::browser_serial_esp32::BrowserSerialEsp32Options, + /// Browser worker provider options, including app-owned module/wasm paths. + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + pub browser_worker: crate::providers::browser_worker::BrowserWorkerOptions, + /// Host serial ESP32 provider options. + #[cfg(feature = "host-serial-esp32")] + pub host_serial_esp32: crate::providers::host_serial_esp32::HostSerialEsp32Options, +} diff --git a/lp-app/lpa-link/src/registry/instance.rs b/lp-app/lpa-link/src/registry/instance.rs new file mode 100644 index 000000000..8c0645df4 --- /dev/null +++ b/lp-app/lpa-link/src/registry/instance.rs @@ -0,0 +1,248 @@ +use crate::provider::endpoint::{LinkEndpointId, LinkEndpointStatus}; +use crate::provider::management_event::LinkManagementEventSink; +use crate::provider::management_request::LinkManagementRequest; +use crate::provider::management_result::LinkManagementResult; +use crate::provider::session::LinkSessionId; +use crate::providers::{LinkProviderDescriptor, LinkProviderKind}; +use crate::{ + LinkConnection, LinkDiagnostic, LinkEndpoint, LinkError, LinkLogEntry, LinkProvider, + LinkSession, +}; + +/// Enum-dispatched owner for concrete provider implementations. +/// +/// `LinkProvider` is not object-safe because it has async methods, so the +/// registry cannot store `Box` today. This enum gives the +/// registry a single stored type while preserving concrete provider ownership +/// and forwarding the shared controller interface. +pub enum LinkProviderInstance { + Fake(crate::providers::fake::FakeProvider), + #[cfg(feature = "host-process")] + HostProcess(crate::providers::host_process::HostProcessProvider), + #[cfg(feature = "host-serial-esp32")] + HostSerialEsp32(crate::providers::host_serial_esp32::HostSerialEsp32Provider), + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + BrowserWorker(crate::providers::browser_worker::BrowserWorkerProvider), + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + BrowserSerialEsp32(crate::providers::browser_serial_esp32::BrowserSerialEsp32Provider), +} + +impl LinkProviderInstance { + /// Descriptor for the concrete provider's kind. + pub fn descriptor(&self) -> LinkProviderDescriptor { + self.kind().descriptor() + } +} + +impl LinkProvider for LinkProviderInstance { + fn kind(&self) -> LinkProviderKind { + match self { + Self::Fake(provider) => provider.kind(), + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.kind(), + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.kind(), + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.kind(), + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.kind(), + } + } + + async fn discover(&mut self) -> Result, LinkError> { + match self { + Self::Fake(provider) => provider.discover().await, + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.discover().await, + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.discover().await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.discover().await, + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.discover().await, + } + } + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result { + match self { + Self::Fake(provider) => provider.status(endpoint_id).await, + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.status(endpoint_id).await, + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.status(endpoint_id).await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.status(endpoint_id).await, + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.status(endpoint_id).await, + } + } + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result { + match self { + Self::Fake(provider) => provider.connect(endpoint_id).await, + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.connect(endpoint_id).await, + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.connect(endpoint_id).await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.connect(endpoint_id).await, + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.connect(endpoint_id).await, + } + } + + async fn connection( + &mut self, + session_id: &LinkSessionId, + ) -> Result { + match self { + Self::Fake(provider) => provider.connection(session_id).await, + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.connection(session_id).await, + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.connection(session_id).await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.connection(session_id).await, + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.connection(session_id).await, + } + } + + fn logs(&self, session_id: &LinkSessionId) -> Result, LinkError> { + match self { + Self::Fake(provider) => provider.logs(session_id), + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.logs(session_id), + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.logs(session_id), + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.logs(session_id), + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.logs(session_id), + } + } + + fn diagnostics(&self, session_id: &LinkSessionId) -> Result, LinkError> { + match self { + Self::Fake(provider) => provider.diagnostics(session_id), + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.diagnostics(session_id), + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.diagnostics(session_id), + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.diagnostics(session_id), + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.diagnostics(session_id), + } + } + + async fn manage( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + ) -> Result { + match self { + Self::Fake(provider) => provider.manage(session_id, request).await, + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.manage(session_id, request).await, + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.manage(session_id, request).await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.manage(session_id, request).await, + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.manage(session_id, request).await, + } + } + + async fn manage_with_events( + &mut self, + session_id: &LinkSessionId, + request: LinkManagementRequest, + events: LinkManagementEventSink, + ) -> Result { + match self { + Self::Fake(provider) => { + provider + .manage_with_events(session_id, request, events) + .await + } + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => { + provider + .manage_with_events(session_id, request, events) + .await + } + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => { + provider + .manage_with_events(session_id, request, events) + .await + } + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => { + provider + .manage_with_events(session_id, request, events) + .await + } + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => { + provider + .manage_with_events(session_id, request, events) + .await + } + } + } + + async fn close(&mut self, session_id: &LinkSessionId) -> Result<(), LinkError> { + match self { + Self::Fake(provider) => provider.close(session_id).await, + #[cfg(feature = "host-process")] + Self::HostProcess(provider) => provider.close(session_id).await, + #[cfg(feature = "host-serial-esp32")] + Self::HostSerialEsp32(provider) => provider.close(session_id).await, + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + Self::BrowserWorker(provider) => provider.close(session_id).await, + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + Self::BrowserSerialEsp32(provider) => provider.close(session_id).await, + } + } +} + +impl From for LinkProviderInstance { + fn from(provider: crate::providers::fake::FakeProvider) -> Self { + Self::Fake(provider) + } +} + +#[cfg(feature = "host-process")] +impl From for LinkProviderInstance { + fn from(provider: crate::providers::host_process::HostProcessProvider) -> Self { + Self::HostProcess(provider) + } +} + +#[cfg(feature = "host-serial-esp32")] +impl From for LinkProviderInstance { + fn from(provider: crate::providers::host_serial_esp32::HostSerialEsp32Provider) -> Self { + Self::HostSerialEsp32(provider) + } +} + +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +impl From for LinkProviderInstance { + fn from(provider: crate::providers::browser_worker::BrowserWorkerProvider) -> Self { + Self::BrowserWorker(provider) + } +} + +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +impl From + for LinkProviderInstance +{ + fn from(provider: crate::providers::browser_serial_esp32::BrowserSerialEsp32Provider) -> Self { + Self::BrowserSerialEsp32(provider) + } +} diff --git a/lp-app/lpa-link/src/registry/kind.rs b/lp-app/lpa-link/src/registry/kind.rs new file mode 100644 index 000000000..9d4351462 --- /dev/null +++ b/lp-app/lpa-link/src/registry/kind.rs @@ -0,0 +1,87 @@ +use std::str::FromStr; + +use crate::{LinkCapabilities, LinkOperation}; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumIter, EnumString, IntoStaticStr}; + +/// Stable built-in provider class. +/// +/// A kind is the identity of a provider implementation, not a configured +/// instance id. The current link model has at most one provider per kind in a +/// registry. String conversions use kebab-case keys such as +/// `browser-serial-esp32`, derived by `strum`/`serde` from the enum variants. +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Display, + EnumIter, + EnumString, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + Serialize, + IntoStaticStr, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum LinkProviderKind { + /// Test provider with in-memory fake endpoints and diagnostics. + Fake, + /// Host process provider that spawns local `fw-host` runtimes. + HostProcess, + /// Host serial provider for ESP32 hardware over OS serial ports. + HostSerialEsp32, + /// Browser worker provider backed by `fw-browser`. + BrowserWorker, + /// Browser Web Serial provider for ESP32 hardware and flashing. + BrowserSerialEsp32, +} + +impl LinkProviderKind { + /// Stable kebab-case key used in serialized state and app boundaries. + pub fn key(self) -> &'static str { + self.into() + } + + /// Alias for `key` for call sites that want string-like access. + pub fn as_str(&self) -> &'static str { + self.key() + } + + /// Parse a provider key, returning `None` for unknown keys. + pub fn from_key(key: &str) -> Option { + Self::from_str(key).ok() + } + + /// Technical label supplied by `lpa-link`. + pub fn label(self) -> &'static str { + match self { + Self::Fake => "Fake", + Self::HostProcess => "Host process", + Self::HostSerialEsp32 => "Host serial ESP32", + Self::BrowserWorker => "Browser worker", + Self::BrowserSerialEsp32 => "Browser serial ESP32", + } + } + + /// Baseline provider-class capabilities before endpoint/session specifics. + pub fn capabilities(self) -> LinkCapabilities { + match self { + Self::Fake => LinkCapabilities::diagnostics_only(), + Self::HostProcess | Self::BrowserWorker => LinkCapabilities::default() + .with(LinkOperation::ReadLogs) + .with(LinkOperation::ReadDiagnostics), + Self::HostSerialEsp32 => LinkCapabilities::esp32_serial_base(), + Self::BrowserSerialEsp32 => LinkCapabilities::esp32_serial_base().with_flash(), + } + } + + /// Static provider descriptor for this built-in kind. + pub fn descriptor(self) -> crate::providers::LinkProviderDescriptor { + crate::providers::LinkProviderDescriptor::available(self, self.label(), self.capabilities()) + } +} diff --git a/lp-app/lpa-link/src/registry/mod.rs b/lp-app/lpa-link/src/registry/mod.rs new file mode 100644 index 000000000..a0f52d158 --- /dev/null +++ b/lp-app/lpa-link/src/registry/mod.rs @@ -0,0 +1,18 @@ +//! Provider registry, metadata, and built-in provider keys. +//! +//! The registry layer answers "which providers exist in this build?" and owns +//! the compiled-in provider instances keyed by `LinkProviderKind`. It keeps the +//! feature/target matrix in `lpa-link`, so applications can enumerate providers +//! and construct the default registry without duplicating conditional logic. +//! +//! `LinkEnv` is the application-supplied construction input for resources that +//! cannot live inside the crate, such as browser asset paths or host serial +//! options. `LinkProviderInstance` is the enum-dispatched storage type used +//! because `LinkProvider` has async methods and is not object-safe. + +pub mod availability; +pub mod descriptor; +pub mod env; +pub mod instance; +pub mod kind; +pub mod registry; diff --git a/lp-app/lpa-link/src/registry/registry.rs b/lp-app/lpa-link/src/registry/registry.rs new file mode 100644 index 000000000..444e693a1 --- /dev/null +++ b/lp-app/lpa-link/src/registry/registry.rs @@ -0,0 +1,180 @@ +use std::collections::BTreeMap; + +use crate::LinkProvider; +use crate::providers::{LinkEnv, LinkProviderDescriptor, LinkProviderInstance, LinkProviderKind}; + +/// Runtime collection of provider implementations compiled into `lpa-link`. +/// +/// The registry owns provider instances keyed by `LinkProviderKind`. It is the +/// high-level entry point for applications that want to enumerate available +/// providers from the same feature/target matrix that compiled `lpa-link`, +/// without duplicating provider availability logic in the application crate. +#[derive(Default)] +pub struct LinkProviderRegistry { + providers: BTreeMap, +} + +impl LinkProviderRegistry { + /// Create an empty registry for manual provider insertion. + pub fn new() -> Self { + Self::default() + } + + /// Construct the default registry for the current build and target. + /// + /// Providers are inserted only when their crate feature and target + /// conditions are satisfied. App-owned configuration is read from `env` + /// through feature-gated fields. + pub fn from_env(env: LinkEnv) -> Self { + let mut registry = Self::new(); + let _ = &env; + + registry.insert(crate::providers::fake::FakeProvider::new()); + + #[cfg(feature = "host-process")] + { + let mut provider = crate::providers::host_process::HostProcessProvider::new(); + provider.create_memory_endpoint("Host process runtime"); + registry.insert(provider); + } + + #[cfg(feature = "host-serial-esp32")] + registry.insert( + crate::providers::host_serial_esp32::HostSerialEsp32Provider::with_options( + env.host_serial_esp32, + ), + ); + + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + { + let mut provider = + crate::providers::browser_worker::BrowserWorkerProvider::with_options( + env.browser_worker, + ); + provider.create_worker_endpoint("Browser firmware runtime"); + registry.insert(provider); + } + + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + registry.insert( + crate::providers::browser_serial_esp32::BrowserSerialEsp32Provider::with_options( + env.browser_serial_esp32, + ), + ); + + registry + } + + /// Insert or replace a provider by its `LinkProviderKind`. + pub fn insert(&mut self, provider: impl Into) { + let provider = provider.into(); + self.providers.insert(provider.kind(), provider); + } + + /// Iterate over provider instances in key order. + pub fn providers(&self) -> impl Iterator { + self.providers.values() + } + + /// Mutably iterate over provider instances in key order. + pub fn providers_mut(&mut self) -> impl Iterator { + self.providers.values_mut() + } + + /// Return the provider for a kind, if it is available in this registry. + pub fn provider(&self, kind: LinkProviderKind) -> Option<&LinkProviderInstance> { + self.providers.get(&kind) + } + + /// Return the mutable provider for a kind, if it is available in this registry. + pub fn provider_mut(&mut self, kind: LinkProviderKind) -> Option<&mut LinkProviderInstance> { + self.providers.get_mut(&kind) + } + + /// Return descriptors for all providers currently owned by the registry. + pub fn descriptors(&self) -> Vec { + self.providers() + .map(LinkProviderInstance::descriptor) + .collect() + } + + /// Return all provider kinds currently owned by the registry. + pub fn kinds(&self) -> Vec { + self.providers.keys().copied().collect() + } +} + +impl From for LinkProviderRegistry { + fn from(env: LinkEnv) -> Self { + Self::from_env(env) + } +} + +/// Convenience descriptor list for apps that only need provider metadata. +pub fn available_provider_descriptors() -> Vec { + LinkProviderRegistry::from_env(LinkEnv::default()).descriptors() +} + +#[cfg(test)] +mod tests { + use crate::providers::{LinkEnv, LinkProviderKind, LinkProviderRegistry}; + + #[test] + fn default_registry_includes_fake_provider() { + let registry = LinkProviderRegistry::from_env(LinkEnv::default()); + + assert!(registry.provider(LinkProviderKind::Fake).is_some()); + } + + #[cfg(feature = "host-process")] + #[test] + fn host_process_feature_adds_host_process_provider() { + let registry = LinkProviderRegistry::from_env(LinkEnv::default()); + + assert!(registry.provider(LinkProviderKind::HostProcess).is_some()); + } + + #[cfg(feature = "host-serial-esp32")] + #[test] + fn host_serial_feature_adds_host_serial_provider() { + let registry = LinkProviderRegistry::from_env(LinkEnv::default()); + + assert!( + registry + .provider(LinkProviderKind::HostSerialEsp32) + .is_some() + ); + } + + #[cfg(all(feature = "browser-worker", not(target_arch = "wasm32")))] + #[test] + fn browser_worker_feature_does_not_add_host_provider() { + let registry = LinkProviderRegistry::from_env(LinkEnv::default()); + + assert!(registry.provider(LinkProviderKind::BrowserWorker).is_none()); + } + + #[cfg(all(feature = "browser-serial-esp32", not(target_arch = "wasm32")))] + #[test] + fn browser_serial_feature_does_not_add_host_provider() { + let registry = LinkProviderRegistry::from_env(LinkEnv::default()); + + assert!( + registry + .provider(LinkProviderKind::BrowserSerialEsp32) + .is_none() + ); + } + + #[test] + fn provider_kind_uses_kebab_case_keys() { + assert_eq!( + LinkProviderKind::BrowserSerialEsp32.key(), + "browser-serial-esp32" + ); + assert_eq!( + LinkProviderKind::from_key("host-process"), + Some(LinkProviderKind::HostProcess) + ); + } +} diff --git a/lp-app/lpa-server/src/handlers.rs b/lp-app/lpa-server/src/handlers.rs index 3c2c6a9ae..510b2f281 100644 --- a/lp-app/lpa-server/src/handlers.rs +++ b/lp-app/lpa-server/src/handlers.rs @@ -188,6 +188,16 @@ fn handle_load_project( ) -> Result { backtrace::set_oom_context("server handler: load project"); log::info!("Loading project: {}", path.as_str()); + let loaded_count = project_manager.list_loaded_projects().len(); + if loaded_count > 0 { + log::info!( + "Unloading {loaded_count} project(s) before loading {}", + path.as_str() + ); + log_memory(memory_stats, "load_project unload existing before"); + project_manager.unload_all_projects()?; + log_memory(memory_stats, "load_project unload existing after"); + } log_memory(memory_stats, "load_project before"); let handle = project_manager.load_project( path, diff --git a/lp-app/lpa-studio-ux/Cargo.toml b/lp-app/lpa-studio-ux/Cargo.toml new file mode 100644 index 000000000..c3ca16eb9 --- /dev/null +++ b/lp-app/lpa-studio-ux/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "lpa-studio-ux" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "UI-independent LightPlayer Studio UX surface" + +[dependencies] +lpa-client = { path = "../lpa-client", default-features = false } +lpa-link = { path = "../lpa-link" } +lpc-model = { path = "../../lp-core/lpc-model" } +lpc-wire = { path = "../../lp-core/lpc-wire" } +async-trait = { version = "0.1", optional = true } +js-sys = { version = "0.3", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +web-sys = { version = "0.3", optional = true, features = ["Window"] } + +[features] +default = [] +browser-worker = [ + "lpa-link/browser-worker", + "dep:async-trait", + "dep:js-sys", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] +browser-serial-esp32 = [ + "lpa-link/browser-serial-esp32", + "dep:async-trait", + "dep:js-sys", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] + +[lints] +workspace = true diff --git a/lp-app/lpa-studio-ux/README.md b/lp-app/lpa-studio-ux/README.md new file mode 100644 index 000000000..0c77f48b7 --- /dev/null +++ b/lp-app/lpa-studio-ux/README.md @@ -0,0 +1,190 @@ +# lpa-studio-ux + +`lpa-studio-ux` is the UI-independent Studio UX layer. + +`Ux` means a resource-owning product surface. This crate sits above lower-level +services such as `lpa-link` and `lpa-client`, owns those services for Studio, +and exposes a user-shaped language of views, actions, progress, issues, logs, +and project summaries. + +The UI layer should render this language and dispatch actions back into +`StudioUx`. It should not own provider runtimes, drain service effects, +correlate protocol responses, or implement project attach/load policy. + +```text +lpa-link / lpa-client / lp-server protocol + owned by +lpa-studio-ux + rendered by +lpa-studio-web, future CLI, future desktop, tests, and agents +``` + +## Boundaries + +- `lpa-link` owns provider resources such as browser workers, endpoint/session + identity, and device/runtime management. +- `lpa-client` owns server protocol request ids, response correlation, typed + project operations, and side-channel protocol events. +- `lpa-studio-ux` owns Studio product state, the `LinkProviderRegistry`, the + connected server client, project attach/load policy, and async action + execution above those services. +- `lpa-studio-web` renders `StudioView` panes, stack sections, terminal output, + and available actions. + +## Public Model + +- `StudioUx` is the top-level controller. It owns `DeviceUx` and `ProjectUx`. +- `DeviceUx` is the user-facing device workflow. It owns the lower-level link + and server controllers and presents one stack of steps: select connection, + connect device, connect LightPlayer, and open project. The stack is + progressive: completed steps remain as compact history, the current relevant + step owns the available actions, and future steps are hidden until they are + useful. +- Device exposes the open-project step only after LightPlayer is connected. That + step offers running-project attach and demo-load actions until a project is + loaded. +- `LinkUx` owns link-provider selection, the `LinkProviderRegistry`, and the + active link session. It remains an implementation detail below `DeviceUx`. +- `ServerUx` owns the connected `lpa-client` protocol client once a link exposes + server I/O. It remains an implementation detail below `DeviceUx`. +- `ProjectUx` owns Studio's view of the loaded project and is shown only after a + project is loaded. +- `UiAction` is an in-process action offering: target `UxNodeId`, boxed typed + operation, and metadata such as label, summary, priority, icon, enablement, + and confirmation. +- `DeviceOp` and `ProjectOp` are the typed user-facing operations. Operation + identity is the enum type and variant, not a parallel string action kind. +- `StudioView` is the semantic render surface. It contains a Device + `UiPaneView`, an optional loaded Project `UiPaneView`, and recent logs. +- `UiBody` is intentionally small: text, progress/activity, issue, metrics, + stack, or empty. It is not a generic component schema. +- `UiStackView` / `UiStackSection` model reusable multi-step product workflows. + Device uses them for connection, LightPlayer attach, provisioning, and project + opening. Section-local actions are the action surface. +- `UiActivity` describes live work inside a pane or stack section: title, + optional progress, optional milestone steps, and optional terminal lines. +- `UxUpdate` / `UxUpdateSink` let `StudioUx::dispatch_with_updates` publish + live pane activity or fresh `StudioView` snapshots while an async action is + still running. +- `StudioSnapshot` and the node snapshots remain cloneable domain read models, + but web rendering should prefer `StudioView`. + +The first slice supports the browser-worker simulator and browser Web Serial +ESP32 entrypoints. It launches `fw-browser` through `lpa-link`, talks to the +real `lp-server` protocol through `lpa-client`, attaches to a running project +when one is already loaded, can load the demo project, and reads project +inventory. + +Project attach behavior is UX-owned: + +- zero loaded projects: once LightPlayer is connected, offer to load the demo + project in the Device open-project step; +- one loaded project: auto-attach after server connection and then show the + Project pane; +- multiple loaded projects: show the selection in the Device open-project step + and expose one action per loaded project. + +For the browser-worker simulator, the zero-loaded-project case auto-loads the +demo project. Real hardware remains conservative and requires explicit project +loading when nothing is running. + +## Feedback And Recovery + +Recoverable connection problems are modeled in the same view/action language as +the rest of Studio. If opening a device fails, `LinkUx` returns to provider +selection with an inline `UxIssue` and the normal provider actions still +available. Retrying is therefore the same operation as choosing a connection +again; `Refresh connections` is reserved for rebuilding the provider catalog. + +Canceling the browser Web Serial chooser is a normal UX outcome, not a failed +link. `lpa-link` preserves chooser cancellation as a typed cancellation error, +`LinkUx` returns to provider selection without an issue, and `StudioUx` reports +only a low-key notice suitable for a console or activity log. + +Generic notices and action failures are expected to flow into recent activity +logs. Actionable issues that affect the next user choice should live inline in +the relevant `UiStackSection` body. + +## Device Management UX + +Blank-device provisioning and recovery are modeled as Device actions backed by +link-level management because they happen below the running server protocol: + +- `Flash firmware` is offered in the Connect LightPlayer step when the + connected device session supports `FlashFirmware` and Studio is not currently + attached to a server. +- `Wipe device` is offered as a tertiary destructive Device action when the + connected device session supports `EraseDeviceFlash`. + +Both actions flow through `lpa-link::LinkProvider::manage_with_events`. +`StudioUx` clears project and server state before executing them because +firmware flashing and full-device erase invalidate any previous server/client +connection. Browser Web Serial ESP32 management streams progress into the +active Device step and raw esptool output into the Studio log while the action +is running. + +After provisioning, Studio attempts to reopen the server protocol and resume the +normal server/project workflow. If the browser or device needs more time after +reset, Studio keeps the link context and reports that the user should reconnect +after boot. + +For Browser Web Serial ESP32 links, opening or reopening the server protocol +goes through the provider-owned browser ESP32 device controller. The controller +opens the Web Serial port once, starts reading immediately, then attempts a +best-effort reset while raw boot output is being captured. The browser-serial +client waits for either a valid protocol frame or the firmware's server-loop +startup line before sending the first request, so a just-reset device does not +lose the initial project probe while firmware is still booting. Reset signal +failures are reported as diagnostics; the user-facing readiness result comes +from raw serial output and protocol frames. + +While waiting for browser serial readiness, Studio publishes a stepped +`UiActivity` in the Device pane. The reusable activity data includes serial +access, device reset, boot output, and LightPlayer protocol readiness; raw boot +lines are emitted as logs so the web UI, agents, and future CLI shells can +render progress and logs in separate places. + +If ESP32 boot output includes patterns such as `invalid header: 0xffffffff`, +Studio classifies the device as blank/erased instead of surfacing a generic +protocol timeout. The link session remains open, project/server state is +detached, and `Flash firmware` remains available when the selected provider +advertises flashing support. + +After wipe, Studio leaves project and server detached and returns to a link +state that can offer firmware flashing again. Wipe is not a server filesystem +clear; it is a destructive whole-device erase through the link provider. + +Disconnect semantics are intentionally distinct: + +- disconnecting a project detaches Studio from the project and leaves the server + and link connected; +- disconnecting the Device clears project/server/link and returns to connection + choices. + +## Agent And CLI Use + +The same tree can be rendered textually for agents or future CLI shells: + +```rust +let view = studio.view(); +let text = view.render_text(); +``` + +Actions remain in-process values. Text rendering can describe available actions, +but it is not a stable wire protocol and does not serialize operations. +Interactive shells can use `dispatch_with_updates` to show progress/terminal +updates during long actions without owning provider resources themselves. + +## Removed Old Split + +The old `lpa-studio-core` / `lpa-studio-runtime` split has been removed from +the active workspace. The UX crate owns the controller logic directly instead +of routing application work through a separate effect/event executor. + +## Validation + +```bash +cargo check -p lpa-studio-ux +cargo test -p lpa-studio-ux +cargo check -p lpa-studio-ux --target wasm32-unknown-unknown --features browser-worker,browser-serial-esp32 +``` diff --git a/lp-app/lpa-studio-ux/src/lib.rs b/lp-app/lpa-studio-ux/src/lib.rs new file mode 100644 index 000000000..bfe2cd022 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/lib.rs @@ -0,0 +1,37 @@ +//! UI-independent LightPlayer Studio UX surface. + +pub use lpa_link::{LinkEndpointId, LinkEndpointStatus, LinkProviderKind}; + +pub mod node; + +pub mod nodes; +pub mod ui; + +pub use node::{ + ActionConfirmation, ActionEnablement, ActionMeta, ActionPriority, UiAction, UiActions, + UxContext, UxNode, UxNodeId, UxOp, +}; +pub use nodes::device::{DeviceOp, DeviceSnapshot, DeviceUx}; +pub use nodes::link::{ + ConnectedDeviceSummary, ConnectedLink, EndpointChoice, LinkManagementOutcome, LinkOp, + LinkOpenOutcome, LinkSnapshot, LinkState, LinkUx, ProgressState, ProviderChoice, + SharedLinkRegistry, UxIssue, +}; +pub use nodes::project::{ + LoadedProjectChoice, ProjectConnectResult, ProjectInventorySummary, ProjectOp, ProjectSnapshot, + ProjectState, ProjectUx, +}; +pub use nodes::server::{ + LoadedDemoProject, LoadedProjectCatalog, ServerFailureKind, ServerOp, ServerSnapshot, + ServerState, ServerUx, StudioServerClient, +}; +pub use nodes::studio::{ + StudioSnapshot, StudioUx, UxActivityTarget, UxError, UxLogEntry, UxLogLevel, UxNotice, + UxNoticeLevel, UxOutcome, UxResult, UxUpdate, UxUpdateSink, +}; +pub use ui::{ + StudioView, UiActivity, UiActivityStep, UiActivityStepState, UiBody, UiMetric, UiPaneView, + UiProgress, UiStackSection, UiStackView, UiStatus, UiStatusKind, UiStepState, UiTerminalLine, +}; + +pub const STUDIO_DEMO_PROJECT_ID: &str = "studio-demo"; diff --git a/lp-app/lpa-studio-ux/src/node/mod.rs b/lp-app/lpa-studio-ux/src/node/mod.rs new file mode 100644 index 000000000..71702fb4f --- /dev/null +++ b/lp-app/lpa-studio-ux/src/node/mod.rs @@ -0,0 +1,15 @@ +pub mod ux_context; +pub mod ux_node; +pub mod ux_node_id; +pub mod ux_op; + +pub use crate::ui::action::action_confirmation::ActionConfirmation; +pub use crate::ui::action::action_enablement::ActionEnablement; +pub use crate::ui::action::action_meta::ActionMeta; +pub use crate::ui::action::action_priority::ActionPriority; +pub use crate::ui::action::ui_action::UiAction; +pub use crate::ui::action::ui_actions::UiActions; +pub use ux_context::UxContext; +pub use ux_node::UxNode; +pub use ux_node_id::UxNodeId; +pub use ux_op::UxOp; diff --git a/lp-app/lpa-studio-ux/src/node/ux_context.rs b/lp-app/lpa-studio-ux/src/node/ux_context.rs new file mode 100644 index 000000000..7a792354e --- /dev/null +++ b/lp-app/lpa-studio-ux/src/node/ux_context.rs @@ -0,0 +1,8 @@ +use core::future::Future; +use core::pin::Pin; + +use crate::{UiAction, UxResult}; + +pub trait UxContext { + fn dispatch(&mut self, action: UiAction) -> Pin + '_>>; +} diff --git a/lp-app/lpa-studio-ux/src/node/ux_node.rs b/lp-app/lpa-studio-ux/src/node/ux_node.rs new file mode 100644 index 000000000..97cc9cfcd --- /dev/null +++ b/lp-app/lpa-studio-ux/src/node/ux_node.rs @@ -0,0 +1,15 @@ +use crate::{UiAction, UxNodeId, UxOp}; + +pub trait UxNode { + type Op: UxOp; + + fn node_id(&self) -> UxNodeId; + + fn action(&self, op: Self::Op) -> UiAction { + UiAction::from_op(self.node_id(), op) + } + + fn actions_from_ops(&self, ops: impl IntoIterator) -> Vec { + ops.into_iter().map(|op| self.action(op)).collect() + } +} diff --git a/lp-app/lpa-studio-ux/src/node/ux_node_id.rs b/lp-app/lpa-studio-ux/src/node/ux_node_id.rs new file mode 100644 index 000000000..e77af7b28 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/node/ux_node_id.rs @@ -0,0 +1,32 @@ +use core::fmt; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct UxNodeId(String); + +impl UxNodeId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for UxNodeId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for UxNodeId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for UxNodeId { + fn from(value: &str) -> Self { + Self::new(value) + } +} diff --git a/lp-app/lpa-studio-ux/src/node/ux_op.rs b/lp-app/lpa-studio-ux/src/node/ux_op.rs new file mode 100644 index 000000000..699c81dcb --- /dev/null +++ b/lp-app/lpa-studio-ux/src/node/ux_op.rs @@ -0,0 +1,18 @@ +use core::any::Any; +use core::fmt; + +use crate::ActionMeta; + +pub trait UxOp: fmt::Debug + 'static { + fn default_action_meta(&self) -> ActionMeta; + fn clone_box(&self) -> Box; + fn eq_op(&self, other: &dyn UxOp) -> bool; + fn as_any(&self) -> &dyn Any; + fn into_any(self: Box) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + self.clone_box() + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/device/device_op.rs b/lp-app/lpa-studio-ux/src/nodes/device/device_op.rs new file mode 100644 index 000000000..e64327038 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/device/device_op.rs @@ -0,0 +1,95 @@ +use core::any::Any; + +use lpa_link::{LinkEndpointId, LinkProviderKind}; + +use crate::{ActionConfirmation, ActionMeta, ActionPriority, UxOp}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DeviceOp { + OpenProvider { + provider_id: LinkProviderKind, + }, + ConnectEndpoint { + provider_id: LinkProviderKind, + endpoint_id: LinkEndpointId, + }, + ConnectLightPlayer, + DisconnectLightPlayer, + ProvisionFirmware, + ResetToBlank, + DisconnectDevice, + RefreshConnections, +} + +impl UxOp for DeviceOp { + fn default_action_meta(&self) -> ActionMeta { + match self { + Self::OpenProvider { .. } => ActionMeta::new( + "Choose connection", + "Select this way to connect a LightPlayer device.", + ActionPriority::Primary, + ), + Self::ConnectEndpoint { .. } => ActionMeta::new( + "Connect device", + "Open this device endpoint.", + ActionPriority::Primary, + ), + Self::ConnectLightPlayer => ActionMeta::new( + "Connect LightPlayer", + "Attach Studio to LightPlayer on the connected device.", + ActionPriority::Primary, + ), + Self::DisconnectLightPlayer => ActionMeta::new( + "Disconnect", + "Detach Studio from LightPlayer while keeping the device connected.", + ActionPriority::Tertiary, + ), + Self::ProvisionFirmware => ActionMeta::new( + "Flash firmware", + "Flash the packaged LightPlayer firmware onto this ESP32.", + ActionPriority::Primary, + ) + .with_confirmation(ActionConfirmation::new( + "Flash firmware", + "This will write LightPlayer firmware to the selected ESP32. Continue?", + "Flash firmware", + )), + Self::ResetToBlank => ActionMeta::new( + "Wipe device", + "Erase firmware and device data from this ESP32.", + ActionPriority::Tertiary, + ) + .with_confirmation(ActionConfirmation::new( + "Wipe device", + "This erases firmware and device data from the selected ESP32.", + "Wipe device", + )), + Self::DisconnectDevice => ActionMeta::new( + "Disconnect", + "Close the current device session and return to connection choices.", + ActionPriority::Tertiary, + ), + Self::RefreshConnections => ActionMeta::new( + "Refresh connections", + "Rebuild the connection catalog from available providers.", + ActionPriority::Secondary, + ), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn UxOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/device/device_snapshot.rs b/lp-app/lpa-studio-ux/src/nodes/device/device_snapshot.rs new file mode 100644 index 000000000..a10504c86 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/device/device_snapshot.rs @@ -0,0 +1,13 @@ +use crate::{LinkSnapshot, ServerSnapshot}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeviceSnapshot { + pub link: LinkSnapshot, + pub server: ServerSnapshot, +} + +impl DeviceSnapshot { + pub fn new(link: LinkSnapshot, server: ServerSnapshot) -> Self { + Self { link, server } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/device/device_ux.rs b/lp-app/lpa-studio-ux/src/nodes/device/device_ux.rs new file mode 100644 index 000000000..abcd84603 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/device/device_ux.rs @@ -0,0 +1,592 @@ +use crate::{ + ConnectedDeviceSummary, DeviceOp, DeviceSnapshot, EndpointChoice, LinkOp, LinkState, LinkUx, + ProjectOp, ProjectState, ProviderChoice, ServerFailureKind, ServerState, ServerUx, UiAction, + UiBody, UiMetric, UiPaneView, UiStackSection, UiStackView, UiStatus, UiStepState, + UiTerminalLine, UxLogEntry, UxNode, UxNodeId, +}; + +pub struct DeviceUx { + pub(crate) link: LinkUx, + pub(crate) server: ServerUx, + terminal: Vec, +} + +impl DeviceUx { + pub const NODE_ID: &'static str = "studio.device"; + pub const SECTION_SELECT_CONNECTION: &'static str = "select-connection"; + pub const SECTION_CONNECT_DEVICE: &'static str = "connect-device"; + pub const SECTION_CONNECT_LIGHTPLAYER: &'static str = "connect-lightplayer"; + pub const SECTION_OPEN_PROJECT: &'static str = "open-project"; + + pub fn new() -> Self { + Self { + link: LinkUx::new(), + server: ServerUx::new(), + terminal: Vec::new(), + } + } + + pub fn snapshot(&self) -> DeviceSnapshot { + DeviceSnapshot::new(self.link.snapshot(), self.server.snapshot()) + } + + pub fn is_lightplayer_connected(&self) -> bool { + self.server.is_connected() + } + + pub fn has_lightplayer_state(&self) -> bool { + matches!(self.server.snapshot().state, ServerState::Connected { .. }) + } + + pub fn needs_firmware(&self) -> bool { + matches!( + self.server.snapshot().state, + ServerState::Failed { + kind: ServerFailureKind::NoFirmware, + .. + } + ) + } + + pub fn has_meaningful_terminal(&self) -> bool { + !matches!(self.link.state(), LinkState::SelectingProvider { .. }) + } + + pub fn record_logs(&mut self, logs: &[UxLogEntry]) { + self.terminal.extend( + logs.iter() + .filter(|log| is_device_log_source(&log.source)) + .map(|log| UiTerminalLine::new(format!("[{}] {}", log.source, log.message))), + ); + if self.terminal.len() > 240 { + let remove_count = self.terminal.len() - 240; + self.terminal.drain(0..remove_count); + } + } + + pub fn view(&self, project_state: &ProjectState, project_actions: Vec) -> UiPaneView { + let stack = UiStackView::new(self.sections(project_state, project_actions)).with_terminal( + if self.has_meaningful_terminal() { + self.terminal.clone() + } else { + Vec::new() + }, + ); + + UiPaneView::new( + Self::NODE_ID, + "Device", + self.status(), + UiBody::Stack(Box::new(stack)), + Vec::new(), + ) + } + + fn sections( + &self, + project_state: &ProjectState, + project_actions: Vec, + ) -> Vec { + let mut sections = vec![self.select_connection_section()]; + if self.should_show_connect_device() { + sections.push(self.connect_device_section()); + } + if self.should_show_connect_lightplayer() { + sections.push(self.connect_lightplayer_section()); + } + if self.should_show_open_project(project_state) { + sections.push(self.open_project_section(project_state, project_actions)); + } + sections + } + + fn should_show_connect_device(&self) -> bool { + !matches!( + self.link.state(), + LinkState::SelectingProvider { .. } | LinkState::Failed { .. } + ) + } + + fn should_show_connect_lightplayer(&self) -> bool { + matches!( + self.link.state(), + LinkState::Connected { .. } | LinkState::Managing { .. } + ) + } + + fn should_show_open_project(&self, _project_state: &ProjectState) -> bool { + self.has_lightplayer_state() + } + + fn should_show_device_controls(&self) -> bool { + matches!( + self.server.snapshot().state, + ServerState::Disconnected | ServerState::Failed { .. } + ) + } + + fn status(&self) -> UiStatus { + match (self.link.state(), &self.server.snapshot().state) { + (LinkState::SelectingProvider { issue: Some(_), .. }, _) => { + UiStatus::error("Needs attention") + } + (LinkState::SelectingProvider { .. }, _) => UiStatus::neutral("Choose connection"), + ( + LinkState::Connected { .. }, + ServerState::Failed { + kind: ServerFailureKind::NoFirmware, + .. + }, + ) => UiStatus::warning("Ready to flash"), + (LinkState::Failed { .. }, _) | (_, ServerState::Failed { .. }) => { + UiStatus::error("Needs attention") + } + (LinkState::DiscoveringEndpoints { .. }, _) + | (LinkState::SelectingEndpoint { .. }, _) + | (LinkState::Connecting { .. }, _) + | (LinkState::Managing { .. }, _) + | (_, ServerState::Connecting { .. }) => UiStatus::working("Connecting"), + (_, ServerState::Connected { .. }) => UiStatus::good("LightPlayer ready"), + (LinkState::Connected { device }, ServerState::Disconnected) => { + UiStatus::good(device.label.clone()) + } + } + } + + fn select_connection_section(&self) -> UiStackSection { + match self.link.state() { + LinkState::SelectingProvider { providers, issue } => { + let section = UiStackSection::new( + Self::SECTION_SELECT_CONNECTION, + "Select connection", + if issue.is_some() { + UiStepState::NeedsAttention + } else { + UiStepState::Active + }, + ) + .with_actions(provider_actions(providers, self.node_id())); + match issue { + Some(issue) => section.with_body(UiBody::Issue(issue.clone())), + None => section.with_body(UiBody::text("Choose how Studio should connect.")), + } + } + LinkState::Failed { .. } => UiStackSection::new( + Self::SECTION_SELECT_CONNECTION, + "Select connection", + UiStepState::NeedsAttention, + ) + .with_body(UiBody::text("Refresh connections to try again.")) + .with_actions(vec![self.action(DeviceOp::RefreshConnections)]), + _ => UiStackSection::new( + Self::SECTION_SELECT_CONNECTION, + "Select connection", + UiStepState::Complete, + ) + .with_body(UiBody::text(selected_connection_label(self.link.state()))), + } + } + + fn connect_device_section(&self) -> UiStackSection { + match self.link.state() { + LinkState::SelectingProvider { .. } => UiStackSection::new( + Self::SECTION_CONNECT_DEVICE, + "Connect device", + UiStepState::Pending, + ) + .with_body(UiBody::text("Choose a connection first.")), + LinkState::DiscoveringEndpoints { + provider_id, + progress, + } => UiStackSection::new( + Self::SECTION_CONNECT_DEVICE, + "Connect device", + UiStepState::Active, + ) + .with_body(UiBody::Progress(progress.clone().with_detail(format!( + "Discovering endpoints from {}.", + provider_id.label() + )))), + LinkState::SelectingEndpoint { + provider_id, + endpoints, + } => UiStackSection::new( + Self::SECTION_CONNECT_DEVICE, + "Connect device", + UiStepState::Active, + ) + .with_body(UiBody::text("Choose the device endpoint to open.")) + .with_actions(endpoint_actions(*provider_id, endpoints, self.node_id())), + LinkState::Connecting { progress, .. } => UiStackSection::new( + Self::SECTION_CONNECT_DEVICE, + "Connect device", + UiStepState::Active, + ) + .with_body(UiBody::Progress(progress.clone())), + LinkState::Connected { device } | LinkState::Managing { device, .. } => { + let section = UiStackSection::new( + Self::SECTION_CONNECT_DEVICE, + "Connect device", + UiStepState::Complete, + ) + .with_body(device_summary_body(device)); + if self.should_show_device_controls() { + section.with_actions(self.device_control_actions()) + } else { + section + } + } + LinkState::Failed { issue } => UiStackSection::new( + Self::SECTION_CONNECT_DEVICE, + "Connect device", + UiStepState::NeedsAttention, + ) + .with_body(UiBody::Issue(issue.clone())) + .with_actions(vec![self.action(DeviceOp::RefreshConnections)]), + } + } + + fn connect_lightplayer_section(&self) -> UiStackSection { + match (self.link.state(), &self.server.snapshot().state) { + (LinkState::Connected { .. }, ServerState::Disconnected) => UiStackSection::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + "Connect LightPlayer", + UiStepState::Active, + ) + .with_body(UiBody::text( + "Attach Studio to LightPlayer on the connected device.", + )) + .with_actions(self.connect_lightplayer_actions()), + (LinkState::Connected { .. }, ServerState::Connecting { progress }) => { + UiStackSection::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + "Connect LightPlayer", + UiStepState::Active, + ) + .with_body(UiBody::Progress(progress.clone())) + } + (LinkState::Connected { .. }, ServerState::Connected { protocol }) => { + UiStackSection::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + "Connect LightPlayer", + UiStepState::Complete, + ) + .with_body(UiBody::Metrics(vec![UiMetric::new("Protocol", protocol)])) + .with_actions(vec![self.action(DeviceOp::DisconnectLightPlayer)]) + } + (LinkState::Connected { .. }, ServerState::Failed { issue, kind }) => { + let no_firmware = *kind == ServerFailureKind::NoFirmware; + UiStackSection::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + if no_firmware { + "LightPlayer unavailable" + } else { + "Connect LightPlayer" + }, + if no_firmware { + UiStepState::Active + } else { + UiStepState::NeedsAttention + }, + ) + .with_body(if no_firmware { + UiBody::text("No LightPlayer firmware is running on this ESP32.") + } else { + UiBody::Issue(issue.clone()) + }) + .with_actions(if no_firmware { + Vec::new() + } else { + self.connect_lightplayer_actions() + }) + } + (LinkState::Managing { progress, .. }, _) => UiStackSection::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + progress.label.clone(), + UiStepState::Active, + ) + .with_body(UiBody::Progress(progress.clone())), + _ => UiStackSection::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + "Connect LightPlayer", + UiStepState::Pending, + ) + .with_body(UiBody::text("Connect a device first.")), + } + } + + fn open_project_section( + &self, + project_state: &ProjectState, + actions: Vec, + ) -> UiStackSection { + if !self.has_lightplayer_state() { + if self.needs_firmware() { + return UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::Pending, + ) + .with_body(UiBody::text("Flash firmware before opening a project.")); + } + return UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::Pending, + ) + .with_body(UiBody::text("Connect LightPlayer first.")); + } + + match project_state { + ProjectState::NotLoaded => UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::Active, + ) + .with_body(UiBody::text(not_loaded_project_prompt(&actions))) + .with_actions(actions), + ProjectState::SelectingLoadedProject { projects } => UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::Active, + ) + .with_body(UiBody::text(format!( + "{} projects are running. Choose one to open.", + projects.len() + ))) + .with_actions(actions), + ProjectState::ConnectingRunningProject { progress } + | ProjectState::LoadingDemoProject { progress } => UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::Active, + ) + .with_body(UiBody::Progress(progress.clone())), + ProjectState::Ready { project_id, .. } => UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::Complete, + ) + .with_body(UiBody::text(format!("{project_id} is loaded."))), + ProjectState::Failed { issue } => UiStackSection::new( + Self::SECTION_OPEN_PROJECT, + "Open project", + UiStepState::NeedsAttention, + ) + .with_body(UiBody::Issue(issue.clone())) + .with_actions(actions), + } + } + + fn lightplayer_actions(&self, server_connected: bool) -> Vec { + self.link + .actions(server_connected) + .into_iter() + .filter_map(|action| map_link_action(action, self.node_id())) + .collect() + } + + fn connect_lightplayer_actions(&self) -> Vec { + self.lightplayer_actions(false) + .into_iter() + .filter(|action| { + matches!( + action.op_as::(), + Some(DeviceOp::ConnectLightPlayer) + ) + }) + .collect() + } + + fn device_control_actions(&self) -> Vec { + self.lightplayer_actions(false) + .into_iter() + .filter(|action| { + !matches!( + action.op_as::(), + Some(DeviceOp::ConnectLightPlayer) + ) + }) + .collect() + } +} + +fn not_loaded_project_prompt(actions: &[UiAction]) -> &'static str { + if actions.iter().any(|action| { + matches!( + action.op_as::(), + Some(ProjectOp::ConnectRunningProject) + ) + }) { + "Connect to a running project or load the demo project." + } else { + "No running project is loaded. Load the demo project when you're ready." + } +} + +impl UxNode for DeviceUx { + type Op = DeviceOp; + + fn node_id(&self) -> UxNodeId { + UxNodeId::new(Self::NODE_ID) + } +} + +impl Default for DeviceUx { + fn default() -> Self { + Self::new() + } +} + +fn provider_actions(providers: &[ProviderChoice], node_id: UxNodeId) -> Vec { + providers + .iter() + .map(|provider| { + UiAction::from_op( + node_id.clone(), + DeviceOp::OpenProvider { + provider_id: provider.id, + }, + ) + .with_label(provider_action_label(provider.id)) + .with_summary(provider.summary.clone()) + .with_short_label(provider_action_short_label(provider.id)) + .with_icon(provider_action_icon(provider.id)) + .with_priority(provider_action_priority(provider.id)) + }) + .collect() +} + +fn endpoint_actions( + provider_id: lpa_link::LinkProviderKind, + endpoints: &[EndpointChoice], + node_id: UxNodeId, +) -> Vec { + endpoints + .iter() + .map(|endpoint| { + UiAction::from_op( + node_id.clone(), + DeviceOp::ConnectEndpoint { + provider_id, + endpoint_id: endpoint.id.clone(), + }, + ) + .with_label(format!("Open {}", endpoint.label)) + .with_summary(endpoint.summary.clone()) + }) + .collect() +} + +fn map_link_action(action: UiAction, node_id: UxNodeId) -> Option { + let meta = action.meta().clone(); + let op = match action.op_as::()? { + LinkOp::RefreshProviders => DeviceOp::RefreshConnections, + LinkOp::ConnectServer => DeviceOp::ConnectLightPlayer, + LinkOp::ProvisionFirmware => DeviceOp::ProvisionFirmware, + LinkOp::ResetToBlank => DeviceOp::ResetToBlank, + LinkOp::DisconnectLink => DeviceOp::DisconnectDevice, + LinkOp::OpenProvider { provider_id } => DeviceOp::OpenProvider { + provider_id: *provider_id, + }, + LinkOp::ConnectEndpoint { + provider_id, + endpoint_id, + } => DeviceOp::ConnectEndpoint { + provider_id: *provider_id, + endpoint_id: endpoint_id.clone(), + }, + }; + let action = UiAction::from_op(node_id, op).with_meta(meta); + if matches!( + action.op_as::(), + Some(DeviceOp::ConnectLightPlayer) + ) { + Some( + action + .with_label("Connect LightPlayer") + .with_summary("Attach Studio to LightPlayer on the connected device."), + ) + } else if matches!(action.op_as::(), Some(DeviceOp::DisconnectDevice)) { + Some( + action + .with_label("Disconnect") + .with_summary("Close the current device session."), + ) + } else { + Some(action) + } +} + +fn device_summary_body(device: &ConnectedDeviceSummary) -> UiBody { + UiBody::Metrics(vec![ + UiMetric::new("Provider", device.provider_id.label()), + UiMetric::new("Endpoint", &device.endpoint_id), + UiMetric::new("Session", &device.session_id), + ]) +} + +fn selected_connection_label(state: &LinkState) -> String { + match state { + LinkState::DiscoveringEndpoints { provider_id, .. } + | LinkState::SelectingEndpoint { provider_id, .. } => provider_id.label().to_string(), + LinkState::Connecting { endpoint, .. } => endpoint.label.clone(), + LinkState::Managing { device, .. } | LinkState::Connected { device } => { + device.label.clone() + } + LinkState::Failed { .. } => "Connection needs attention.".to_string(), + LinkState::SelectingProvider { + issue: Some(issue), .. + } => issue.message.clone(), + LinkState::SelectingProvider { .. } => "Choose how to connect.".to_string(), + } +} + +fn provider_action_label(kind: lpa_link::LinkProviderKind) -> String { + match kind { + lpa_link::LinkProviderKind::BrowserWorker => "Start simulator".to_string(), + lpa_link::LinkProviderKind::HostProcess => "Start host runtime".to_string(), + lpa_link::LinkProviderKind::BrowserSerialEsp32 => "Connect ESP32".to_string(), + lpa_link::LinkProviderKind::HostSerialEsp32 => "Select hardware".to_string(), + lpa_link::LinkProviderKind::Fake => "Select fake provider".to_string(), + } +} + +fn provider_action_short_label(kind: lpa_link::LinkProviderKind) -> String { + match kind { + lpa_link::LinkProviderKind::BrowserWorker => "Simulator".to_string(), + lpa_link::LinkProviderKind::HostProcess => "Host".to_string(), + lpa_link::LinkProviderKind::BrowserSerialEsp32 + | lpa_link::LinkProviderKind::HostSerialEsp32 => "ESP32".to_string(), + lpa_link::LinkProviderKind::Fake => "Fake".to_string(), + } +} + +fn provider_action_icon(kind: lpa_link::LinkProviderKind) -> String { + match kind { + lpa_link::LinkProviderKind::BrowserWorker | lpa_link::LinkProviderKind::HostProcess => { + "play".to_string() + } + lpa_link::LinkProviderKind::BrowserSerialEsp32 + | lpa_link::LinkProviderKind::HostSerialEsp32 => "usb".to_string(), + lpa_link::LinkProviderKind::Fake => "test-tube".to_string(), + } +} + +fn provider_action_priority(kind: lpa_link::LinkProviderKind) -> crate::ActionPriority { + match kind { + lpa_link::LinkProviderKind::BrowserWorker | lpa_link::LinkProviderKind::HostProcess => { + crate::ActionPriority::Primary + } + lpa_link::LinkProviderKind::BrowserSerialEsp32 + | lpa_link::LinkProviderKind::HostSerialEsp32 => crate::ActionPriority::Secondary, + lpa_link::LinkProviderKind::Fake => crate::ActionPriority::Tertiary, + } +} + +fn is_device_log_source(source: &str) -> bool { + matches!( + source, + "lpa-link" | "browser-serial" | "fw-esp32" | "fw-browser" | "lp-server" + ) +} diff --git a/lp-app/lpa-studio-ux/src/nodes/device/mod.rs b/lp-app/lpa-studio-ux/src/nodes/device/mod.rs new file mode 100644 index 000000000..23efcf9f1 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/device/mod.rs @@ -0,0 +1,7 @@ +pub mod device_op; +pub mod device_snapshot; +pub mod device_ux; + +pub use device_op::DeviceOp; +pub use device_snapshot::DeviceSnapshot; +pub use device_ux::DeviceUx; diff --git a/lp-app/lpa-studio-ux/src/nodes/link/connected_device_summary.rs b/lp-app/lpa-studio-ux/src/nodes/link/connected_device_summary.rs new file mode 100644 index 000000000..b99dbafe4 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/connected_device_summary.rs @@ -0,0 +1,25 @@ +use lpa_link::LinkProviderKind; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConnectedDeviceSummary { + pub provider_id: LinkProviderKind, + pub endpoint_id: String, + pub session_id: String, + pub label: String, +} + +impl ConnectedDeviceSummary { + pub fn new( + provider_id: LinkProviderKind, + endpoint_id: impl Into, + session_id: impl Into, + label: impl Into, + ) -> Self { + Self { + provider_id, + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + label: label.into(), + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/endpoint_choice.rs b/lp-app/lpa-studio-ux/src/nodes/link/endpoint_choice.rs new file mode 100644 index 000000000..0b1f1551d --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/endpoint_choice.rs @@ -0,0 +1,45 @@ +use lpa_link::{LinkEndpoint, LinkEndpointId, LinkEndpointStatus, LinkProviderKind}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EndpointChoice { + pub provider_id: LinkProviderKind, + pub id: LinkEndpointId, + pub label: String, + pub summary: String, + pub status: LinkEndpointStatus, +} + +impl EndpointChoice { + pub fn from_endpoint(endpoint: LinkEndpoint) -> Self { + let summary = endpoint_summary(&endpoint); + Self { + provider_id: endpoint.provider_kind, + id: endpoint.id, + label: endpoint.label, + summary, + status: endpoint.status, + } + } + + #[cfg(any(test, feature = "browser-worker"))] + pub fn browser_worker() -> Self { + Self { + provider_id: LinkProviderKind::BrowserWorker, + id: LinkEndpointId::new("browser-worker-worker-1"), + label: "Browser firmware runtime".to_string(), + summary: "Spawn a browser-local firmware runtime.".to_string(), + status: LinkEndpointStatus::Available, + } + } +} + +fn endpoint_summary(endpoint: &LinkEndpoint) -> String { + match endpoint.provider_kind { + LinkProviderKind::BrowserWorker => "Spawn a browser-local firmware runtime.".to_string(), + LinkProviderKind::HostProcess => "Spawn a host-local firmware runtime.".to_string(), + LinkProviderKind::BrowserSerialEsp32 | LinkProviderKind::HostSerialEsp32 => { + "Open this ESP32 endpoint.".to_string() + } + LinkProviderKind::Fake => "Open this test endpoint.".to_string(), + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_op.rs b/lp-app/lpa-studio-ux/src/nodes/link/link_op.rs new file mode 100644 index 000000000..1e7c6f439 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/link_op.rs @@ -0,0 +1,89 @@ +use core::any::Any; + +use lpa_link::{LinkEndpointId, LinkProviderKind}; + +use crate::{ActionConfirmation, ActionMeta, ActionPriority, UxOp}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LinkOp { + RefreshProviders, + ConnectServer, + ProvisionFirmware, + ResetToBlank, + DisconnectLink, + OpenProvider { + provider_id: LinkProviderKind, + }, + ConnectEndpoint { + provider_id: LinkProviderKind, + endpoint_id: LinkEndpointId, + }, +} + +impl UxOp for LinkOp { + fn default_action_meta(&self) -> ActionMeta { + match self { + Self::ProvisionFirmware => ActionMeta::new( + "Flash firmware", + "Flash the packaged LightPlayer firmware onto this ESP32.", + ActionPriority::Primary, + ) + .with_confirmation(ActionConfirmation::new( + "Flash firmware", + "This will write LightPlayer firmware to the selected ESP32. Continue?", + "Flash firmware", + )), + Self::ResetToBlank => ActionMeta::new( + "Wipe device", + "Erase firmware and device data from this ESP32.", + ActionPriority::Tertiary, + ) + .with_confirmation(ActionConfirmation::new( + "Wipe device", + "This erases firmware and device data from the selected ESP32.", + "Wipe device", + )), + Self::ConnectServer => ActionMeta::new( + "Connect server", + "Attach Studio to the server protocol over the open link session.", + ActionPriority::Primary, + ), + Self::DisconnectLink => ActionMeta::new( + "Disconnect", + "Close the current device session and return to connection choices.", + ActionPriority::Tertiary, + ), + Self::RefreshProviders => ActionMeta::new( + "Refresh providers", + "Rebuild the provider catalog from lpa-link.", + ActionPriority::Secondary, + ), + Self::OpenProvider { .. } => ActionMeta::new( + "Open provider", + "Open a link provider.", + ActionPriority::Primary, + ), + Self::ConnectEndpoint { .. } => ActionMeta::new( + "Open endpoint", + "Open the selected link endpoint.", + ActionPriority::Primary, + ), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn UxOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_snapshot.rs b/lp-app/lpa-studio-ux/src/nodes/link/link_snapshot.rs new file mode 100644 index 000000000..9223b3d55 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/link_snapshot.rs @@ -0,0 +1,12 @@ +use crate::LinkState; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LinkSnapshot { + pub state: LinkState, +} + +impl LinkSnapshot { + pub fn new(state: LinkState) -> Self { + Self { state } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_state.rs b/lp-app/lpa-studio-ux/src/nodes/link/link_state.rs new file mode 100644 index 000000000..314d95e38 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/link_state.rs @@ -0,0 +1,32 @@ +use crate::{ConnectedDeviceSummary, EndpointChoice, ProgressState, ProviderChoice, UxIssue}; +use lpa_link::LinkProviderKind; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LinkState { + SelectingProvider { + providers: Vec, + issue: Option, + }, + DiscoveringEndpoints { + provider_id: LinkProviderKind, + progress: ProgressState, + }, + SelectingEndpoint { + provider_id: LinkProviderKind, + endpoints: Vec, + }, + Connecting { + endpoint: EndpointChoice, + progress: ProgressState, + }, + Managing { + device: ConnectedDeviceSummary, + progress: ProgressState, + }, + Connected { + device: ConnectedDeviceSummary, + }, + Failed { + issue: UxIssue, + }, +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_ux.rs b/lp-app/lpa-studio-ux/src/nodes/link/link_ux.rs new file mode 100644 index 000000000..846215126 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/link_ux.rs @@ -0,0 +1,1178 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use lpa_link::providers::{LinkEnv, LinkProviderInstance, LinkProviderRegistry}; +use lpa_link::{ + LinkConnection, LinkDiagnosticSeverity, LinkEndpointId, LinkError, LinkLogLevel, + LinkManagementRequest, LinkManagementResult, LinkOperation, LinkProvider, LinkProviderKind, + LinkSession, LinkSessionId, +}; +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +use lpc_model::DEFAULT_SERIAL_BAUD_RATE; + +use crate::{ + ActionPriority, ConnectedDeviceSummary, EndpointChoice, LinkOp, LinkSnapshot, LinkState, + ProgressState, ProviderChoice, UiAction, UiActivity, UiBody, UiMetric, UiPaneView, UiProgress, + UiStatus, UxError, UxIssue, UxLogEntry, UxLogLevel, UxNode, UxNodeId, UxUpdate, UxUpdateSink, +}; +use lpa_link::{LinkManagementEvent, LinkManagementEventSink}; + +pub type SharedLinkRegistry = Rc>; + +pub struct LinkUx { + state: LinkState, + registry: SharedLinkRegistry, + active_provider: Option, + active_endpoint: Option, + active_session: Option, + active_connection: Option, +} + +impl LinkUx { + pub const NODE_ID: &'static str = "studio.link"; + + pub fn new() -> Self { + Self::with_env(LinkEnv::default()) + } + + pub fn with_env(env: LinkEnv) -> Self { + Self::with_registry(LinkProviderRegistry::from_env(env)) + } + + pub fn with_registry(registry: LinkProviderRegistry) -> Self { + let registry = Rc::new(RefCell::new(registry)); + let providers = provider_choices(®istry.borrow()); + Self { + state: LinkState::SelectingProvider { + providers, + issue: None, + }, + registry, + active_provider: None, + active_endpoint: None, + active_session: None, + active_connection: None, + } + } + + pub fn state(&self) -> &LinkState { + &self.state + } + + pub fn set_state(&mut self, state: LinkState) { + self.state = state; + } + + #[cfg(test)] + pub(crate) fn set_active_session_for_test(&mut self, session: LinkSession) { + self.active_session = Some(session); + } + + pub fn snapshot(&self) -> LinkSnapshot { + LinkSnapshot::new(self.state.clone()) + } + + pub fn registry_handle(&self) -> SharedLinkRegistry { + Rc::clone(&self.registry) + } + + pub fn active_connection(&self) -> Option { + self.active_connection.clone() + } + + pub fn actions(&self, server_connected: bool) -> Vec { + match &self.state { + LinkState::SelectingProvider { providers, .. } => providers + .iter() + .map(|provider| { + self.action(LinkOp::OpenProvider { + provider_id: provider.id, + }) + .with_label(provider_action_label(provider.id)) + .with_summary(provider.summary.clone()) + .with_short_label(provider_action_short_label(provider.id)) + .with_icon(provider_action_icon(provider.id)) + .with_priority(provider_action_priority(provider.id)) + }) + .collect(), + LinkState::SelectingEndpoint { + provider_id, + endpoints, + } => endpoints + .iter() + .map(|endpoint| { + self.action(LinkOp::ConnectEndpoint { + provider_id: *provider_id, + endpoint_id: endpoint.id.clone(), + }) + .with_label(format!("Open {}", endpoint.label)) + .with_summary(endpoint.summary.clone()) + }) + .collect(), + LinkState::Failed { .. } => vec![self.action(LinkOp::RefreshProviders)], + LinkState::DiscoveringEndpoints { .. } + | LinkState::Connecting { .. } + | LinkState::Managing { .. } => Vec::new(), + LinkState::Connected { .. } if server_connected => Vec::new(), + LinkState::Connected { .. } => { + let mut actions = Vec::new(); + if self.active_supports(LinkOperation::FlashFirmware) { + actions.push(self.action(LinkOp::ProvisionFirmware)); + } + actions.push(self.action(LinkOp::ConnectServer)); + if self.active_supports(LinkOperation::EraseDeviceFlash) { + actions.push(self.action(LinkOp::ResetToBlank)); + } + actions.push(self.action(LinkOp::DisconnectLink)); + actions + } + } + } + + pub fn view(&self, server_connected: bool) -> UiPaneView { + UiPaneView::new( + Self::NODE_ID, + "Link", + link_status(&self.state), + link_body(&self.state), + self.actions(server_connected), + ) + } + + pub fn refresh_provider_catalog(&mut self) { + self.reset_to_provider_selection(None); + } + + fn reset_to_provider_selection(&mut self, issue: Option) { + self.active_provider = None; + self.active_endpoint = None; + self.active_session = None; + self.active_connection = None; + let providers = provider_choices(&self.registry.borrow()); + self.state = LinkState::SelectingProvider { providers, issue }; + } + + fn recover_to_provider_selection(&mut self, message: impl Into) { + self.reset_to_provider_selection(Some(UxIssue::new(message))); + } + + pub async fn disconnect(&mut self) -> Result<(), UxError> { + let provider_id = self.active_provider; + let session_id = self + .active_session + .as_ref() + .map(|session| session.id.clone()); + let result = match (provider_id, session_id) { + (Some(provider_id), Some(session_id)) => { + let mut registry = self.registry.borrow_mut(); + let provider = registry + .provider_mut(provider_id) + .ok_or_else(|| missing_provider(provider_id))?; + provider.close(&session_id).await.map_err(map_link_error) + } + _ => Ok(()), + }; + match result { + Ok(()) => { + self.refresh_provider_catalog(); + Ok(()) + } + Err(error) => { + self.fail(error.message()); + Err(error) + } + } + } + + pub async fn open_provider( + &mut self, + provider_id: LinkProviderKind, + ) -> Result { + if provider_id == LinkProviderKind::BrowserSerialEsp32 { + return self.open_browser_serial_provider().await; + } + + self.discover_provider_endpoints(provider_id).await?; + let endpoints = match &self.state { + LinkState::SelectingEndpoint { endpoints, .. } => endpoints.clone(), + _ => Vec::new(), + }; + if endpoints.len() == 1 && provider_auto_connects(provider_id) { + let endpoint_id = endpoints[0].id.clone(); + return self + .connect_endpoint(provider_id, endpoint_id) + .await + .map(LinkOpenOutcome::Connected); + } + Ok(LinkOpenOutcome::Opened) + } + + pub async fn discover_provider_endpoints( + &mut self, + provider_id: LinkProviderKind, + ) -> Result<(), UxError> { + self.active_provider = Some(provider_id); + self.active_endpoint = None; + self.active_session = None; + self.active_connection = None; + self.state = LinkState::DiscoveringEndpoints { + provider_id, + progress: ProgressState::new("Discovering endpoints"), + }; + + let result = { + let mut registry = self.registry.borrow_mut(); + match registry.provider_mut(provider_id) { + Some(provider) => provider.discover().await.map_err(map_link_error), + None => Err(missing_provider(provider_id)), + } + }; + let endpoints = match result { + Ok(endpoints) => endpoints, + Err(error) => { + self.recover_to_provider_selection(error.message()); + return Err(error); + } + }; + if endpoints.is_empty() { + let message = format!("{} did not report any endpoints", provider_id.label()); + self.recover_to_provider_selection(message.clone()); + return Err(UxError::Link(message)); + } + + self.state = LinkState::SelectingEndpoint { + provider_id, + endpoints: endpoints + .into_iter() + .map(EndpointChoice::from_endpoint) + .collect(), + }; + Ok(()) + } + + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + async fn open_browser_serial_provider(&mut self) -> Result { + self.active_provider = Some(LinkProviderKind::BrowserSerialEsp32); + self.active_endpoint = None; + self.active_session = None; + self.active_connection = None; + self.state = LinkState::DiscoveringEndpoints { + provider_id: LinkProviderKind::BrowserSerialEsp32, + progress: ProgressState::new("Requesting browser serial access"), + }; + + let result = { + let mut registry = self.registry.borrow_mut(); + match registry.provider_mut(LinkProviderKind::BrowserSerialEsp32) { + Some(LinkProviderInstance::BrowserSerialEsp32(provider)) => { + provider.request_access().await.map_err(map_link_error) + } + Some(_) => Err(UxError::Link( + "browser serial registry entry has the wrong provider type".to_string(), + )), + None => Err(missing_provider(LinkProviderKind::BrowserSerialEsp32)), + } + }; + let endpoint = match result { + Ok(endpoint) => endpoint, + Err(UxError::Cancelled(message)) => { + self.reset_to_provider_selection(None); + return Ok(LinkOpenOutcome::Cancelled { message }); + } + Err(error) => { + self.recover_to_provider_selection(error.message()); + return Err(error); + } + }; + let endpoint_choice = EndpointChoice::from_endpoint(endpoint); + let endpoint_id = endpoint_choice.id.clone(); + self.state = LinkState::SelectingEndpoint { + provider_id: LinkProviderKind::BrowserSerialEsp32, + endpoints: vec![endpoint_choice], + }; + self.connect_endpoint(LinkProviderKind::BrowserSerialEsp32, endpoint_id) + .await + .map(LinkOpenOutcome::Connected) + } + + #[cfg(not(all(feature = "browser-serial-esp32", target_arch = "wasm32")))] + async fn open_browser_serial_provider(&mut self) -> Result { + Err(UxError::UnsupportedFeature( + "browser serial ESP32 access requires the browser-serial-esp32 feature on wasm" + .to_string(), + )) + } + + pub async fn connect_endpoint( + &mut self, + provider_id: LinkProviderKind, + endpoint_id: LinkEndpointId, + ) -> Result { + let endpoint = self + .endpoint_choice(provider_id, &endpoint_id) + .unwrap_or_else(|| EndpointChoice { + provider_id, + id: endpoint_id.clone(), + label: endpoint_id.as_str().to_string(), + summary: "Open this endpoint.".to_string(), + status: lpa_link::LinkEndpointStatus::Available, + }); + self.state = LinkState::Connecting { + endpoint: endpoint.clone(), + progress: ProgressState::new("Opening link session"), + }; + + let result = { + let mut registry = self.registry.borrow_mut(); + match registry.provider_mut(provider_id) { + Some(provider) => { + open_connected_provider(provider_id, provider, &endpoint_id).await + } + None => Err(missing_provider(provider_id)), + } + }; + let (session, connection, logs) = match result { + Ok(result) => result, + Err(error) => { + self.active_session = None; + self.active_connection = None; + self.recover_to_provider_selection(error.message()); + return Err(error); + } + }; + + self.active_provider = Some(provider_id); + self.active_endpoint = Some(endpoint_id); + self.active_session = Some(session.clone()); + self.active_connection = Some(connection.clone()); + self.state = LinkState::Connected { + device: ConnectedDeviceSummary::new( + provider_id, + session.endpoint_id.as_str(), + session.id().as_str(), + endpoint.label, + ), + }; + + Ok(ConnectedLink { connection, logs }) + } + + pub async fn manage( + &mut self, + request: LinkManagementRequest, + progress_label: impl Into, + ) -> Result { + self.manage_with_updates(request, progress_label, UxUpdateSink::noop()) + .await + } + + pub async fn manage_with_updates( + &mut self, + request: LinkManagementRequest, + progress_label: impl Into, + updates: UxUpdateSink, + ) -> Result { + let provider_id = self + .active_provider + .ok_or_else(|| UxError::MissingSession("link provider is not selected".to_string()))?; + let session_id = self + .active_session + .as_ref() + .map(|session| session.id.clone()) + .ok_or_else(|| UxError::MissingSession("link session is not open".to_string()))?; + let device = self.connected_device_summary()?; + let progress_label = progress_label.into(); + self.active_connection = None; + self.state = LinkState::Managing { + device: device.clone(), + progress: ProgressState::new(progress_label.clone()), + }; + let node_id = self.node_id(); + let activity = Rc::new(RefCell::new( + UiActivity::new(progress_label.clone()) + .with_progress(UiProgress::indeterminate(progress_label.clone())), + )); + updates.emit(UxUpdate::Activity { + target: crate::UxActivityTarget::pane(node_id.clone()), + status: UiStatus::working("Managing"), + activity: activity.borrow().clone(), + }); + let event_sink = management_activity_sink(node_id, activity, updates); + + let result = { + let mut registry = self.registry.borrow_mut(); + match registry.provider_mut(provider_id) { + Some(provider) => provider + .manage_with_events(&session_id, request, event_sink) + .await + .map_err(map_link_error), + None => Err(missing_provider(provider_id)), + } + }; + self.state = LinkState::Connected { device }; + let result = result?; + let logs = management_result_logs(&result); + Ok(LinkManagementOutcome { result, logs }) + } + + pub async fn reopen_active_connection(&mut self) -> Result { + let provider_id = self + .active_provider + .ok_or_else(|| UxError::MissingSession("link provider is not selected".to_string()))?; + let session_id = self + .active_session + .as_ref() + .map(|session| session.id.clone()) + .ok_or_else(|| UxError::MissingSession("link session is not open".to_string()))?; + let (connection, logs) = { + let mut registry = self.registry.borrow_mut(); + let provider = registry + .provider_mut(provider_id) + .ok_or_else(|| missing_provider(provider_id))?; + open_provider_protocol_if_needed(provider_id, provider, &session_id).await?; + let connection = provider + .connection(&session_id) + .await + .map_err(map_link_error)?; + let logs = link_session_logs(provider, &session_id)?; + (connection, logs) + }; + self.active_connection = Some(connection.clone()); + Ok(ConnectedLink { connection, logs }) + } + + pub fn fail(&mut self, message: impl Into) { + self.state = LinkState::Failed { + issue: UxIssue::new(message), + }; + } + + fn endpoint_choice( + &self, + provider_id: LinkProviderKind, + endpoint_id: &LinkEndpointId, + ) -> Option { + match &self.state { + LinkState::SelectingEndpoint { + provider_id: state_provider, + endpoints, + } if *state_provider == provider_id => endpoints + .iter() + .find(|endpoint| endpoint.id == *endpoint_id) + .cloned(), + LinkState::Connecting { endpoint, .. } + if endpoint.provider_id == provider_id && endpoint.id == *endpoint_id => + { + Some(endpoint.clone()) + } + _ => None, + } + } + + fn active_supports(&self, operation: LinkOperation) -> bool { + self.active_session + .as_ref() + .is_some_and(|session| session.capabilities.supports(operation)) + } + + fn connected_device_summary(&self) -> Result { + match &self.state { + LinkState::Connected { device } | LinkState::Managing { device, .. } => { + Ok(device.clone()) + } + _ => Err(UxError::MissingSession( + "link is not connected to a device".to_string(), + )), + } + } +} + +impl UxNode for LinkUx { + type Op = LinkOp; + + fn node_id(&self) -> UxNodeId { + UxNodeId::new(Self::NODE_ID) + } +} + +pub struct ConnectedLink { + pub connection: LinkConnection, + pub logs: Vec, +} + +pub enum LinkOpenOutcome { + Opened, + Connected(ConnectedLink), + Cancelled { message: String }, +} + +pub struct LinkManagementOutcome { + pub result: LinkManagementResult, + pub logs: Vec, +} + +impl Default for LinkUx { + fn default() -> Self { + Self::new() + } +} + +fn provider_choices(registry: &LinkProviderRegistry) -> Vec { + let descriptors = registry.descriptors(); + let server_descriptors = descriptors + .iter() + .filter(|descriptor| provider_can_open_server(descriptor.kind)) + .cloned() + .collect::>(); + let visible_descriptors = if server_descriptors.is_empty() { + descriptors + } else { + server_descriptors + }; + visible_descriptors + .into_iter() + .map(ProviderChoice::from_descriptor) + .collect() +} + +fn link_status(state: &LinkState) -> UiStatus { + match state { + LinkState::SelectingProvider { .. } => UiStatus::neutral("Choose runtime"), + LinkState::DiscoveringEndpoints { .. } => UiStatus::working("Discovering"), + LinkState::SelectingEndpoint { .. } => UiStatus::neutral("Choose endpoint"), + LinkState::Connecting { .. } => UiStatus::working("Connecting"), + LinkState::Managing { .. } => UiStatus::working("Managing"), + LinkState::Connected { device } => UiStatus::good(device.label.clone()), + LinkState::Failed { .. } => UiStatus::error("Link failed"), + } +} + +fn link_body(state: &LinkState) -> UiBody { + match state { + LinkState::SelectingProvider { + issue: Some(issue), .. + } => UiBody::Issue(issue.clone()), + LinkState::SelectingProvider { providers, .. } => providers + .first() + .map(|provider| UiBody::text(provider.summary.clone())) + .unwrap_or_else(|| UiBody::text("No link providers are available.")), + LinkState::DiscoveringEndpoints { + provider_id, + progress, + } => UiBody::Progress(progress.clone().with_detail(format!( + "Discovering endpoints from {}.", + provider_id.label() + ))), + LinkState::SelectingEndpoint { endpoints, .. } => endpoints + .first() + .map(|endpoint| UiBody::text(endpoint.summary.clone())) + .unwrap_or_else(|| UiBody::text("No endpoints are available for this provider.")), + LinkState::Connecting { progress, .. } => UiBody::Progress(progress.clone()), + LinkState::Managing { progress, .. } => UiBody::Progress(progress.clone()), + LinkState::Connected { device } => UiBody::Metrics(vec![ + UiMetric::new("Provider", device.provider_id.label()), + UiMetric::new("Endpoint", &device.endpoint_id), + UiMetric::new("Session", &device.session_id), + ]), + LinkState::Failed { issue } => UiBody::Issue(issue.clone()), + } +} + +fn management_result_logs(result: &LinkManagementResult) -> Vec { + match result { + LinkManagementResult::FlashFirmware(result) => { + let mut logs = result + .logs + .iter() + .map(|message| UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone())) + .collect::>(); + logs.extend(result.progress.iter().map(|progress| { + UxLogEntry::new(UxLogLevel::Info, "lpa-link", progress.label.clone()) + })); + logs + } + LinkManagementResult::EraseDeviceFlash(result) => { + let mut logs = result + .logs + .iter() + .map(|message| UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone())) + .collect::>(); + logs.extend(result.progress.iter().map(|progress| { + UxLogEntry::new(UxLogLevel::Info, "lpa-link", progress.label.clone()) + })); + logs + } + LinkManagementResult::EraseRawFilesystem(result) => { + let mut logs = result + .logs + .iter() + .map(|message| UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone())) + .collect::>(); + logs.extend(result.progress.iter().map(|progress| { + UxLogEntry::new(UxLogLevel::Info, "lpa-link", progress.label.clone()) + })); + logs + } + LinkManagementResult::ResetRuntime => { + vec![UxLogEntry::new( + UxLogLevel::Info, + "lpa-link", + "runtime reset completed", + )] + } + } +} + +fn management_activity_sink( + node_id: UxNodeId, + activity: Rc>, + updates: UxUpdateSink, +) -> LinkManagementEventSink { + LinkManagementEventSink::new(move |event| { + let log_update = management_event_log(&event); + let mut activity = activity.borrow_mut(); + apply_management_event(&mut activity, event); + updates.emit(UxUpdate::Activity { + target: crate::UxActivityTarget::pane(node_id.clone()), + status: UiStatus::working("Managing"), + activity: (*activity).clone(), + }); + if let Some(log) = log_update { + updates.emit(UxUpdate::Log(log)); + } + }) +} + +fn apply_management_event(activity: &mut UiActivity, event: LinkManagementEvent) { + match event { + LinkManagementEvent::Log { .. } => {} + LinkManagementEvent::Progress(progress) => { + let mut ux_progress = match progress.percent { + Some(percent) => UiProgress::determinate(progress.label, percent), + None => UiProgress::indeterminate(progress.label), + }; + if let Some(total_steps) = progress.total_steps { + ux_progress = ux_progress.with_detail(format!( + "Step {} of {}", + progress.completed_steps.min(total_steps), + total_steps + )); + } + activity.progress = Some(ux_progress); + } + } +} + +fn management_event_log(event: &LinkManagementEvent) -> Option { + match event { + LinkManagementEvent::Log { message } if !message.trim().is_empty() => Some( + UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone()), + ), + _ => None, + } +} + +fn provider_can_open_server(kind: LinkProviderKind) -> bool { + matches!( + kind, + LinkProviderKind::BrowserWorker + | LinkProviderKind::HostProcess + | LinkProviderKind::BrowserSerialEsp32 + | LinkProviderKind::HostSerialEsp32 + ) +} + +fn provider_action_label(kind: LinkProviderKind) -> String { + match kind { + LinkProviderKind::BrowserWorker => "Start simulator".to_string(), + LinkProviderKind::HostProcess => "Start host runtime".to_string(), + LinkProviderKind::BrowserSerialEsp32 => "Connect ESP32".to_string(), + LinkProviderKind::HostSerialEsp32 => "Select hardware".to_string(), + LinkProviderKind::Fake => "Select fake provider".to_string(), + } +} + +fn provider_action_short_label(kind: LinkProviderKind) -> String { + match kind { + LinkProviderKind::BrowserWorker => "Simulator".to_string(), + LinkProviderKind::HostProcess => "Host".to_string(), + LinkProviderKind::BrowserSerialEsp32 | LinkProviderKind::HostSerialEsp32 => { + "ESP32".to_string() + } + LinkProviderKind::Fake => "Fake".to_string(), + } +} + +fn provider_action_icon(kind: LinkProviderKind) -> String { + match kind { + LinkProviderKind::BrowserWorker | LinkProviderKind::HostProcess => "play".to_string(), + LinkProviderKind::BrowserSerialEsp32 | LinkProviderKind::HostSerialEsp32 => { + "usb".to_string() + } + LinkProviderKind::Fake => "test-tube".to_string(), + } +} + +fn provider_auto_connects(kind: LinkProviderKind) -> bool { + matches!( + kind, + LinkProviderKind::BrowserWorker | LinkProviderKind::HostProcess + ) +} + +async fn open_connected_provider( + provider_id: LinkProviderKind, + provider: &mut LinkProviderInstance, + endpoint_id: &LinkEndpointId, +) -> Result<(LinkSession, LinkConnection, Vec), UxError> { + let session = provider + .connect(endpoint_id) + .await + .map_err(map_link_error)?; + if let Err(error) = open_provider_protocol_if_needed(provider_id, provider, session.id()).await + { + close_failed_session(provider, session.id()).await; + return Err(error); + } + let connection = match provider.connection(session.id()).await { + Ok(connection) => connection, + Err(error) => { + close_failed_session(provider, session.id()).await; + return Err(map_link_error(error)); + } + }; + let logs = match link_session_logs(provider, session.id()) { + Ok(logs) => logs, + Err(error) => { + close_failed_session(provider, session.id()).await; + return Err(error); + } + }; + Ok((session, connection, logs)) +} + +async fn close_failed_session(provider: &mut LinkProviderInstance, session_id: &LinkSessionId) { + let _ = provider.close(session_id).await; +} + +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +async fn open_provider_protocol_if_needed( + provider_id: LinkProviderKind, + provider: &mut LinkProviderInstance, + session_id: &LinkSessionId, +) -> Result<(), UxError> { + if provider_id != LinkProviderKind::BrowserSerialEsp32 { + return Ok(()); + } + let LinkProviderInstance::BrowserSerialEsp32(provider) = provider else { + return Err(UxError::Link( + "browser serial registry entry has the wrong provider type".to_string(), + )); + }; + provider + .open_protocol(session_id, DEFAULT_SERIAL_BAUD_RATE) + .await + .map_err(map_link_error) +} + +#[cfg(not(all(feature = "browser-serial-esp32", target_arch = "wasm32")))] +async fn open_provider_protocol_if_needed( + provider_id: LinkProviderKind, + _provider: &mut LinkProviderInstance, + _session_id: &LinkSessionId, +) -> Result<(), UxError> { + let _ = provider_id; + Ok(()) +} + +fn provider_action_priority(kind: LinkProviderKind) -> ActionPriority { + match kind { + LinkProviderKind::BrowserWorker | LinkProviderKind::HostProcess => ActionPriority::Primary, + LinkProviderKind::BrowserSerialEsp32 | LinkProviderKind::HostSerialEsp32 => { + ActionPriority::Secondary + } + LinkProviderKind::Fake => ActionPriority::Tertiary, + } +} + +fn link_session_logs( + provider: &lpa_link::providers::LinkProviderInstance, + session_id: &lpa_link::LinkSessionId, +) -> Result, UxError> { + let mut logs = provider + .logs(session_id) + .map_err(map_link_error)? + .into_iter() + .map(|entry| UxLogEntry::new(map_link_log_level(entry.level), "lpa-link", entry.message)) + .collect::>(); + logs.extend( + provider + .diagnostics(session_id) + .map_err(map_link_error)? + .into_iter() + .map(|diagnostic| { + UxLogEntry::new( + map_diagnostic_level(diagnostic.severity), + "lpa-link", + diagnostic.message, + ) + }), + ); + Ok(logs) +} + +fn missing_provider(provider_id: LinkProviderKind) -> UxError { + UxError::Link(format!("provider {} is not available", provider_id.key())) +} + +fn map_link_error(error: LinkError) -> UxError { + match error { + LinkError::Cancelled { message } => UxError::Cancelled(message), + _ => UxError::Link(error.to_string()), + } +} + +fn map_link_log_level(level: LinkLogLevel) -> UxLogLevel { + match level { + LinkLogLevel::Trace | LinkLogLevel::Debug => UxLogLevel::Debug, + LinkLogLevel::Info => UxLogLevel::Info, + LinkLogLevel::Warn => UxLogLevel::Warn, + LinkLogLevel::Error => UxLogLevel::Error, + } +} + +fn map_diagnostic_level(level: LinkDiagnosticSeverity) -> UxLogLevel { + match level { + LinkDiagnosticSeverity::Info => UxLogLevel::Info, + LinkDiagnosticSeverity::Warning => UxLogLevel::Warn, + LinkDiagnosticSeverity::Error => UxLogLevel::Error, + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; + + use lpa_link::providers::LinkProviderRegistry; + use lpa_link::providers::fake::FakeProvider; + use lpa_link::{ + LinkCapabilities, LinkConnectionKind, LinkEndpoint, LinkManagementEvent, + LinkManagementRequest, LinkProviderKind, LinkSession, + }; + + use super::*; + + #[test] + fn selecting_provider_offers_registry_provider_actions() { + let link = LinkUx::with_registry(registry_with_fake_endpoint()); + + let actions = link.actions(false); + + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0].op_as::(), + Some(&LinkOp::OpenProvider { + provider_id: LinkProviderKind::Fake + }) + ); + assert_eq!(actions[0].node_id().as_str(), LinkUx::NODE_ID); + assert_eq!(actions[0].meta().label, "Select fake provider"); + } + + #[test] + fn connected_link_without_server_offers_server_attach() { + let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + link.set_state(LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::Fake, + "fake-runtime", + "fake-session", + "Fake runtime", + ), + }); + + let actions = link.actions(false); + + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].op_as::(), Some(&LinkOp::ConnectServer)); + assert_eq!(actions[1].op_as::(), Some(&LinkOp::DisconnectLink)); + } + + #[test] + fn connected_link_with_server_hides_link_level_actions() { + let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + link.set_state(LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::Fake, + "fake-runtime", + "fake-session", + "Fake runtime", + ), + }); + + let actions = link.actions(true); + + assert!(actions.is_empty()); + } + + #[test] + fn connected_management_capable_link_offers_provision_and_reset_without_server() { + let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + link.active_session = Some(management_capable_session()); + link.set_state(LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::Fake, + "fake-runtime", + "fake-session", + "Fake runtime", + ), + }); + + let actions = link.actions(false); + + assert_eq!(actions.len(), 4); + assert_eq!( + actions[0].op_as::(), + Some(&LinkOp::ProvisionFirmware) + ); + assert_eq!(actions[1].op_as::(), Some(&LinkOp::ConnectServer)); + assert_eq!(actions[2].op_as::(), Some(&LinkOp::ResetToBlank)); + assert_eq!(actions[3].op_as::(), Some(&LinkOp::DisconnectLink)); + } + + #[test] + fn connected_management_capable_link_hides_management_actions_with_server() { + let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + link.active_session = Some(management_capable_session()); + link.set_state(LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::Fake, + "fake-runtime", + "fake-session", + "Fake runtime", + ), + }); + + let actions = link.actions(true); + + assert!(actions.is_empty()); + } + + #[test] + fn failed_management_returns_to_recoverable_connected_state() { + let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + link.active_provider = Some(LinkProviderKind::Fake); + link.active_session = Some(management_capable_session()); + link.set_state(LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::Fake, + "fake-runtime", + "fake-session", + "Fake runtime", + ), + }); + + let result = + block_on_ready(link.manage(LinkManagementRequest::EraseDeviceFlash, "Wiping device")); + + assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!(link.state(), LinkState::Connected { .. })); + assert!(!link.actions(false).is_empty()); + } + + #[test] + fn management_log_events_are_ux_logs_not_activity_terminal_lines() { + let mut activity = UiActivity::new("Flashing firmware"); + let event = LinkManagementEvent::log("Writing at 0x10000... (42%)"); + + let log = management_event_log(&event).expect("log event should produce a UX log"); + apply_management_event(&mut activity, event); + + assert_eq!(log.source, "lpa-link"); + assert_eq!(log.message, "Writing at 0x10000... (42%)"); + assert!(activity.terminal.is_empty()); + } + + #[test] + fn cancelled_link_error_maps_to_cancelled_ux_error() { + let error = map_link_error(LinkError::cancelled("Port selection canceled")); + + assert_eq!( + error, + UxError::Cancelled("Port selection canceled".to_string()) + ); + } + + #[test] + fn failed_endpoint_discovery_returns_to_provider_selection_with_issue() { + let mut link = + LinkUx::with_registry(registry_with_fake_discover_error("serial discovery failed")); + + let result = block_on_ready(link.open_provider(LinkProviderKind::Fake)); + + assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!( + link.state(), + LinkState::SelectingProvider { + issue: Some(issue), + .. + } if issue.message.contains("serial discovery failed") + )); + assert_eq!(link.actions(false).len(), 1); + assert_eq!( + link.actions(false)[0].op_as::(), + Some(&LinkOp::OpenProvider { + provider_id: LinkProviderKind::Fake + }) + ); + } + + #[test] + fn failed_endpoint_connect_returns_to_provider_selection_with_issue() { + let mut link = LinkUx::with_registry(registry_with_fake_connect_error( + "Failed to open serial port.", + )); + + let result = block_on_ready( + link.connect_endpoint(LinkProviderKind::Fake, LinkEndpointId::new("fake-runtime")), + ); + + assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!( + link.state(), + LinkState::SelectingProvider { + issue: Some(issue), + .. + } if issue.message.contains("Failed to open serial port") + )); + assert_eq!(link.actions(false).len(), 1); + assert_eq!( + link.actions(false)[0].op_as::(), + Some(&LinkOp::OpenProvider { + provider_id: LinkProviderKind::Fake + }) + ); + } + + #[test] + fn failed_connection_handoff_returns_to_provider_selection_with_issue() { + let mut link = + LinkUx::with_registry(registry_with_fake_connection_error("server handoff failed")); + + let result = block_on_ready( + link.connect_endpoint(LinkProviderKind::Fake, LinkEndpointId::new("fake-runtime")), + ); + + assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!( + link.state(), + LinkState::SelectingProvider { + issue: Some(issue), + .. + } if issue.message.contains("server handoff failed") + )); + assert_eq!(link.actions(false).len(), 1); + assert_eq!( + link.actions(false)[0].op_as::(), + Some(&LinkOp::OpenProvider { + provider_id: LinkProviderKind::Fake + }) + ); + } + + #[test] + fn snapshot_projects_provider_catalog_from_registry() { + let link = LinkUx::with_registry(registry_with_fake_endpoint()); + + assert!(matches!( + link.snapshot().state, + LinkState::SelectingProvider { ref providers, .. } + if providers.len() == 1 && providers[0].id == LinkProviderKind::Fake + )); + } + + fn registry_with_fake_endpoint() -> LinkProviderRegistry { + let mut registry = LinkProviderRegistry::new(); + registry.insert(FakeProvider::new().with_endpoint(LinkEndpoint::new( + "fake-runtime", + LinkProviderKind::Fake, + "Fake runtime", + ))); + registry + } + + fn registry_with_fake_discover_error(message: impl Into) -> LinkProviderRegistry { + let mut registry = LinkProviderRegistry::new(); + registry.insert( + FakeProvider::new() + .with_endpoint(LinkEndpoint::new( + "fake-runtime", + LinkProviderKind::Fake, + "Fake runtime", + )) + .with_discover_error(message), + ); + registry + } + + fn registry_with_fake_connect_error(message: impl Into) -> LinkProviderRegistry { + let mut registry = LinkProviderRegistry::new(); + registry.insert( + FakeProvider::new() + .with_endpoint(LinkEndpoint::new( + "fake-runtime", + LinkProviderKind::Fake, + "Fake runtime", + )) + .with_connect_error(message), + ); + registry + } + + fn registry_with_fake_connection_error(message: impl Into) -> LinkProviderRegistry { + let mut registry = LinkProviderRegistry::new(); + registry.insert( + FakeProvider::new() + .with_endpoint(LinkEndpoint::new( + "fake-runtime", + LinkProviderKind::Fake, + "Fake runtime", + )) + .with_connection_error(message), + ); + registry + } + + fn management_capable_session() -> LinkSession { + LinkSession::new( + "fake-session", + LinkProviderKind::Fake, + "fake-runtime", + LinkConnectionKind::Fake, + LinkCapabilities::esp32_serial_base() + .with_flash() + .with_device_erase(), + ) + } + + fn block_on_ready(future: F) -> F::Output + where + F: Future, + { + let waker = Waker::from(Arc::new(NoopWake)); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => output, + Poll::Pending => panic!("test future unexpectedly yielded"), + } + } + + struct NoopWake; + + impl Wake for NoopWake { + fn wake(self: Arc) {} + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/mod.rs b/lp-app/lpa-studio-ux/src/nodes/link/mod.rs new file mode 100644 index 000000000..b478d3639 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/mod.rs @@ -0,0 +1,21 @@ +pub mod connected_device_summary; +pub mod endpoint_choice; +pub mod link_op; +pub mod link_snapshot; +pub mod link_state; +pub mod link_ux; +pub mod progress_state; +pub mod provider_choice; +pub mod ux_issue; + +pub use connected_device_summary::ConnectedDeviceSummary; +pub use endpoint_choice::EndpointChoice; +pub use link_op::LinkOp; +pub use link_snapshot::LinkSnapshot; +pub use link_state::LinkState; +pub use link_ux::{ + ConnectedLink, LinkManagementOutcome, LinkOpenOutcome, LinkUx, SharedLinkRegistry, +}; +pub use progress_state::ProgressState; +pub use provider_choice::ProviderChoice; +pub use ux_issue::UxIssue; diff --git a/lp-app/lpa-studio-ux/src/nodes/link/progress_state.rs b/lp-app/lpa-studio-ux/src/nodes/link/progress_state.rs new file mode 100644 index 000000000..d68b38937 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/progress_state.rs @@ -0,0 +1,19 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProgressState { + pub label: String, + pub detail: Option, +} + +impl ProgressState { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + detail: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/provider_choice.rs b/lp-app/lpa-studio-ux/src/nodes/link/provider_choice.rs new file mode 100644 index 000000000..b8ebea240 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/provider_choice.rs @@ -0,0 +1,55 @@ +use lpa_link::LinkProviderKind; +use lpa_link::providers::{LinkProviderAvailability, LinkProviderDescriptor}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderChoice { + pub id: LinkProviderKind, + pub label: String, + pub summary: String, +} + +impl ProviderChoice { + pub fn from_descriptor(descriptor: LinkProviderDescriptor) -> Self { + Self { + id: descriptor.kind, + label: provider_label(descriptor.kind, descriptor.label), + summary: provider_summary(descriptor.kind, descriptor.availability), + } + } + + #[cfg(any(test, feature = "browser-worker"))] + pub fn browser_worker() -> Self { + Self { + id: LinkProviderKind::BrowserWorker, + label: "Simulator".to_string(), + summary: "Run LightPlayer locally in a browser worker.".to_string(), + } + } +} + +fn provider_label(kind: LinkProviderKind, fallback: &str) -> String { + match kind { + LinkProviderKind::BrowserWorker => "Simulator".to_string(), + LinkProviderKind::HostProcess => "Host runtime".to_string(), + _ => fallback.to_string(), + } +} + +fn provider_summary(kind: LinkProviderKind, availability: LinkProviderAvailability) -> String { + if let LinkProviderAvailability::Unavailable { reason } = availability { + return reason.to_string(); + } + match kind { + LinkProviderKind::BrowserWorker => { + "Run LightPlayer locally in a browser worker.".to_string() + } + LinkProviderKind::HostProcess => "Run LightPlayer locally in a host process.".to_string(), + LinkProviderKind::BrowserSerialEsp32 => { + "Connect to ESP32 hardware through browser Web Serial.".to_string() + } + LinkProviderKind::HostSerialEsp32 => { + "Connect to ESP32 hardware through a host serial port.".to_string() + } + LinkProviderKind::Fake => "Use an in-memory test link provider.".to_string(), + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/ux_issue.rs b/lp-app/lpa-studio-ux/src/nodes/link/ux_issue.rs new file mode 100644 index 000000000..942277217 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/link/ux_issue.rs @@ -0,0 +1,19 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UxIssue { + pub message: String, + pub detail: Option, +} + +impl UxIssue { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + detail: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/mod.rs b/lp-app/lpa-studio-ux/src/nodes/mod.rs new file mode 100644 index 000000000..27e55cff4 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/mod.rs @@ -0,0 +1,5 @@ +pub mod device; +pub mod link; +pub mod project; +pub mod server; +pub mod studio; diff --git a/lp-app/lpa-studio-ux/src/nodes/project/demo_project.rs b/lp-app/lpa-studio-ux/src/nodes/project/demo_project.rs new file mode 100644 index 000000000..4c396ff99 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/demo_project.rs @@ -0,0 +1,46 @@ +use lpa_client::ProjectDeployFile; + +use crate::STUDIO_DEMO_PROJECT_ID; + +pub const DEMO_PROJECT_ID: &str = STUDIO_DEMO_PROJECT_ID; + +pub struct DemoProjectFile { + pub relative_path: &'static str, + pub bytes: &'static [u8], +} + +pub fn demo_project_files() -> &'static [DemoProjectFile] { + &[ + DemoProjectFile { + relative_path: "clock.toml", + bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/clock.toml"), + }, + DemoProjectFile { + relative_path: "fixture.toml", + bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/fixture.toml"), + }, + DemoProjectFile { + relative_path: "output.toml", + bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/output.toml"), + }, + DemoProjectFile { + relative_path: "project.toml", + bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/project.toml"), + }, + DemoProjectFile { + relative_path: "shader.glsl", + bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/shader.glsl"), + }, + DemoProjectFile { + relative_path: "shader.toml", + bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/shader.toml"), + }, + ] +} + +pub fn demo_project_deploy_files() -> Vec { + demo_project_files() + .iter() + .map(|file| ProjectDeployFile::new(file.relative_path, file.bytes.to_vec())) + .collect() +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/loaded_project_choice.rs b/lp-app/lpa-studio-ux/src/nodes/project/loaded_project_choice.rs new file mode 100644 index 000000000..f335376e0 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/loaded_project_choice.rs @@ -0,0 +1,14 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LoadedProjectChoice { + pub project_id: String, + pub handle_id: u32, +} + +impl LoadedProjectChoice { + pub fn new(project_id: impl Into, handle_id: u32) -> Self { + Self { + project_id: project_id.into(), + handle_id, + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/mod.rs b/lp-app/lpa-studio-ux/src/nodes/project/mod.rs new file mode 100644 index 000000000..8213c5257 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/mod.rs @@ -0,0 +1,16 @@ +pub mod demo_project; +pub mod loaded_project_choice; +pub mod project_connect_result; +pub mod project_inventory_summary; +pub mod project_op; +pub mod project_snapshot; +pub mod project_state; +pub mod project_ux; + +pub use loaded_project_choice::LoadedProjectChoice; +pub use project_connect_result::ProjectConnectResult; +pub use project_inventory_summary::ProjectInventorySummary; +pub use project_op::ProjectOp; +pub use project_snapshot::ProjectSnapshot; +pub use project_state::ProjectState; +pub use project_ux::ProjectUx; diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_connect_result.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_connect_result.rs new file mode 100644 index 000000000..eecff45ab --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/project_connect_result.rs @@ -0,0 +1,18 @@ +use crate::UxLogEntry; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectConnectResult { + Connected { logs: Vec }, + SelectionRequired { logs: Vec }, + NotFound { logs: Vec }, +} + +impl ProjectConnectResult { + pub fn logs(self) -> Vec { + match self { + Self::Connected { logs } + | Self::SelectionRequired { logs } + | Self::NotFound { logs } => logs, + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_inventory_summary.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_inventory_summary.rs new file mode 100644 index 000000000..0e290ae49 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/project_inventory_summary.rs @@ -0,0 +1,18 @@ +use lpc_wire::WireProjectInventoryReadResponse; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectInventorySummary { + pub node_count: usize, + pub definition_count: usize, + pub asset_count: usize, +} + +impl From<&WireProjectInventoryReadResponse> for ProjectInventorySummary { + fn from(inventory: &WireProjectInventoryReadResponse) -> Self { + Self { + node_count: inventory.nodes.len(), + definition_count: inventory.defs.len(), + asset_count: inventory.assets.len(), + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_op.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_op.rs new file mode 100644 index 000000000..ea96a406f --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/project_op.rs @@ -0,0 +1,54 @@ +use core::any::Any; + +use crate::{ActionMeta, ActionPriority, UxOp}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectOp { + ConnectRunningProject, + ConnectLoadedProject { handle_id: u32 }, + LoadDemoProject, + DisconnectProject, +} + +impl UxOp for ProjectOp { + fn default_action_meta(&self) -> ActionMeta { + match self { + Self::ConnectRunningProject => ActionMeta::new( + "Connect running project", + "Attach to a project that is already loaded on the connected server.", + ActionPriority::Primary, + ), + Self::ConnectLoadedProject { .. } => ActionMeta::new( + "Connect project", + "Attach to this already-loaded project.", + ActionPriority::Primary, + ), + Self::LoadDemoProject => ActionMeta::new( + "Load demo project", + "Upload and run the built-in demo project.", + ActionPriority::Secondary, + ), + Self::DisconnectProject => ActionMeta::new( + "Disconnect project", + "Detach Studio from the current project without stopping it on the device.", + ActionPriority::Tertiary, + ), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn UxOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_snapshot.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_snapshot.rs new file mode 100644 index 000000000..171fc362b --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/project_snapshot.rs @@ -0,0 +1,12 @@ +use crate::ProjectState; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectSnapshot { + pub state: ProjectState, +} + +impl ProjectSnapshot { + pub fn new(state: ProjectState) -> Self { + Self { state } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_state.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_state.rs new file mode 100644 index 000000000..9dcf3e3a3 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/project_state.rs @@ -0,0 +1,23 @@ +use crate::{LoadedProjectChoice, ProgressState, ProjectInventorySummary, UxIssue}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectState { + NotLoaded, + SelectingLoadedProject { + projects: Vec, + }, + ConnectingRunningProject { + progress: ProgressState, + }, + LoadingDemoProject { + progress: ProgressState, + }, + Ready { + project_id: String, + handle_id: u32, + inventory: ProjectInventorySummary, + }, + Failed { + issue: UxIssue, + }, +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_ux.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_ux.rs new file mode 100644 index 000000000..a6e115126 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/project/project_ux.rs @@ -0,0 +1,360 @@ +use crate::{ + LoadedProjectChoice, ProgressState, ProjectConnectResult, ProjectInventorySummary, ProjectOp, + ProjectSnapshot, ProjectState, StudioServerClient, UiAction, UiBody, UiMetric, UiPaneView, + UiStatus, UxError, UxIssue, UxLogEntry, UxNode, UxNodeId, +}; + +pub struct ProjectUx { + state: ProjectState, + running_project_status: RunningProjectStatus, +} + +impl ProjectUx { + pub const NODE_ID: &'static str = "studio.project"; + + pub fn new() -> Self { + Self { + state: ProjectState::NotLoaded, + running_project_status: RunningProjectStatus::Unknown, + } + } + + pub fn set_state(&mut self, state: ProjectState) { + self.state = state; + } + + pub fn snapshot(&self) -> ProjectSnapshot { + ProjectSnapshot::new(self.state.clone()) + } + + pub fn actions(&self, server_connected: bool) -> Vec { + if !server_connected { + return Vec::new(); + } + match self.state { + ProjectState::NotLoaded => { + let mut actions = Vec::new(); + if self.running_project_status != RunningProjectStatus::NoneKnown { + actions.push(self.action(ProjectOp::ConnectRunningProject)); + } + actions.push(self.action(ProjectOp::LoadDemoProject)); + actions + } + ProjectState::Failed { .. } => vec![ + self.action(ProjectOp::ConnectRunningProject), + self.action(ProjectOp::LoadDemoProject), + ], + ProjectState::SelectingLoadedProject { ref projects } => projects + .iter() + .map(|project| { + self.action(ProjectOp::ConnectLoadedProject { + handle_id: project.handle_id, + }) + .with_label(format!("Connect {}", project.project_id)) + .with_summary(format!( + "Attach to running project handle {}.", + project.handle_id + )) + }) + .collect(), + ProjectState::ConnectingRunningProject { .. } + | ProjectState::LoadingDemoProject { .. } => Vec::new(), + ProjectState::Ready { .. } => vec![self.action(ProjectOp::DisconnectProject)], + } + } + + pub fn view(&self, server_connected: bool) -> UiPaneView { + UiPaneView::new( + Self::NODE_ID, + "Project", + project_status(&self.state), + project_body(&self.state, self.running_project_status), + self.actions(server_connected), + ) + } + + pub fn mark_connecting_running(&mut self) { + self.state = ProjectState::ConnectingRunningProject { + progress: ProgressState::new("Connecting running project"), + }; + } + + pub fn mark_selecting_loaded_project(&mut self, projects: Vec) { + self.running_project_status = RunningProjectStatus::Available; + self.state = ProjectState::SelectingLoadedProject { projects }; + } + + pub fn mark_loading_demo(&mut self) { + self.state = ProjectState::LoadingDemoProject { + progress: ProgressState::new("Loading demo project"), + }; + } + + pub fn mark_ready( + &mut self, + project_id: impl Into, + handle_id: u32, + inventory: ProjectInventorySummary, + ) { + self.running_project_status = RunningProjectStatus::Available; + self.state = ProjectState::Ready { + project_id: project_id.into(), + handle_id, + inventory, + }; + } + + pub fn fail(&mut self, message: impl Into) { + self.running_project_status = RunningProjectStatus::Unknown; + self.state = ProjectState::Failed { + issue: UxIssue::new(message), + }; + } + + pub fn disconnect(&mut self) { + self.running_project_status = if matches!(self.state, ProjectState::Ready { .. }) { + RunningProjectStatus::Available + } else { + RunningProjectStatus::Unknown + }; + self.state = ProjectState::NotLoaded; + } + + pub fn reset(&mut self) { + self.running_project_status = RunningProjectStatus::Unknown; + self.state = ProjectState::NotLoaded; + } + + pub fn mark_no_running_project(&mut self) { + self.running_project_status = RunningProjectStatus::NoneKnown; + self.state = ProjectState::NotLoaded; + } + + pub async fn load_demo_project( + &mut self, + server: &mut StudioServerClient, + ) -> Result, UxError> { + self.mark_loading_demo(); + let loaded = server.load_demo_project().await?; + self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); + Ok(loaded.logs) + } + + pub async fn connect_running_project( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + self.mark_connecting_running(); + let catalog = server.list_loaded_projects().await?; + self.connect_from_catalog(server, catalog.projects, catalog.logs) + .await + } + + pub async fn connect_running_project_if_available( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + let catalog = server.list_loaded_projects().await?; + self.connect_from_catalog(server, catalog.projects, catalog.logs) + .await + } + + pub async fn connect_loaded_project( + &mut self, + server: &mut StudioServerClient, + handle_id: u32, + ) -> Result, UxError> { + let choice = self.loaded_project_choice(handle_id)?; + self.mark_connecting_running(); + let project = server.connect_loaded_project(choice).await?; + let logs = server.take_pending_logs(); + self.mark_ready(project.project_id, project.handle_id, project.inventory); + Ok(logs) + } + + async fn connect_from_catalog( + &mut self, + server: &mut StudioServerClient, + projects: Vec, + mut logs: Vec, + ) -> Result { + match projects.as_slice() { + [] => { + self.mark_no_running_project(); + Ok(ProjectConnectResult::NotFound { logs }) + } + [project] => { + let loaded = server.connect_loaded_project(project.clone()).await?; + logs.extend(server.take_pending_logs()); + self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); + Ok(ProjectConnectResult::Connected { logs }) + } + _ => { + self.mark_selecting_loaded_project(projects); + Ok(ProjectConnectResult::SelectionRequired { logs }) + } + } + } + + fn loaded_project_choice(&self, handle_id: u32) -> Result { + match &self.state { + ProjectState::SelectingLoadedProject { projects } => projects + .iter() + .find(|project| project.handle_id == handle_id) + .cloned() + .ok_or_else(|| { + UxError::Project(format!( + "loaded project handle {handle_id} is not available" + )) + }), + _ => Err(UxError::Project( + "loaded project selection is not active".to_string(), + )), + } + } +} + +impl UxNode for ProjectUx { + type Op = ProjectOp; + + fn node_id(&self) -> UxNodeId { + UxNodeId::new(Self::NODE_ID) + } +} + +impl Default for ProjectUx { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RunningProjectStatus { + Unknown, + NoneKnown, + Available, +} + +fn project_status(state: &ProjectState) -> UiStatus { + match state { + ProjectState::NotLoaded => UiStatus::neutral("Not loaded"), + ProjectState::SelectingLoadedProject { .. } => UiStatus::neutral("Choose project"), + ProjectState::ConnectingRunningProject { .. } => UiStatus::working("Connecting"), + ProjectState::LoadingDemoProject { .. } => UiStatus::working("Loading"), + ProjectState::Ready { .. } => UiStatus::good("Ready"), + ProjectState::Failed { .. } => UiStatus::error("Failed"), + } +} + +fn project_body(state: &ProjectState, running_project_status: RunningProjectStatus) -> UiBody { + match state { + ProjectState::NotLoaded if running_project_status == RunningProjectStatus::NoneKnown => { + UiBody::text("No running project is loaded. Load the demo project when you're ready.") + } + ProjectState::NotLoaded => { + UiBody::text("Connect to a running project or load the demo project.") + } + ProjectState::SelectingLoadedProject { projects } => UiBody::text(format!( + "{} projects are running. Choose one to attach.", + projects.len() + )), + ProjectState::ConnectingRunningProject { progress } + | ProjectState::LoadingDemoProject { progress } => UiBody::Progress(progress.clone()), + ProjectState::Ready { + project_id, + handle_id, + inventory, + } => UiBody::Metrics(vec![ + UiMetric::new("Project", project_id), + UiMetric::new("Handle", *handle_id), + UiMetric::new("Nodes", inventory.node_count), + UiMetric::new("Definitions", inventory.definition_count), + UiMetric::new("Assets", inventory.asset_count), + ]), + ProjectState::Failed { issue } => UiBody::Issue(issue.clone()), + } +} + +#[cfg(test)] +mod tests { + use crate::{ActionPriority, ProjectOp}; + + use super::*; + + #[test] + fn disconnected_project_has_no_actions() { + let project = ProjectUx::new(); + + assert!(project.actions(false).is_empty()); + } + + #[test] + fn connected_not_loaded_project_offers_attach_and_demo_actions() { + let project = ProjectUx::new(); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 2); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::ConnectRunningProject) + ); + assert_eq!(actions[0].meta().priority, ActionPriority::Primary); + assert_eq!( + actions[1].op_as::(), + Some(&ProjectOp::LoadDemoProject) + ); + assert_eq!(actions[1].meta().priority, ActionPriority::Secondary); + } + + #[test] + fn connected_project_with_no_running_project_only_offers_demo_load() { + let mut project = ProjectUx::new(); + project.mark_no_running_project(); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::LoadDemoProject) + ); + } + + #[test] + fn multiple_loaded_projects_offer_project_specific_actions() { + let mut project = ProjectUx::new(); + project.mark_selecting_loaded_project(vec![ + LoadedProjectChoice::new("/projects/a", 1), + LoadedProjectChoice::new("/projects/b", 2), + ]); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 2); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::ConnectLoadedProject { handle_id: 1 }) + ); + assert_eq!(actions[0].meta().label, "Connect /projects/a"); + assert_eq!( + actions[1].op_as::(), + Some(&ProjectOp::ConnectLoadedProject { handle_id: 2 }) + ); + } + + #[test] + fn ready_project_offers_disconnect_action() { + let mut project = ProjectUx::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::DisconnectProject) + ); + assert_eq!(actions[0].meta().priority, ActionPriority::Tertiary); + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_client_io.rs b/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_client_io.rs new file mode 100644 index 000000000..2bbd6df66 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_client_io.rs @@ -0,0 +1,531 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use async_trait::async_trait; +use lpa_client::ClientIo; +use lpa_client::project_deploy::request_label; +use lpa_link::provider::session::LinkSessionId; +use lpa_link::providers::browser_serial_esp32::BrowserSerialEsp32Provider; +use lpa_link::providers::{LinkProviderInstance, LinkProviderRegistry}; +use lpa_link::{LinkProvider, LinkProviderKind}; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage, json}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; +use wasm_bindgen_futures::JsFuture; + +use super::browser_serial_readiness::{ + BrowserSerialReadinessClassifier, BrowserSerialReadinessFailure, +}; +use crate::{ + ServerUx, SharedLinkRegistry, UiActivity, UiActivityStep, UiActivityStepState, UiStatus, + UxActivityTarget, UxLogEntry, UxLogLevel, UxNodeId, UxUpdate, UxUpdateSink, +}; + +const RESPONSE_POLL_LIMIT: usize = 500; +const READINESS_POLL_LIMIT: usize = 500; +const RESPONSE_POLL_DELAY_MS: i32 = 10; +const MALFORMED_PROTOCOL_SNIPPET_LIMIT: usize = 4_096; +const DEVICE_LOG_SNIPPET_LIMIT: usize = 1_024; +const STEP_SERIAL_ACCESS: &str = "serial-access"; +const STEP_RESET_DEVICE: &str = "reset-device"; +const STEP_BOOT_OUTPUT: &str = "boot-output"; +const STEP_PROTOCOL: &str = "server-protocol"; + +pub struct BrowserSerialClientIo { + state: Rc>, +} + +impl BrowserSerialClientIo { + pub fn new( + registry: SharedLinkRegistry, + session_id: LinkSessionId, + logs: Rc>>, + updates: UxUpdateSink, + ) -> Self { + let readiness_activity = initial_readiness_activity(); + updates.emit(UxUpdate::Activity { + target: UxActivityTarget::pane(server_node_id()), + status: UiStatus::working("Connecting"), + activity: readiness_activity.clone(), + }); + Self { + state: Rc::new(RefCell::new(BrowserSerialClientState { + registry, + session_id, + logs, + updates, + readiness_activity, + readiness_classifier: BrowserSerialReadinessClassifier::new(), + boot_output_seen: false, + last_request: None, + last_protocol_issue: None, + protocol_ready: false, + })), + } + } + + async fn ensure_protocol_ready(&self) -> Result<(), TransportError> { + if self.state.borrow().protocol_ready { + return Ok(()); + } + self.state + .borrow() + .emit_readiness_activity(UiStatus::working("Connecting")); + + for _ in 0..READINESS_POLL_LIMIT { + let (registry, session_id) = { + let state = self.state.borrow(); + (Rc::clone(&state.registry), state.session_id.clone()) + }; + + let (errors, lines) = { + let mut registry = registry.borrow_mut(); + let provider = browser_serial_provider_mut(&mut registry)?; + let errors = provider + .take_errors(&session_id) + .map_err(link_error_to_transport)?; + let lines = provider + .take_lines(&session_id) + .map_err(link_error_to_transport)?; + (errors, lines) + }; + + let mut protocol_ready = false; + for line in lines { + if self.handle_line(line)?.is_some() { + protocol_ready = true; + } + if self.server_started() { + protocol_ready = true; + } + if let Some(message) = self.detect_readiness_failure() { + self.state.borrow_mut().mark_protocol_failed(&message, true); + return Err(TransportError::Other(message)); + } + } + + if let Some(error) = errors.into_iter().next() { + return Err(self.readiness_error(error)); + } + + if protocol_ready { + let mut state = self.state.borrow_mut(); + state.protocol_ready = true; + state.mark_protocol_ready(); + state.push_log( + UxLogLevel::Info, + "browser-serial", + "server protocol stream is ready", + ); + return Ok(()); + } + + sleep_ms(RESPONSE_POLL_DELAY_MS).await?; + } + + let failure = self.state.borrow().readiness_classifier.classify_timeout(); + let no_firmware = matches!( + failure, + BrowserSerialReadinessFailure::NoFirmwareDetected { .. } + ); + let message = failure.message(); + self.state + .borrow_mut() + .mark_protocol_failed(&message, no_firmware); + Err(TransportError::Other(message)) + } + + fn detect_readiness_failure(&self) -> Option { + let state = self.state.borrow(); + if state.readiness_classifier.no_firmware_detected() { + Some(state.readiness_classifier.classify_timeout().message()) + } else { + None + } + } + + fn server_started(&self) -> bool { + self.state.borrow().readiness_classifier.server_started() + } + + fn readiness_error(&self, error: String) -> TransportError { + let message = format!("browser serial error while waiting for server readiness: {error}"); + if let Some(no_firmware_message) = self.detect_readiness_failure() { + console_warn(&format!( + "[browser-serial] {message}; treating as no firmware" + )); + self.state + .borrow() + .push_log(UxLogLevel::Warn, "browser-serial", message); + self.state + .borrow_mut() + .mark_protocol_failed(&no_firmware_message, true); + return TransportError::Other(no_firmware_message); + } + + console_error(&format!("[browser-serial] {message}")); + self.state + .borrow() + .push_log(UxLogLevel::Error, "browser-serial", message.clone()); + self.state + .borrow_mut() + .mark_protocol_failed(&message, false); + TransportError::Other(message) + } + + fn handle_line(&self, line: String) -> Result, TransportError> { + if line.is_empty() { + return Ok(None); + } + + let Some(json_frame) = line.strip_prefix("M!") else { + self.record_device_line(&line); + return Ok(None); + }; + + match json::from_str::(json_frame) { + Ok(response) => Ok(Some(response)), + Err(error) => { + let snippet = line_snippet(json_frame, MALFORMED_PROTOCOL_SNIPPET_LIMIT); + let issue = format!("{error}; json={snippet}"); + self.record_malformed_frame(issue.clone()); + if let Some(next_frame) = nested_protocol_frame(json_frame) { + console_warn(&format!( + "[browser-serial] attempting resync at nested M! frame while {}", + self.wait_context() + )); + self.handle_line(next_frame.to_string()) + } else { + Ok(None) + } + } + } + } + + fn record_device_line(&self, line: &str) { + let level = device_line_level(line); + let message = line_snippet(line, DEVICE_LOG_SNIPPET_LIMIT); + log_device_line(level, &message); + self.state + .borrow_mut() + .record_readiness_device_line(level, message); + } + + fn record_malformed_frame(&self, issue: String) { + let message = format!("malformed M! frame while {}: {issue}", self.wait_context()); + console_warn(&format!("[browser-serial] {message}")); + let mut state = self.state.borrow_mut(); + state.last_protocol_issue = Some(issue); + state.push_log(UxLogLevel::Warn, "browser-serial", message); + } + + fn wait_context(&self) -> String { + self.state.borrow().wait_context() + } +} + +#[async_trait(?Send)] +impl ClientIo for BrowserSerialClientIo { + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { + self.ensure_protocol_ready().await?; + + let request_id = msg.id; + let label = request_label(&msg.msg); + let frame = json::to_string(&msg) + .map_err(|error| TransportError::Serialization(error.to_string()))?; + + { + let mut state = self.state.borrow_mut(); + state.last_request = Some(BrowserSerialRequest { + id: request_id, + label, + }); + state.last_protocol_issue = None; + } + + console_debug(&format!( + "[browser-serial] tx request id={request_id} kind={label} json_bytes={}", + frame.len() + )); + + let (registry, session_id) = { + let state = self.state.borrow(); + (Rc::clone(&state.registry), state.session_id.clone()) + }; + let mut registry = registry.borrow_mut(); + let provider = browser_serial_provider_mut(&mut registry)?; + provider + .write_line(&session_id, &format!("M!{frame}\n")) + .await + .map_err(link_error_to_transport) + } + + async fn receive(&mut self) -> Result { + for _ in 0..RESPONSE_POLL_LIMIT { + let (registry, session_id) = { + let state = self.state.borrow(); + (Rc::clone(&state.registry), state.session_id.clone()) + }; + + let (errors, lines) = { + let mut registry = registry.borrow_mut(); + let provider = browser_serial_provider_mut(&mut registry)?; + let errors = provider + .take_errors(&session_id) + .map_err(link_error_to_transport)?; + let lines = provider + .take_lines(&session_id) + .map_err(link_error_to_transport)?; + (errors, lines) + }; + + for error in errors { + let message = format!( + "browser serial error while {}: {error}", + self.wait_context() + ); + console_error(&format!("[browser-serial] {message}")); + self.state + .borrow() + .push_log(UxLogLevel::Error, "browser-serial", message.clone()); + return Err(TransportError::Other(message)); + } + + for line in lines { + if let Some(response) = self.handle_line(line)? { + return Ok(response); + } + } + + sleep_ms(RESPONSE_POLL_DELAY_MS).await?; + } + + let mut message = format!( + "timed out waiting for browser serial protocol response while {}", + self.wait_context() + ); + if let Some(issue) = self.state.borrow().last_protocol_issue.clone() { + message.push_str("; last malformed protocol frame: "); + message.push_str(&issue); + } + Err(TransportError::Other(message)) + } + + async fn close(&mut self) -> Result<(), TransportError> { + let (registry, session_id) = { + let state = self.state.borrow(); + (Rc::clone(&state.registry), state.session_id.clone()) + }; + let mut registry = registry.borrow_mut(); + browser_serial_provider_mut(&mut registry)? + .close(&session_id) + .await + .map_err(link_error_to_transport) + } +} + +struct BrowserSerialClientState { + registry: SharedLinkRegistry, + session_id: LinkSessionId, + logs: Rc>>, + updates: UxUpdateSink, + readiness_activity: UiActivity, + readiness_classifier: BrowserSerialReadinessClassifier, + boot_output_seen: bool, + last_request: Option, + last_protocol_issue: Option, + protocol_ready: bool, +} + +impl BrowserSerialClientState { + fn push_log(&self, level: UxLogLevel, source: impl Into, message: impl Into) { + self.logs + .borrow_mut() + .push(UxLogEntry::new(level, source, message)); + } + + fn record_readiness_device_line(&mut self, level: UxLogLevel, message: String) { + self.readiness_classifier.observe_line(message.clone()); + let entry = UxLogEntry::new(level, "fw-esp32", message.clone()); + self.logs.borrow_mut().push(entry.clone()); + self.updates.emit(UxUpdate::Log(entry)); + if !self.boot_output_seen { + self.boot_output_seen = true; + self.readiness_activity + .set_step_state(STEP_BOOT_OUTPUT, UiActivityStepState::Complete); + } + self.readiness_activity + .set_step_state(STEP_PROTOCOL, UiActivityStepState::Active); + self.emit_readiness_activity(UiStatus::working("Connecting")); + } + + fn mark_protocol_ready(&mut self) { + self.readiness_activity + .set_step_state(STEP_BOOT_OUTPUT, UiActivityStepState::Complete); + self.readiness_activity + .set_step_state(STEP_PROTOCOL, UiActivityStepState::Complete); + self.readiness_activity.progress = None; + self.emit_readiness_activity(UiStatus::good("Connected")); + } + + fn mark_protocol_failed(&mut self, message: &str, no_firmware: bool) { + if self.readiness_classifier.recent_lines().is_empty() { + self.readiness_activity + .set_step_state(STEP_BOOT_OUTPUT, UiActivityStepState::Failed); + } else { + self.readiness_activity + .set_step_state(STEP_BOOT_OUTPUT, UiActivityStepState::Complete); + } + self.readiness_activity + .set_step_state(STEP_PROTOCOL, UiActivityStepState::Failed); + self.readiness_activity.detail = Some(message.to_string()); + self.readiness_activity.progress = None; + let status = if no_firmware { + UiStatus::warning("Flash ready") + } else { + UiStatus::error("Timeout") + }; + self.emit_readiness_activity(status); + } + + fn emit_readiness_activity(&self, status: UiStatus) { + self.updates.emit(UxUpdate::Activity { + target: UxActivityTarget::pane(server_node_id()), + status, + activity: self.readiness_activity.clone(), + }); + } + + fn wait_context(&self) -> String { + match self.last_request { + Some(request) => { + format!( + "waiting for response id={} kind={}", + request.id, request.label + ) + } + None => "waiting for a protocol response".to_string(), + } + } +} + +fn initial_readiness_activity() -> UiActivity { + UiActivity::new("Connecting ESP32 server") + .with_detail("Waiting for LightPlayer boot output and protocol frames.") + .with_steps(vec![ + UiActivityStep::new(STEP_SERIAL_ACCESS, "Serial access") + .with_state(UiActivityStepState::Complete) + .with_detail("Browser serial port is open."), + UiActivityStep::new(STEP_RESET_DEVICE, "Reset device") + .with_state(UiActivityStepState::Complete) + .with_detail("Device reset was requested while serial output was being read."), + UiActivityStep::new(STEP_BOOT_OUTPUT, "Boot output") + .with_state(UiActivityStepState::Active), + UiActivityStep::new(STEP_PROTOCOL, "LightPlayer protocol") + .with_state(UiActivityStepState::Active), + ]) +} + +fn server_node_id() -> UxNodeId { + UxNodeId::new(ServerUx::NODE_ID) +} + +#[derive(Clone, Copy)] +struct BrowserSerialRequest { + id: u64, + label: &'static str, +} + +fn browser_serial_provider_mut( + registry: &mut LinkProviderRegistry, +) -> Result<&mut BrowserSerialEsp32Provider, TransportError> { + match registry.provider_mut(LinkProviderKind::BrowserSerialEsp32) { + Some(LinkProviderInstance::BrowserSerialEsp32(provider)) => Ok(provider), + Some(_) => Err(TransportError::Other( + "browser-serial-esp32 registry entry has the wrong provider type".to_string(), + )), + None => Err(TransportError::Other( + "browser-serial-esp32 provider is not available".to_string(), + )), + } +} + +fn link_error_to_transport(error: lpa_link::LinkError) -> TransportError { + TransportError::Other(error.to_string()) +} + +fn device_line_level(line: &str) -> UxLogLevel { + if line.starts_with("[ERROR]") { + UxLogLevel::Error + } else if line.starts_with("[WARN]") { + UxLogLevel::Warn + } else if line.starts_with("[DEBUG]") || line.starts_with("[TRACE]") { + UxLogLevel::Debug + } else { + UxLogLevel::Info + } +} + +fn log_device_line(level: UxLogLevel, message: &str) { + let message = format!("[fw-esp32] {message}"); + match level { + UxLogLevel::Error => console_error(&message), + UxLogLevel::Warn => console_warn(&message), + UxLogLevel::Debug => console_debug(&message), + UxLogLevel::Info => console_log(&message), + } +} + +fn nested_protocol_frame(json_frame: &str) -> Option<&str> { + json_frame + .find("M!") + .filter(|offset| *offset > 0) + .map(|offset| &json_frame[offset..]) +} + +fn line_snippet(line: &str, max_len: usize) -> String { + let mut output = String::new(); + for c in line.chars() { + if output.len() >= max_len { + output.push_str("..."); + break; + } + for escaped in c.escape_default() { + output.push(escaped); + } + } + output +} + +async fn sleep_ms(ms: i32) -> Result<(), TransportError> { + let promise = + js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| { + let Some(window) = web_sys::window() else { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("missing window")); + return; + }; + if let Err(error) = + window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms) + { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + JsFuture::from(promise) + .await + .map(|_| ()) + .map_err(|error| TransportError::Other(format!("{error:?}"))) +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = log)] + fn console_log(message: &str); + + #[wasm_bindgen(js_namespace = console, js_name = debug)] + fn console_debug(message: &str); + + #[wasm_bindgen(js_namespace = console, js_name = warn)] + fn console_warn(message: &str); + + #[wasm_bindgen(js_namespace = console, js_name = error)] + fn console_error(message: &str); +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_readiness.rs b/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_readiness.rs new file mode 100644 index 000000000..7b20b99d5 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_readiness.rs @@ -0,0 +1,264 @@ +#![cfg_attr( + not(all(feature = "browser-serial-esp32", target_arch = "wasm32")), + allow( + dead_code, + reason = "browser serial readiness runs only in the wasm Web Serial adapter" + ) +)] + +pub const NO_FIRMWARE_DETECTED_PREFIX: &str = "no LightPlayer firmware detected"; + +const RECENT_LINE_LIMIT: usize = 80; +const FAILURE_SNIPPET_LINE_LIMIT: usize = 6; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrowserSerialReadinessClassifier { + recent_lines: Vec, + invalid_blank_header_count: usize, + rom_download_mode_count: usize, + server_started: bool, +} + +impl BrowserSerialReadinessClassifier { + pub fn new() -> Self { + Self::default() + } + + pub fn observe_line(&mut self, line: impl Into) { + let line = line.into(); + let normalized = line.to_ascii_lowercase(); + if normalized.contains("invalid header: 0xffffffff") { + self.invalid_blank_header_count += 1; + } + if normalized.contains("waiting for download") || normalized.contains("(download(") { + self.rom_download_mode_count += 1; + } + if normalized.contains("fw-esp32 initialized, starting server loop") { + self.server_started = true; + } + self.recent_lines.push(line); + if self.recent_lines.len() > RECENT_LINE_LIMIT { + let remove_count = self.recent_lines.len() - RECENT_LINE_LIMIT; + self.recent_lines.drain(0..remove_count); + } + } + + pub fn classify_timeout(&self) -> BrowserSerialReadinessFailure { + if self.no_firmware_detected() { + BrowserSerialReadinessFailure::NoFirmwareDetected { + recent_lines: self.recent_lines.clone(), + reason: self.no_firmware_reason(), + } + } else if self.recent_lines.is_empty() { + BrowserSerialReadinessFailure::NoSerialOutput + } else { + BrowserSerialReadinessFailure::ProtocolTimeout { + recent_lines: self.recent_lines.clone(), + } + } + } + + pub fn no_firmware_detected(&self) -> bool { + self.invalid_blank_header_count > 0 || self.rom_download_mode_count > 0 + } + + pub fn server_started(&self) -> bool { + self.server_started + } + + pub fn recent_lines(&self) -> &[String] { + &self.recent_lines + } + + fn no_firmware_reason(&self) -> NoFirmwareReason { + if self.rom_download_mode_count > 0 { + NoFirmwareReason::RomDownloadMode + } else { + NoFirmwareReason::BlankOrErasedFlash + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrowserSerialReadinessFailure { + NoSerialOutput, + NoFirmwareDetected { + recent_lines: Vec, + reason: NoFirmwareReason, + }, + ProtocolTimeout { + recent_lines: Vec, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NoFirmwareReason { + BlankOrErasedFlash, + RomDownloadMode, +} + +impl BrowserSerialReadinessFailure { + pub fn message(&self) -> String { + match self { + Self::NoSerialOutput => "timed out waiting for browser serial server readiness; no serial output was received from the device".to_string(), + Self::NoFirmwareDetected { + recent_lines, + reason, + } => { + let mut message = match reason { + NoFirmwareReason::BlankOrErasedFlash => format!( + "{NO_FIRMWARE_DETECTED_PREFIX}; ESP32 boot output looks like blank or erased flash" + ), + NoFirmwareReason::RomDownloadMode => format!( + "{NO_FIRMWARE_DETECTED_PREFIX}; ESP32 is waiting in ROM download mode" + ), + }; + append_recent_lines(&mut message, recent_lines); + message + } + Self::ProtocolTimeout { recent_lines } => { + let mut message = + "timed out waiting for browser serial server readiness".to_string(); + append_recent_lines(&mut message, recent_lines); + message + } + } + } +} + +pub fn is_no_firmware_detected_message(message: &str) -> bool { + message.contains(NO_FIRMWARE_DETECTED_PREFIX) +} + +fn append_recent_lines(message: &mut String, recent_lines: &[String]) { + let Some(summary) = recent_line_summary(recent_lines) else { + return; + }; + message.push_str("; recent serial output: "); + message.push_str(&summary); +} + +fn recent_line_summary(recent_lines: &[String]) -> Option { + if recent_lines.is_empty() { + return None; + } + let start = recent_lines + .len() + .saturating_sub(FAILURE_SNIPPET_LINE_LIMIT); + Some(recent_lines[start..].join(" | ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_blank_header_classifies_as_no_firmware() { + let mut classifier = BrowserSerialReadinessClassifier::new(); + + classifier.observe_line("ESP-ROM:esp32c6-20220919"); + classifier.observe_line("invalid header: 0xffffffff"); + + assert_eq!( + classifier.classify_timeout(), + BrowserSerialReadinessFailure::NoFirmwareDetected { + recent_lines: vec![ + "ESP-ROM:esp32c6-20220919".to_string(), + "invalid header: 0xffffffff".to_string(), + ], + reason: NoFirmwareReason::BlankOrErasedFlash, + } + ); + } + + #[test] + fn rom_download_mode_classifies_as_no_firmware() { + let mut classifier = BrowserSerialReadinessClassifier::new(); + + classifier.observe_line("boot:0x16 (DOWNLOAD(USB/UART0/SDIO_REI_FEO))"); + classifier.observe_line("waiting for download"); + + assert_eq!( + classifier.classify_timeout(), + BrowserSerialReadinessFailure::NoFirmwareDetected { + recent_lines: vec![ + "boot:0x16 (DOWNLOAD(USB/UART0/SDIO_REI_FEO))".to_string(), + "waiting for download".to_string(), + ], + reason: NoFirmwareReason::RomDownloadMode, + } + ); + assert!( + classifier + .classify_timeout() + .message() + .contains("ESP32 is waiting in ROM download mode") + ); + } + + #[test] + fn unrelated_boot_output_classifies_as_protocol_timeout() { + let mut classifier = BrowserSerialReadinessClassifier::new(); + + classifier.observe_line("ESP-ROM:esp32c6-20220919"); + classifier.observe_line("[INIT] fw-esp32 starting..."); + + assert!(matches!( + classifier.classify_timeout(), + BrowserSerialReadinessFailure::ProtocolTimeout { .. } + )); + } + + #[test] + fn server_loop_start_marks_server_started() { + let mut classifier = BrowserSerialReadinessClassifier::new(); + + classifier.observe_line("[INIT] fw-esp32 initialized, starting server loop..."); + + assert!(classifier.server_started()); + assert!(!classifier.no_firmware_detected()); + } + + #[test] + fn no_output_classifies_as_no_serial_output() { + let classifier = BrowserSerialReadinessClassifier::new(); + + assert_eq!( + classifier.classify_timeout(), + BrowserSerialReadinessFailure::NoSerialOutput + ); + assert!( + classifier + .classify_timeout() + .message() + .contains("no serial output") + ); + } + + #[test] + fn failure_message_includes_recent_serial_lines() { + let failure = BrowserSerialReadinessFailure::ProtocolTimeout { + recent_lines: vec![ + "line 1".to_string(), + "line 2".to_string(), + "line 3".to_string(), + "line 4".to_string(), + "line 5".to_string(), + "line 6".to_string(), + "line 7".to_string(), + ], + }; + + let message = failure.message(); + + assert!(message.contains("line 2 | line 3 | line 4 | line 5 | line 6 | line 7")); + assert!(!message.contains("line 1 |")); + } + + #[test] + fn no_firmware_prefix_can_be_recovered_after_transport_wrapping() { + assert!(is_no_firmware_detected_message(&format!( + "Transport error: {NO_FIRMWARE_DETECTED_PREFIX}; recent serial output: invalid header" + ))); + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/browser_worker_client_io.rs b/lp-app/lpa-studio-ux/src/nodes/server/browser_worker_client_io.rs new file mode 100644 index 000000000..b2fa63686 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/browser_worker_client_io.rs @@ -0,0 +1,179 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use async_trait::async_trait; +use lpa_client::ClientIo; +use lpa_link::provider::session::LinkSessionId; +use lpa_link::providers::browser_worker::{ + BrowserInputEnvelope, BrowserOutputEnvelope, BrowserWorkerProvider, +}; +use lpa_link::providers::{LinkProviderInstance, LinkProviderRegistry}; +use lpa_link::{LinkProvider, LinkProviderKind}; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage, json}; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; + +use crate::{SharedLinkRegistry, UxLogEntry, UxLogLevel}; + +const RESPONSE_POLL_LIMIT: usize = 240; + +pub struct BrowserWorkerClientIo { + state: Rc>, +} + +impl BrowserWorkerClientIo { + pub fn new( + registry: SharedLinkRegistry, + session_id: LinkSessionId, + logs: Rc>>, + ) -> Self { + Self { + state: Rc::new(RefCell::new(BrowserWorkerClientState { + registry, + session_id, + logs, + })), + } + } +} + +#[async_trait(?Send)] +impl ClientIo for BrowserWorkerClientIo { + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { + let frame = json::to_string(&msg) + .map_err(|error| TransportError::Serialization(error.to_string()))?; + self.state + .borrow() + .post(&BrowserInputEnvelope::ProtocolIn { frame }) + } + + async fn receive(&mut self) -> Result { + for _ in 0..RESPONSE_POLL_LIMIT { + self.state + .borrow() + .post(&BrowserInputEnvelope::Tick { delta_ms: Some(16) })?; + sleep_ms(4).await?; + + let outputs = self.state.borrow().take_outputs()?; + for output in outputs { + match output { + BrowserOutputEnvelope::ProtocolOut { frame } => { + let response = json::from_str(&frame) + .map_err(|error| TransportError::Deserialization(error.to_string()))?; + return Ok(response); + } + output => self.state.borrow().record_output(output), + } + } + } + Err(TransportError::Other( + "timed out waiting for browser worker protocol output".to_string(), + )) + } + + async fn close(&mut self) -> Result<(), TransportError> { + let (registry, session_id) = { + let state = self.state.borrow(); + (Rc::clone(&state.registry), state.session_id.clone()) + }; + let mut registry = registry.borrow_mut(); + let provider = browser_worker_provider_mut(&mut registry)?; + provider + .close(&session_id) + .await + .map_err(|error| TransportError::Other(error.to_string())) + } +} + +struct BrowserWorkerClientState { + registry: SharedLinkRegistry, + session_id: LinkSessionId, + logs: Rc>>, +} + +impl BrowserWorkerClientState { + fn post(&self, envelope: &BrowserInputEnvelope) -> Result<(), TransportError> { + let mut registry = self.registry.borrow_mut(); + browser_worker_provider_mut(&mut registry)? + .post(&self.session_id, envelope) + .map_err(|error| TransportError::Other(error.to_string())) + } + + fn take_outputs(&self) -> Result, TransportError> { + let mut registry = self.registry.borrow_mut(); + browser_worker_provider_mut(&mut registry)? + .take_outputs(&self.session_id) + .map_err(|error| TransportError::Other(error.to_string())) + } + + fn record_output(&self, output: BrowserOutputEnvelope) { + if let Some(log) = worker_output_to_log(output) { + self.logs.borrow_mut().push(log); + } + } +} + +fn browser_worker_provider_mut( + registry: &mut LinkProviderRegistry, +) -> Result<&mut BrowserWorkerProvider, TransportError> { + match registry.provider_mut(LinkProviderKind::BrowserWorker) { + Some(LinkProviderInstance::BrowserWorker(provider)) => Ok(provider), + Some(_) => Err(TransportError::Other( + "browser-worker registry entry has the wrong provider type".to_string(), + )), + None => Err(TransportError::Other( + "browser-worker provider is not available".to_string(), + )), + } +} + +fn worker_output_to_log(output: BrowserOutputEnvelope) -> Option { + match output { + BrowserOutputEnvelope::Status { + status, message, .. + } => Some(UxLogEntry::new( + UxLogLevel::Info, + "fw-browser", + message.unwrap_or(status), + )), + BrowserOutputEnvelope::Log { + level, + target, + message, + .. + } => Some(UxLogEntry::new( + parse_worker_log_level(&level), + target, + message, + )), + BrowserOutputEnvelope::ProtocolOut { .. } => None, + } +} + +fn parse_worker_log_level(level: &str) -> UxLogLevel { + match level { + "trace" | "debug" => UxLogLevel::Debug, + "warn" => UxLogLevel::Warn, + "error" => UxLogLevel::Error, + _ => UxLogLevel::Info, + } +} + +async fn sleep_ms(ms: i32) -> Result<(), TransportError> { + let promise = + js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| { + let Some(window) = web_sys::window() else { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("missing window")); + return; + }; + if let Err(error) = + window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms) + { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + JsFuture::from(promise) + .await + .map(|_| ()) + .map_err(|error| TransportError::Other(format!("{error:?}"))) +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/mod.rs b/lp-app/lpa-studio-ux/src/nodes/server/mod.rs new file mode 100644 index 000000000..0468e66b4 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/mod.rs @@ -0,0 +1,18 @@ +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +mod browser_serial_client_io; +mod browser_serial_readiness; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +mod browser_worker_client_io; +pub mod server_op; +pub mod server_snapshot; +pub mod server_state; +pub mod server_ux; +pub mod studio_server_client; + +pub use server_op::ServerOp; +pub use server_snapshot::ServerSnapshot; +pub use server_state::{ServerFailureKind, ServerState}; +pub use server_ux::ServerUx; +pub use studio_server_client::{ + LoadedDemoProject, LoadedProjectCatalog, LoadedRunningProject, StudioServerClient, +}; diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_op.rs b/lp-app/lpa-studio-ux/src/nodes/server/server_op.rs new file mode 100644 index 000000000..21abd8d62 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/server_op.rs @@ -0,0 +1,36 @@ +use core::any::Any; + +use crate::{ActionMeta, ActionPriority, UxOp}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ServerOp { + DisconnectServer, +} + +impl UxOp for ServerOp { + fn default_action_meta(&self) -> ActionMeta { + match self { + Self::DisconnectServer => ActionMeta::new( + "Disconnect server", + "Detach Studio from the server protocol while keeping the link session open.", + ActionPriority::Tertiary, + ), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn UxOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_snapshot.rs b/lp-app/lpa-studio-ux/src/nodes/server/server_snapshot.rs new file mode 100644 index 000000000..81ea1735a --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/server_snapshot.rs @@ -0,0 +1,12 @@ +use crate::ServerState; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServerSnapshot { + pub state: ServerState, +} + +impl ServerSnapshot { + pub fn new(state: ServerState) -> Self { + Self { state } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_state.rs b/lp-app/lpa-studio-ux/src/nodes/server/server_state.rs new file mode 100644 index 000000000..8f092a2f0 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/server_state.rs @@ -0,0 +1,22 @@ +use crate::{ProgressState, UxIssue}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ServerState { + Disconnected, + Connecting { + progress: ProgressState, + }, + Connected { + protocol: String, + }, + Failed { + issue: UxIssue, + kind: ServerFailureKind, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerFailureKind { + NoFirmware, + Unknown, +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_ux.rs b/lp-app/lpa-studio-ux/src/nodes/server/server_ux.rs new file mode 100644 index 000000000..a9078ada8 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/server_ux.rs @@ -0,0 +1,147 @@ +use lpa_link::LinkConnection; + +use crate::{ + ProgressState, ServerFailureKind, ServerOp, ServerSnapshot, ServerState, SharedLinkRegistry, + StudioServerClient, UiAction, UiBody, UiMetric, UiPaneView, UiStatus, UxError, UxIssue, UxNode, + UxNodeId, UxUpdateSink, +}; + +pub struct ServerUx { + state: ServerState, + client: Option, +} + +impl ServerUx { + pub const NODE_ID: &'static str = "studio.server"; + + pub fn new() -> Self { + Self { + state: ServerState::Disconnected, + client: None, + } + } + + pub fn set_state(&mut self, state: ServerState) { + self.state = state; + } + + pub fn snapshot(&self) -> ServerSnapshot { + ServerSnapshot::new(self.state.clone()) + } + + pub fn is_connected(&self) -> bool { + matches!(self.state, ServerState::Connected { .. }) && self.client.is_some() + } + + pub fn actions(&self) -> Vec { + match self.state { + ServerState::Connected { .. } => vec![self.action(ServerOp::DisconnectServer)], + ServerState::Disconnected + | ServerState::Connecting { .. } + | ServerState::Failed { .. } => Vec::new(), + } + } + + pub fn view(&self) -> UiPaneView { + UiPaneView::new( + Self::NODE_ID, + "Server", + server_status(&self.state), + server_body(&self.state), + self.actions(), + ) + } + + pub fn mark_connecting(&mut self, label: impl Into) { + self.state = ServerState::Connecting { + progress: ProgressState::new(label), + }; + } + + pub fn attach_link_connection( + &mut self, + registry: SharedLinkRegistry, + connection: &LinkConnection, + updates: UxUpdateSink, + ) -> Result<(), UxError> { + self.mark_connecting("Opening server protocol"); + let client = StudioServerClient::from_link_connection(registry, connection, updates)?; + let protocol = client.protocol().to_string(); + self.client = Some(client); + self.state = ServerState::Connected { protocol }; + Ok(()) + } + + pub fn client_mut(&mut self) -> Result<&mut StudioServerClient, UxError> { + self.client + .as_mut() + .ok_or_else(|| UxError::MissingSession("server client is not connected".to_string())) + } + + pub fn take_pending_logs(&mut self) -> Vec { + self.client + .as_mut() + .map(StudioServerClient::take_pending_logs) + .unwrap_or_default() + } + + pub fn fail(&mut self, message: impl Into) { + self.fail_with_kind(message, ServerFailureKind::Unknown); + } + + pub fn fail_no_firmware(&mut self) { + self.fail_with_kind( + "No LightPlayer firmware detected.", + ServerFailureKind::NoFirmware, + ); + } + + pub fn fail_with_kind(&mut self, message: impl Into, kind: ServerFailureKind) { + self.client = None; + self.state = ServerState::Failed { + issue: UxIssue::new(message), + kind, + }; + } + + pub fn disconnect(&mut self) { + self.client = None; + self.state = ServerState::Disconnected; + } +} + +impl UxNode for ServerUx { + type Op = ServerOp; + + fn node_id(&self) -> UxNodeId { + UxNodeId::new(Self::NODE_ID) + } +} + +impl Default for ServerUx { + fn default() -> Self { + Self::new() + } +} + +fn server_status(state: &ServerState) -> UiStatus { + match state { + ServerState::Disconnected => UiStatus::neutral("Offline"), + ServerState::Connecting { .. } => UiStatus::working("Connecting"), + ServerState::Connected { .. } => UiStatus::good("Connected"), + ServerState::Failed { .. } => UiStatus::error("Failed"), + } +} + +fn server_body(state: &ServerState) -> UiBody { + match state { + ServerState::Disconnected => { + UiBody::text("Open a link endpoint to attach the server protocol.") + } + ServerState::Connecting { progress } => UiBody::Progress(progress.clone()), + ServerState::Connected { protocol } => { + UiBody::Metrics(vec![UiMetric::new("Protocol", protocol)]) + } + ServerState::Failed { issue, .. } => UiBody::Issue(issue.clone()), + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/studio_server_client.rs b/lp-app/lpa-studio-ux/src/nodes/server/studio_server_client.rs new file mode 100644 index 000000000..ccc8d57ef --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/server/studio_server_client.rs @@ -0,0 +1,252 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use lpa_client::{ClientError, ClientEvent, ClientIo, LpClient}; +use lpa_link::{LinkConnection, LinkConnectionKind}; +use lpc_wire::WireProjectHandle; + +use crate::nodes::project::demo_project::{DEMO_PROJECT_ID, demo_project_deploy_files}; +use crate::{ + LoadedProjectChoice, ProjectInventorySummary, SharedLinkRegistry, UxError, UxLogEntry, + UxLogLevel, UxUpdateSink, +}; + +pub struct StudioServerClient { + client: LpClient>, + protocol: String, + pending_logs: Rc>>, +} + +impl StudioServerClient { + pub fn from_link_connection( + registry: SharedLinkRegistry, + connection: &LinkConnection, + updates: UxUpdateSink, + ) -> Result { + let pending_logs = Rc::new(RefCell::new(Vec::new())); + let protocol = connection_protocol(&connection.kind); + let io = server_io_from_link_connection( + registry, + connection, + Rc::clone(&pending_logs), + updates, + )?; + Ok(Self { + client: LpClient::new(io), + protocol, + pending_logs, + }) + } + + pub fn protocol(&self) -> &str { + &self.protocol + } + + pub async fn load_demo_project(&mut self) -> Result { + let deploy = self + .client + .deploy_project_files(DEMO_PROJECT_ID, demo_project_deploy_files()) + .await + .map_err(map_client_error)?; + let handle = deploy.value; + let inventory = self + .client + .project_inventory_read(handle) + .await + .map_err(map_client_error)?; + let mut logs = map_client_events(deploy.events); + logs.extend(map_client_events(inventory.events)); + logs.extend(self.take_pending_logs()); + + Ok(LoadedDemoProject { + project_id: DEMO_PROJECT_ID.to_string(), + handle_id: handle.id(), + inventory: ProjectInventorySummary::from(&inventory.value), + logs, + }) + } + + pub fn take_pending_logs(&mut self) -> Vec { + core::mem::take(&mut *self.pending_logs.borrow_mut()) + } +} + +pub struct LoadedDemoProject { + pub project_id: String, + pub handle_id: u32, + pub inventory: ProjectInventorySummary, + pub logs: Vec, +} + +pub struct LoadedProjectCatalog { + pub projects: Vec, + pub logs: Vec, +} + +pub struct LoadedRunningProject { + pub project_id: String, + pub handle_id: u32, + pub inventory: ProjectInventorySummary, +} + +impl StudioServerClient { + pub async fn list_loaded_projects(&mut self) -> Result { + let loaded = self + .client + .project_list_loaded() + .await + .map_err(map_client_error)?; + let mut logs = map_client_events(loaded.events); + logs.extend(self.take_pending_logs()); + Ok(LoadedProjectCatalog { + projects: loaded + .value + .into_iter() + .map(|project| LoadedProjectChoice::new(project.path.as_str(), project.handle.id())) + .collect(), + logs, + }) + } + + pub async fn connect_loaded_project( + &mut self, + choice: LoadedProjectChoice, + ) -> Result { + let inventory = self + .client + .project_inventory_read(WireProjectHandle::new(choice.handle_id)) + .await + .map_err(map_client_error)?; + self.pending_logs + .borrow_mut() + .extend(map_client_events(inventory.events)); + Ok(LoadedRunningProject { + project_id: choice.project_id, + handle_id: choice.handle_id, + inventory: ProjectInventorySummary::from(&inventory.value), + }) + } +} + +fn server_io_from_link_connection( + _registry: SharedLinkRegistry, + connection: &LinkConnection, + _pending_logs: Rc>>, + _updates: UxUpdateSink, +) -> Result, UxError> { + match &connection.kind { + #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] + LinkConnectionKind::BrowserWorker { .. } => Ok(Box::new( + super::browser_worker_client_io::BrowserWorkerClientIo::new( + _registry, + connection.session_id.clone(), + _pending_logs, + ), + )), + #[cfg(not(all(feature = "browser-worker", target_arch = "wasm32")))] + LinkConnectionKind::BrowserWorker { .. } => Err(UxError::UnsupportedFeature( + "browser worker server I/O requires the browser-worker feature on wasm".to_string(), + )), + #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] + LinkConnectionKind::BrowserSerialEsp32 { .. } => Ok(Box::new( + super::browser_serial_client_io::BrowserSerialClientIo::new( + _registry, + connection.session_id.clone(), + _pending_logs, + _updates, + ), + )), + #[cfg(not(all(feature = "browser-serial-esp32", target_arch = "wasm32")))] + LinkConnectionKind::BrowserSerialEsp32 { .. } => Err(UxError::UnsupportedFeature( + "browser serial ESP32 server I/O requires the browser-serial-esp32 feature on wasm" + .to_string(), + )), + LinkConnectionKind::Fake => Err(UxError::UnsupportedFeature( + "fake links do not expose a server protocol".to_string(), + )), + LinkConnectionKind::HostProcess + | LinkConnectionKind::HostSerialEsp32 + | LinkConnectionKind::PendingImplementation { .. } => Err(UxError::UnsupportedFeature( + format!("server I/O is not implemented for {:?}", connection.kind), + )), + } +} + +fn connection_protocol(kind: &LinkConnectionKind) -> String { + match kind { + LinkConnectionKind::BrowserWorker { protocol } + | LinkConnectionKind::BrowserSerialEsp32 { protocol } => protocol.clone(), + LinkConnectionKind::HostProcess => "host-process".to_string(), + LinkConnectionKind::HostSerialEsp32 => "host-serial-esp32".to_string(), + LinkConnectionKind::Fake => "fake".to_string(), + LinkConnectionKind::PendingImplementation { kind } => kind.clone(), + } +} + +fn map_client_events(events: Vec) -> Vec { + events + .into_iter() + .map(|event| match event { + ClientEvent::Heartbeat { + frame_count, + uptime_ms, + .. + } => UxLogEntry::new( + UxLogLevel::Debug, + "lp-server", + format!("heartbeat frame={frame_count} uptime_ms={uptime_ms}"), + ), + ClientEvent::Log { level, message } => { + UxLogEntry::new(map_server_log_level(level), "lp-server", message) + } + ClientEvent::UncorrelatedResponse { + response_id, + expected_id, + } => UxLogEntry::new( + UxLogLevel::Warn, + "lp-server", + format!("uncorrelated response {response_id}; expected {expected_id}"), + ), + }) + .collect() +} + +fn map_client_error(error: ClientError) -> UxError { + match error { + ClientError::Transport(message) + if super::browser_serial_readiness::is_no_firmware_detected_message(&message) => + { + UxError::NoFirmwareDetected(message) + } + ClientError::Transport(message) => UxError::Transport(message), + ClientError::Server(message) | ClientError::Protocol(message) => UxError::Protocol(message), + ClientError::UnexpectedResponse { + operation, + response, + } => UxError::Protocol(format!("unexpected response for {operation}: {response}")), + } +} + +fn map_server_log_level(level: lpc_wire::server::api::LogLevel) -> UxLogLevel { + match level { + lpc_wire::server::api::LogLevel::Debug => UxLogLevel::Debug, + lpc_wire::server::api::LogLevel::Info => UxLogLevel::Info, + lpc_wire::server::api::LogLevel::Warn => UxLogLevel::Warn, + lpc_wire::server::api::LogLevel::Error => UxLogLevel::Error, + } +} + +#[cfg(test)] +mod tests { + use super::super::browser_serial_readiness::NO_FIRMWARE_DETECTED_PREFIX; + use super::*; + + #[test] + fn no_firmware_transport_error_maps_to_no_firmware_ux_error() { + let error = map_client_error(ClientError::Transport(format!( + "Transport error: {NO_FIRMWARE_DETECTED_PREFIX}; recent serial output: invalid header" + ))); + + assert!(matches!(error, UxError::NoFirmwareDetected(_))); + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/mod.rs b/lp-app/lpa-studio-ux/src/nodes/studio/mod.rs new file mode 100644 index 000000000..7669216cb --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/mod.rs @@ -0,0 +1,17 @@ +pub mod studio_snapshot; +pub mod studio_ux; +pub mod ux_error; +pub mod ux_log_entry; +pub mod ux_notice; +pub mod ux_outcome; +pub mod ux_update; +pub mod ux_update_sink; + +pub use studio_snapshot::StudioSnapshot; +pub use studio_ux::StudioUx; +pub use ux_error::{UxError, UxResult}; +pub use ux_log_entry::{UxLogEntry, UxLogLevel}; +pub use ux_notice::{UxNotice, UxNoticeLevel}; +pub use ux_outcome::UxOutcome; +pub use ux_update::{UxActivityTarget, UxUpdate}; +pub use ux_update_sink::UxUpdateSink; diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/studio_snapshot.rs b/lp-app/lpa-studio-ux/src/nodes/studio/studio_snapshot.rs new file mode 100644 index 000000000..0f1ac6b7e --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/studio_snapshot.rs @@ -0,0 +1,25 @@ +use crate::{LinkSnapshot, ProjectSnapshot, ServerSnapshot, UxLogEntry}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StudioSnapshot { + pub link: LinkSnapshot, + pub server: ServerSnapshot, + pub project: ProjectSnapshot, + pub logs: Vec, +} + +impl StudioSnapshot { + pub fn new( + link: LinkSnapshot, + server: ServerSnapshot, + project: ProjectSnapshot, + logs: Vec, + ) -> Self { + Self { + link, + server, + project, + logs, + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/studio_ux.rs b/lp-app/lpa-studio-ux/src/nodes/studio/studio_ux.rs new file mode 100644 index 000000000..e62729daf --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/studio_ux.rs @@ -0,0 +1,1161 @@ +use core::future::Future; +use std::cell::RefCell; +use std::rc::Rc; + +use lpa_link::{ + LinkConnection, LinkConnectionKind, LinkManagementRequest, LinkManagementResult, + LinkProviderKind, +}; + +use crate::{ + ConnectedLink, DeviceOp, DeviceUx, LinkOpenOutcome, ProjectConnectResult, ProjectOp, + ProjectState, ProjectUx, StudioSnapshot, StudioView, UiAction, UiActions, UiActivity, UiBody, + UiStatus, UxActivityTarget, UxContext, UxError, UxLogEntry, UxLogLevel, UxNode, UxNotice, + UxOutcome, UxResult, UxUpdate, UxUpdateSink, +}; + +pub struct StudioUx { + device: DeviceUx, + project: ProjectUx, + logs: Vec, +} + +impl StudioUx { + pub fn new() -> Self { + Self { + device: DeviceUx::new(), + project: ProjectUx::new(), + logs: Vec::new(), + } + } + + pub fn snapshot(&self) -> StudioSnapshot { + StudioSnapshot::new( + self.device.snapshot().link, + self.device.snapshot().server, + self.project.snapshot(), + self.logs.clone(), + ) + } + + pub fn actions(&self) -> UiActions { + UiActions::new(view_actions(&self.view())) + } + + pub fn view(&self) -> StudioView { + let project_snapshot = self.project.snapshot(); + let project_actions = self.project.actions(self.device.has_lightplayer_state()); + let device_view = self.device.view(&project_snapshot.state, project_actions); + let panes = if self.project_is_loaded() { + vec![ + self.project.view(self.device.has_lightplayer_state()), + device_view, + ] + } else { + vec![device_view] + }; + StudioView::new(panes, self.logs.clone()) + } + + pub async fn dispatch(&mut self, action: UiAction) -> UxResult { + self.dispatch_with_updates(action, UxUpdateSink::noop()) + .await + } + + pub async fn dispatch_with_updates( + &mut self, + action: UiAction, + updates: UxUpdateSink, + ) -> UxResult { + updates.emit(UxUpdate::View(self.view())); + let result = self.dispatch_inner(action, updates.clone()).await; + updates.emit(UxUpdate::View(self.view())); + result + } + + async fn dispatch_inner(&mut self, action: UiAction, updates: UxUpdateSink) -> UxResult { + if action.node_id() == &self.device.node_id() { + let op = action.into_op::()?; + return self.execute_device_op(op, updates).await; + } + if action.node_id() == &self.project.node_id() { + let op = action.into_op::()?; + return self.execute_project_op(op, updates).await; + } + Err(crate::UxError::UnsupportedAction(format!( + "unknown UX node {}", + action.node_id() + ))) + } + + async fn execute_device_op(&mut self, op: DeviceOp, updates: UxUpdateSink) -> UxResult { + match op { + DeviceOp::DisconnectDevice => self.disconnect_device().await, + DeviceOp::DisconnectLightPlayer => self.disconnect_lightplayer().await, + DeviceOp::ConnectLightPlayer => self.connect_server_from_link(updates).await, + DeviceOp::ProvisionFirmware => self.provision_firmware(updates).await, + DeviceOp::ResetToBlank => self.reset_to_blank(updates).await, + DeviceOp::RefreshConnections => { + self.device.link.refresh_provider_catalog(); + self.device.server.disconnect(); + self.project.reset(); + Ok(UxOutcome::new().with_notice(UxNotice::info("Connection catalog refreshed"))) + } + DeviceOp::OpenProvider { provider_id } => { + if provider_id != LinkProviderKind::BrowserSerialEsp32 { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_CONNECT_DEVICE), + "Opening device", + "Opening", + format!("Opening {}", provider_id.label()), + ); + } + match self.device.link.open_provider(provider_id).await? { + LinkOpenOutcome::Opened => Ok(UxOutcome::new()), + LinkOpenOutcome::Cancelled { message } => { + Ok(UxOutcome::new().with_notice(UxNotice::info(message))) + } + LinkOpenOutcome::Connected(connected) => { + self.attach_connected_link(connected, updates).await + } + } + } + DeviceOp::ConnectEndpoint { + provider_id, + endpoint_id, + } => { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_CONNECT_DEVICE), + "Opening device session", + "Connecting", + "Opening device endpoint", + ); + let connected = self + .device + .link + .connect_endpoint(provider_id, endpoint_id) + .await?; + self.attach_connected_link(connected, updates).await + } + } + } + + async fn execute_project_op(&mut self, op: ProjectOp, updates: UxUpdateSink) -> UxResult { + match op { + ProjectOp::ConnectRunningProject => self.connect_running_project(updates).await, + ProjectOp::ConnectLoadedProject { handle_id } => { + self.connect_loaded_project(handle_id, updates).await + } + ProjectOp::LoadDemoProject => self.load_demo_project(updates).await, + ProjectOp::DisconnectProject => self.disconnect_project().await, + } + } + + async fn attach_connected_link( + &mut self, + connected: ConnectedLink, + updates: UxUpdateSink, + ) -> UxResult { + self.device.record_logs(&connected.logs); + self.logs.extend(connected.logs); + self.connect_server_connection(&connected.connection, updates) + .await + } + + async fn connect_server_from_link(&mut self, updates: UxUpdateSink) -> UxResult { + let connection = + self.device.link.active_connection().ok_or_else(|| { + UxError::MissingSession("link connection is not open".to_string()) + })?; + if should_reopen_before_server_connect(&connection) { + self.project.reset(); + self.device.server.disconnect(); + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + "Reopening device", + "Connecting", + "Resetting device before server connect", + ); + let connected = self.device.link.reopen_active_connection().await?; + return self.attach_connected_link(connected, updates).await; + } + self.connect_server_connection(&connection, updates).await + } + + async fn connect_server_connection( + &mut self, + connection: &LinkConnection, + updates: UxUpdateSink, + ) -> UxResult { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + "Connecting LightPlayer", + "Connecting", + "Opening server protocol", + ); + let server_updates = retarget_activity_updates( + updates.clone(), + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + ); + match self.device.server.attach_link_connection( + self.device.link.registry_handle(), + connection, + server_updates, + ) { + Ok(()) => { + let mut outcome = + UxOutcome::new().with_notice(UxNotice::info("Server protocol connected")); + updates.emit(UxUpdate::View(self.view())); + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + "Checking running projects", + "Checking", + "Checking server response", + ); + let auto_connect = match self + .connect_running_project_if_available(updates.clone()) + .await + { + Ok(auto_connect) => auto_connect, + Err(error) => { + let pending_logs = self.device.server.take_pending_logs(); + self.device.record_logs(&pending_logs); + self.logs.extend(pending_logs); + self.project.reset(); + if matches!(error, UxError::NoFirmwareDetected(_)) { + self.logs.push(UxLogEntry::new( + UxLogLevel::Info, + "lpa-studio-ux", + "No LightPlayer firmware detected during server readiness", + )); + self.device.server.fail_no_firmware(); + return Ok(UxOutcome::new().with_notice(UxNotice::info( + "No LightPlayer firmware detected; flash firmware onto the selected ESP32", + ))); + } + self.logs.push(UxLogEntry::new( + UxLogLevel::Error, + "lpa-studio-ux", + format!("server readiness probe failed: {error}"), + )); + self.device.server.fail(error.to_string()); + return Err(error); + } + }; + match auto_connect { + AutoProjectConnect::Connected => { + outcome = outcome.with_notice(UxNotice::info("Connected running project")); + } + AutoProjectConnect::SelectionRequired => { + outcome = outcome.with_notice(UxNotice::info("Choose running project")); + } + AutoProjectConnect::NotFound if should_auto_load_demo_project(connection) => { + let demo_outcome = self.load_demo_project(updates).await?; + outcome.notices.extend(demo_outcome.notices); + } + AutoProjectConnect::NotFound => {} + } + Ok(outcome) + } + Err(error) => { + self.device.server.fail(error.to_string()); + Err(error) + } + } + } + + async fn connect_running_project(&mut self, updates: UxUpdateSink) -> UxResult { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + "Connecting project", + "Connecting", + "Checking loaded projects", + ); + let result = { + let server = self.device.server.client_mut()?; + self.project.connect_running_project(server).await + }; + match result { + Ok(ProjectConnectResult::Connected { logs }) => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(UxOutcome::new().with_notice(UxNotice::info("Connected running project"))) + } + Ok(ProjectConnectResult::SelectionRequired { logs }) => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(UxOutcome::new().with_notice(UxNotice::info("Choose running project"))) + } + Ok(ProjectConnectResult::NotFound { logs }) => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(UxOutcome::new().with_notice(UxNotice::info("No running project found"))) + } + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Error, + "lpa-studio-ux", + error.to_string(), + )); + self.project.fail(error.to_string()); + Err(error) + } + } + } + + async fn connect_running_project_if_available( + &mut self, + updates: UxUpdateSink, + ) -> Result { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + "Checking running projects", + "Checking", + "Checking loaded projects", + ); + let result = { + let server = self.device.server.client_mut()?; + self.project + .connect_running_project_if_available(server) + .await + }; + match result? { + ProjectConnectResult::Connected { logs } => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(AutoProjectConnect::Connected) + } + ProjectConnectResult::SelectionRequired { logs } => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(AutoProjectConnect::SelectionRequired) + } + ProjectConnectResult::NotFound { logs } => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(AutoProjectConnect::NotFound) + } + } + } + + async fn connect_loaded_project(&mut self, handle_id: u32, updates: UxUpdateSink) -> UxResult { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + "Connecting project", + "Connecting", + "Loading project shape", + ); + let result = { + let server = self.device.server.client_mut()?; + self.project.connect_loaded_project(server, handle_id).await + }; + match result { + Ok(logs) => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(UxOutcome::new().with_notice(UxNotice::info("Connected running project"))) + } + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Error, + "lpa-studio-ux", + error.to_string(), + )); + self.project.fail(error.to_string()); + Err(error) + } + } + } + + async fn load_demo_project(&mut self, updates: UxUpdateSink) -> UxResult { + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + "Loading demo project", + "Loading", + "Uploading demo project", + ); + let result = { + let server = self.device.server.client_mut()?; + self.project.load_demo_project(server).await + }; + match result { + Ok(logs) => { + self.device.record_logs(&logs); + self.logs.extend(logs); + Ok(UxOutcome::new().with_notice(UxNotice::info("Demo project loaded"))) + } + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Error, + "lpa-studio-ux", + error.to_string(), + )); + self.project.fail(error.to_string()); + Err(error) + } + } + } + + async fn disconnect_project(&mut self) -> UxResult { + self.project.disconnect(); + Ok(UxOutcome::new().with_notice(UxNotice::info("Project disconnected"))) + } + + async fn disconnect_device(&mut self) -> UxResult { + self.project.reset(); + self.device.server.disconnect(); + self.device.link.disconnect().await?; + Ok(UxOutcome::new().with_notice(UxNotice::info("Device disconnected"))) + } + + async fn disconnect_lightplayer(&mut self) -> UxResult { + self.project.reset(); + self.device.server.disconnect(); + Ok(UxOutcome::new().with_notice(UxNotice::info("LightPlayer disconnected"))) + } + + async fn provision_firmware(&mut self, updates: UxUpdateSink) -> UxResult { + self.project.reset(); + self.device.server.disconnect(); + let captured_logs = Rc::new(RefCell::new(Vec::new())); + let management_updates = capture_log_updates( + retarget_activity_updates( + updates.clone(), + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + ), + Rc::clone(&captured_logs), + ); + let management = match self + .device + .link + .manage_with_updates( + LinkManagementRequest::FlashFirmware, + "Flashing firmware", + management_updates, + ) + .await + { + Ok(management) => management, + Err(error) => { + self.record_logs(core::mem::take(&mut *captured_logs.borrow_mut())); + return Err(error); + } + }; + self.device.record_logs(&management.logs); + self.logs.extend(management.logs); + let mut outcome = UxOutcome::new().with_notice(provision_notice(&management.result)); + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + "Reconnecting device", + "Connecting", + "Waiting for firmware boot", + ); + match self.device.link.reopen_active_connection().await { + Ok(connected) => match self.attach_connected_link(connected, updates).await { + Ok(mut attach_outcome) => { + outcome.notices.append(&mut attach_outcome.notices); + Ok(outcome) + } + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Warn, + "lpa-studio-ux", + format!("firmware flashed but server reconnect failed: {error}"), + )); + self.device.server.fail(error.to_string()); + Ok(outcome.with_notice(UxNotice::info( + "Firmware flashed; reconnect the server after the device finishes booting", + ))) + } + }, + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Warn, + "lpa-studio-ux", + format!("firmware flashed but serial reopen failed: {error}"), + )); + self.device.server.fail(error.to_string()); + Ok(outcome.with_notice(UxNotice::info( + "Firmware flashed; reconnect the device after it finishes booting", + ))) + } + } + } + + async fn reset_to_blank(&mut self, updates: UxUpdateSink) -> UxResult { + self.project.reset(); + self.device.server.disconnect(); + let captured_logs = Rc::new(RefCell::new(Vec::new())); + let management_updates = capture_log_updates( + retarget_activity_updates( + updates.clone(), + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + ), + Rc::clone(&captured_logs), + ); + let management = match self + .device + .link + .manage_with_updates( + LinkManagementRequest::EraseDeviceFlash, + "Wiping device", + management_updates, + ) + .await + { + Ok(management) => management, + Err(error) => { + self.record_logs(core::mem::take(&mut *captured_logs.borrow_mut())); + return Err(error); + } + }; + self.device.record_logs(&management.logs); + self.logs.extend(management.logs); + let mut outcome = UxOutcome::new().with_notice(reset_notice(&management.result)); + emit_activity( + &updates, + device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + "Reconnecting device", + "Connecting", + "Checking for LightPlayer firmware", + ); + match self.device.link.reopen_active_connection().await { + Ok(connected) => match self.attach_connected_link(connected, updates).await { + Ok(mut attach_outcome) => { + outcome.notices.append(&mut attach_outcome.notices); + Ok(outcome) + } + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Warn, + "lpa-studio-ux", + format!("device wiped but server reconnect failed: {error}"), + )); + self.device.server.fail(error.to_string()); + Ok(outcome.with_notice(UxNotice::info( + "Device wiped; reconnect after the device finishes booting", + ))) + } + }, + Err(error) => { + self.logs.push(UxLogEntry::new( + UxLogLevel::Warn, + "lpa-studio-ux", + format!("device wiped but serial reopen failed: {error}"), + )); + self.device.server.fail(error.to_string()); + Ok(outcome.with_notice(UxNotice::info( + "Device wiped; reconnect the device after it finishes booting", + ))) + } + } + } + + fn project_is_loaded(&self) -> bool { + matches!(self.project.snapshot().state, ProjectState::Ready { .. }) + } + + fn record_logs(&mut self, logs: Vec) { + if logs.is_empty() { + return; + } + self.device.record_logs(&logs); + self.logs.extend(logs); + } +} + +impl Default for StudioUx { + fn default() -> Self { + Self::new() + } +} + +impl UxContext for StudioUx { + fn dispatch( + &mut self, + action: UiAction, + ) -> core::pin::Pin + '_>> { + Box::pin(StudioUx::dispatch(self, action)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AutoProjectConnect { + Connected, + SelectionRequired, + NotFound, +} + +fn should_auto_load_demo_project(connection: &LinkConnection) -> bool { + matches!(connection.kind, LinkConnectionKind::BrowserWorker { .. }) +} + +fn emit_activity( + updates: &UxUpdateSink, + target: UxActivityTarget, + title: impl Into, + status: impl Into, + detail: impl Into, +) { + updates.emit(UxUpdate::Activity { + target, + status: UiStatus::working(status), + activity: UiActivity::new(title).with_detail(detail), + }); +} + +fn device_section_target(section_id: &'static str) -> UxActivityTarget { + UxActivityTarget::stack_section(DeviceUx::NODE_ID, section_id) +} + +fn retarget_activity_updates(updates: UxUpdateSink, target: UxActivityTarget) -> UxUpdateSink { + UxUpdateSink::new(move |update| match update { + UxUpdate::Activity { + status, activity, .. + } => updates.emit(UxUpdate::Activity { + target: target.clone(), + status, + activity, + }), + update => updates.emit(update), + }) +} + +fn capture_log_updates( + updates: UxUpdateSink, + captured_logs: Rc>>, +) -> UxUpdateSink { + UxUpdateSink::new(move |update| { + if let UxUpdate::Log(log) = &update { + captured_logs.borrow_mut().push(log.clone()); + } + updates.emit(update); + }) +} + +fn view_actions(view: &StudioView) -> Vec { + let mut actions = Vec::new(); + for pane in &view.panes { + actions.extend(pane.actions.clone()); + actions.extend(body_actions(&pane.body)); + } + actions +} + +fn body_actions(body: &UiBody) -> Vec { + match body { + UiBody::Stack(stack) => stack + .sections + .iter() + .flat_map(|section| { + let mut actions = section.actions.clone(); + actions.extend(body_actions(§ion.body)); + actions + }) + .collect(), + UiBody::Empty + | UiBody::Text(_) + | UiBody::Progress(_) + | UiBody::Activity(_) + | UiBody::Issue(_) + | UiBody::Metrics(_) => Vec::new(), + } +} + +fn should_reopen_before_server_connect(connection: &LinkConnection) -> bool { + matches!( + connection.kind, + LinkConnectionKind::BrowserSerialEsp32 { .. } + ) +} + +fn provision_notice(result: &LinkManagementResult) -> UxNotice { + match result { + LinkManagementResult::FlashFirmware(result) => { + UxNotice::info(format!("Flashed {}", result.manifest.display_name)) + } + _ => UxNotice::info("Firmware flashed"), + } +} + +fn reset_notice(result: &LinkManagementResult) -> UxNotice { + match result { + LinkManagementResult::EraseDeviceFlash(result) => { + let label = result.chip_name.as_deref().unwrap_or("selected ESP32"); + UxNotice::info(format!("{label} wiped")) + } + _ => UxNotice::info("Device wiped"), + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::sync::Arc; + use std::task::{Context, Poll, Wake, Waker}; + + use std::cell::RefCell; + use std::rc::Rc; + + use lpa_link::providers::LinkProviderRegistry; + use lpa_link::providers::fake::FakeProvider; + use lpa_link::{ + LinkCapabilities, LinkConnection, LinkConnectionKind, LinkEndpoint, LinkEndpointId, + LinkProviderKind, LinkSession, + }; + + use crate::{ + ConnectedDeviceSummary, LinkState, LinkUx, ProjectInventorySummary, ProjectState, + ProjectUx, ServerFailureKind, ServerState, ServerUx, UiStatusKind, UiStepState, UxIssue, + UxNodeId, + }; + + use super::*; + + #[test] + fn initial_snapshot_selects_provider() { + let studio = StudioUx::new(); + + assert!(matches!( + studio.snapshot().link.state, + LinkState::SelectingProvider { .. } + )); + } + + #[test] + fn initial_actions_target_device_node() { + let studio = StudioUx::new(); + + let actions = studio.actions(); + + assert!( + actions + .iter() + .all(|action| action.node_id().as_str() == DeviceUx::NODE_ID) + ); + } + + #[test] + fn initial_view_exposes_device_pane() { + let studio = StudioUx::new(); + + let view = studio.view(); + + assert_eq!(view.panes.len(), 1); + assert_eq!(view.panes[0].node_id.as_str(), DeviceUx::NODE_ID); + assert_eq!(device_section_ids(&view), vec!["select-connection"]); + } + + #[test] + fn connected_without_project_keeps_project_actions_in_device_pane() { + let mut studio = connected_studio(); + studio.project.reset(); + + let view = studio.view(); + let actions = view_actions(&view); + + assert_eq!(view.panes.len(), 1); + assert_eq!(view.panes[0].node_id.as_str(), DeviceUx::NODE_ID); + assert_eq!( + device_section_ids(&view), + vec![ + "select-connection", + "connect-device", + "connect-lightplayer", + "open-project" + ] + ); + assert!(actions.iter().any(|action| { + matches!( + action.op_as::(), + Some(ProjectOp::ConnectRunningProject) + ) + })); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(ProjectOp::LoadDemoProject) + ))); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::DisconnectLightPlayer) + ))); + assert!(!actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ResetToBlank | DeviceOp::DisconnectDevice) + ))); + } + + #[test] + fn connected_link_without_server_hides_open_project_step() { + let studio = link_connected_studio(); + + let view = studio.view(); + let actions = view_actions(&view); + + assert_eq!( + device_section_ids(&view), + vec!["select-connection", "connect-device", "connect-lightplayer"] + ); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ConnectLightPlayer) + ))); + assert!(!actions.iter().any(|action| matches!( + action.op_as::(), + Some(ProjectOp::ConnectRunningProject | ProjectOp::LoadDemoProject) + ))); + } + + #[test] + fn no_firmware_failure_hides_connect_lightplayer_action() { + let mut studio = connected_studio(); + studio.project.reset(); + studio + .device + .link + .set_active_session_for_test(management_capable_session()); + studio.device.server.set_state(ServerState::Failed { + issue: UxIssue::new("No LightPlayer firmware detected."), + kind: ServerFailureKind::NoFirmware, + }); + + let view = studio.view(); + let actions = view_actions(&view); + + assert_eq!(view.panes[0].status.kind, UiStatusKind::Warning); + assert_eq!(view.panes[0].status.label, "Ready to flash"); + assert_eq!( + device_section_ids(&view), + vec!["select-connection", "connect-device", "connect-lightplayer"] + ); + let UiBody::Stack(stack) = &view.panes[0].body else { + panic!("device pane should render a stack view"); + }; + let lightplayer_section = stack + .sections + .iter() + .find(|section| section.id == "connect-lightplayer") + .expect("connect lightplayer section should exist"); + assert_eq!(lightplayer_section.title, "LightPlayer unavailable"); + assert_eq!(lightplayer_section.state, UiStepState::Active); + assert!(matches!(lightplayer_section.body, UiBody::Text(_))); + let device_section = stack + .sections + .iter() + .find(|section| section.id == "connect-device") + .expect("connect device section should exist"); + assert!(device_section.actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ProvisionFirmware) + ))); + assert!( + device_section + .actions + .iter() + .any(|action| matches!(action.op_as::(), Some(DeviceOp::ResetToBlank))) + ); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ProvisionFirmware) + ))); + assert!( + actions + .iter() + .any(|action| matches!(action.op_as::(), Some(DeviceOp::ResetToBlank))) + ); + assert!(!actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ConnectLightPlayer) + ))); + } + + #[test] + fn loaded_project_gets_project_pane() { + let studio = connected_studio(); + + let view = studio.view(); + let actions = view_actions(&view); + + assert_eq!(view.panes.len(), 2); + assert_eq!(view.panes[0].node_id.as_str(), ProjectUx::NODE_ID); + assert_eq!(view.panes[1].node_id.as_str(), DeviceUx::NODE_ID); + assert_eq!( + device_section_ids(&view), + vec![ + "select-connection", + "connect-device", + "connect-lightplayer", + "open-project" + ] + ); + assert!(!actions.iter().any(|action| matches!( + action.op_as::(), + Some(ProjectOp::ConnectRunningProject | ProjectOp::LoadDemoProject) + ))); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::DisconnectLightPlayer) + ))); + assert!(!actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ResetToBlank | DeviceOp::DisconnectDevice) + ))); + } + + #[test] + fn project_disconnect_leaves_server_and_link_connected() { + let mut studio = connected_studio(); + + block_on_ready(studio.disconnect_project()).unwrap(); + + assert!(matches!( + studio.project.snapshot().state, + ProjectState::NotLoaded + )); + assert!(matches!( + studio.device.server.snapshot().state, + ServerState::Connected { .. } + )); + assert!(matches!( + studio.device.link.snapshot().state, + LinkState::Connected { .. } + )); + } + + #[test] + fn lightplayer_disconnect_leaves_device_link_connected() { + let mut studio = connected_studio(); + + block_on_ready(studio.disconnect_lightplayer()).unwrap(); + + assert!(matches!( + studio.project.snapshot().state, + ProjectState::NotLoaded + )); + assert!(matches!( + studio.device.server.snapshot().state, + ServerState::Disconnected + )); + assert!(matches!( + studio.device.link.snapshot().state, + LinkState::Connected { .. } + )); + let actions = view_actions(&studio.view()); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::ConnectLightPlayer) + ))); + assert!( + actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::DisconnectDevice) + )) + ); + } + + #[test] + fn device_disconnect_clears_project_server_and_link() { + let mut studio = connected_studio(); + + block_on_ready(studio.disconnect_device()).unwrap(); + + assert!(matches!( + studio.project.snapshot().state, + ProjectState::NotLoaded + )); + assert!(matches!( + studio.device.server.snapshot().state, + ServerState::Disconnected + )); + assert!(matches!( + studio.device.link.snapshot().state, + LinkState::SelectingProvider { .. } + )); + } + + #[test] + fn failed_link_dispatch_emits_final_failed_view_after_activity() { + let mut studio = StudioUx::new(); + studio.device.link = LinkUx::with_registry(registry_with_fake_connect_error( + "Failed to open serial port.", + )); + let updates = Rc::new(RefCell::new(Vec::new())); + let sink = UxUpdateSink::new({ + let updates = Rc::clone(&updates); + move |update| { + updates.borrow_mut().push(update); + } + }); + let action = UiAction::from_op( + UxNodeId::new(DeviceUx::NODE_ID), + DeviceOp::ConnectEndpoint { + provider_id: LinkProviderKind::Fake, + endpoint_id: LinkEndpointId::new("fake-runtime"), + }, + ); + + let result = block_on_ready(studio.dispatch_with_updates(action, sink)); + + assert!(matches!(result, Err(UxError::Link(_)))); + assert!(updates.borrow().iter().any(|update| { + matches!( + update, + UxUpdate::Activity { + target: UxActivityTarget::StackSection { + pane_node_id, + section_id, + }, + activity, + .. + } if pane_node_id.as_str() == DeviceUx::NODE_ID + && section_id == DeviceUx::SECTION_CONNECT_DEVICE + && activity.title == "Opening device session" + ) + })); + let last_view = updates + .borrow() + .iter() + .rev() + .find_map(|update| match update { + UxUpdate::View(view) => Some(view.clone()), + _ => None, + }) + .expect("dispatch should emit a final view"); + assert_eq!(last_view.panes[0].status.kind, UiStatusKind::Error); + assert_eq!(last_view.panes[0].status.label, "Needs attention"); + } + + #[test] + fn only_browser_worker_connections_auto_load_demo_project() { + let browser_worker = LinkConnection::browser_worker("browser-worker-worker-1", "session-1"); + let fake = LinkConnection::fake("fake-runtime", "fake-session"); + + assert!(should_auto_load_demo_project(&browser_worker)); + assert!(!should_auto_load_demo_project(&fake)); + } + + #[test] + fn retarget_activity_updates_rewrites_activity_target() { + let updates = Rc::new(RefCell::new(Vec::new())); + let sink = UxUpdateSink::new({ + let updates = Rc::clone(&updates); + move |update| { + updates.borrow_mut().push(update); + } + }); + let target = UxActivityTarget::stack_section( + DeviceUx::NODE_ID, + DeviceUx::SECTION_CONNECT_LIGHTPLAYER, + ); + let retargeted = retarget_activity_updates(sink, target.clone()); + + retargeted.emit(UxUpdate::Activity { + target: UxActivityTarget::pane(ServerUx::NODE_ID), + status: UiStatus::working("Connecting"), + activity: UiActivity::new("Connecting ESP32 server"), + }); + + assert!(matches!( + updates.borrow().as_slice(), + [UxUpdate::Activity { + target: actual_target, + .. + }] if *actual_target == target + )); + } + + fn connected_studio() -> StudioUx { + let mut studio = link_connected_studio(); + studio.device.server.set_state(ServerState::Connected { + protocol: "fake-protocol".to_string(), + }); + studio + .project + .mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + studio + } + + fn link_connected_studio() -> StudioUx { + let mut studio = StudioUx::new(); + studio.device.link.set_state(LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::Fake, + "fake-runtime", + "fake-session", + "Fake runtime", + ), + }); + studio + } + + fn device_section_ids(view: &StudioView) -> Vec<&str> { + let device_pane = view + .panes + .iter() + .find(|pane| pane.node_id.as_str() == DeviceUx::NODE_ID) + .expect("device pane should exist"); + let UiBody::Stack(stack) = &device_pane.body else { + panic!("device pane should render stack"); + }; + stack + .sections + .iter() + .map(|section| section.id.as_str()) + .collect() + } + + fn registry_with_fake_connect_error(message: impl Into) -> LinkProviderRegistry { + let mut registry = LinkProviderRegistry::new(); + registry.insert( + FakeProvider::new() + .with_endpoint(LinkEndpoint::new( + "fake-runtime", + LinkProviderKind::Fake, + "Fake runtime", + )) + .with_connect_error(message), + ); + registry + } + + fn management_capable_session() -> LinkSession { + LinkSession::new( + "fake-session", + LinkProviderKind::Fake, + "fake-runtime", + LinkConnectionKind::Fake, + LinkCapabilities::esp32_serial_base() + .with_flash() + .with_device_erase(), + ) + } + + fn block_on_ready(future: F) -> F::Output + where + F: Future, + { + let waker = Waker::from(Arc::new(NoopWake)); + let mut context = Context::from_waker(&waker); + let mut future = Box::pin(future); + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => output, + Poll::Pending => panic!("test future unexpectedly yielded"), + } + } + + struct NoopWake; + + impl Wake for NoopWake { + fn wake(self: Arc) {} + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_error.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_error.rs new file mode 100644 index 000000000..0612604be --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/ux_error.rs @@ -0,0 +1,53 @@ +use core::fmt; + +pub type UxResult = Result; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UxError { + UnsupportedFeature(String), + UnsupportedAction(String), + MissingSession(String), + Link(String), + Project(String), + Transport(String), + Protocol(String), + Browser(String), + Cancelled(String), + NoFirmwareDetected(String), +} + +impl UxError { + pub fn message(&self) -> &str { + match self { + Self::UnsupportedFeature(message) + | Self::UnsupportedAction(message) + | Self::MissingSession(message) + | Self::Link(message) + | Self::Project(message) + | Self::Transport(message) + | Self::Protocol(message) + | Self::Browser(message) + | Self::Cancelled(message) + | Self::NoFirmwareDetected(message) => message, + } + } +} + +impl fmt::Display for UxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedFeature(message) => write!(f, "unsupported feature: {message}"), + Self::UnsupportedAction(message) => write!(f, "unsupported action: {message}"), + Self::MissingSession(message) => write!(f, "missing session: {message}"), + Self::Link(message) => write!(f, "link error: {message}"), + Self::Project(message) => write!(f, "project error: {message}"), + Self::Transport(message) => write!(f, "transport error: {message}"), + Self::Protocol(message) => write!(f, "protocol error: {message}"), + Self::Browser(message) => write!(f, "browser error: {message}"), + Self::Cancelled(message) => f.write_str(message), + Self::NoFirmwareDetected(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for UxError {} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_log_entry.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_log_entry.rs new file mode 100644 index 000000000..b61fc5fb2 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/ux_log_entry.rs @@ -0,0 +1,24 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UxLogLevel { + Debug, + Info, + Warn, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UxLogEntry { + pub level: UxLogLevel, + pub source: String, + pub message: String, +} + +impl UxLogEntry { + pub fn new(level: UxLogLevel, source: impl Into, message: impl Into) -> Self { + Self { + level, + source: source.into(), + message: message.into(), + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_notice.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_notice.rs new file mode 100644 index 000000000..0826ab858 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/ux_notice.rs @@ -0,0 +1,21 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UxNoticeLevel { + Info, + Warning, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UxNotice { + pub level: UxNoticeLevel, + pub message: String, +} + +impl UxNotice { + pub fn info(message: impl Into) -> Self { + Self { + level: UxNoticeLevel::Info, + message: message.into(), + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_outcome.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_outcome.rs new file mode 100644 index 000000000..dfdee2350 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/ux_outcome.rs @@ -0,0 +1,17 @@ +use crate::UxNotice; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UxOutcome { + pub notices: Vec, +} + +impl UxOutcome { + pub fn new() -> Self { + Self::default() + } + + pub fn with_notice(mut self, notice: UxNotice) -> Self { + self.notices.push(notice); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_update.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_update.rs new file mode 100644 index 000000000..c1bd01401 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/ux_update.rs @@ -0,0 +1,45 @@ +use crate::{StudioView, UiActivity, UiStatus, UxLogEntry, UxNodeId}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UxUpdate { + View(StudioView), + Activity { + target: UxActivityTarget, + status: UiStatus, + activity: UiActivity, + }, + Log(UxLogEntry), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UxActivityTarget { + Pane { + node_id: UxNodeId, + }, + StackSection { + pane_node_id: UxNodeId, + section_id: String, + }, +} + +impl UxActivityTarget { + pub fn pane(node_id: impl Into) -> Self { + Self::Pane { + node_id: node_id.into(), + } + } + + pub fn stack_section(pane_node_id: impl Into, section_id: impl Into) -> Self { + Self::StackSection { + pane_node_id: pane_node_id.into(), + section_id: section_id.into(), + } + } + + pub fn pane_node_id(&self) -> &UxNodeId { + match self { + Self::Pane { node_id } => node_id, + Self::StackSection { pane_node_id, .. } => pane_node_id, + } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_update_sink.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_update_sink.rs new file mode 100644 index 000000000..702d4a3d1 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/nodes/studio/ux_update_sink.rs @@ -0,0 +1,51 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::UxUpdate; + +#[derive(Clone)] +pub struct UxUpdateSink { + on_update: Rc>, +} + +impl UxUpdateSink { + pub fn new(on_update: impl FnMut(UxUpdate) + 'static) -> Self { + Self { + on_update: Rc::new(RefCell::new(on_update)), + } + } + + pub fn noop() -> Self { + Self::new(|_| {}) + } + + pub fn emit(&self, update: UxUpdate) { + (self.on_update.borrow_mut())(update); + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use crate::{StudioView, UxUpdate}; + + use super::*; + + #[test] + fn sink_accepts_mutating_callbacks() { + let count = Rc::new(RefCell::new(0_u32)); + let sink = UxUpdateSink::new({ + let count = Rc::clone(&count); + move |_| { + *count.borrow_mut() += 1; + } + }); + + sink.emit(UxUpdate::View(StudioView::new(Vec::new(), Vec::new()))); + sink.emit(UxUpdate::View(StudioView::new(Vec::new(), Vec::new()))); + + assert_eq!(*count.borrow(), 2); + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_confirmation.rs b/lp-app/lpa-studio-ux/src/ui/action/action_confirmation.rs new file mode 100644 index 000000000..4cdc103f2 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/action_confirmation.rs @@ -0,0 +1,20 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionConfirmation { + pub title: String, + pub message: String, + pub confirm_label: String, +} + +impl ActionConfirmation { + pub fn new( + title: impl Into, + message: impl Into, + confirm_label: impl Into, + ) -> Self { + Self { + title: title.into(), + message: message.into(), + confirm_label: confirm_label.into(), + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_enablement.rs b/lp-app/lpa-studio-ux/src/ui/action/action_enablement.rs new file mode 100644 index 000000000..cfd489852 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/action_enablement.rs @@ -0,0 +1,11 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ActionEnablement { + Enabled, + Disabled { reason: String }, +} + +impl ActionEnablement { + pub fn is_enabled(&self) -> bool { + matches!(self, Self::Enabled) + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_meta.rs b/lp-app/lpa-studio-ux/src/ui/action/action_meta.rs new file mode 100644 index 000000000..379b9b532 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/action_meta.rs @@ -0,0 +1,62 @@ +use crate::{ActionConfirmation, ActionEnablement, ActionPriority}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionMeta { + pub label: String, + pub short_label: Option, + pub summary: String, + pub icon: Option, + pub priority: ActionPriority, + pub enablement: ActionEnablement, + pub confirmation: Option, +} + +impl ActionMeta { + pub fn new( + label: impl Into, + summary: impl Into, + priority: ActionPriority, + ) -> Self { + Self { + label: label.into(), + short_label: None, + summary: summary.into(), + icon: None, + priority, + enablement: ActionEnablement::Enabled, + confirmation: None, + } + } + + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + + pub fn with_summary(mut self, summary: impl Into) -> Self { + self.summary = summary.into(); + self + } + + pub fn with_short_label(mut self, short_label: impl Into) -> Self { + self.short_label = Some(short_label.into()); + self + } + + pub fn with_icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn disabled(mut self, reason: impl Into) -> Self { + self.enablement = ActionEnablement::Disabled { + reason: reason.into(), + }; + self + } + + pub fn with_confirmation(mut self, confirmation: ActionConfirmation) -> Self { + self.confirmation = Some(confirmation); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_priority.rs b/lp-app/lpa-studio-ux/src/ui/action/action_priority.rs new file mode 100644 index 000000000..d12fdf923 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/action_priority.rs @@ -0,0 +1,6 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActionPriority { + Primary, + Secondary, + Tertiary, +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/mod.rs b/lp-app/lpa-studio-ux/src/ui/action/mod.rs new file mode 100644 index 000000000..69be68774 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/mod.rs @@ -0,0 +1,6 @@ +pub mod action_confirmation; +pub mod action_enablement; +pub mod action_meta; +pub mod action_priority; +pub mod ui_action; +pub mod ui_actions; diff --git a/lp-app/lpa-studio-ux/src/ui/action/ui_action.rs b/lp-app/lpa-studio-ux/src/ui/action/ui_action.rs new file mode 100644 index 000000000..7ee0c323d --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/ui_action.rs @@ -0,0 +1,206 @@ +use crate::{ActionConfirmation, ActionMeta, ActionPriority, UxError, UxNodeId, UxOp}; + +#[derive(Clone, Debug)] +pub struct UiAction { + node_id: UxNodeId, + op: Box, + meta: ActionMeta, +} + +impl PartialEq for UiAction { + fn eq(&self, other: &Self) -> bool { + self.node_id == other.node_id && self.meta == other.meta && self.op.eq_op(other.op.as_ref()) + } +} + +impl Eq for UiAction {} + +impl UiAction { + pub fn from_op(node_id: impl Into, op: impl UxOp) -> Self { + let meta = op.default_action_meta(); + Self { + node_id: node_id.into(), + op: Box::new(op), + meta, + } + } + + pub fn node_id(&self) -> &UxNodeId { + &self.node_id + } + + pub fn meta(&self) -> &ActionMeta { + &self.meta + } + + pub fn is_for_node(&self, node_id: &str) -> bool { + self.node_id.as_str() == node_id + } + + pub fn op_as(&self) -> Option<&T> + where + T: UxOp, + { + self.op.as_any().downcast_ref::() + } + + pub fn into_op(self) -> Result + where + T: UxOp, + { + let node_id = self.node_id; + self.op + .into_any() + .downcast::() + .map(|op| *op) + .map_err(|_| { + UxError::UnsupportedAction(format!( + "action for node {node_id} did not contain operation {}", + core::any::type_name::() + )) + }) + } + + pub async fn execute(self, ctx: &mut impl crate::UxContext) -> crate::UxResult { + ctx.dispatch(self).await + } + + pub fn with_label(mut self, label: impl Into) -> Self { + self.meta = self.meta.with_label(label); + self + } + + pub fn with_summary(mut self, summary: impl Into) -> Self { + self.meta = self.meta.with_summary(summary); + self + } + + pub fn with_short_label(mut self, short_label: impl Into) -> Self { + self.meta = self.meta.with_short_label(short_label); + self + } + + pub fn with_icon(mut self, icon: impl Into) -> Self { + self.meta = self.meta.with_icon(icon); + self + } + + pub fn with_priority(mut self, priority: ActionPriority) -> Self { + self.meta.priority = priority; + self + } + + pub fn disabled(mut self, reason: impl Into) -> Self { + self.meta = self.meta.disabled(reason); + self + } + + pub fn with_confirmation(mut self, confirmation: ActionConfirmation) -> Self { + self.meta = self.meta.with_confirmation(confirmation); + self + } + + pub fn with_meta(mut self, meta: ActionMeta) -> Self { + self.meta = meta; + self + } +} + +#[cfg(test)] +mod tests { + use core::any::Any; + + use crate::{ActionMeta, ActionPriority, UiAction, UxNodeId, UxOp}; + + #[test] + fn cloned_action_clones_boxed_op() { + let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run); + + let cloned = action.clone(); + + assert!(matches!(cloned.op_as::(), Some(TestOp::Run))); + } + + #[test] + fn into_op_downcasts_matching_type() { + let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run); + + let op = action.into_op::().unwrap(); + + assert_eq!(op, TestOp::Run); + } + + #[test] + fn into_op_rejects_wrong_type() { + let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run); + + assert!(action.into_op::().is_err()); + } + + #[test] + fn metadata_overrides_change_only_metadata() { + let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run) + .with_label("Go") + .with_summary("Run it") + .with_short_label("Go") + .with_icon("play"); + + assert_eq!(action.meta().label, "Go"); + assert_eq!(action.meta().summary, "Run it"); + assert_eq!(action.meta().short_label.as_deref(), Some("Go")); + assert_eq!(action.meta().icon.as_deref(), Some("play")); + assert!(matches!(action.op_as::(), Some(TestOp::Run))); + } + + #[derive(Clone, Debug, Eq, PartialEq)] + enum TestOp { + Run, + } + + impl UxOp for TestOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new("Run", "Run the test operation.", ActionPriority::Primary) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn UxOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } + } + + #[derive(Clone, Debug, Eq, PartialEq)] + struct OtherOp; + + impl UxOp for OtherOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new("Other", "Run the other operation.", ActionPriority::Primary) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn UxOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/ui_actions.rs b/lp-app/lpa-studio-ux/src/ui/action/ui_actions.rs new file mode 100644 index 000000000..591849ceb --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/action/ui_actions.rs @@ -0,0 +1,67 @@ +use crate::{UiAction, UxNodeId}; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UiActions { + actions: Vec, +} + +impl UiActions { + pub fn new(actions: Vec) -> Self { + Self { actions } + } + + pub fn is_empty(&self) -> bool { + self.actions.is_empty() + } + + pub fn len(&self) -> usize { + self.actions.len() + } + + pub fn iter(&self) -> impl Iterator { + self.actions.iter() + } + + pub fn push(&mut self, action: UiAction) { + self.actions.push(action); + } + + pub fn extend(&mut self, actions: impl IntoIterator) { + self.actions.extend(actions); + } + + pub fn for_node(&self, node_id: &UxNodeId) -> Vec { + self.actions + .iter() + .filter(|action| action.node_id() == node_id) + .cloned() + .collect() + } + + pub fn for_node_id(&self, node_id: &str) -> Vec { + self.actions + .iter() + .filter(|action| action.is_for_node(node_id)) + .cloned() + .collect() + } + + pub fn into_vec(self) -> Vec { + self.actions + } +} + +impl From> for UiActions { + fn from(actions: Vec) -> Self { + Self::new(actions) + } +} + +impl IntoIterator for UiActions { + type Item = UiAction; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.actions.into_iter() + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/activity.rs b/lp-app/lpa-studio-ux/src/ui/activity.rs new file mode 100644 index 000000000..4ad04f833 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/activity.rs @@ -0,0 +1,54 @@ +use crate::{UiActivityStep, UiActivityStepState, UiProgress, UiTerminalLine}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiActivity { + pub title: String, + pub detail: Option, + pub progress: Option, + pub steps: Vec, + pub terminal: Vec, +} + +impl UiActivity { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + detail: None, + progress: None, + steps: Vec::new(), + terminal: Vec::new(), + } + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn with_progress(mut self, progress: UiProgress) -> Self { + self.progress = Some(progress); + self + } + + pub fn with_steps(mut self, steps: Vec) -> Self { + self.steps = steps; + self + } + + pub fn set_step_state(&mut self, id: &str, state: UiActivityStepState) { + if let Some(step) = self.steps.iter_mut().find(|step| step.id == id) { + step.state = state; + } + } + + pub fn push_terminal_line(&mut self, line: impl Into) { + self.terminal.push(UiTerminalLine::new(line)); + } + + pub fn retain_recent_terminal_lines(&mut self, max_lines: usize) { + if self.terminal.len() > max_lines { + let remove_count = self.terminal.len() - max_lines; + self.terminal.drain(0..remove_count); + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/activity_step.rs b/lp-app/lpa-studio-ux/src/ui/activity_step.rs new file mode 100644 index 000000000..49b688baa --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/activity_step.rs @@ -0,0 +1,30 @@ +use crate::UiActivityStepState; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiActivityStep { + pub id: String, + pub label: String, + pub state: UiActivityStepState, + pub detail: Option, +} + +impl UiActivityStep { + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + state: UiActivityStepState::Pending, + detail: None, + } + } + + pub fn with_state(mut self, state: UiActivityStepState) -> Self { + self.state = state; + self + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/activity_step_state.rs b/lp-app/lpa-studio-ux/src/ui/activity_step_state.rs new file mode 100644 index 000000000..20cbe011f --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/activity_step_state.rs @@ -0,0 +1,18 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiActivityStepState { + Pending, + Active, + Complete, + Failed, +} + +impl UiActivityStepState { + pub fn text_marker(self) -> &'static str { + match self { + Self::Pending => "[ ]", + Self::Active => "[*]", + Self::Complete => "[x]", + Self::Failed => "[!]", + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/body.rs b/lp-app/lpa-studio-ux/src/ui/body.rs new file mode 100644 index 000000000..650210f59 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/body.rs @@ -0,0 +1,92 @@ +use crate::{ProgressState, UiActivity, UiMetric, UiStackView, UxIssue}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UiBody { + Empty, + Text(String), + Progress(ProgressState), + Activity(UiActivity), + Issue(UxIssue), + Metrics(Vec), + Stack(Box), +} + +impl UiBody { + pub fn text(value: impl Into) -> Self { + Self::Text(value.into()) + } + + pub fn render_text_lines(&self) -> Vec { + match self { + Self::Empty => Vec::new(), + Self::Text(text) => vec![text.clone()], + Self::Progress(progress) => match &progress.detail { + Some(detail) => vec![progress.label.clone(), detail.clone()], + None => vec![progress.label.clone()], + }, + Self::Activity(activity) => { + let mut lines = vec![activity.title.clone()]; + if let Some(detail) = &activity.detail { + lines.push(detail.clone()); + } + if let Some(progress) = &activity.progress { + lines.push(progress.label.clone()); + if let Some(detail) = &progress.detail { + lines.push(detail.clone()); + } + } + lines.extend(activity.steps.iter().map(|step| { + let line = format!("{} {}", step.state.text_marker(), step.label); + match &step.detail { + Some(detail) => format!("{line}: {detail}"), + None => line, + } + })); + lines + } + Self::Issue(issue) => match &issue.detail { + Some(detail) => vec![issue.message.clone(), detail.clone()], + None => vec![issue.message.clone()], + }, + Self::Metrics(metrics) => metrics + .iter() + .map(|metric| format!("{}: {}", metric.label, metric.value)) + .collect(), + Self::Stack(stack) => { + let mut lines = Vec::new(); + for section in &stack.sections { + lines.push(format!("{} {}", section.state.text_marker(), section.title)); + lines.extend( + section + .body + .render_text_lines() + .into_iter() + .map(|line| format!(" {line}")), + ); + if !section.actions.is_empty() { + lines.push(" actions:".to_string()); + lines.extend( + section + .actions + .iter() + .map(|action| format!(" - {}", action.meta().label)), + ); + } + } + if !stack.terminal.is_empty() { + lines.push("terminal:".to_string()); + lines.extend( + stack + .terminal + .iter() + .rev() + .take(12) + .rev() + .map(|line| format!(" {}", line.text)), + ); + } + lines + } + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/metric.rs b/lp-app/lpa-studio-ux/src/ui/metric.rs new file mode 100644 index 000000000..60adbeb67 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/metric.rs @@ -0,0 +1,14 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiMetric { + pub label: String, + pub value: String, +} + +impl UiMetric { + pub fn new(label: impl Into, value: impl ToString) -> Self { + Self { + label: label.into(), + value: value.to_string(), + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/mod.rs b/lp-app/lpa-studio-ux/src/ui/mod.rs new file mode 100644 index 000000000..d596a390e --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/mod.rs @@ -0,0 +1,30 @@ +pub mod action; +pub mod activity; +pub mod activity_step; +pub mod activity_step_state; +pub mod body; +pub mod metric; +pub mod pane_view; +pub mod progress; +pub mod stack_section; +pub mod stack_view; +pub mod status; +pub mod status_kind; +pub mod step_state; +pub mod studio_view; +pub mod terminal_line; + +pub use activity::UiActivity; +pub use activity_step::UiActivityStep; +pub use activity_step_state::UiActivityStepState; +pub use body::UiBody; +pub use metric::UiMetric; +pub use pane_view::UiPaneView; +pub use progress::UiProgress; +pub use stack_section::UiStackSection; +pub use stack_view::UiStackView; +pub use status::UiStatus; +pub use status_kind::UiStatusKind; +pub use step_state::UiStepState; +pub use studio_view::StudioView; +pub use terminal_line::UiTerminalLine; diff --git a/lp-app/lpa-studio-ux/src/ui/pane_view.rs b/lp-app/lpa-studio-ux/src/ui/pane_view.rs new file mode 100644 index 000000000..6d06ecbf5 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/pane_view.rs @@ -0,0 +1,28 @@ +use crate::{UiAction, UiBody, UiStatus, UxNodeId}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiPaneView { + pub node_id: UxNodeId, + pub title: String, + pub status: UiStatus, + pub body: UiBody, + pub actions: Vec, +} + +impl UiPaneView { + pub fn new( + node_id: impl Into, + title: impl Into, + status: UiStatus, + body: UiBody, + actions: Vec, + ) -> Self { + Self { + node_id: node_id.into(), + title: title.into(), + status, + body, + actions, + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/progress.rs b/lp-app/lpa-studio-ux/src/ui/progress.rs new file mode 100644 index 000000000..5dac519bf --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/progress.rs @@ -0,0 +1,45 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiProgress { + pub label: String, + pub detail: Option, + pub percent: Option, + pub timeout_ms: Option, +} + +impl UiProgress { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + detail: None, + percent: None, + timeout_ms: None, + } + } + + pub fn indeterminate(label: impl Into) -> Self { + Self::new(label) + } + + pub fn determinate(label: impl Into, percent: u32) -> Self { + Self::new(label).with_percent(percent) + } + + pub fn timeout(label: impl Into, timeout_ms: u32) -> Self { + Self::new(label).with_timeout_ms(timeout_ms) + } + + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + pub fn with_percent(mut self, percent: u32) -> Self { + self.percent = Some(percent.min(100)); + self + } + + pub fn with_timeout_ms(mut self, timeout_ms: u32) -> Self { + self.timeout_ms = Some(timeout_ms); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/stack_section.rs b/lp-app/lpa-studio-ux/src/ui/stack_section.rs new file mode 100644 index 000000000..57f1bc54e --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/stack_section.rs @@ -0,0 +1,32 @@ +use crate::{UiAction, UiBody, UiStepState}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiStackSection { + pub id: String, + pub title: String, + pub state: UiStepState, + pub body: UiBody, + pub actions: Vec, +} + +impl UiStackSection { + pub fn new(id: impl Into, title: impl Into, state: UiStepState) -> Self { + Self { + id: id.into(), + title: title.into(), + state, + body: UiBody::Empty, + actions: Vec::new(), + } + } + + pub fn with_body(mut self, body: UiBody) -> Self { + self.body = body; + self + } + + pub fn with_actions(mut self, actions: Vec) -> Self { + self.actions = actions; + self + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/stack_view.rs b/lp-app/lpa-studio-ux/src/ui/stack_view.rs new file mode 100644 index 000000000..35913396d --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/stack_view.rs @@ -0,0 +1,21 @@ +use crate::{UiStackSection, UiTerminalLine}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiStackView { + pub sections: Vec, + pub terminal: Vec, +} + +impl UiStackView { + pub fn new(sections: Vec) -> Self { + Self { + sections, + terminal: Vec::new(), + } + } + + pub fn with_terminal(mut self, terminal: Vec) -> Self { + self.terminal = terminal; + self + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/status.rs b/lp-app/lpa-studio-ux/src/ui/status.rs new file mode 100644 index 000000000..930a74fc3 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/status.rs @@ -0,0 +1,36 @@ +use crate::UiStatusKind; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiStatus { + pub label: String, + pub kind: UiStatusKind, +} + +impl UiStatus { + pub fn new(label: impl Into, kind: UiStatusKind) -> Self { + Self { + label: label.into(), + kind, + } + } + + pub fn neutral(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Neutral) + } + + pub fn working(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Working) + } + + pub fn good(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Good) + } + + pub fn warning(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Warning) + } + + pub fn error(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Error) + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/status_kind.rs b/lp-app/lpa-studio-ux/src/ui/status_kind.rs new file mode 100644 index 000000000..b0b1d726d --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/status_kind.rs @@ -0,0 +1,8 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiStatusKind { + Neutral, + Working, + Good, + Warning, + Error, +} diff --git a/lp-app/lpa-studio-ux/src/ui/step_state.rs b/lp-app/lpa-studio-ux/src/ui/step_state.rs new file mode 100644 index 000000000..8b591af8c --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/step_state.rs @@ -0,0 +1,18 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiStepState { + Pending, + Active, + Complete, + NeedsAttention, +} + +impl UiStepState { + pub fn text_marker(self) -> &'static str { + match self { + Self::Pending => "[ ]", + Self::Active => "[*]", + Self::Complete => "[x]", + Self::NeedsAttention => "[!]", + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/studio_view.rs b/lp-app/lpa-studio-ux/src/ui/studio_view.rs new file mode 100644 index 000000000..733e9da13 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/studio_view.rs @@ -0,0 +1,55 @@ +use core::fmt::Write; + +use crate::{ActionPriority, UiPaneView, UxLogEntry}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StudioView { + pub panes: Vec, + pub logs: Vec, +} + +impl StudioView { + pub fn new(panes: Vec, logs: Vec) -> Self { + Self { panes, logs } + } + + pub fn render_text(&self) -> String { + let mut output = String::new(); + for pane in &self.panes { + let _ = writeln!(output, "{}", pane.title); + let _ = writeln!(output, " node: {}", pane.node_id); + let _ = writeln!(output, " status: {}", pane.status.label); + for line in pane.body.render_text_lines() { + let _ = writeln!(output, " {line}"); + } + if !pane.actions.is_empty() { + let _ = writeln!(output, " actions:"); + for action in &pane.actions { + let meta = action.meta(); + let _ = writeln!( + output, + " - [{}] {}", + priority_label(meta.priority), + meta.label + ); + } + } + output.push('\n'); + } + if !self.logs.is_empty() { + let _ = writeln!(output, "Runtime"); + for log in self.logs.iter().rev().take(8) { + let _ = writeln!(output, " {:?} {}: {}", log.level, log.source, log.message); + } + } + output + } +} + +fn priority_label(priority: ActionPriority) -> &'static str { + match priority { + ActionPriority::Primary => "primary", + ActionPriority::Secondary => "secondary", + ActionPriority::Tertiary => "tertiary", + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/terminal_line.rs b/lp-app/lpa-studio-ux/src/ui/terminal_line.rs new file mode 100644 index 000000000..c1aad3604 --- /dev/null +++ b/lp-app/lpa-studio-ux/src/ui/terminal_line.rs @@ -0,0 +1,10 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiTerminalLine { + pub text: String, +} + +impl UiTerminalLine { + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} diff --git a/lp-app/lpa-studio-web/Cargo.toml b/lp-app/lpa-studio-web/Cargo.toml new file mode 100644 index 000000000..0489a6e0e --- /dev/null +++ b/lp-app/lpa-studio-web/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lpa-studio-web" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "Static Dioxus web shell for LightPlayer Studio" + +[features] +stories = [] + +[dependencies] +dioxus = { version = "0.7", features = ["web"] } +lpa-studio-ux = { path = "../lpa-studio-ux", features = ["browser-worker", "browser-serial-esp32"] } +web-sys = { version = "0.3", features = ["Location", "Window"] } + +[lints] +workspace = true diff --git a/lp-app/lpa-studio-web/Dioxus.toml b/lp-app/lpa-studio-web/Dioxus.toml new file mode 100644 index 000000000..32a9dca36 --- /dev/null +++ b/lp-app/lpa-studio-web/Dioxus.toml @@ -0,0 +1,9 @@ +[application] +name = "lpa-studio-web" +default_platform = "web" +asset_dir = "public" +out_dir = "dist" + +[web.app] +title = "LightPlayer Studio" +base_path = "/" diff --git a/lp-app/lpa-studio-web/README.md b/lp-app/lpa-studio-web/README.md new file mode 100644 index 000000000..51a9cd6bb --- /dev/null +++ b/lp-app/lpa-studio-web/README.md @@ -0,0 +1,161 @@ +# lpa-studio-web + +`lpa-studio-web` is the static browser shell for the `lpa-studio-ux` slice. + +The web app owns Dioxus presentation. It renders `StudioView` panes and +contextual `UiAction` controls, then dispatches those actions back into +`StudioUx`. It also applies live `UxUpdate` values while long async actions are +running. Browser-worker lifecycle, provider routing, protocol request +correlation, running-project attach, demo project deployment, and project +inventory reads belong below the UI in `lpa-studio-ux`, `lpa-link`, and +`lpa-client`. + +## Current Surface + +The active first screen is the Device pane, rendered from stack sections and +actions owned by the UX layer. In the browser build it starts with simulator +and ESP32 connection actions: + +```text +lpa-studio-web -> lpa-studio-ux -> DeviceUx -> LinkProviderRegistry -> browser-worker -> fw-browser -> lp-server +``` + +`DeviceUx` is the user-facing workflow for selecting a connection, opening the +device session, attaching the LightPlayer server protocol, and handing off to +project controls. It owns the lower-level `LinkUx` and `ServerUx` internals, so +the web UI does not present separate Link and Server panes. The simulator +provider auto-discovers and connects its single browser-worker endpoint, opens +the server protocol, and auto-loads the demo project when no project is already +running. Starting the simulator is one click. + +The WebSerial ESP32 provider is visible as a provider action when browser serial +support is compiled in. The browser still owns the serial port picker and +permission prompt; the UI does not model that picker as an endpoint-selection +screen. + +The current surface can launch the browser-local firmware runtime with the demo +project, connect browser serial hardware, open the LightPlayer server protocol, +attach to an already-loaded running project, explicitly load the built-in demo +project on hardware, provision a blank ESP32-C6 with packaged LightPlayer +firmware, reset a provisioned ESP32-C6 back to blank, and display a small +project inventory summary. Project attach/load choices appear in the Device +pane. The Project pane appears once a project is loaded. + +## Run + +```bash +just studio-dev +``` + +`studio-dev` builds debug wasm artifacts for `lpa-studio-web` and `fw-browser`, +packages them with wasm-bindgen, prepares the wasm sidecar assets, and serves +`http://127.0.0.1:2820/`. + +Use `just studio-web-build` or `just studio-web` for the release/static build +path. The release build still packages ESP32-C6 firmware assets for future +browser flashing work. + +## Deploy + +Production Studio deploys to `https://lightplayer.app/` through GitHub Pages +from Actions. Build a clean release artifact locally with: + +```bash +just studio-web-deploy-dir production target/pages/studio lightplayer.app +just studio-web-smoke target/pages/studio +``` + +The deploy artifact is staged under `target/pages/studio` and includes +`version.json`, `.nojekyll`, and `CNAME`. It is built from release wasm outputs +so stale debug artifacts left by `studio-dev` are not uploaded. + +Manual beta deployment uses the same artifact recipe with +`beta.lightplayer.app` and is published by the `Deploy Pages Channel` workflow. +Operational setup, DNS records, and GitHub Pages HTTPS steps are documented in +[`docs/deploy/studio-pages.md`](../../docs/deploy/studio-pages.md). + +Browser-worker assets are served from `public/pkg/`. The UX boot path resolves +those paths to page-absolute URLs before sending them into the embedded blob +worker, which lets worker import/init failures surface as actionable link +errors instead of silent boot timeouts. + +Browser ESP32 Web Serial uses the shared app-served controller at +`public/lpa-link/browser_esp32_device_controller.js`. Both Studio's wasm-bound +`lpa-link` provider and the standalone `serial-debug.html` page import that +module, so normal connect/reset/read debugging exercises the same Web Serial +lifecycle code that Studio uses. + +ESP32-C6 firmware assets are served from +`public/firmware/esp32c6/manifest.json`. Browser serial provisioning imports a +pinned browser ESM `esptool-js` module from +`https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm` by default; deployments can +override the `BrowserSerialEsp32Options` path if they want to serve that module +themselves. The CDN ESM endpoint avoids raw package bare imports such as `pako`, +which browsers cannot resolve directly, and it decodes the ESP32-C6 flasher +stub used by reset/provisioning. Firmware flashing and device wipe both +require a browser with Web Serial support and a user-granted serial port. + +## Hardware Flow + +Start the dev server, open `http://127.0.0.1:2820/`, and choose the ESP32 Web +Serial action. Browser port selection is handled by the browser permission +prompt, not by a Studio endpoint picker. + +For a blank or non-LightPlayer ESP32-C6, Studio keeps the device session and +offers `Flash firmware` in the LightPlayer step. Confirming the action +writes the packaged firmware and then attempts to reconnect to the LightPlayer +server after reset. Flashing renders live progress in the active Device step +and raw esptool output in the Console below the Device panel. + +During the initial browser-serial server attach, the Device pane shows a +stepped readiness activity while raw boot lines stream into the Console below +the Device panel. Blank or erased devices are recognized from ESP32 ROM output +such as `invalid header: 0xffffffff`, so the app lands in a provision-ready +state instead of a generic action failure. + +For an already provisioned ESP32-C6, Studio can connect to the server/project +workflow. The Device pane also offers `Wipe device` as a destructive +tertiary action when the provider advertises whole-device erase. Confirming it +erases the device flash, clears server/project state, and returns the device to a +provisionable state. Wipe uses the same live activity renderer. + +For low-level browser serial debugging, open: + +```text +http://127.0.0.1:2820/serial-debug.html +``` + +The page can select a Web Serial port, run the same normal reset/read path as +Studio, exercise explicit USB-JTAG downloader reset experiments, and show raw +serial output without involving the full Studio UX. + +## Stories + +The storybook covers the active UX shell, connection action strip, Device stack +states, loaded Project pane state, browser-serial blank-firmware readiness, +provision-ready/provisioning/provision-failed, and wipe states. +Run the dev server and open: + +```text +http://127.0.0.1:2820/#/stories +``` + +Generate or update visual baselines with: + +```bash +just studio-story-baselines-if-needed +``` + +The baseline set intentionally reflects the active view-driven UX surface rather +than the old provisioning journey fixtures. + +## Boundary + +- `lpa-studio-ux` owns Studio product state, `StudioView` panes, stack views, + snapshots, actions, live `UxUpdate` activity, async dispatch, UX node ids, the + link provider registry, and the connected server client. +- `lpa-link` owns provider implementations, provider resources, sessions, and + lifecycle. +- `lpa-client` owns server protocol correlation and typed project operations. +- `lpa-studio-web` owns Dioxus rendering, view composition, and browser event + handling. diff --git a/lp-app/lpa-studio-web/public/index.html b/lp-app/lpa-studio-web/public/index.html new file mode 100644 index 000000000..91ec0331e --- /dev/null +++ b/lp-app/lpa-studio-web/public/index.html @@ -0,0 +1,15 @@ + + + + + + LightPlayer Studio + + +
+ + + diff --git a/lp-app/lpa-studio-web/public/lpa-link/browser_esp32_device_controller.js b/lp-app/lpa-studio-web/public/lpa-link/browser_esp32_device_controller.js new file mode 100644 index 000000000..9c2935e4e --- /dev/null +++ b/lp-app/lpa-studio-web/public/lpa-link/browser_esp32_device_controller.js @@ -0,0 +1,456 @@ +export class BrowserEsp32DeviceController { + constructor({ port, label = null } = {}) { + if (!port) { + throw new Error("BrowserEsp32DeviceController requires a SerialPort."); + } + this.port = port; + this.label = label ?? labelForPort(port); + this.reader = null; + this.writer = null; + this.readLoopPromise = null; + this.readStopRequested = false; + this.releasing = false; + this.closed = true; + this.decoder = new TextDecoder(); + this.encoder = new TextEncoder(); + this.buffer = ""; + this.lines = []; + this.errors = []; + this.listeners = new Set(); + } + + static isSupported() { + return Boolean(globalThis.navigator?.serial); + } + + static async requestPort() { + if (!this.isSupported()) { + throw new Error("Web Serial is not supported in this browser."); + } + const port = await navigator.serial.requestPort(); + return { port, label: labelForPort(port) }; + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async openProtocol({ baudRate = 115200, reset = true } = {}) { + const logs = []; + const progress = []; + const log = (source, text) => { + const message = String(text ?? ""); + logs.push(message); + this.emit({ type: "log", source, text: message }); + }; + const pushProgress = (entry) => { + const normalized = normalizeProgress(entry); + const previous = progress.at(-1); + if ( + previous && + previous.label === normalized.label && + previous.completedSteps === normalized.completedSteps && + previous.totalSteps === normalized.totalSteps && + previous.percent === normalized.percent + ) { + return; + } + progress.push(normalized); + this.emit({ type: "progress", ...normalized }); + }; + + this.clearBufferedInput(); + await this.releaseProtocol({ collectErrors: false }); + pushProgress({ + label: "Opening serial port", + completedSteps: 1, + totalSteps: reset ? 3 : 2, + percent: reset ? 20 : 50, + }); + await this.openForRead({ baudRate, clear: false, log }); + pushProgress({ + label: "Reading serial output", + completedSteps: 2, + totalSteps: reset ? 3 : 2, + percent: reset ? 40 : 100, + }); + + let resetOutcome = { ok: false, skipped: true, message: "reset skipped" }; + if (reset) { + pushProgress({ + label: "Resetting device", + completedSteps: 2, + totalSteps: 3, + percent: 60, + }); + resetOutcome = await this.tryNormalReset({ log }); + pushProgress({ + label: "Waiting for device boot", + completedSteps: 3, + totalSteps: 3, + percent: 100, + }); + } + + return { logs, progress, resetOutcome }; + } + + async openForRead({ baudRate = 115200, clear = true, log = null } = {}) { + if (clear) { + this.clearBufferedInput(); + } + await this.openPortWithRetry(baudRate, log); + this.writer = this.port.writable.getWriter(); + this.closed = false; + this.releasing = false; + this.startReadPump(); + return { label: this.label }; + } + + async resetAndRead({ baudRate = 115200, readWindowMs = 5000, resetKind = "normal" } = {}) { + const logs = []; + const log = (source, text) => { + const message = String(text ?? ""); + logs.push(message); + this.emit({ type: "log", source, text: message }); + }; + this.clearBufferedInput(); + await this.releaseProtocol({ collectErrors: false }); + await this.openForRead({ baudRate, clear: false, log }); + const resetOutcome = await this.runReset(resetKind, { log }); + if (readWindowMs > 0) { + await sleep(readWindowMs); + await this.stopReadPump(); + log("lpa-link", `read window complete (${readWindowMs}ms)`); + } + return { logs, resetOutcome }; + } + + async readFor({ baudRate = 115200, readWindowMs = 5000 } = {}) { + const logs = []; + const log = (source, text) => { + const message = String(text ?? ""); + logs.push(message); + this.emit({ type: "log", source, text: message }); + }; + this.clearBufferedInput(); + await this.releaseProtocol({ collectErrors: false }); + await this.openForRead({ baudRate, clear: false, log }); + if (readWindowMs > 0) { + await sleep(readWindowMs); + await this.stopReadPump(); + log("lpa-link", `read window complete (${readWindowMs}ms)`); + } + return { logs }; + } + + async writeLine(line) { + if (!this.writer) { + throw new Error("Serial port is not open."); + } + await this.writer.write(this.encoder.encode(line)); + } + + takeLines() { + return this.lines.splice(0, this.lines.length); + } + + takeErrors() { + return this.errors.splice(0, this.errors.length); + } + + async releaseProtocol({ collectErrors = true } = {}) { + this.releasing = true; + this.closed = true; + await this.stopReadPump({ collectErrors }); + await safeCloseWriter(this, collectErrors); + await safeClosePort(this, collectErrors); + this.reader = null; + this.writer = null; + this.releasing = false; + } + + async close() { + await this.releaseProtocol(); + } + + async stopReadPump({ collectErrors = true } = {}) { + this.readStopRequested = true; + const activeReader = this.reader; + if (!activeReader) { + return; + } + try { + await activeReader.cancel(); + } catch (error) { + if (collectErrors) { + this.pushError(errorMessage(error)); + } + } + try { + await this.readLoopPromise; + } finally { + this.readLoopPromise = null; + this.readStopRequested = false; + } + } + + async setDTR(value) { + await this.setSignals({ dataTerminalReady: Boolean(value) }); + } + + async setRTS(value) { + await this.setSignals({ requestToSend: Boolean(value) }); + } + + async snapshotSignals() { + if (typeof this.port.getSignals !== "function") { + throw new Error("getSignals() is not available for this port."); + } + return this.port.getSignals(); + } + + async runSignalSequence(sequence, { log = null } = {}) { + const commands = String(sequence ?? "") + .split(/[\s|,]+/) + .map((command) => command.trim()) + .filter(Boolean); + for (const command of commands) { + const op = command[0]?.toUpperCase(); + const arg = command.slice(1); + if (op === "D") { + await this.setDTR(parseBinaryArg(command, arg)); + log?.("lpa-link", `DTR=${arg}`); + } else if (op === "R") { + await this.setRTS(parseBinaryArg(command, arg)); + log?.("lpa-link", `RTS=${arg}`); + } else if (op === "W") { + const ms = Number(arg); + if (!Number.isFinite(ms) || ms < 0) { + throw new Error(`invalid wait command: ${command}`); + } + log?.("lpa-link", `wait ${ms}ms`); + await sleep(ms); + } else { + throw new Error(`unknown sequence command: ${command}`); + } + } + } + + async tryNormalReset({ log = null } = {}) { + return this.runReset("normal", { log }); + } + + async runReset(resetKind, { log = null } = {}) { + try { + if (resetKind === "usb-jtag-download") { + log?.("lpa-link", "USB-JTAG download reset: R0 D0 W100 D1 R0 W100 R1 D0 R1 W100 R0 D0"); + await this.runSignalSequence("R0 D0 W100 D1 R0 W100 R1 D0 R1 W100 R0 D0", { log }); + } else if (resetKind === "rts-only") { + log?.("lpa-link", "RTS-only reset: R1 W100 R0"); + await this.runSignalSequence("R1 W100 R0", { log }); + } else { + log?.("lpa-link", "Hard resetting via RTS pin..."); + await this.runSignalSequence("D0 W100 R1 W100 R0", { log }); + } + return { ok: true, skipped: false, message: "reset complete" }; + } catch (error) { + const message = `Reset signal control failed: ${errorMessage(error)}`; + log?.("lpa-link", message); + log?.("lpa-link", "Continuing without a hardware reset."); + return { ok: false, skipped: false, message }; + } + } + + isOpen() { + return Boolean(this.port?.readable || this.port?.writable); + } + + clearBufferedInput() { + this.buffer = ""; + this.lines = []; + this.errors = []; + } + + emit(event) { + for (const listener of this.listeners) { + try { + listener(event); + } catch (error) { + console.warn("[browser-esp32-device] listener failed", error); + } + } + } + + pushError(message) { + this.errors.push(message); + this.emit({ type: "error", error: message }); + } + + async openPortWithRetry(baudRate, log) { + if (this.isOpen()) { + return; + } + try { + await this.port.open({ baudRate }); + log?.("lpa-link", `Serial port ${this.label}`); + return; + } catch (firstError) { + const firstMessage = errorMessage(firstError); + log?.("lpa-link", `Serial open failed: ${firstMessage}`); + await safeClosePort(this, false); + await sleep(250); + try { + await this.port.open({ baudRate }); + log?.("lpa-link", `Serial port ${this.label}`); + } catch (secondError) { + throw new Error(`Failed to open serial port: ${errorMessage(secondError)}`); + } + } + } + + startReadPump() { + if (this.reader) { + return; + } + if (!this.port.readable) { + throw new Error("Serial port readable stream is unavailable."); + } + this.readStopRequested = false; + this.reader = this.port.readable.getReader(); + this.readLoopPromise = this.readPump(this.reader); + } + + async readPump(activeReader) { + try { + for (;;) { + const { value, done } = await activeReader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + const text = this.decoder.decode(value, { stream: true }); + this.buffer += text; + this.emit({ type: "raw", source: "serial", text }); + this.drainCompleteLines(); + } + } catch (error) { + if (!this.closed && !this.readStopRequested) { + this.pushError(errorMessage(error)); + } + } finally { + try { + activeReader.releaseLock(); + } catch {} + if (this.reader === activeReader) { + this.reader = null; + } + if (!this.closed && !this.releasing && !this.readStopRequested) { + this.pushError("Serial port disconnected."); + } + } + } + + drainCompleteLines() { + for (;;) { + const newline = this.buffer.indexOf("\n"); + if (newline < 0) { + return; + } + const line = this.buffer.slice(0, newline).replace(/\r$/, ""); + this.buffer = this.buffer.slice(newline + 1); + this.lines.push(line); + this.emit({ type: "line", source: "serial", text: line }); + } + } + + async setSignals(signals) { + if (typeof this.port.setSignals !== "function") { + throw new Error("Web Serial port does not support DTR/RTS reset signals."); + } + await this.port.setSignals(signals); + } +} + +export function labelForPort(port) { + const info = port.getInfo?.() ?? {}; + const vendor = numberToHex(info.usbVendorId); + const product = numberToHex(info.usbProductId); + if (vendor && product) { + return `ESP32 Serial (${vendor}:${product})`; + } + return "Browser serial device"; +} + +export function errorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeProgress(entry) { + return { + label: String(entry.label ?? ""), + completedSteps: Number(entry.completedSteps ?? 0), + totalSteps: entry.totalSteps == null ? null : Number(entry.totalSteps), + percent: entry.percent == null ? null : Number(entry.percent), + }; +} + +async function safeCloseWriter(controller, collectErrors) { + const writer = controller.writer; + if (!writer) { + return; + } + try { + await writer.close(); + } catch (error) { + if (collectErrors && !isAlreadyClosedError(error)) { + controller.pushError(errorMessage(error)); + } + } + try { + writer.releaseLock(); + } catch (error) { + if (collectErrors && !isAlreadyClosedError(error)) { + controller.pushError(errorMessage(error)); + } + } +} + +async function safeClosePort(controller, collectErrors) { + try { + await controller.port.close(); + } catch (error) { + if (collectErrors && !isAlreadyClosedError(error)) { + controller.pushError(errorMessage(error)); + } + } +} + +function isAlreadyClosedError(error) { + const message = errorMessage(error).toLowerCase(); + return message.includes("already closed") || message.includes("port is already closed"); +} + +function parseBinaryArg(command, arg) { + if (arg !== "0" && arg !== "1") { + throw new Error(`invalid binary command: ${command}`); + } + return arg === "1"; +} + +function numberToHex(value) { + if (typeof value !== "number") { + return null; + } + return value.toString(16).padStart(4, "0"); +} diff --git a/lp-app/lpa-studio-web/public/serial-debug.html b/lp-app/lpa-studio-web/public/serial-debug.html new file mode 100644 index 000000000..6bbdc86c8 --- /dev/null +++ b/lp-app/lpa-studio-web/public/serial-debug.html @@ -0,0 +1,604 @@ + + + + + + LightPlayer Serial Debug + + + +
+
+
+

LightPlayer Serial Debug

+
Checking Web Serial...
+
+
No port selected
+
+ +
+
+
+

Port

+ +
+ + + + +
+
+
+ Port + none +
+
+ Reader + stopped +
+
+ DTR + unknown +
+
+ RTS + unknown +
+
+
+ +
+

Reset

+ +
+ + + + +
+
+ + + + +
+
+ +
+

Custom Sequence

+ +
+ + +
+
+
+ +
+
+

Serial Output

+
+ + + +
+
+
+
+
+
+ + + + diff --git a/lp-app/lpa-studio-web/scripts/studio-firmware-manifest.mjs b/lp-app/lpa-studio-web/scripts/studio-firmware-manifest.mjs new file mode 100644 index 000000000..ccea1918c --- /dev/null +++ b/lp-app/lpa-studio-web/scripts/studio-firmware-manifest.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import fs from "node:fs"; + +const [manifestFile] = process.argv.slice(2); +if (!manifestFile) { + throw new Error("usage: studio-firmware-manifest.mjs "); +} + +const env = process.env; +const required = [ + "MANIFEST_FIRMWARE_ID", + "MANIFEST_DISPLAY_NAME", + "MANIFEST_TARGET", + "MANIFEST_PROFILE", + "MANIFEST_SOURCE_COMMIT", + "MANIFEST_SOURCE_DIRTY", + "MANIFEST_GENERATED_AT", + "MANIFEST_IMAGE_PATH", + "MANIFEST_IMAGE_SIZE", + "MANIFEST_IMAGE_SHA256", +]; + +for (const key of required) { + if (!env[key]) { + throw new Error(`missing environment variable: ${key}`); + } +} + +const manifest = { + schemaVersion: 1, + firmwareId: env.MANIFEST_FIRMWARE_ID, + displayName: env.MANIFEST_DISPLAY_NAME, + target: { + family: "esp32", + chip: "esp32c6", + }, + build: { + package: "fw-esp32", + target: env.MANIFEST_TARGET, + profile: env.MANIFEST_PROFILE, + features: ["esp32c6", "server"], + sourceCommit: env.MANIFEST_SOURCE_COMMIT, + sourceDirty: env.MANIFEST_SOURCE_DIRTY === "true", + generatedAt: env.MANIFEST_GENERATED_AT, + }, + flash: { + format: "espflash-merged-image", + address: "0x0", + erasePolicy: "write-bootloader-partition-table-and-app", + mayAffectDeviceData: true, + resetAfterFlash: true, + notes: + "Merged image generated by espflash save-image --merge --skip-padding. Treat as destructive until browser flashing behavior is validated on hardware.", + }, + images: [ + { + path: env.MANIFEST_IMAGE_PATH, + address: "0x0", + sizeBytes: Number(env.MANIFEST_IMAGE_SIZE), + sha256: env.MANIFEST_IMAGE_SHA256, + }, + ], +}; + +if (!Number.isSafeInteger(manifest.images[0].sizeBytes)) { + throw new Error(`invalid image size: ${env.MANIFEST_IMAGE_SIZE}`); +} + +fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`); +JSON.parse(fs.readFileSync(manifestFile, "utf8")); diff --git a/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs b/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs new file mode 100644 index 000000000..0a35bd588 --- /dev/null +++ b/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs @@ -0,0 +1,594 @@ +#!/usr/bin/env node + +import { + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../../.."); +const publicDir = path.join(repoRoot, "lp-app/lpa-studio-web/public"); +const storyRoot = path.join(repoRoot, "lp-app/lpa-studio-web"); +const mode = parseMode(process.argv.slice(2)); +const port = process.env.STUDIO_STORY_PNGS_PORT ?? "2822"; +const requestedCaptureConcurrency = parseCaptureConcurrency(); +const baseUrl = `http://127.0.0.1:${port}/`; +const chrome = process.env.CHROME_BIN ?? findChrome(); +const baselineDir = path.resolve(repoRoot, baselineDirFromEnv()); +const outputDir = path.resolve(repoRoot, outputDirForMode(mode)); +const captureDir = mode === "baselines" ? path.join(baselineDir, ".new") : outputDir; + +class CdpConnection { + static async open(url) { + const ws = new WebSocket(url); + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, { once: true }); + ws.addEventListener("error", reject, { once: true }); + }); + return new CdpConnection(ws); + } + + constructor(ws) { + this.nextId = 1; + this.pending = new Map(); + this.ws = ws; + this.ws.addEventListener("message", (event) => this.onMessage(event)); + this.ws.addEventListener("close", () => this.rejectAll(new Error("Chrome DevTools closed"))); + this.ws.addEventListener("error", () => { + this.rejectAll(new Error("Chrome DevTools connection failed")); + }); + } + + send(method, params = {}, sessionId = undefined) { + const id = this.nextId; + this.nextId += 1; + const message = { id, method, params }; + if (sessionId) { + message.sessionId = sessionId; + } + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify(message)); + }); + } + + close() { + this.ws.close(); + } + + onMessage(event) { + const message = JSON.parse(event.data.toString()); + if (!message.id) { + return; + } + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(`${message.error.message}: ${message.error.data ?? ""}`)); + } else { + pending.resolve(message.result ?? {}); + } + } + + rejectAll(error) { + for (const pending of this.pending.values()) { + pending.reject(error); + } + this.pending.clear(); + } +} + +if (!chrome) { + console.error( + "Could not find Google Chrome. Set CHROME_BIN=/path/to/chrome to generate story PNGs.", + ); + process.exit(1); +} + +await rm(captureDir, { recursive: true, force: true }); +await mkdir(captureDir, { recursive: true }); + +const server = spawn("python3", ["-m", "http.server", port, "--bind", "127.0.0.1"], { + cwd: publicDir, + stdio: ["ignore", "pipe", "pipe"], +}); +const serverExited = once(server, "exit").catch(() => {}); +server.once("error", (error) => { + console.error(`Failed to start static server from ${publicDir}: ${error.message}`); +}); + +try { + await waitForServer(baseUrl); + const storyIds = await discoverStoryIds(); + if (storyIds.length === 0) { + throw new Error("No story links were discovered from the storybook page."); + } + + const files = await captureStories(storyIds, captureDir); + await optimizePngs(files, { required: mode !== "pngs" }); + + if (mode === "baselines") { + await replaceBaselineImages(captureDir, outputDir); + console.log(`Story baselines: ${path.relative(repoRoot, outputDir)}`); + } else if (mode === "check") { + const ok = await compareBaselines(storyIds, baselineDir, outputDir); + if (!ok) { + console.error("\nStory baselines differ. Run `just studio-story-baselines` to update them."); + process.exitCode = 1; + } + } else { + console.log(`Story PNGs: ${path.relative(repoRoot, outputDir)}`); + } +} finally { + if (server.exitCode === null) { + server.kill("SIGTERM"); + } + await Promise.race([serverExited, delay(1_000)]); +} + +function parseMode(args) { + const value = args[0] ?? "pngs"; + if (["pngs", "baselines", "check"].includes(value)) { + return value; + } + console.error("Usage: studio-story-pngs.mjs [pngs|baselines|check]"); + process.exit(2); +} + +function outputDirForMode(currentMode) { + if (currentMode === "baselines") { + return baselineDirFromEnv(); + } + if (currentMode === "check") { + return ( + process.env.STUDIO_STORY_NEW_DIR ?? + process.env.STUDIO_STORY_PNGS_DIR ?? + "lp-app/lpa-studio-web/story-images/.new" + ); + } + return ( + process.env.STUDIO_STORY_SCRATCH_DIR ?? + process.env.STUDIO_STORY_PNGS_DIR ?? + "lp-app/lpa-studio-web/story-images/.scratch" + ); +} + +function baselineDirFromEnv() { + return ( + process.env.STUDIO_STORY_IMAGES_DIR ?? + process.env.STUDIO_STORY_BASELINES_DIR ?? + "lp-app/lpa-studio-web/story-images" + ); +} + +function parseCaptureConcurrency() { + const value = process.env.STUDIO_STORY_PNGS_CONCURRENCY ?? "4"; + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed.toString() !== value) { + console.error("STUDIO_STORY_PNGS_CONCURRENCY must be a positive integer."); + process.exit(2); + } + return parsed; +} + +async function discoverStoryIds() { + const html = await runChrome([ + "--headless=new", + "--disable-gpu", + "--virtual-time-budget=5000", + "--dump-dom", + `${baseUrl}#/stories`, + ]); + return Array.from(html.matchAll(/href="#\/stories\/([^"]+)"/g)) + .map((match) => decodeURIComponent(match[1])) + .filter((value, index, values) => values.indexOf(value) === index) + .sort(); +} + +async function captureStories(storyIds, directory) { + const concurrency = Math.min(requestedCaptureConcurrency, storyIds.length); + const files = new Array(storyIds.length); + const browser = await launchCaptureBrowser(concurrency); + let nextStoryIndex = 0; + + console.log(`Capturing ${storyIds.length} stories with ${concurrency} Chrome pages...`); + + try { + await Promise.all( + Array.from({ length: concurrency }, (_, pageIndex) => + captureStoryWorker({ + browser, + directory, + files, + nextStoryIndex: () => nextStoryIndex++, + pageIndex, + storyIds, + }), + ), + ); + } finally { + await browser.close(); + } + return files; +} + +async function captureStoryWorker({ + browser, + directory, + files, + nextStoryIndex, + pageIndex, + storyIds, +}) { + while (true) { + const storyIndex = nextStoryIndex(); + if (storyIndex >= storyIds.length) { + return; + } + + const storyId = storyIds[storyIndex]; + const file = path.join(directory, storyFileName(storyId)); + await browser.capture(pageIndex, storyPngUrl(storyId), storyId, file); + console.log(`wrote ${path.relative(repoRoot, file)}`); + files[storyIndex] = file; + } +} + +async function launchCaptureBrowser(pageCount) { + const userDataDir = await mkdtemp(path.join(tmpdir(), "lp-studio-story-chrome-")); + const child = spawn( + chrome, + [ + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + "--window-size=1080,760", + `--user-data-dir=${userDataDir}`, + "about:blank", + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + const childExited = once(child, "exit").catch(() => {}); + const wsUrl = await waitForDevTools(child); + const cdp = await CdpConnection.open(wsUrl); + const pages = await Promise.all( + Array.from({ length: pageCount }, () => createCapturePage(cdp)), + ); + + return { + async capture(pageIndex, url, storyId, file) { + await pages[pageIndex].capture(url, storyId, file); + }, + + async close() { + try { + await cdp.send("Browser.close"); + } catch { + cdp.close(); + } + if (child.exitCode === null) { + child.kill("SIGTERM"); + } + await Promise.race([childExited, delay(1_000)]); + await rm(userDataDir, { recursive: true, force: true }); + }, + }; +} + +async function createCapturePage(cdp) { + const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await cdp.send("Target.attachToTarget", { + targetId, + flatten: true, + }); + await cdp.send("Page.enable", {}, sessionId); + await cdp.send("Runtime.enable", {}, sessionId); + await cdp.send( + "Emulation.setDeviceMetricsOverride", + { + width: 1080, + height: 760, + deviceScaleFactor: 1, + mobile: false, + }, + sessionId, + ); + + return { + async capture(url, storyId, file) { + await cdp.send("Page.navigate", { url }, sessionId); + const box = await waitForCaptureBox(cdp, sessionId, storyId); + const clip = captureClip(box); + const { data } = await cdp.send( + "Page.captureScreenshot", + { + format: "png", + captureBeyondViewport: true, + fromSurface: true, + clip, + }, + sessionId, + ); + await writeFile(file, Buffer.from(data, "base64")); + }, + }; +} + +async function waitForDevTools(child) { + return new Promise((resolve, reject) => { + let stderr = ""; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for Chrome DevTools. ${stderr.trim()}`)); + }, 10_000); + + const onData = (chunk) => { + stderr += chunk; + const match = stderr.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) { + cleanup(); + resolve(match[1]); + } + }; + const onExit = (code) => { + cleanup(); + reject(new Error(`Chrome exited before DevTools started (${code}). ${stderr.trim()}`)); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + clearTimeout(timeout); + child.stderr.off("data", onData); + child.off("exit", onExit); + child.off("error", onError); + }; + + child.stderr.on("data", onData); + child.once("exit", onExit); + child.once("error", onError); + }); +} + +async function waitForCaptureBox(cdp, sessionId, storyId) { + const expression = ` + (() => { + const el = document.querySelector('[data-story-capture="1"]'); + if (!el || el.getAttribute('data-story-id') !== ${JSON.stringify(storyId)}) { + return null; + } + const rect = el.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) { + return null; + } + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height + }; + })() + `; + const started = Date.now(); + while (Date.now() - started < 10_000) { + const box = await evaluate(cdp, sessionId, expression); + if (box) { + return box; + } + await delay(100); + } + throw new Error(`Timed out waiting for story capture target: ${storyId}`); +} + +async function evaluate(cdp, sessionId, expression) { + const response = await cdp.send( + "Runtime.evaluate", + { + expression, + awaitPromise: true, + returnByValue: true, + }, + sessionId, + ); + if (response.exceptionDetails) { + throw new Error(`Chrome evaluation failed: ${JSON.stringify(response.exceptionDetails)}`); + } + return response.result.value; +} + +function captureClip(box) { + const x = Math.max(0, Math.floor(box.x)); + const y = Math.max(0, Math.floor(box.y)); + return { + x, + y, + width: Math.ceil(box.width + box.x - x), + height: Math.ceil(box.height + box.y - y), + scale: 1, + }; +} + +async function optimizePngs(files, { required }) { + const oxipng = findCommand("oxipng"); + if (!oxipng) { + if (required) { + throw new Error( + "oxipng is required for story baselines and checks. Install with `cargo install oxipng` or `brew install oxipng`.", + ); + } + console.warn("oxipng not found; PNGs were not losslessly optimized."); + return; + } + await runProcess(oxipng, ["-o", "2", "--strip", "safe", ...files]); +} + +async function compareBaselines(storyIds, expectedDir, actualDir) { + const expectedFiles = new Set(storyIds.map(storyFileName)); + const baselineFiles = await listPngFiles(expectedDir); + const unexpected = baselineFiles.filter((file) => !expectedFiles.has(file)); + const missing = []; + const changed = []; + + for (const storyId of storyIds) { + const fileName = storyFileName(storyId); + const expectedFile = path.join(expectedDir, fileName); + const actualFile = path.join(actualDir, fileName); + const expected = await readOptionalFile(expectedFile); + const actual = await readFile(actualFile); + + if (!expected) { + missing.push(fileName); + } else if (!expected.equals(actual)) { + changed.push(fileName); + } + } + + printComparison("changed", changed); + printComparison("new", missing); + printComparison("removed", unexpected); + + if (changed.length === 0 && missing.length === 0 && unexpected.length === 0) { + console.log("Story baselines match."); + return true; + } + console.log(`Fresh PNGs: ${path.relative(repoRoot, actualDir)}`); + return false; +} + +async function listPngFiles(directory) { + try { + return (await readdir(directory)).filter((entry) => entry.endsWith(".png")).sort(); + } catch (error) { + if (error.code === "ENOENT") { + return []; + } + throw error; + } +} + +async function readOptionalFile(file) { + try { + return await readFile(file); + } catch (error) { + if (error.code === "ENOENT") { + return null; + } + throw error; + } +} + +async function replaceBaselineImages(source, destination) { + await mkdir(destination, { recursive: true }); + + for (const fileName of await listPngFiles(destination)) { + await unlink(path.join(destination, fileName)); + } + + for (const fileName of await listPngFiles(source)) { + await rename(path.join(source, fileName), path.join(destination, fileName)); + } + + await rm(source, { recursive: true, force: true }); +} + +async function waitForServer(url) { + const started = Date.now(); + while (Date.now() - started < 10_000) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + await delay(100); + } + } + throw new Error(`Timed out waiting for ${url}`); +} + +async function runChrome(args) { + return await runProcess(chrome, [ + "--no-first-run", + "--no-default-browser-check", + ...args, + ]); +} + +async function runProcess(command, args) { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const [code] = await once(child, "exit"); + if (code !== 0) { + throw new Error(`${command} exited with ${code}: ${stderr.trim()}`); + } + return stdout; +} + +function printComparison(label, files) { + if (files.length === 0) { + return; + } + console.log(`${label}:`); + for (const file of files) { + console.log(` ${file}`); + } +} + +function storyFileName(storyId) { + return `${storyId.replaceAll("/", "__")}.png`; +} + +function storyPngUrl(storyId) { + return `${baseUrl}?story-png=1&story=${encodeURIComponent(storyId)}#/stories/${storyId}`; +} + +function findCommand(command) { + const lookup = process.platform === "win32" ? "where" : "which"; + const result = spawnSync(lookup, [command], { + encoding: "utf8", + }); + if (result.status !== 0) { + return null; + } + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null; +} + +function findChrome() { + if (process.platform === "darwin") { + return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + } + return "google-chrome"; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/lp-app/lpa-studio-web/src/app.rs b/lp-app/lpa-studio-web/src/app.rs new file mode 100644 index 000000000..bb95ae410 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app.rs @@ -0,0 +1,206 @@ +use std::cell::Cell; +use std::rc::Rc; + +use dioxus::prelude::*; +use lpa_studio_ux::{ + StudioUx, StudioView, UiAction, UiActivity, UiBody, UiStatus, UiStepState, UxActivityTarget, + UxError, UxLogEntry, UxLogLevel, UxNotice, UxNoticeLevel, UxUpdate, UxUpdateSink, +}; + +use crate::components::StudioShell; + +const STYLE: &str = include_str!("style.css"); + +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn App() -> Element { + #[cfg(feature = "stories")] + if crate::stories::story_book::should_show_story_book() { + return rsx! { + style { "{STYLE}" } + crate::stories::story_book::StoryBook {} + }; + } + + let model = use_signal(StudioWebModel::new); + let view = model.read().view.clone(); + let running = model.read().running; + let on_action = move |action: UiAction| { + spawn(async move { + execute_action(model, action).await; + }); + }; + + rsx! { + style { "{STYLE}" } + StudioShell { + view, + running, + on_action, + } + } +} + +struct StudioWebModel { + ux: Option, + view: StudioView, + running: bool, + console_logs: Vec, +} + +impl StudioWebModel { + fn new() -> Self { + let ux = StudioUx::new(); + let view = ux.view(); + Self { + ux: Some(ux), + view, + running: false, + console_logs: Vec::new(), + } + } + + fn refresh_from_ux(&mut self) { + if let Some(ux) = &self.ux { + self.view = ux.view(); + self.append_console_logs_to_view(); + } + } + + fn apply_update(&mut self, update: UxUpdate) { + match update { + UxUpdate::View(mut view) => { + view.logs.extend(self.console_logs.clone()); + self.view = view; + } + UxUpdate::Activity { + target, + status, + activity, + } => { + self.apply_activity_update(target, status, activity); + } + UxUpdate::Log(log) => { + self.view.logs.push(log); + } + } + } + + fn push_console_log(&mut self, log: UxLogEntry) { + self.console_logs.push(log.clone()); + if self.console_logs.len() > 80 { + let remove_count = self.console_logs.len() - 80; + self.console_logs.drain(0..remove_count); + } + self.view.logs.push(log); + } + + fn append_console_logs_to_view(&mut self) { + self.view.logs.extend(self.console_logs.clone()); + } + + fn apply_activity_update( + &mut self, + target: UxActivityTarget, + status: UiStatus, + activity: UiActivity, + ) { + let Some(pane) = self + .view + .panes + .iter_mut() + .find(|pane| pane.node_id.as_str() == target.pane_node_id().as_str()) + else { + return; + }; + pane.status = status; + + match target { + UxActivityTarget::Pane { .. } => { + pane.body = UiBody::Activity(activity); + } + UxActivityTarget::StackSection { section_id, .. } => { + if let UiBody::Stack(stack) = &mut pane.body { + if let Some(section) = stack + .sections + .iter_mut() + .find(|section| section.id == section_id) + { + section.state = UiStepState::Active; + section.body = UiBody::Activity(activity); + section.actions.clear(); + return; + } + } + pane.body = UiBody::Activity(activity); + } + } + } +} + +async fn execute_action(mut model: Signal, action: UiAction) { + let Some(mut ux) = ({ + let mut state = model.write(); + if state.running { + return; + } + state.running = true; + state.ux.take() + }) else { + model.write().push_console_log(UxLogEntry::new( + UxLogLevel::Error, + "studio", + "Studio UX is already busy.", + )); + return; + }; + + let accepting_updates = Rc::new(Cell::new(true)); + let mut update_model = model; + let update_gate = Rc::clone(&accepting_updates); + let updates = UxUpdateSink::new(move |update| { + if update_gate.get() { + update_model.write().apply_update(update); + } + }); + let result = ux.dispatch_with_updates(action, updates).await; + accepting_updates.set(false); + let mut state = model.write(); + state.ux = Some(ux); + state.refresh_from_ux(); + match result { + Ok(outcome) => { + for notice in outcome.notices { + state.push_console_log(log_from_notice(notice)); + } + } + Err(error) => { + state.push_console_log(log_from_error(error)); + } + } + state.running = false; +} + +fn log_from_notice(notice: UxNotice) -> UxLogEntry { + UxLogEntry::new( + log_level_from_notice(notice.level), + "studio", + notice.message, + ) +} + +fn log_level_from_notice(level: UxNoticeLevel) -> UxLogLevel { + match level { + UxNoticeLevel::Info => UxLogLevel::Info, + UxNoticeLevel::Warning => UxLogLevel::Warn, + UxNoticeLevel::Error => UxLogLevel::Error, + } +} + +fn log_from_error(error: UxError) -> UxLogEntry { + let level = if matches!(&error, UxError::Cancelled(_)) { + UxLogLevel::Info + } else { + UxLogLevel::Error + }; + UxLogEntry::new(level, "studio", error.to_string()) +} diff --git a/lp-app/lpa-studio-web/src/components/action_button.rs b/lp-app/lpa-studio-web/src/components/action_button.rs new file mode 100644 index 000000000..fdb2a79ce --- /dev/null +++ b/lp-app/lpa-studio-web/src/components/action_button.rs @@ -0,0 +1,73 @@ +use dioxus::prelude::*; +use lpa_studio_ux::{ActionEnablement, ActionPriority, UiAction}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ActionButton(action: UiAction, running: bool, on_action: EventHandler) -> Element { + let action_to_run = action.clone(); + let meta = action.meta().clone(); + let disabled = running || !meta.enablement.is_enabled(); + let class = action_class(meta.priority); + let disabled_reason = disabled_reason(&meta.enablement).map(ToString::to_string); + let icon_class = action_icon_class(meta.icon.as_deref()); + let confirmation = meta.confirmation.clone(); + let label = meta.label; + let summary = meta.summary; + + rsx! { + div { class: "ux-action-item", + button { + class, + r#type: "button", + disabled, + title: "{summary}", + onclick: move |_| { + if confirmation_confirmed(confirmation.as_ref()) { + on_action.call(action_to_run.clone()); + } + }, + if let Some(icon_class) = icon_class { + span { class: "{icon_class}", aria_hidden: "true" } + } + span { "{label}" } + } + if let Some(reason) = disabled_reason.as_ref() { + p { class: "ux-disabled-reason", "{reason}" } + } + } + } +} + +fn confirmation_confirmed(confirmation: Option<&lpa_studio_ux::ActionConfirmation>) -> bool { + let Some(confirmation) = confirmation else { + return true; + }; + let message = format!("{}\n\n{}", confirmation.title, confirmation.message); + web_sys::window() + .and_then(|window| window.confirm_with_message(&message).ok()) + .unwrap_or(false) +} + +fn action_class(priority: ActionPriority) -> &'static str { + match priority { + ActionPriority::Primary => "ux-action ux-action-primary", + ActionPriority::Secondary => "ux-action ux-action-secondary", + ActionPriority::Tertiary => "ux-action ux-action-tertiary", + } +} + +fn disabled_reason(enablement: &ActionEnablement) -> Option<&str> { + match enablement { + ActionEnablement::Enabled => None, + ActionEnablement::Disabled { reason } => Some(reason.as_str()), + } +} + +fn action_icon_class(icon: Option<&str>) -> Option<&'static str> { + match icon { + Some("play") => Some("ux-action-icon ux-action-icon-play"), + Some("usb") => Some("ux-action-icon ux-action-icon-usb"), + Some("test-tube") => Some("ux-action-icon ux-action-icon-test"), + _ => None, + } +} diff --git a/lp-app/lpa-studio-web/src/components/action_strip.rs b/lp-app/lpa-studio-web/src/components/action_strip.rs new file mode 100644 index 000000000..619a5635e --- /dev/null +++ b/lp-app/lpa-studio-web/src/components/action_strip.rs @@ -0,0 +1,28 @@ +use dioxus::prelude::*; +use lpa_studio_ux::UiAction; + +use crate::components::ActionButton; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ActionStrip( + actions: Vec, + running: bool, + on_action: EventHandler, +) -> Element { + rsx! { + div { class: "ux-actions", + if actions.is_empty() { + p { class: "ux-panel-copy", "No actions are currently available." } + } else { + for action in actions { + ActionButton { + action, + running, + on_action, + } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/components/mod.rs b/lp-app/lpa-studio-web/src/components/mod.rs new file mode 100644 index 000000000..f1a740a49 --- /dev/null +++ b/lp-app/lpa-studio-web/src/components/mod.rs @@ -0,0 +1,13 @@ +//! Dioxus components for the active Studio UX shell. + +mod action_button; +mod action_strip; +mod runtime_log; +mod studio_shell; +mod ux_pane; + +pub use action_button::ActionButton; +pub use action_strip::ActionStrip; +pub use runtime_log::RuntimeLog; +pub use studio_shell::StudioShell; +pub use ux_pane::UxPane; diff --git a/lp-app/lpa-studio-web/src/components/runtime_log.rs b/lp-app/lpa-studio-web/src/components/runtime_log.rs new file mode 100644 index 000000000..f426138dd --- /dev/null +++ b/lp-app/lpa-studio-web/src/components/runtime_log.rs @@ -0,0 +1,102 @@ +use dioxus::prelude::*; +use dioxus::{html::geometry::PixelsVector2D, prelude::dioxus_core::use_after_render}; +use lpa_studio_ux::{UxLogEntry, UxLogLevel}; +use std::rc::Rc; + +const LOG_STICKY_THRESHOLD_PX: f64 = 48.0; +const LOG_ENTRY_LIMIT: usize = 80; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn RuntimeLog(logs: Vec) -> Element { + let visible_logs = log_tail(logs, LOG_ENTRY_LIMIT); + let mut log_element = use_signal(|| None::>); + let mut stick_to_bottom = use_signal(|| true); + + use_after_render(move || { + if !stick_to_bottom() { + return; + } + + let Some(element) = log_element.read().as_ref().cloned() else { + return; + }; + + spawn(async move { + let Ok(scroll_size) = element.get_scroll_size().await else { + return; + }; + let coordinates = PixelsVector2D::new(0.0, scroll_size.height); + let _ = element.scroll(coordinates, ScrollBehavior::Instant).await; + }); + }); + + rsx! { + section { class: "ux-log-panel", + div { class: "ux-log-heading", + p { "Console" } + } + if visible_logs.is_empty() { + ol { + class: "ux-log-list", + onmounted: move |event| { + log_element.set(Some(event.data())); + }, + li { class: "ux-log ux-log-empty", + span { "idle" } + strong { "studio" } + p { "No messages yet." } + } + } + } else { + ol { + class: "ux-log-list", + onmounted: move |event| { + log_element.set(Some(event.data())); + }, + onscroll: move |event| { + stick_to_bottom.set(is_log_near_bottom( + event.scroll_top(), + event.scroll_height(), + event.client_height(), + )); + }, + for entry in visible_logs.iter() { + li { class: log_class(entry.level), + span { "{log_level_label(entry.level)}" } + strong { "{entry.source}" } + p { "{entry.message}" } + } + } + } + } + } + } +} + +fn log_tail(logs: Vec, max_entries: usize) -> Vec { + let skip_count = logs.len().saturating_sub(max_entries); + logs.into_iter().skip(skip_count).collect() +} + +fn is_log_near_bottom(scroll_top: f64, scroll_height: i32, client_height: i32) -> bool { + f64::from(scroll_height) - scroll_top - f64::from(client_height) <= LOG_STICKY_THRESHOLD_PX +} + +fn log_level_label(level: UxLogLevel) -> &'static str { + match level { + UxLogLevel::Debug => "debug", + UxLogLevel::Info => "info", + UxLogLevel::Warn => "warn", + UxLogLevel::Error => "error", + } +} + +fn log_class(level: UxLogLevel) -> &'static str { + match level { + UxLogLevel::Debug => "ux-log ux-log-debug", + UxLogLevel::Info => "ux-log ux-log-info", + UxLogLevel::Warn => "ux-log ux-log-warn", + UxLogLevel::Error => "ux-log ux-log-error", + } +} diff --git a/lp-app/lpa-studio-web/src/components/studio_shell.rs b/lp-app/lpa-studio-web/src/components/studio_shell.rs new file mode 100644 index 000000000..b06cf54e8 --- /dev/null +++ b/lp-app/lpa-studio-web/src/components/studio_shell.rs @@ -0,0 +1,74 @@ +use dioxus::prelude::*; +use lpa_studio_ux::{DeviceUx, StudioView, UiAction, UiPaneView}; + +use crate::components::{RuntimeLog, UxPane}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StudioShell(view: StudioView, running: bool, on_action: EventHandler) -> Element { + let StudioView { panes, logs } = view; + let PaneGroups { main, device } = group_panes(panes); + let layout_class = if main.is_empty() { + "ux-layout ux-layout-device-only" + } else { + "ux-layout ux-layout-main-device" + }; + let device_is_primary = main.is_empty(); + + rsx! { + main { class: "ux-shell", + header { class: "ux-header", + div { + p { class: "ux-eyebrow", "LightPlayer Studio" } + } + } + + section { class: "{layout_class}", + if !main.is_empty() { + div { class: "ux-main-column", + for (index, pane) in main.into_iter().enumerate() { + UxPane { + key: "{pane.node_id}", + view: pane, + primary: index == 0, + running, + on_action, + } + } + } + } + + div { class: "ux-device-column", + if let Some(device) = device { + UxPane { + key: "{device.node_id}", + view: device, + primary: device_is_primary, + running, + on_action, + } + } + RuntimeLog { logs } + } + } + } + } +} + +struct PaneGroups { + main: Vec, + device: Option, +} + +fn group_panes(panes: Vec) -> PaneGroups { + let mut main = Vec::new(); + let mut device = None; + for pane in panes { + if pane.node_id.as_str() == DeviceUx::NODE_ID { + device = Some(pane); + } else { + main.push(pane); + } + } + PaneGroups { main, device } +} diff --git a/lp-app/lpa-studio-web/src/components/ux_pane.rs b/lp-app/lpa-studio-web/src/components/ux_pane.rs new file mode 100644 index 000000000..37a8c7dd5 --- /dev/null +++ b/lp-app/lpa-studio-web/src/components/ux_pane.rs @@ -0,0 +1,260 @@ +use dioxus::prelude::*; +use lpa_studio_ux::{ + UiAction, UiActivity, UiActivityStepState, UiBody, UiPaneView, UiProgress, UiStackView, + UiStatus, UiStatusKind, UiStepState, +}; + +use crate::components::ActionStrip; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn UxPane( + view: UiPaneView, + primary: bool, + running: bool, + on_action: EventHandler, +) -> Element { + let UiPaneView { + title, + status, + body, + actions, + .. + } = view; + let panel_class = if primary { + "ux-panel ux-panel-primary" + } else { + "ux-panel" + }; + + rsx! { + section { class: "{panel_class}", + div { class: "ux-panel-heading", + p { "{title}" } + UxStatusChip { status } + } + UxPaneBody { + body, + running, + on_action, + } + if !actions.is_empty() { + ActionStrip { + actions, + running, + on_action, + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn UxStatusChip(status: UiStatus) -> Element { + rsx! { + span { class: "{status_class(status.kind)}", "{status.label}" } + } +} + +fn status_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => "ux-status ux-status-neutral", + UiStatusKind::Working => "ux-status ux-status-working", + UiStatusKind::Good => "ux-status ux-status-good", + UiStatusKind::Warning => "ux-status ux-status-warning", + UiStatusKind::Error => "ux-status ux-status-error", + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn UxPaneBody(body: UiBody, running: bool, on_action: EventHandler) -> Element { + match body { + UiBody::Empty => rsx! {}, + UiBody::Text(text) => rsx! { + p { class: "ux-panel-copy", "{text}" } + }, + UiBody::Progress(progress) => { + let label = progress.label; + let detail = progress.detail; + rsx! { + p { class: "ux-panel-copy", "{label}" } + if let Some(detail) = detail.as_ref() { + p { class: "ux-panel-copy ux-panel-detail", "{detail}" } + } + } + } + UiBody::Activity(activity) => rsx! { + UxActivityBody { activity } + }, + UiBody::Issue(issue) => { + let message = issue.message; + let detail = issue.detail; + rsx! { + p { class: "ux-panel-copy ux-panel-issue", "{message}" } + if let Some(detail) = detail.as_ref() { + p { class: "ux-panel-copy ux-panel-detail", "{detail}" } + } + } + } + UiBody::Metrics(metrics) => rsx! { + dl { class: "ux-metrics", + for metric in metrics { + div { + dt { "{metric.label}" } + dd { "{metric.value}" } + } + } + } + }, + UiBody::Stack(stack) => rsx! { + UxStackBody { + stack: *stack, + running, + on_action, + } + }, + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn UxStackBody(stack: UiStackView, running: bool, on_action: EventHandler) -> Element { + let sections = stack + .sections + .into_iter() + .enumerate() + .map(|(index, section)| (index + 1, section)) + .collect::>(); + + rsx! { + div { class: "ux-stack", + ol { class: "ux-stack-sections", + for (step_number, section) in sections { + li { class: "{stack_section_class(section.state)}", + div { class: "ux-stack-section-marker", "{step_number}" } + div { class: "ux-stack-section-content", + h3 { "{section.title}" } + div { class: "ux-stack-section-body", + UxPaneBody { + body: section.body, + running, + on_action, + } + } + if !section.actions.is_empty() { + ActionStrip { + actions: section.actions, + running, + on_action, + } + } + } + } + } + } + } + } +} + +fn stack_section_class(state: UiStepState) -> &'static str { + match state { + UiStepState::Pending => "ux-stack-section ux-stack-section-pending", + UiStepState::Active => "ux-stack-section ux-stack-section-active", + UiStepState::Complete => "ux-stack-section ux-stack-section-complete", + UiStepState::NeedsAttention => "ux-stack-section ux-stack-section-attention", + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn UxActivityBody(activity: UiActivity) -> Element { + let title = activity.title; + let detail = activity.detail; + let progress = activity.progress; + let steps = activity.steps; + + rsx! { + div { class: "ux-activity", + p { class: "ux-panel-copy ux-activity-title", "{title}" } + if let Some(detail) = detail.as_ref() { + p { class: "ux-panel-copy ux-panel-detail", "{detail}" } + } + if let Some(progress) = progress { + UxProgressBar { progress } + } + if !steps.is_empty() { + ol { class: "ux-activity-steps", + for step in steps { + li { class: "{activity_step_class(step.state)}", + span { class: "ux-activity-step-marker", "{activity_step_marker(step.state)}" } + div { class: "ux-activity-step-copy", + span { "{step.label}" } + if let Some(detail) = step.detail.as_ref() { + small { "{detail}" } + } + } + } + } + } + } + } + } +} + +fn activity_step_class(state: UiActivityStepState) -> &'static str { + match state { + UiActivityStepState::Pending => "ux-activity-step ux-activity-step-pending", + UiActivityStepState::Active => "ux-activity-step ux-activity-step-active", + UiActivityStepState::Complete => "ux-activity-step ux-activity-step-complete", + UiActivityStepState::Failed => "ux-activity-step ux-activity-step-failed", + } +} + +fn activity_step_marker(state: UiActivityStepState) -> &'static str { + state.text_marker() +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn UxProgressBar(progress: UiProgress) -> Element { + let label = progress.label; + let detail = progress.detail; + let percent = progress.percent; + let timeout_ms = progress.timeout_ms.unwrap_or(0); + let bar_class = if percent.is_some() { + "ux-progress-fill ux-progress-fill-determinate" + } else if progress.timeout_ms.is_some() { + "ux-progress-fill ux-progress-fill-timeout" + } else { + "ux-progress-fill ux-progress-fill-indeterminate" + }; + let fill_style = match (percent, progress.timeout_ms) { + (Some(percent), _) => format!("width: {}%;", percent.min(100)), + (None, Some(_)) => "width: 100%;".to_string(), + (None, None) => String::new(), + }; + let timeout_style = if timeout_ms > 0 { + format!("animation-duration: {timeout_ms}ms;") + } else { + String::new() + }; + + rsx! { + div { class: "ux-progress", + div { class: "ux-progress-meta", + span { "{label}" } + if let Some(percent) = percent { + strong { "{percent.min(100)}%" } + } + } + div { class: "ux-progress-track", + div { class: "{bar_class}", style: "{fill_style}{timeout_style}" } + } + if let Some(detail) = detail.as_ref() { + p { class: "ux-progress-detail", "{detail}" } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/main.rs b/lp-app/lpa-studio-web/src/main.rs new file mode 100644 index 000000000..00ecad8a6 --- /dev/null +++ b/lp-app/lpa-studio-web/src/main.rs @@ -0,0 +1,8 @@ +mod app; +mod components; +#[cfg(feature = "stories")] +mod stories; + +fn main() { + dioxus::launch(app::App); +} diff --git a/lp-app/lpa-studio-web/src/stories/mod.rs b/lp-app/lpa-studio-web/src/stories/mod.rs new file mode 100644 index 000000000..b8c8e8f6e --- /dev/null +++ b/lp-app/lpa-studio-web/src/stories/mod.rs @@ -0,0 +1,6 @@ +//! Studio-local component storybook support. + +pub mod story; +pub mod story_book; +pub mod story_registry; +pub mod studio_ux_stories; diff --git a/lp-app/lpa-studio-web/src/stories/story.rs b/lp-app/lpa-studio-web/src/stories/story.rs new file mode 100644 index 000000000..6588059de --- /dev/null +++ b/lp-app/lpa-studio-web/src/stories/story.rs @@ -0,0 +1,24 @@ +/// Metadata for one Studio component story. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StoryDescriptor { + pub id: &'static str, + pub group: &'static str, + pub label: &'static str, + pub description: &'static str, +} + +impl StoryDescriptor { + pub const fn new( + id: &'static str, + group: &'static str, + label: &'static str, + description: &'static str, + ) -> Self { + Self { + id, + group, + label, + description, + } + } +} diff --git a/lp-app/lpa-studio-web/src/stories/story_book.rs b/lp-app/lpa-studio-web/src/stories/story_book.rs new file mode 100644 index 000000000..e0a11303b --- /dev/null +++ b/lp-app/lpa-studio-web/src/stories/story_book.rs @@ -0,0 +1,181 @@ +use dioxus::prelude::*; + +use crate::stories::story_registry::{DEFAULT_STORY_ID, all_stories, render_story, story_by_id}; + +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StoryBook() -> Element { + let initial_story_id = selected_story_id_from_hash(); + let mut selected_story_id = use_signal(move || initial_story_id); + let mut viewport = use_signal(|| StoryViewport::Wide); + let selected = selected_story_id.read().clone(); + let descriptor = story_by_id(&selected).unwrap_or_else(|| { + story_by_id(DEFAULT_STORY_ID).expect("default story descriptor is registered") + }); + let stories = all_stories(); + + if is_story_png_mode() { + return rsx! { + main { class: "story-png-page", + StoryCanvas { + story_id: descriptor.id, + label: descriptor.label, + description: descriptor.description, + frame_style: StoryViewport::Wide.frame_style(), + } + } + }; + } + + let frame_style = viewport.read().frame_style(); + rsx! { + main { class: "story-book", + aside { class: "story-sidebar", + div { class: "story-sidebar-heading", + h1 { "Studio Stories" } + p { "{stories.len()} component states" } + } + nav { class: "story-nav", + for story in stories.iter() { + { + let story_id = story.id; + let link_class = if story.id == selected { + "story-nav-link is-active" + } else { + "story-nav-link" + }; + rsx! { + a { + class: "{link_class}", + href: "#/stories/{story.id}", + onclick: move |_| selected_story_id.set(story_id.to_string()), + span { class: "story-nav-group", "{story.group}" } + strong { "{story.label}" } + } + } + } + } + } + } + section { class: "story-stage", + div { class: "story-toolbar", + div { + h2 { "{descriptor.label}" } + p { "{descriptor.group} / {descriptor.id}" } + } + div { class: "story-viewport-controls", + ViewportButton { + label: "Narrow", + active: *viewport.read() == StoryViewport::Narrow, + onclick: move |_| viewport.set(StoryViewport::Narrow), + } + ViewportButton { + label: "Panel", + active: *viewport.read() == StoryViewport::Panel, + onclick: move |_| viewport.set(StoryViewport::Panel), + } + ViewportButton { + label: "Wide", + active: *viewport.read() == StoryViewport::Wide, + onclick: move |_| viewport.set(StoryViewport::Wide), + } + } + } + StoryCanvas { + story_id: descriptor.id, + label: descriptor.label, + description: descriptor.description, + frame_style, + } + } + } + } +} + +pub fn should_show_story_book() -> bool { + location_hash().is_some_and(|hash| hash.starts_with("#/stories")) +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn StoryCanvas( + story_id: &'static str, + label: &'static str, + description: &'static str, + frame_style: &'static str, +) -> Element { + rsx! { + div { + class: "story-canvas-shell", + "data-story-capture": "1", + "data-story-id": "{story_id}", + "data-story-label": "{label}", + div { class: "story-canvas-meta", + h3 { "{label}" } + p { "{description}" } + } + div { class: "story-frame", style: "{frame_style}", + {render_story(story_id)} + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ViewportButton(label: &'static str, active: bool, onclick: EventHandler) -> Element { + let class = if active { + "story-viewport-button is-active" + } else { + "story-viewport-button" + }; + rsx! { + button { + class, + type: "button", + onclick: move |event| onclick.call(event), + "{label}" + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StoryViewport { + Narrow, + Panel, + Wide, +} + +impl StoryViewport { + fn frame_style(self) -> &'static str { + match self { + Self::Narrow => "max-width: 390px;", + Self::Panel => "max-width: 720px;", + Self::Wide => "max-width: 1040px;", + } + } +} + +fn selected_story_id_from_hash() -> String { + location_hash() + .and_then(|hash| hash.strip_prefix("#/stories/").map(str::to_string)) + .filter(|id| story_by_id(id).is_some()) + .unwrap_or_else(|| DEFAULT_STORY_ID.to_string()) +} + +fn is_story_png_mode() -> bool { + web_sys::window() + .map(|window| window.location()) + .and_then(|location| location.search().ok()) + .is_some_and(|search| { + search + .trim_start_matches('?') + .split('&') + .any(|part| part == "story-png=1") + }) +} + +fn location_hash() -> Option { + web_sys::window() + .map(|window| window.location()) + .and_then(|location| location.hash().ok()) +} diff --git a/lp-app/lpa-studio-web/src/stories/story_registry.rs b/lp-app/lpa-studio-web/src/stories/story_registry.rs new file mode 100644 index 000000000..6c0343d50 --- /dev/null +++ b/lp-app/lpa-studio-web/src/stories/story_registry.rs @@ -0,0 +1,27 @@ +use dioxus::prelude::*; + +use crate::stories::story::StoryDescriptor; +use crate::stories::studio_ux_stories; + +pub const DEFAULT_STORY_ID: &str = "studio/simulator-idle"; + +pub fn all_stories() -> Vec { + studio_ux_stories::STORIES.to_vec() +} + +pub fn story_by_id(id: &str) -> Option { + all_stories().into_iter().find(|story| story.id == id) +} + +pub fn render_story(id: &str) -> Element { + studio_ux_stories::render_story(id).unwrap_or_else(|| { + rsx! { + section { class: "ux-panel", + div { class: "ux-panel-heading", + h2 { "Story not found" } + } + p { class: "ux-panel-copy", "No story is registered for `{id}`." } + } + } + }) +} diff --git a/lp-app/lpa-studio-web/src/stories/studio_ux_stories.rs b/lp-app/lpa-studio-web/src/stories/studio_ux_stories.rs new file mode 100644 index 000000000..a2e236e3f --- /dev/null +++ b/lp-app/lpa-studio-web/src/stories/studio_ux_stories.rs @@ -0,0 +1,898 @@ +use dioxus::prelude::*; +use lpa_studio_ux::{ + DeviceOp, DeviceUx, LinkEndpointId, LinkProviderKind, ProgressState, ProjectInventorySummary, + ProjectOp, ProjectState, ProjectUx, StudioView, UiAction, UiActivity, UiActivityStep, + UiActivityStepState, UiBody, UiMetric, UiPaneView, UiProgress, UiStackSection, UiStackView, + UiStatus, UiStepState, UiTerminalLine, UxIssue, UxLogEntry, UxLogLevel, UxNodeId, +}; + +use crate::components::{ActionStrip, StudioShell, UxPane}; +use crate::stories::story::StoryDescriptor; + +pub const STORIES: &[StoryDescriptor] = &[ + StoryDescriptor::new( + "studio/actions/provider-actions", + "Studio UX", + "Connection actions", + "Generic action strip for connection choices exposed by Device UX.", + ), + StoryDescriptor::new( + "studio/panes/device", + "Studio UX", + "Device pane", + "Device pane rendered from a stack of connection, LightPlayer, and project steps.", + ), + StoryDescriptor::new( + "studio/panes/project", + "Studio UX", + "Project pane", + "Loaded project pane rendered directly from the Project UX view.", + ), + StoryDescriptor::new( + "studio/device-project-empty", + "Studio UX", + "Project launcher", + "Device pane offering running-project attach and demo loading.", + ), + StoryDescriptor::new( + "studio/device-project-selection", + "Studio UX", + "Project selection", + "Loaded project choices exposed in the Device open-project step.", + ), + StoryDescriptor::new( + "studio/simulator-idle", + "Studio UX", + "Simulator idle", + "Initial Studio UX state before launching the browser simulator.", + ), + StoryDescriptor::new( + "studio/browser-serial-canceled", + "Studio UX", + "Serial chooser canceled", + "Browser serial picker after the native dialog was canceled.", + ), + StoryDescriptor::new( + "studio/browser-serial-open-failed", + "Studio UX", + "Serial open failed", + "Recoverable serial-open failure with picker actions still available.", + ), + StoryDescriptor::new( + "studio/simulator-endpoint", + "Studio UX", + "Simulator endpoint", + "Endpoint choices returned by the selected lpa-link provider.", + ), + StoryDescriptor::new( + "studio/simulator-starting", + "Studio UX", + "Simulator starting", + "Progress state while the selected endpoint is opening.", + ), + StoryDescriptor::new( + "studio/simulator-ready", + "Studio UX", + "Simulator ready", + "Connected simulator after the UX layer auto-loads the demo project.", + ), + StoryDescriptor::new( + "studio/server-disconnected-link-ready", + "Studio UX", + "Server disconnected", + "Open device session with LightPlayer detached and reconnect action available.", + ), + StoryDescriptor::new( + "studio/provision-ready", + "Studio UX", + "Flash ready", + "Blank ESP32 device session offering firmware flashing.", + ), + StoryDescriptor::new( + "studio/browser-serial-blank-firmware", + "Studio UX", + "Blank firmware readiness", + "Browser serial readiness with boot logs and firmware flashing available.", + ), + StoryDescriptor::new( + "studio/provisioning", + "Studio UX", + "Flashing", + "Progress while Studio flashes packaged LightPlayer firmware.", + ), + StoryDescriptor::new( + "studio/provision-failed", + "Studio UX", + "Flash failed", + "Firmware flashing issue with retry and disconnect actions.", + ), + StoryDescriptor::new( + "studio/resetting-to-blank", + "Studio UX", + "Wiping", + "Progress while Studio erases an existing ESP32.", + ), + StoryDescriptor::new( + "studio/reset-complete", + "Studio UX", + "Reset complete", + "Blank ESP32 after erase with firmware flashing available again.", + ), + StoryDescriptor::new( + "studio/project-ready", + "Studio UX", + "Project ready", + "Demo project loaded and summarized through the UX view.", + ), + StoryDescriptor::new( + "studio/error", + "Studio UX", + "Action error", + "Failure state shown through the same shell and action surface.", + ), +]; + +pub fn render_story(id: &str) -> Option { + match id { + "studio/actions/provider-actions" => { + return Some(rsx! { + section { class: "ux-panel ux-panel-primary", + div { class: "ux-panel-heading", + p { "Actions" } + } + ActionStrip { + actions: start_actions(), + running: false, + on_action: move |_| {}, + } + } + }); + } + "studio/panes/device" => { + let view = idle_device_view(); + return Some(rsx! { + UxPane { + view, + primary: true, + running: false, + on_action: move |_| {}, + } + }); + } + "studio/panes/project" => { + let view = project_view(project_ready_state(), true); + return Some(rsx! { + UxPane { + view, + primary: false, + running: false, + on_action: move |_| {}, + } + }); + } + "studio/device-project-empty" => { + let view = device_project_empty_view(); + return Some(rsx! { + UxPane { + view, + primary: true, + running: false, + on_action: move |_| {}, + } + }); + } + "studio/device-project-selection" => { + let view = device_project_selection_view(); + return Some(rsx! { + UxPane { + view, + primary: true, + running: false, + on_action: move |_| {}, + } + }); + } + _ => {} + } + + let (mut view, running, story_logs) = match id { + "studio/simulator-idle" => (idle_view(), false, Vec::new()), + "studio/browser-serial-canceled" => (browser_serial_canceled_view(), false, Vec::new()), + "studio/browser-serial-open-failed" => { + (browser_serial_open_failed_view(), false, Vec::new()) + } + "studio/simulator-endpoint" => (endpoint_view(), false, Vec::new()), + "studio/simulator-starting" => (starting_view(), true, Vec::new()), + "studio/simulator-ready" => ( + simulator_ready_view(), + false, + vec![ + studio_log(UxLogLevel::Info, "Simulator is running"), + studio_log(UxLogLevel::Info, "Demo project loaded"), + ], + ), + "studio/server-disconnected-link-ready" => ( + lightplayer_disconnected_view(), + false, + vec![studio_log(UxLogLevel::Info, "LightPlayer disconnected")], + ), + "studio/provision-ready" => (provision_ready_view(), false, Vec::new()), + "studio/browser-serial-blank-firmware" => { + (browser_serial_blank_firmware_view(), false, Vec::new()) + } + "studio/provisioning" => (provisioning_view(), true, Vec::new()), + "studio/provision-failed" => ( + provision_failed_view(), + false, + vec![studio_log( + UxLogLevel::Error, + "browser serial firmware flashing failed", + )], + ), + "studio/resetting-to-blank" => (resetting_to_blank_view(), true, Vec::new()), + "studio/reset-complete" => ( + reset_complete_view(), + false, + vec![studio_log(UxLogLevel::Info, "ESP32-C6 wiped")], + ), + "studio/project-ready" => ( + project_ready_view(), + false, + vec![studio_log(UxLogLevel::Info, "Demo project loaded")], + ), + "studio/error" => ( + error_view(), + false, + vec![studio_log( + UxLogLevel::Error, + "browser worker boot timed out", + )], + ), + _ => return None, + }; + view.logs.extend(story_logs); + + Some(rsx! { + StudioShell { + view, + running, + on_action: move |_| {}, + } + }) +} + +fn studio_log(level: UxLogLevel, message: impl Into) -> UxLogEntry { + UxLogEntry::new(level, "studio", message) +} + +fn idle_view() -> StudioView { + StudioView::new(vec![idle_device_view()], Vec::new()) +} + +fn browser_serial_canceled_view() -> StudioView { + StudioView::new( + vec![idle_device_view()], + vec![studio_log(UxLogLevel::Info, "Port selection canceled")], + ) +} + +fn browser_serial_open_failed_view() -> StudioView { + picker_issue_view( + "Failed to open serial port.", + "Failed to execute 'open' on 'SerialPort': Failed to open serial port.", + ) +} + +fn endpoint_view() -> StudioView { + StudioView::new(vec![endpoint_device_view()], Vec::new()) +} + +fn starting_view() -> StudioView { + StudioView::new( + vec![starting_device_view()], + vec![UxLogEntry::new( + UxLogLevel::Info, + "lpa-link", + "browser worker session created", + )], + ) +} + +fn simulator_ready_view() -> StudioView { + StudioView::new( + vec![ + project_view(project_ready_state(), true), + simulator_ready_device_view(), + ], + vec![ + UxLogEntry::new(UxLogLevel::Info, "fw-browser", "ready"), + UxLogEntry::new( + UxLogLevel::Info, + "lpa-link", + "browser worker session owns Worker lifecycle in lpa-link", + ), + UxLogEntry::new(UxLogLevel::Info, "fw-browser", "project loaded"), + ], + ) +} + +fn project_ready_view() -> StudioView { + StudioView::new( + vec![ + project_view(project_ready_state(), true), + simulator_ready_device_view(), + ], + vec![ + UxLogEntry::new(UxLogLevel::Info, "fw-browser", "project loaded"), + UxLogEntry::new( + UxLogLevel::Debug, + "lp-server", + "heartbeat frame=42 uptime_ms=700", + ), + ], + ) +} + +fn lightplayer_disconnected_view() -> StudioView { + StudioView::new( + vec![device_view( + UiStatus::good("Simulator connected"), + vec![ + select_connection_complete("Simulator"), + connect_device_complete_with_actions( + browser_worker_metrics(), + vec![disconnect_device_action()], + ), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Active, + UiBody::text("Attach Studio to LightPlayer on the connected simulator."), + vec![connect_lightplayer_action()], + ), + ], + vec!["[lpa-studio-ux] LightPlayer protocol detached; device session remains open"], + )], + vec![UxLogEntry::new( + UxLogLevel::Info, + "lpa-studio-ux", + "LightPlayer protocol detached; device session remains open", + )], + ) +} + +fn provision_ready_view() -> StudioView { + StudioView::new( + vec![blank_device_view( + UiStatus::warning("Ready to flash"), + UiBody::text("No LightPlayer firmware is running on this ESP32."), + false, + )], + vec![UxLogEntry::new( + UxLogLevel::Warn, + "lpa-studio-ux", + "server protocol is unavailable; firmware flashing is available", + )], + ) +} + +fn browser_serial_blank_firmware_view() -> StudioView { + StudioView::new( + vec![blank_device_view( + UiStatus::warning("Ready to flash"), + UiBody::Activity(blank_firmware_activity()), + false, + )], + vec![ + UxLogEntry::new(UxLogLevel::Info, "fw-esp32", "ESP-ROM:esp32c6-20220919"), + UxLogEntry::new(UxLogLevel::Info, "fw-esp32", "invalid header: 0xffffffff"), + UxLogEntry::new( + UxLogLevel::Warn, + "lpa-studio-ux", + "no LightPlayer firmware detected; firmware flashing is available", + ), + ], + ) +} + +fn provisioning_view() -> StudioView { + StudioView::new( + vec![device_view( + UiStatus::working("Flashing"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Flashing firmware", + UiStepState::Active, + UiBody::Activity(provisioning_activity()), + Vec::new(), + ), + ], + vec![ + "[lpa-link] Connected to ESP32 bootloader", + "[lpa-link] Writing app image at 0x10000", + "[lpa-link] Progress 42%", + ], + )], + vec![UxLogEntry::new( + UxLogLevel::Info, + "lpa-link", + "Connected to ESP32 bootloader", + )], + ) +} + +fn provision_failed_view() -> StudioView { + StudioView::new( + vec![device_view( + UiStatus::error("Needs attention"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete_with_actions(esp32_metrics(), device_management_actions()), + stack_section( + "connect-lightplayer", + "Flashing firmware", + UiStepState::NeedsAttention, + UiBody::Issue( + UxIssue::new("firmware flashing failed").with_detail( + "Check the cable, boot mode, and browser serial permission.", + ), + ), + Vec::new(), + ), + ], + vec![ + "[lpa-link] Connected to ESP32 bootloader", + "[lpa-link] failed to write firmware image", + ], + )], + vec![UxLogEntry::new( + UxLogLevel::Error, + "lpa-link", + "failed to write firmware image", + )], + ) +} + +fn resetting_to_blank_view() -> StudioView { + StudioView::new( + vec![device_view( + UiStatus::working("Resetting"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Wiping device", + UiStepState::Active, + UiBody::Activity(reset_activity()), + Vec::new(), + ), + ], + vec![ + "[lpa-link] Connected to ESP32 bootloader", + "[lpa-link] Erasing device flash", + ], + )], + vec![UxLogEntry::new( + UxLogLevel::Info, + "lpa-link", + "Erasing device flash", + )], + ) +} + +fn reset_complete_view() -> StudioView { + StudioView::new( + vec![blank_device_view( + UiStatus::warning("Blank ESP32"), + UiBody::text("The device has been erased and can be flashed again."), + true, + )], + vec![UxLogEntry::new( + UxLogLevel::Info, + "lpa-link", + "Chip erase completed successfully", + )], + ) +} + +fn error_view() -> StudioView { + picker_issue_view( + "browser worker boot timed out", + "browser worker boot timed out", + ) +} + +fn picker_issue_view(message: &'static str, log_message: &'static str) -> StudioView { + StudioView::new( + vec![device_view( + UiStatus::error("Needs attention"), + vec![stack_section( + "select-connection", + "Select connection", + UiStepState::NeedsAttention, + UiBody::Issue(UxIssue::new(message)), + start_actions(), + )], + Vec::new(), + )], + vec![studio_log(UxLogLevel::Error, log_message)], + ) +} + +fn idle_device_view() -> UiPaneView { + device_view( + UiStatus::neutral("Choose connection"), + vec![stack_section( + "select-connection", + "Select connection", + UiStepState::Active, + UiBody::text("Choose how Studio should connect."), + start_actions(), + )], + Vec::new(), + ) +} + +fn endpoint_device_view() -> UiPaneView { + device_view( + UiStatus::working("Connecting"), + vec![ + select_connection_complete("Simulator"), + stack_section( + "connect-device", + "Connect device", + UiStepState::Active, + UiBody::text("Choose the device endpoint to open."), + vec![ + device_action(DeviceOp::ConnectEndpoint { + provider_id: LinkProviderKind::BrowserWorker, + endpoint_id: LinkEndpointId::new("browser-worker-worker-1"), + }) + .with_label("Open browser simulator") + .with_summary("Open the browser-local firmware runtime."), + ], + ), + ], + vec!["[lpa-link] Browser worker provider selected"], + ) +} + +fn starting_device_view() -> UiPaneView { + device_view( + UiStatus::working("Connecting"), + vec![ + select_connection_complete("Simulator"), + connect_device_complete(browser_worker_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Active, + UiBody::Progress(ProgressState::new("Opening server protocol")), + Vec::new(), + ), + ], + vec![ + "[lpa-link] browser worker session created", + "[fw-browser] booting firmware runtime", + ], + ) +} + +fn simulator_ready_device_view() -> UiPaneView { + device_view( + UiStatus::good("LightPlayer ready"), + vec![ + select_connection_complete("Simulator"), + connect_device_complete(browser_worker_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Complete, + UiBody::Metrics(vec![UiMetric::new( + "Protocol", + "fw-browser-post-message-v1", + )]), + vec![disconnect_lightplayer_action()], + ), + stack_section( + "open-project", + "Open project", + UiStepState::Complete, + UiBody::text("Project controls are available in the Project pane."), + Vec::new(), + ), + ], + vec![ + "[fw-browser] ready", + "[lp-server] loaded project studio-demo", + "[fw-browser] heartbeat frame=42", + ], + ) +} + +fn device_project_empty_view() -> UiPaneView { + device_view( + UiStatus::good("LightPlayer ready"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Complete, + UiBody::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), + vec![disconnect_lightplayer_action()], + ), + stack_section( + "open-project", + "Open project", + UiStepState::Active, + UiBody::text("Connect to a running project or load the demo project."), + vec![ + project_action(ProjectOp::ConnectRunningProject), + project_action(ProjectOp::LoadDemoProject), + ], + ), + ], + vec![ + "[fw-esp32] LightPlayer protocol ready", + "[lp-server] loaded projects: 0", + ], + ) +} + +fn device_project_selection_view() -> UiPaneView { + device_view( + UiStatus::good("LightPlayer ready"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Complete, + UiBody::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), + vec![disconnect_lightplayer_action()], + ), + stack_section( + "open-project", + "Open project", + UiStepState::Active, + UiBody::text("2 projects are running. Choose one to open."), + vec![ + project_action(ProjectOp::ConnectLoadedProject { handle_id: 1 }) + .with_label("Connect /projects/ambient") + .with_summary("Attach to running project handle 1."), + project_action(ProjectOp::ConnectLoadedProject { handle_id: 2 }) + .with_label("Connect /projects/palette-test") + .with_summary("Attach to running project handle 2."), + ], + ), + ], + vec![ + "[fw-esp32] LightPlayer protocol ready", + "[lp-server] loaded projects: 2", + ], + ) +} + +fn blank_device_view(status: UiStatus, body: UiBody, after_reset: bool) -> UiPaneView { + let detail = if after_reset { + vec![ + "[lpa-link] Chip erase completed successfully", + "[fw-esp32] invalid header: 0xffffffff", + ] + } else { + vec![ + "[esp32-reset] Hard resetting via RTS pin...", + "[fw-esp32] ESP-ROM:esp32c6-20220919", + "[fw-esp32] invalid header: 0xffffffff", + ] + }; + device_view( + status, + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete_with_actions(esp32_metrics(), device_management_actions()), + stack_section( + "connect-lightplayer", + "LightPlayer unavailable", + UiStepState::Active, + body, + Vec::new(), + ), + ], + detail, + ) +} + +fn blank_firmware_activity() -> UiActivity { + UiActivity::new("Connecting ESP32 server") + .with_detail("ESP32 boot output looks like blank or erased flash.") + .with_steps(vec![ + UiActivityStep::new("serial-access", "Serial access") + .with_state(UiActivityStepState::Complete) + .with_detail("Browser serial port is open."), + UiActivityStep::new("reset-device", "Reset device") + .with_state(UiActivityStepState::Complete) + .with_detail("Device reset was requested before protocol attach."), + UiActivityStep::new("boot-output", "Boot output") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("server-protocol", "LightPlayer protocol") + .with_state(UiActivityStepState::Failed), + ]) +} + +fn provisioning_activity() -> UiActivity { + UiActivity::new("Flashing firmware") + .with_detail("Writing packaged LightPlayer ESP32-C6 firmware.") + .with_progress(UiProgress::determinate("Writing flash", 42)) + .with_steps(vec![ + UiActivityStep::new("bootloader", "Bootloader") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("erase", "Erase").with_state(UiActivityStepState::Complete), + UiActivityStep::new("write", "Write firmware").with_state(UiActivityStepState::Active), + UiActivityStep::new("reboot", "Reboot").with_state(UiActivityStepState::Pending), + ]) +} + +fn reset_activity() -> UiActivity { + UiActivity::new("Wiping device") + .with_detail("Erasing ESP32 flash through the bootloader.") + .with_progress(UiProgress::determinate("Erasing flash", 58)) + .with_steps(vec![ + UiActivityStep::new("bootloader", "Bootloader") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("erase", "Erase flash").with_state(UiActivityStepState::Active), + UiActivityStep::new("blank", "Blank device").with_state(UiActivityStepState::Pending), + ]) +} + +fn device_view( + status: UiStatus, + sections: Vec, + terminal: Vec<&'static str>, +) -> UiPaneView { + UiPaneView::new( + DeviceUx::NODE_ID, + "Device", + status, + UiBody::Stack(Box::new( + UiStackView::new(sections).with_terminal( + terminal + .into_iter() + .map(UiTerminalLine::new) + .collect::>(), + ), + )), + Vec::new(), + ) +} + +fn stack_section( + id: &'static str, + title: &'static str, + state: UiStepState, + body: UiBody, + actions: Vec, +) -> UiStackSection { + UiStackSection::new(id, title, state) + .with_body(body) + .with_actions(actions) +} + +fn select_connection_complete(label: &'static str) -> UiStackSection { + stack_section( + "select-connection", + "Select connection", + UiStepState::Complete, + UiBody::text(label), + Vec::new(), + ) +} + +fn connect_device_complete(metrics: Vec) -> UiStackSection { + connect_device_complete_with_actions(metrics, Vec::new()) +} + +fn connect_device_complete_with_actions( + metrics: Vec, + actions: Vec, +) -> UiStackSection { + stack_section( + "connect-device", + "Connect device", + UiStepState::Complete, + UiBody::Metrics(metrics), + actions, + ) +} + +fn browser_worker_metrics() -> Vec { + vec![ + UiMetric::new("Provider", "Browser worker"), + UiMetric::new("Endpoint", "browser-worker-worker-1"), + UiMetric::new("Session", "browser-worker-worker-1:1"), + ] +} + +fn esp32_metrics() -> Vec { + vec![ + UiMetric::new("Provider", "Browser serial ESP32"), + UiMetric::new("Endpoint", "browser-serial-esp32-port-1"), + UiMetric::new("Session", "browser-serial-esp32-port-1:1"), + ] +} + +fn project_view(state: ProjectState, server_connected: bool) -> UiPaneView { + let mut project = ProjectUx::new(); + let no_running_project = matches!(state, ProjectState::NotLoaded) && server_connected; + project.set_state(state); + if no_running_project { + project.mark_no_running_project(); + } + project.view(server_connected) +} + +fn project_ready_state() -> ProjectState { + ProjectState::Ready { + project_id: "studio-demo".to_string(), + handle_id: 1, + inventory: ProjectInventorySummary { + node_count: 4, + definition_count: 3, + asset_count: 1, + }, + } +} + +fn start_actions() -> Vec { + vec![ + device_action(DeviceOp::OpenProvider { + provider_id: LinkProviderKind::BrowserWorker, + }) + .with_label("Start simulator") + .with_summary("Run LightPlayer locally in a browser worker.") + .with_short_label("Simulator") + .with_icon("play"), + device_action(DeviceOp::OpenProvider { + provider_id: LinkProviderKind::BrowserSerialEsp32, + }) + .with_label("Connect ESP32") + .with_summary("Connect to ESP32 hardware through browser Web Serial.") + .with_short_label("ESP32") + .with_icon("usb"), + ] +} + +fn disconnect_device_action() -> UiAction { + device_action(DeviceOp::DisconnectDevice) +} + +fn disconnect_lightplayer_action() -> UiAction { + device_action(DeviceOp::DisconnectLightPlayer) +} + +fn connect_lightplayer_action() -> UiAction { + device_action(DeviceOp::ConnectLightPlayer) +} + +fn device_management_actions() -> Vec { + vec![ + device_action(DeviceOp::ProvisionFirmware), + device_action(DeviceOp::ResetToBlank), + disconnect_device_action(), + ] +} + +fn device_action(op: DeviceOp) -> UiAction { + UiAction::from_op(UxNodeId::new(DeviceUx::NODE_ID), op) +} + +fn project_action(op: ProjectOp) -> UiAction { + UiAction::from_op(UxNodeId::new(ProjectUx::NODE_ID), op) +} diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css new file mode 100644 index 000000000..23f94d5db --- /dev/null +++ b/lp-app/lpa-studio-web/src/style.css @@ -0,0 +1,748 @@ +:root { + color-scheme: dark; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + background: #101317; + color: #f2f0e8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 240px), + #101317; +} + +button, +input, +textarea, +select { + font: inherit; +} + +.ux-shell { + width: min(1180px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: 28px 28px 64px; +} + +.ux-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 20px; + margin-bottom: 18px; +} + +.ux-eyebrow, +.ux-panel-heading p, +.ux-log-heading p { + margin: 0; + color: #94b8aa; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.ux-layout { + display: grid; + gap: 14px; +} + +.ux-layout-device-only { + grid-template-columns: minmax(0, 1fr); +} + +.ux-layout-main-device { + grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); +} + +.ux-layout-triple { + grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr) minmax(260px, 0.9fr); +} + +.ux-main-column, +.ux-device-column { + display: grid; + align-content: start; + gap: 14px; + min-width: 0; +} + +.ux-panel, +.ux-log-panel { + border: 1px solid #2a3138; + border-radius: 8px; + background: #171b20; +} + +.ux-panel { + padding: 18px; +} + +.ux-panel-primary { + background: #18201d; + border-color: #34463e; +} + +.ux-panel-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.ux-status { + display: inline-flex; + align-items: center; + flex-shrink: 1; + min-width: 0; + min-height: 24px; + max-width: 100%; + padding: 0 8px; + border: 1px solid #3a444c; + border-radius: 999px; + color: #d9e2df; + background: #12171c; + font-size: 0.76rem; + font-weight: 700; + line-height: 1; + overflow-wrap: anywhere; +} + +.ux-status-working { + border-color: #706730; + color: #f2e6a2; + background: #24230f; +} + +.ux-status-good { + border-color: #365545; + color: #7be0b2; + background: #13241d; +} + +.ux-status-warning { + border-color: #796c33; + color: #f0dc7a; + background: #292614; +} + +.ux-status-error { + border-color: #874b4b; + color: #ffc7c7; + background: #301b1d; +} + +.ux-panel-copy { + margin: 0; + color: #c7cbd0; + line-height: 1.5; +} + +.ux-panel-detail { + margin-top: 6px; + color: #99a2ad; +} + +.ux-panel-issue { + color: #ffc7c7; +} + +.ux-stack { + display: grid; + gap: 14px; +} + +.ux-stack-sections { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.ux-stack-section { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 11px; + padding: 8px 0; + background: transparent; +} + +.ux-stack-section + .ux-stack-section { + margin-top: 3px; + padding-top: 12px; + border-top: 1px solid #252d34; +} + +.ux-stack-section-content { + display: grid; + gap: 7px; + min-width: 0; +} + +.ux-stack-section-content h3 { + margin: 0; + color: #fffaf0; + font-size: 0.98rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.ux-stack-section-marker { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-top: 1px; + border: 1px solid #3e4852; + border-radius: 50%; + color: #99a2ad; + background: #151a1f; + font-size: 0.76rem; + font-weight: 700; + line-height: 1; +} + +.ux-stack-section-body { + min-width: 0; +} + +.ux-stack-section-active .ux-stack-section-marker { + border-color: #717d44; + color: #f2e6a2; + background: #2a2916; +} + +.ux-stack-section-complete .ux-stack-section-marker { + border-color: #365545; + color: #7be0b2; + background: #13241d; +} + +.ux-stack-section-attention .ux-stack-section-marker { + border-color: #874b4b; + color: #ffc7c7; + background: #301b1d; +} + +.ux-stack-section .ux-actions { + margin-top: 8px; +} + +.ux-activity { + display: grid; + gap: 10px; +} + +.ux-activity-title { + color: #fffaf0; + font-weight: 700; +} + +.ux-activity-steps { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.ux-activity-step { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 8px; + align-items: flex-start; + padding: 8px; + border: 1px solid #293039; + border-radius: 6px; + background: #11161b; +} + +.ux-activity-step-marker { + color: #99a2ad; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.78rem; +} + +.ux-activity-step-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.ux-activity-step-copy span { + color: #dce4df; + font-size: 0.9rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.ux-activity-step-copy small { + color: #99a2ad; + font-size: 0.78rem; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.ux-activity-step-active { + border-color: #717d44; +} + +.ux-activity-step-active .ux-activity-step-marker { + color: #f2e6a2; +} + +.ux-activity-step-complete { + border-color: #365545; +} + +.ux-activity-step-complete .ux-activity-step-marker { + color: #7be0b2; +} + +.ux-activity-step-failed { + border-color: #874b4b; +} + +.ux-activity-step-failed .ux-activity-step-marker { + color: #ffc7c7; +} + +.ux-progress { + display: grid; + gap: 6px; +} + +.ux-progress-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + color: #c7cbd0; + font-size: 0.9rem; +} + +.ux-progress-meta span { + min-width: 0; + overflow-wrap: anywhere; +} + +.ux-progress-meta strong { + color: #f2e6a2; + font-size: 0.86rem; +} + +.ux-progress-track { + position: relative; + height: 9px; + overflow: hidden; + border: 1px solid #303942; + border-radius: 999px; + background: #10151a; +} + +.ux-progress-fill { + height: 100%; + border-radius: inherit; + background: #7be0b2; +} + +.ux-progress-fill-determinate { + transition: width 140ms ease-out; +} + +.ux-progress-fill-indeterminate { + width: 42%; + animation: ux-progress-sweep 1.2s ease-in-out infinite; +} + +.ux-progress-fill-timeout { + transform-origin: left center; + animation-name: ux-progress-timeout; + animation-timing-function: linear; + animation-fill-mode: forwards; +} + +.ux-progress-detail { + margin: 0; + color: #99a2ad; + font-size: 0.88rem; +} + +.ux-terminal { + display: grid; + gap: 3px; + max-height: 170px; + margin: 0; + padding: 10px; + overflow: auto; + border: 1px solid #293039; + border-radius: 6px; + color: #d8dfdb; + background: #0c1114; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.78rem; + line-height: 1.35; + list-style: none; +} + +.ux-terminal li { + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.ux-stack-terminal { + max-height: 220px; +} + +.ux-actions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + margin-top: 18px; +} + +.ux-action-item { + display: grid; + gap: 6px; + min-width: 0; +} + +.ux-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 0 14px; + border: 1px solid #3e4852; + border-radius: 6px; + color: #f9f6eb; + background: #20272e; + cursor: pointer; + font-weight: 700; +} + +.ux-action-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 5px; + border: 1px solid currentColor; + border-radius: 5px; + font-size: 0.72rem; + line-height: 1; +} + +.ux-action-icon-play::before { + content: ">"; +} + +.ux-action-icon-usb::before { + content: "USB"; +} + +.ux-action-icon-test::before { + content: "T"; +} + +.ux-action:hover:not(:disabled) { + border-color: #6fc39f; +} + +.ux-action:disabled { + cursor: default; + opacity: 0.58; +} + +.ux-action-primary { + border-color: #65bd96; + color: #08130d; + background: #7be0b2; +} + +.ux-action-secondary { + background: #26313a; +} + +.ux-action-tertiary { + background: transparent; +} + +.ux-disabled-reason { + width: 100%; + margin: -4px 0 0; + color: #aab2ba; + font-size: 0.88rem; +} + +.ux-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.ux-metrics div { + min-width: 0; + padding: 10px; + border: 1px solid #2d343b; + border-radius: 6px; + background: #11161b; +} + +.ux-metrics dt { + margin-bottom: 3px; + color: #99a2ad; + font-size: 0.78rem; +} + +.ux-metrics dd { + margin: 0; + color: #fffaf0; + font-weight: 700; + overflow-wrap: anywhere; +} + +.ux-log-panel { + min-width: 0; + padding: 10px; + border-color: #303942; + background: #11161b; +} + +.ux-log-heading { + margin-bottom: 6px; +} + +.ux-log-list { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + max-height: min(34vh, 360px); + overflow: auto; + list-style: none; +} + +.ux-log { + display: grid; + grid-template-columns: 44px 88px minmax(0, 1fr); + gap: 8px; + align-items: baseline; + padding: 5px 7px; + border: 1px solid #293039; + border-radius: 5px; + background: #11161b; + font-size: 0.78rem; +} + +.ux-log span { + color: #9aa5af; + font-size: 0.72rem; + text-transform: uppercase; +} + +.ux-log strong { + color: #d9dedf; + font-size: 0.76rem; + overflow-wrap: anywhere; +} + +.ux-log p { + margin: 0; + color: #c7cbd0; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.ux-log-empty { + opacity: 0.68; +} + +.ux-log-warn { + border-color: #6e6031; +} + +.ux-log-error { + border-color: #874b4b; +} + +.story-book { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: 100vh; +} + +.story-sidebar { + border-right: 1px solid #2a3138; + background: #14181d; + padding: 18px; +} + +.story-sidebar-heading h1 { + margin: 0 0 6px; + font-size: 1.1rem; +} + +.story-sidebar-heading p { + margin: 0 0 18px; + color: #9ba4ad; +} + +.story-nav { + display: grid; + gap: 8px; +} + +.story-nav-link { + display: block; + padding: 10px; + border: 1px solid #2d343b; + border-radius: 6px; + color: #e9edf0; + text-decoration: none; + background: #171b20; +} + +.story-nav-link.is-active { + border-color: #65bd96; +} + +.story-nav-group { + display: block; + margin-bottom: 3px; + color: #94b8aa; + font-size: 0.72rem; + text-transform: uppercase; +} + +.story-stage, +.story-png-page { + padding: 22px; +} + +.story-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.story-toolbar h2, +.story-toolbar p { + margin: 0; +} + +.story-toolbar p, +.story-canvas-meta p { + color: #9ba4ad; +} + +.story-viewport-controls { + display: flex; + gap: 8px; +} + +.story-viewport-button { + padding: 7px 10px; + border: 1px solid #3e4852; + border-radius: 6px; + color: #f9f6eb; + background: #20272e; +} + +.story-viewport-button.is-active { + border-color: #65bd96; +} + +.story-canvas-shell { + width: 100%; +} + +.story-canvas-meta { + margin-bottom: 10px; +} + +.story-canvas-meta h3 { + margin: 0 0 4px; +} + +.story-canvas-meta p { + margin: 0; +} + +.story-frame { + width: 100%; +} + +@keyframes ux-progress-sweep { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(250%); + } +} + +@keyframes ux-progress-timeout { + from { + transform: scaleX(1); + } + + to { + transform: scaleX(0); + } +} + +@media (max-width: 880px) { + .ux-shell { + padding: 18px 18px 72px; + } + + .ux-header { + flex-direction: column; + } + + .ux-layout, + .story-book { + grid-template-columns: 1fr; + } + + .story-sidebar { + border-right: 0; + border-bottom: 1px solid #2a3138; + } + + .ux-log-list { + max-height: 260px; + } + + .ux-log { + grid-template-columns: 44px minmax(0, 1fr); + } + + .ux-log strong { + display: none; + } +} diff --git a/lp-app/lpa-studio-web/story-images/studio__actions__provider-actions.png b/lp-app/lpa-studio-web/story-images/studio__actions__provider-actions.png new file mode 100644 index 000000000..7fa0aa7b1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__actions__provider-actions.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__browser-serial-blank-firmware.png b/lp-app/lpa-studio-web/story-images/studio__browser-serial-blank-firmware.png new file mode 100644 index 000000000..7f8835455 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__browser-serial-blank-firmware.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__browser-serial-canceled.png b/lp-app/lpa-studio-web/story-images/studio__browser-serial-canceled.png new file mode 100644 index 000000000..15f62a880 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__browser-serial-canceled.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__browser-serial-open-failed.png b/lp-app/lpa-studio-web/story-images/studio__browser-serial-open-failed.png new file mode 100644 index 000000000..899924b39 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__browser-serial-open-failed.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device-project-empty.png b/lp-app/lpa-studio-web/story-images/studio__device-project-empty.png new file mode 100644 index 000000000..9ae0c5515 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device-project-empty.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device-project-selection.png b/lp-app/lpa-studio-web/story-images/studio__device-project-selection.png new file mode 100644 index 000000000..4f3f995f2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device-project-selection.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__error.png b/lp-app/lpa-studio-web/story-images/studio__error.png new file mode 100644 index 000000000..96d961c8e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__error.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__panes__device.png b/lp-app/lpa-studio-web/story-images/studio__panes__device.png new file mode 100644 index 000000000..dbfccda9a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__panes__device.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__panes__project.png b/lp-app/lpa-studio-web/story-images/studio__panes__project.png new file mode 100644 index 000000000..b2a54dbbc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__panes__project.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project-ready.png b/lp-app/lpa-studio-web/story-images/studio__project-ready.png new file mode 100644 index 000000000..255ac69d7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project-ready.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__provision-failed.png b/lp-app/lpa-studio-web/story-images/studio__provision-failed.png new file mode 100644 index 000000000..31dcdc16b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__provision-failed.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__provision-ready.png b/lp-app/lpa-studio-web/story-images/studio__provision-ready.png new file mode 100644 index 000000000..604de65d4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__provision-ready.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__provisioning.png b/lp-app/lpa-studio-web/story-images/studio__provisioning.png new file mode 100644 index 000000000..5023efd75 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__provisioning.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__reset-complete.png b/lp-app/lpa-studio-web/story-images/studio__reset-complete.png new file mode 100644 index 000000000..6c9d9a25e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__reset-complete.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__resetting-to-blank.png b/lp-app/lpa-studio-web/story-images/studio__resetting-to-blank.png new file mode 100644 index 000000000..0be6933a5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__resetting-to-blank.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__server-disconnected-link-ready.png b/lp-app/lpa-studio-web/story-images/studio__server-disconnected-link-ready.png new file mode 100644 index 000000000..17d1b70bb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__server-disconnected-link-ready.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-endpoint.png b/lp-app/lpa-studio-web/story-images/studio__simulator-endpoint.png new file mode 100644 index 000000000..82acee47c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__simulator-endpoint.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-idle.png b/lp-app/lpa-studio-web/story-images/studio__simulator-idle.png new file mode 100644 index 000000000..ed69fb1f6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__simulator-idle.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-ready.png b/lp-app/lpa-studio-web/story-images/studio__simulator-ready.png new file mode 100644 index 000000000..ac1807e2d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__simulator-ready.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-starting.png b/lp-app/lpa-studio-web/story-images/studio__simulator-starting.png new file mode 100644 index 000000000..29c81ac49 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__simulator-starting.png differ diff --git a/lp-app/web-demo/README.md b/lp-app/web-demo/README.md index 412a873c7..529d867f0 100644 --- a/lp-app/web-demo/README.md +++ b/lp-app/web-demo/README.md @@ -26,7 +26,7 @@ just web-demo-build ``` This builds `web-demo` for wasm32 (release), runs `wasm-bindgen` into `www/pkg/`, and refreshes -`www/rainbow-default.glsl` from `examples/basic/src/rainbow.shader/main.glsl`. +`www/rainbow-default.glsl` from `examples/basic/shader.glsl`. ## Run @@ -37,6 +37,21 @@ just web-demo Open the URL printed by miniserve (default `http://127.0.0.1:2812`). The editor compiles on idle; `render_frame` drives the texture (shader entry point is **`vec4 render(vec2 fragCoord, vec2 outputSize, float time)`**). +## Deploy + +The existing `just web-demo-deploy` recipe still deploys the demo to the +`gh-pages` branch. The `demo.lightplayer.app` channel uses a clean staged +artifact instead: + +```bash +just web-demo-deploy-dir demo target/pages/web-demo demo.lightplayer.app +just web-demo-smoke target/pages/web-demo +``` + +The staged artifact includes `version.json`, `.nojekyll`, and `CNAME`. Manual +deployment to the demo Pages repository runs through the `Deploy Pages Channel` +workflow. See `docs/deploy/studio-pages.md` for DNS and GitHub Pages setup. + ## Linear memory Shader memory comes from the compiled module’s `env.memory` import. The demo grows it as needed for diff --git a/lp-app/web-demo/www/rainbow-default.glsl b/lp-app/web-demo/www/rainbow-default.glsl index cf590b115..539b9f9a5 100644 --- a/lp-app/web-demo/www/rainbow-default.glsl +++ b/lp-app/web-demo/www/rainbow-default.glsl @@ -86,12 +86,17 @@ vec4 render(vec2 pos) { const vec2 REF_SIZE = vec2(32.0, 32.0); vec2 virtCoord = pos * REF_SIZE / outputSize; - // Palette cycle: 5s per palette, 1s smooth transition to next - // Clamp palette to 4 to avoid Q32 edge case where floor(mod(...)) yields 5.0 - float cyclePhase = mod(time, 5.0); - float palette = min(floor(mod(time * 0.2, 5.0)), 4.0); - float nextPalette = mod(palette + 1.0, 5.0); - float blend = smoothstep(4.0, 5.0, cyclePhase); + // Palette cycle: 5s per palette, 1s smooth transition to next. + // Keep palette index and blend phase derived from one timer so fixed-point + // boundary rounding cannot make them disagree at 25s loop points. + float palettePhase = mod(time, 25.0) * 0.2; + float palette = min(floor(palettePhase), 4.0); + float cyclePhase = palettePhase - palette; + float nextPalette = palette + 1.0; + if (nextPalette > 4.5) { + nextPalette = 0.0; + } + float blend = smoothstep(0.8, 1.0, cyclePhase); float panSpeed = .3; float pan = mix(1.0, 8.0, 0.5 * (sin(time * panSpeed) + 1.0)); diff --git a/lp-cli/Cargo.toml b/lp-cli/Cargo.toml index d9b030c87..d3036e4de 100644 --- a/lp-cli/Cargo.toml +++ b/lp-cli/Cargo.toml @@ -26,6 +26,7 @@ notify = "6" lpc-model = { path = "../lp-core/lpc-model" } lpc-wire = { path = "../lp-core/lpc-wire" } lpa-server = { path = "../lp-app/lpa-server" } +lpa-link = { path = "../lp-app/lpa-link", features = ["host-process", "host-serial-esp32"] } lpc-hardware = { path = "../lp-core/lpc-hardware" } lpc-shared = { path = "../lp-core/lpc-shared" } lpfs = { path = "../lp-base/lpfs", features = ["std"] } @@ -48,6 +49,7 @@ lpvm-native = { path = "../lp-shader/lpvm-native", features = ["debug"] } lpvm-cranelift = { path = "../lp-shader/lpvm-cranelift", features = ["riscv32-object"] } unicode-width = "0.2" rustc-demangle = "0.1" +pollster = "0.3" [dev-dependencies] tempfile = "3" diff --git a/lp-cli/src/client/client_connect.rs b/lp-cli/src/client/client_connect.rs index a690bb2a6..7bfe7faa7 100644 --- a/lp-cli/src/client/client_connect.rs +++ b/lp-cli/src/client/client_connect.rs @@ -14,21 +14,22 @@ use lp_riscv_emu::{ #[cfg(feature = "serial")] use lp_riscv_inst::Gpr; #[cfg(feature = "serial")] -use lpa_client::transport_serial::{ - BacktraceInfo, create_emulator_serial_transport_pair, create_hardware_serial_transport_pair, -}; +use lpa_client::transport_serial::{BacktraceInfo, create_emulator_serial_transport_pair}; use lpa_client::{ClientTransport, HostSpecifier, WebSocketClientTransport}; #[cfg(feature = "serial")] use std::sync::{Arc, Mutex}; -use crate::client::local_server::LocalServerTransport; +use crate::client::host_process::connect_host_process; +#[cfg(feature = "serial")] +use crate::client::host_serial_esp32::connect_host_serial_esp32; #[cfg(feature = "serial")] use crate::client::serial_port::detect_serial_port; /// Connect to a server using the specified host specifier /// /// Creates and returns an appropriate `ClientTransport` based on the `HostSpecifier`. -/// For `Local`, creates an in-memory server on a separate thread. +/// For `Local`, creates a `host-process` link session backed by an in-process +/// `fw-host` runtime. /// /// # Arguments /// @@ -46,7 +47,7 @@ use crate::client::serial_port::detect_serial_port; /// use lpa_client::HostSpecifier; /// /// # fn main() -> Result<(), Box> { -/// // Connect to local in-memory server +/// // Connect to a local in-process fw-host runtime /// let mut transport = client_connect(HostSpecifier::Local)?; /// // Note: In real usage, you would use the transport and then close it. /// // For doctest purposes, we just demonstrate creation. @@ -60,9 +61,8 @@ use crate::client::serial_port::detect_serial_port; pub fn client_connect(spec: HostSpecifier) -> Result> { match spec { HostSpecifier::Local => { - // Create local server transport (now implements ClientTransport directly) - let local_server = LocalServerTransport::new()?; - Ok(Box::new(local_server)) + let host_process = connect_host_process()?; + Ok(Box::new(host_process)) } HostSpecifier::WebSocket { url } => { // WebSocketClientTransport::new is async, but client_connect is sync @@ -80,10 +80,8 @@ pub fn client_connect(spec: HostSpecifier) -> Result> { let port_config = detect_serial_port(port.as_deref(), baud_rate.as_ref().copied()) .context("Failed to detect serial port")?; - // Create hardware serial transport - let transport = - create_hardware_serial_transport_pair(&port_config.port, port_config.baud_rate) - .map_err(|e| anyhow::anyhow!("Failed to create serial transport: {e}"))?; + let transport = connect_host_serial_esp32(&port_config.port, port_config.baud_rate) + .map_err(|e| anyhow::anyhow!("Failed to create serial transport: {e}"))?; Ok(Box::new(transport)) } diff --git a/lp-cli/src/client/host_process.rs b/lp-cli/src/client/host_process.rs new file mode 100644 index 000000000..21c42a9f7 --- /dev/null +++ b/lp-cli/src/client/host_process.rs @@ -0,0 +1,129 @@ +//! CLI host-process transport. +//! +//! This module adapts `lpa-link`'s `host-process` provider to the CLI's current +//! `ClientTransport` return shape while keeping the link session alive for the +//! lifetime of the transport. + +use anyhow::Result; +use lpa_client::ClientTransport; +use lpa_link::providers::host_process::HostProcessProvider; +use lpa_link::{LinkError, LinkProvider, LinkSession}; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Client transport backed by an in-process `fw-host` runtime session. +pub struct HostProcessClientTransport { + transport: Option>>>, + provider: HostProcessProvider, + session: LinkSession, + closed: bool, +} + +impl HostProcessClientTransport { + fn new( + transport: Arc>>, + provider: HostProcessProvider, + session: LinkSession, + ) -> Self { + Self { + transport: Some(transport), + provider, + session, + closed: false, + } + } +} + +/// Start a new host-process runtime and return a CLI-compatible transport. +pub fn connect_host_process() -> Result { + let mut provider = HostProcessProvider::new(); + let endpoint_id = provider.create_memory_endpoint("Host Process"); + let session = pollster::block_on(provider.connect(&endpoint_id))?; + let connection = pollster::block_on(provider.connection(session.id()))?; + let transport = connection + .server_connection() + .ok_or_else(|| anyhow::anyhow!("host-process connection did not include a transport"))?; + + Ok(HostProcessClientTransport::new( + transport, provider, session, + )) +} + +#[async_trait::async_trait] +impl ClientTransport for HostProcessClientTransport { + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { + if self.closed { + return Err(TransportError::ConnectionLost); + } + + let Some(transport) = &self.transport else { + return Err(TransportError::ConnectionLost); + }; + transport.lock().await.send(msg).await + } + + async fn receive(&mut self) -> Result { + if self.closed { + return Err(TransportError::ConnectionLost); + } + + let Some(transport) = &self.transport else { + return Err(TransportError::ConnectionLost); + }; + transport.lock().await.receive().await + } + + async fn close(&mut self) -> Result<(), TransportError> { + if self.closed { + return Ok(()); + } + + self.closed = true; + drop(self.transport.take()); + let session_id = self.session.id().clone(); + self.provider + .close(&session_id) + .await + .map_err(link_error_to_transport) + } +} + +impl Drop for HostProcessClientTransport { + fn drop(&mut self) { + drop(self.transport.take()); + } +} + +fn link_error_to_transport(error: LinkError) -> TransportError { + TransportError::Other(error.to_string()) +} + +#[cfg(test)] +mod tests { + use lpa_client::TokioLpClient; + + use super::*; + + #[tokio::test] + async fn host_process_transport_serves_client_requests() { + let transport = connect_host_process().unwrap(); + let client = TokioLpClient::new(Box::new(transport)); + + let projects = client.project_list_available().await.unwrap(); + + assert!(projects.is_empty()); + } + + #[tokio::test] + async fn close_stops_host_process_transport() { + let mut transport = connect_host_process().unwrap(); + + transport.close().await.unwrap(); + + assert!(matches!( + transport.receive().await, + Err(TransportError::ConnectionLost) + )); + } +} diff --git a/lp-cli/src/client/host_serial_esp32.rs b/lp-cli/src/client/host_serial_esp32.rs new file mode 100644 index 000000000..4edcac4ef --- /dev/null +++ b/lp-cli/src/client/host_serial_esp32.rs @@ -0,0 +1,115 @@ +//! CLI host-serial-ESP32 transport. +//! +//! This module adapts `lpa-link`'s `host-serial-esp32` provider to the CLI's +//! current `ClientTransport` return shape while keeping the link session alive +//! for the lifetime of the transport. + +use anyhow::Result; +use lpa_client::ClientTransport; +use lpa_link::providers::host_serial_esp32::{HostSerialEsp32Options, HostSerialEsp32Provider}; +use lpa_link::{LinkError, LinkProvider, LinkSession}; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Client transport backed by an ESP32 over host serial. +pub struct HostSerialEsp32ClientTransport { + transport: Option>>>, + provider: HostSerialEsp32Provider, + session: LinkSession, + closed: bool, +} + +impl HostSerialEsp32ClientTransport { + fn new( + transport: Arc>>, + provider: HostSerialEsp32Provider, + session: LinkSession, + ) -> Self { + Self { + transport: Some(transport), + provider, + session, + closed: false, + } + } +} + +pub fn connect_host_serial_esp32( + port_name: &str, + baud_rate: u32, +) -> Result { + connect_host_serial_esp32_with_options( + port_name, + HostSerialEsp32Options { + baud_rate: Some(baud_rate), + ..HostSerialEsp32Options::default() + }, + ) +} + +pub fn connect_host_serial_esp32_with_options( + port_name: &str, + options: HostSerialEsp32Options, +) -> Result { + let mut provider = HostSerialEsp32Provider::with_options(options); + let endpoint_id = provider.create_endpoint_for_port(port_name, format!("ESP32 ({port_name})")); + let session = pollster::block_on(provider.connect(&endpoint_id))?; + let connection = pollster::block_on(provider.connection(session.id()))?; + let transport = connection.server_connection().ok_or_else(|| { + anyhow::anyhow!("host-serial-esp32 connection did not include a transport") + })?; + + Ok(HostSerialEsp32ClientTransport::new( + transport, provider, session, + )) +} + +#[async_trait::async_trait] +impl ClientTransport for HostSerialEsp32ClientTransport { + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { + if self.closed { + return Err(TransportError::ConnectionLost); + } + + let Some(transport) = &self.transport else { + return Err(TransportError::ConnectionLost); + }; + transport.lock().await.send(msg).await + } + + async fn receive(&mut self) -> Result { + if self.closed { + return Err(TransportError::ConnectionLost); + } + + let Some(transport) = &self.transport else { + return Err(TransportError::ConnectionLost); + }; + transport.lock().await.receive().await + } + + async fn close(&mut self) -> Result<(), TransportError> { + if self.closed { + return Ok(()); + } + + self.closed = true; + drop(self.transport.take()); + let session_id = self.session.id().clone(); + self.provider + .close(&session_id) + .await + .map_err(link_error_to_transport) + } +} + +impl Drop for HostSerialEsp32ClientTransport { + fn drop(&mut self) { + drop(self.transport.take()); + } +} + +fn link_error_to_transport(error: LinkError) -> TransportError { + TransportError::Other(error.to_string()) +} diff --git a/lp-cli/src/client/local_server.rs b/lp-cli/src/client/local_server.rs deleted file mode 100644 index 67a235fbe..000000000 --- a/lp-cli/src/client/local_server.rs +++ /dev/null @@ -1,267 +0,0 @@ -//! Local server transport -//! -//! Encapsulates an in-memory server running on a separate thread and provides -//! a client transport interface for communicating with it. - -use anyhow::Result; -use lpa_client::{AsyncLocalClientTransport, ClientTransport, create_local_transport_pair}; -use lpc_wire::{ClientMessage, TransportError}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::thread::{self, JoinHandle}; - -use crate::server::{create_server, run_server_loop_async}; - -/// Local server transport that manages an in-memory server thread -/// -/// This struct encapsulates the lifecycle of a server running on a separate thread. -/// It provides access to a client transport for communicating with the server. -pub struct LocalServerTransport { - /// Handle to the server thread (None after close()) - server_handle: Option>, - /// Client transport for communicating with the server - client_transport: Option, - /// Whether the transport has been closed - closed: Arc, -} - -impl LocalServerTransport { - /// Create a new local server transport - /// - /// Spawns a server thread with its own tokio runtime and returns a client transport - /// for communicating with it. - /// - /// # Returns - /// - /// * `Ok(Self)` if server was spawned successfully - /// * `Err` if server creation or thread spawning failed - pub fn new() -> Result { - // Create transport pair - let (client_transport, server_transport) = create_local_transport_pair(); - - // Create closed flag (shared between client and server) - let closed = Arc::new(AtomicBool::new(false)); - - // Spawn server thread - let closed_clone = Arc::clone(&closed); - let server_handle = thread::Builder::new() - .name("lpa-server".to_string()) - .spawn(move || { - // Create tokio runtime for server - let runtime = match tokio::runtime::Runtime::new() { - Ok(r) => r, - Err(e) => { - eprintln!("Failed to create tokio runtime for server: {e}"); - return; - } - }; - - // Create server inside the thread (LpServer is not Send) - let (server, _base_fs) = match create_server(None, true, None) { - Ok((s, fs)) => (s, fs), - Err(e) => { - eprintln!("Failed to create server: {e}"); - return; - } - }; - - // Run server loop until transport closes - runtime.block_on(async { - // Create LocalSet for spawn_local (needed because LpServer is not Send) - let local_set = tokio::task::LocalSet::new(); - let _ = local_set - .run_until(run_server_loop_async(server, server_transport)) - .await; - }); - - // Mark as closed when server exits - closed_clone.store(true, Ordering::Relaxed); - }) - .map_err(|e| anyhow::anyhow!("Failed to spawn server thread: {e}"))?; - - Ok(Self { - server_handle: Some(server_handle), - client_transport: Some(client_transport), - closed, - }) - } - - /// Close the transport and stop the server - /// - /// This method is idempotent - calling it multiple times is safe. - /// It closes the client transport (which signals the server to shut down) and - /// waits for the server thread to finish. - /// - /// # Returns - /// - /// * `Ok(())` if the server was stopped successfully (or already closed) - /// * `Err` if waiting for the thread failed - #[allow( - dead_code, - reason = "Will be used in future cleanup/shutdown scenarios" - )] - pub fn close(&mut self) -> Result<()> { - // Check if already closed - if self.closed.load(Ordering::Relaxed) { - return Ok(()); - } - - // Mark as closed - self.closed.store(true, Ordering::Relaxed); - - // Close the client transport (signals server to shut down) - // Dropping the client_transport will close its channels, which signals the server - // to stop (server's receive() will return ConnectionLost) - drop(self.client_transport.take()); - - // Wait for server thread to finish (with timeout to avoid hanging) - if let Some(handle) = self.server_handle.take() { - // Use a timeout to avoid hanging forever if server doesn't stop - let start = std::time::Instant::now(); - loop { - if handle.is_finished() { - handle - .join() - .map_err(|_| anyhow::anyhow!("Server thread panicked"))?; - break; - } - if start.elapsed() > std::time::Duration::from_secs(1) { - // Timeout - server didn't stop, abort the thread - eprintln!("Warning: Server thread did not stop within timeout, aborting"); - return Err(anyhow::anyhow!("Server thread did not stop within timeout")); - } - std::thread::yield_now(); - } - } - - Ok(()) - } -} - -#[async_trait::async_trait] -impl ClientTransport for LocalServerTransport { - async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { - match &mut self.client_transport { - Some(transport) => transport.send(msg).await, - None => Err(TransportError::ConnectionLost), - } - } - - async fn receive(&mut self) -> Result { - match &mut self.client_transport { - Some(transport) => transport.receive().await, - None => Err(TransportError::ConnectionLost), - } - } - - async fn close(&mut self) -> Result<(), TransportError> { - // Check if already closed - if self.closed.load(Ordering::Relaxed) { - return Ok(()); - } - - // Mark as closed - self.closed.store(true, Ordering::Relaxed); - - // Close the client transport (signals server to shut down) - if let Some(mut transport) = self.client_transport.take() { - let _ = transport.close().await; - } - - // Wait for server thread to finish (with timeout) - if let Some(handle) = self.server_handle.take() { - // Use tokio::time::timeout in async context - let start = std::time::Instant::now(); - loop { - if handle.is_finished() { - handle - .join() - .map_err(|_| TransportError::Other("Server thread panicked".to_string()))?; - break; - } - if start.elapsed() > std::time::Duration::from_secs(1) { - return Err(TransportError::Other( - "Server thread did not stop within timeout".to_string(), - )); - } - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - } - } - - Ok(()) - } -} - -impl Drop for LocalServerTransport { - fn drop(&mut self) { - // If not already closed, try to close (best-effort) - if !self.closed.load(Ordering::Relaxed) { - // Mark as closed - self.closed.store(true, Ordering::Relaxed); - // Close the client transport first (signals server to shut down) - drop(self.client_transport.take()); - // Try to join the thread if we still have the handle (with timeout to avoid hanging) - if let Some(handle) = self.server_handle.take() { - // Use a short timeout to avoid hanging forever in doctests - let start = std::time::Instant::now(); - loop { - if handle.is_finished() { - let _ = handle.join(); - break; - } - if start.elapsed() > std::time::Duration::from_millis(100) { - // Timeout - don't wait forever in Drop - break; - } - std::thread::yield_now(); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use lpc_wire::{ClientMessage, ClientRequest}; - - #[tokio::test] - async fn test_local_server_transport_creation() { - let mut transport = LocalServerTransport::new().unwrap(); - // Verify it was created - // Give server time to start - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - // Cleanup - transport.close().unwrap(); - } - - #[tokio::test] - async fn test_client_transport_works() { - let mut transport = LocalServerTransport::new().unwrap(); - - // Give server time to start - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - - // Test that we can send and receive - let msg = ClientMessage { - id: 1, - msg: ClientRequest::ListAvailableProjects, - }; - // Note: send is async, but we can't easily test without a server response - // Just verify the transport exists - let _ = transport.send(msg).await; - - // Close the transport - transport.close().unwrap(); - } - - #[tokio::test] - async fn test_close_stops_server() { - let mut transport = LocalServerTransport::new().unwrap(); - // Give server time to start - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - // Close should wait for server thread - transport.close().unwrap(); - // If we get here, close succeeded - } -} diff --git a/lp-cli/src/client/mod.rs b/lp-cli/src/client/mod.rs index 94cecf3da..eef5621db 100644 --- a/lp-cli/src/client/mod.rs +++ b/lp-cli/src/client/mod.rs @@ -1,9 +1,17 @@ -// Re-export everything from lpa-client for backward compatibility -pub use lpa_client::*; +// Re-export the host/native client shape the CLI uses. +#[allow( + unused_imports, + reason = "CLI modules import lpa-client host API through this compatibility surface" +)] +pub use lpa_client::{ + ClientTransport, HostSpecifier, TokioLpClient as LpClient, WebSocketClientTransport, + create_local_transport_pair, transport, transport_emu_serial, transport_serial, +}; // CLI-specific modules pub mod client_connect; -pub mod local_server; +pub mod host_process; +pub mod host_serial_esp32; pub mod serial_port; // Re-export CLI-specific types diff --git a/lp-cli/src/client/serial_port.rs b/lp-cli/src/client/serial_port.rs index a07d8ea59..11d13118a 100644 --- a/lp-cli/src/client/serial_port.rs +++ b/lp-cli/src/client/serial_port.rs @@ -4,6 +4,10 @@ //! the appropriate port for ESP32 communication. use anyhow::{Context, Result, bail}; +use lpa_link::LinkProvider; +use lpa_link::providers::host_serial_esp32::{ + HostSerialEsp32Provider, is_likely_esp32_serial_port, +}; use lpc_model::DEFAULT_SERIAL_BAUD_RATE; /// Serial port configuration @@ -53,15 +57,15 @@ pub fn detect_serial_port(port: Option<&str>, baud_rate: Option) -> Result< /// Auto-detect serial port /// -/// Lists all `/dev/cu.*` ports and intelligently selects USB serial ports. +/// Lists serial endpoints from `lpa-link` and intelligently selects USB serial ports. /// If exactly one USB serial port is found, uses it automatically. /// Otherwise prompts user if multiple USB serial ports or no USB serial ports found. fn auto_detect_port(baud_rate: u32) -> Result { - let all_ports = list_cu_ports()?; + let all_ports = list_host_serial_esp32_ports()?; if all_ports.is_empty() { bail!( - "No serial ports found (looking for /dev/cu.* devices).\n\ + "No serial ports found.\n\ Make sure your ESP32 is connected via USB." ); } @@ -69,12 +73,7 @@ fn auto_detect_port(baud_rate: u32) -> Result { // Filter for USB serial ports (usbmodem, ttyUSB, etc.) let usb_ports: Vec = all_ports .iter() - .filter(|port| { - port.contains("usbmodem") - || port.contains("ttyUSB") - || port.contains("ttyACM") - || port.contains("tty.usbserial") - }) + .filter(|port| is_likely_esp32_serial_port(port)) .cloned() .collect(); @@ -107,29 +106,23 @@ fn auto_detect_port(baud_rate: u32) -> Result { } } -/// List all `/dev/cu.*` ports -/// -/// Filters to only callout devices (cu.*), ignoring terminal devices (tty.*). -fn list_cu_ports() -> Result> { - let all_ports = serialport::available_ports().context("Failed to list serial ports")?; - - // Filter to only cu.* devices and collect unique base names - let mut cu_ports: Vec = all_ports +/// List host serial ESP32 provider ports without prompting. +fn list_host_serial_esp32_ports() -> Result> { + let mut provider = HostSerialEsp32Provider::new(); + let endpoints = + pollster::block_on(provider.discover()).context("Failed to list serial ports")?; + let mut ports: Vec = endpoints .iter() - .filter_map(|port_info| { - let name = &port_info.port_name; - if name.starts_with("/dev/cu.") { - Some(name.clone()) - } else { - None - } + .filter_map(|endpoint| { + provider + .port_name_for_endpoint(&endpoint.id) + .map(ToOwned::to_owned) }) .collect(); - // Sort for consistent ordering - cu_ports.sort(); + ports.sort(); - Ok(cu_ports) + Ok(ports) } /// Prompt user to select a port from multiple options diff --git a/lp-cli/src/commands/dev/handler.rs b/lp-cli/src/commands/dev/handler.rs index 545bc3c75..198a52ca8 100644 --- a/lp-cli/src/commands/dev/handler.rs +++ b/lp-cli/src/commands/dev/handler.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use tokio::signal; use crate::client::{LpClient, client_connect}; -use crate::commands::dev::{fs_loop, push_project_async, validation}; +use crate::commands::dev::{deploy_project_async, fs_loop, validation}; use crate::debug_ui::DebugUiState; use lpa_client::HostSpecifier; @@ -71,26 +71,11 @@ async fn handle_dev_async( // Create local filesystem let local_fs: Arc = Arc::new(LpFsStd::new(args.dir.clone())); - // Stop all currently loaded projects before pushing - // This ensures a clean state - if let Err(e) = client.stop_all_projects().await { - // Log warning but continue - server might not have any projects loaded - eprintln!("Warning: Failed to stop all projects: {e}"); - eprintln!("Continuing with project push..."); - } - - // Push project to server - // This ensures the project exists on the server before we try to load it - push_project_async(&client, &*local_fs, &project_uid) - .await - .with_context(|| format!("Failed to push project to server (host: {host_spec_str})"))?; - - // Load project on server - let project_path = format!("projects/{project_uid}"); - let project_handle = client - .project_load(&project_path) + // Deploy project to server. This stops old projects, writes files, then + // loads the project so hardware targets do not keep old outputs open. + let project_handle = deploy_project_async(&client, &*local_fs, &project_uid) .await - .context("Failed to load project on server")?; + .with_context(|| format!("Failed to deploy project to server (host: {host_spec_str})"))?; // Spawn fs_loop task let fs_loop_handle = { diff --git a/lp-cli/src/commands/dev/mod.rs b/lp-cli/src/commands/dev/mod.rs index 29e8288ee..6443258a4 100644 --- a/lp-cli/src/commands/dev/mod.rs +++ b/lp-cli/src/commands/dev/mod.rs @@ -14,7 +14,11 @@ pub use fs_loop::fs_loop; pub use handler::handle_dev; #[allow(unused_imports, reason = "May be used in future pull functionality")] pub use pull_project::pull_project_async; -pub use push_project::push_project_async; +#[allow( + unused_imports, + reason = "Write-only push helper remains part of the dev command surface" +)] +pub use push_project::{deploy_project_async, push_project_async}; // Re-export for public API (used internally via direct path) #[allow(unused_imports, reason = "Public API re-export")] pub use sync::sync_file_change; diff --git a/lp-cli/src/commands/dev/push_project.rs b/lp-cli/src/commands/dev/push_project.rs index e0bf21b96..5ccb16c73 100644 --- a/lp-cli/src/commands/dev/push_project.rs +++ b/lp-cli/src/commands/dev/push_project.rs @@ -3,7 +3,9 @@ //! Provides async function to push local project files to the server. use anyhow::{Context, Result}; +use lpa_client::ProjectDeployFile; use lpc_model::AsLpPath; +use lpc_wire::WireProjectHandle; use lpfs::LpFs; use crate::client::LpClient; @@ -23,16 +25,44 @@ use crate::client::LpClient; /// /// * `Ok(())` if all files were pushed successfully /// * `Err` if any file operation failed +#[allow( + dead_code, + reason = "Write-only project sync is retained for file-watch and future partial deploy callers" +)] pub async fn push_project_async( client: &LpClient, local_fs: &dyn LpFs, project_uid: &str, ) -> Result<()> { + let files = collect_project_deploy_files(local_fs)?; + client + .push_project_files(project_uid, files) + .await + .with_context(|| format!("Failed to push project files for {project_uid}"))?; + Ok(()) +} + +/// Stop any loaded projects, push project files, and load the project. +pub async fn deploy_project_async( + client: &LpClient, + local_fs: &dyn LpFs, + project_uid: &str, +) -> Result { + let files = collect_project_deploy_files(local_fs)?; + client + .deploy_project_files(project_uid, files) + .await + .with_context(|| format!("Failed to deploy project {project_uid}")) +} + +fn collect_project_deploy_files(local_fs: &dyn LpFs) -> Result> { // List all files recursively in the project directory let entries = local_fs .list_dir("/".as_path(), true) .map_err(|e| anyhow::anyhow!("Failed to list project files: {e}"))?; + let mut files = Vec::new(); + // Push each file to the server (skip directories) for entry_path in entries { // Skip directories - check if it's a directory before trying to read @@ -71,14 +101,8 @@ pub async fn push_project_async( } else { entry_str }; - let server_path = format!("/projects/{project_uid}/{relative_path}"); - - // Write file to server - client - .fs_write(server_path.as_path(), data) - .await - .with_context(|| format!("Failed to write file to server: {server_path}"))?; + files.push(ProjectDeployFile::new(relative_path, data)); } - Ok(()) + Ok(files) } diff --git a/lp-cli/src/commands/fwcheck/handler.rs b/lp-cli/src/commands/fwcheck/handler.rs index 5cf2bcf58..cfd3a93be 100644 --- a/lp-cli/src/commands/fwcheck/handler.rs +++ b/lp-cli/src/commands/fwcheck/handler.rs @@ -5,15 +5,15 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result, bail}; use fw_checks::{FW_CHECK_JSON_PREFIX, FwCheckConfig, FwCheckTarget, all_checks, find_check}; -use lpa_client::transport_serial::{ - HardwareSerialOptions, SerialLineObserver, create_hardware_serial_transport_pair_with_options, -}; -use lpa_client::{ClientTransport, LpClient}; +use lpa_client::transport_serial::SerialLineObserver; +use lpa_client::{ClientTransport, TokioLpClient}; +use lpa_link::providers::host_serial_esp32::HostSerialEsp32Options; use lpc_model::DEFAULT_SERIAL_BAUD_RATE; use lpfs::{LpFs, LpFsStd}; use tokio::time::sleep; -use crate::commands::dev::{push_project_async, validation}; +use crate::client::host_serial_esp32::connect_host_serial_esp32_with_options; +use crate::commands::dev::{deploy_project_async, validation}; use super::args::{FwcheckCli, FwcheckCommand, FwcheckDemoArgs, FwcheckRunArgs, FwcheckTargetArg}; use super::{port, process, report, trace_dir}; @@ -218,19 +218,16 @@ fn run_demo_capture( ) -> Result { let capture = Arc::new(SerialCapture::new(&trace.trace_txt)?); let observer: Arc = capture.clone(); - let options = HardwareSerialOptions { + let options = HostSerialEsp32Options { + baud_rate: Some(DEFAULT_SERIAL_BAUD_RATE), reset_after_open: true, line_observer: Some(observer), }; - let transport = create_hardware_serial_transport_pair_with_options( - port_name, - DEFAULT_SERIAL_BAUD_RATE, - options, - ) - .map_err(|e| anyhow::anyhow!("Failed to create serial transport: {e}"))?; + let transport = connect_host_serial_esp32_with_options(port_name, options) + .map_err(|e| anyhow::anyhow!("Failed to create serial transport: {e}"))?; let transport: Box = Box::new(transport); let shared_transport = Arc::new(tokio::sync::Mutex::new(transport)); - let client = LpClient::new_shared(Arc::clone(&shared_transport)); + let client = TokioLpClient::new_shared(Arc::clone(&shared_transport)); let local_fs: Arc = Arc::new(LpFsStd::new(project_dir.to_owned())); let runtime = tokio::runtime::Runtime::new()?; @@ -265,7 +262,7 @@ fn run_demo_capture( } async fn run_demo_capture_async( - client: &LpClient, + client: &TokioLpClient, capture: &Arc, local_fs: Arc, project_uid: &str, @@ -274,23 +271,13 @@ async fn run_demo_capture_async( ) -> Result { wait_for_boot_ready(capture).await?; - if let Err(e) = run_client_step(capture, "stop all projects", client.stop_all_projects()).await - { - eprintln!("Warning: Failed to stop all projects: {e}"); - eprintln!("Continuing with project push..."); - } - - run_client_step( + let handle = run_client_step( capture, - "push project", - push_project_async(client, local_fs.as_ref(), project_uid), + "deploy project", + deploy_project_async(client, local_fs.as_ref(), project_uid), ) - .await?; - - let project_path = format!("projects/{project_uid}"); - let handle = run_client_step(capture, "load project", client.project_load(&project_path)) - .await - .with_context(|| format!("load {project_path}"))?; + .await + .with_context(|| format!("deploy projects/{project_uid}"))?; let _ = handle; @@ -310,7 +297,7 @@ async fn run_demo_capture_async( let _ = transport.close().await; Ok(format!( - "status: ok\nproject: {project_path}\nloaded_projects: {loaded}\nsettled_for: {settle_secs}s\n", + "status: ok\nproject: projects/{project_uid}\nloaded_projects: {loaded}\nsettled_for: {settle_secs}s\n", )) } diff --git a/lp-cli/src/commands/profile/handler.rs b/lp-cli/src/commands/profile/handler.rs index 92c85f8fa..2a6661e62 100644 --- a/lp-cli/src/commands/profile/handler.rs +++ b/lp-cli/src/commands/profile/handler.rs @@ -11,7 +11,7 @@ use lp_riscv_emu::{ test_util::{BinaryBuildConfig, ensure_binary_built}, }; use lp_riscv_inst::Gpr; -use lpa_client::LpClient; +use lpa_client::TokioLpClient; use lpa_client::transport_emu_serial::SerialEmuClientTransport; use std::collections::HashSet; use std::path::Component; @@ -139,7 +139,7 @@ async fn handle_profile_async(args: ProfileArgs) -> Result<()> { let transport = SerialEmuClientTransport::new(emulator_arc.clone()) .with_backtrace(load_info.symbol_map.clone(), load_info.code_end); - let client = LpClient::new(Box::new(transport)); + let client = TokioLpClient::new(Box::new(transport)); let workload_result = workload::run_workload(&client, &emulator_arc, &dir, &project_uid, args.max_cycles).await; diff --git a/lp-cli/src/commands/profile/workload.rs b/lp-cli/src/commands/profile/workload.rs index a71275c4d..7fa7fe644 100644 --- a/lp-cli/src/commands/profile/workload.rs +++ b/lp-cli/src/commands/profile/workload.rs @@ -3,11 +3,12 @@ use anyhow::{Context, Result}; use lp_riscv_emu::{FrameOutcome, Riscv32Emulator, profile::HaltReason}; -use lpa_client::LpClient; -use lpc_model::AsLpPath; -use lpfs::{LpFs, LpFsStd}; +use lpa_client::TokioLpClient; +use lpfs::LpFsStd; use std::sync::{Arc, Mutex}; +use crate::commands::dev::deploy_project_async; + /// Wall-clock budget (in simulated ms) per outer iteration. Matches /// the previous m0 cadence. const FRAME_TICK_MS: u32 = 40; @@ -16,7 +17,7 @@ const FRAME_TICK_MS: u32 = 40; /// runaway guest from blocking the cycle-budget check. const MAX_STEPS_PER_FRAME: u64 = 5_000_000; -async fn try_stop_projects(client: &LpClient) { +async fn try_stop_projects(client: &TokioLpClient) { if let Err(e) = client.stop_all_projects().await { eprintln!("warning: failed to stop projects (continuing): {e:#}"); } @@ -34,25 +35,21 @@ pub enum WorkloadOutcome { /// Push project files, load the project, then drive frames until /// `outcome` is determined. Reports progress on stderr. pub async fn run_workload( - client: &LpClient, + client: &TokioLpClient, emulator_arc: &Arc>, dir: &std::path::Path, project_uid: &str, max_cycles: u64, ) -> Result { - eprintln!("Syncing project files..."); + eprintln!("Deploying project..."); let local_fs = LpFsStd::new(dir.to_path_buf()); - push_project_files(client, &local_fs, project_uid).await?; - - eprintln!("Loading project..."); - let project_path = format!("projects/{project_uid}"); - match client.project_load(&project_path).await { + match deploy_project_async(client, &local_fs, project_uid).await { Ok(_) => {} Err(e) if is_profile_stop_error(&e) => { - eprintln!("Profile gate stopped during project load."); + eprintln!("Profile gate stopped during project deploy."); return Ok(WorkloadOutcome::ProfileStopped); } - Err(e) => return Err(e).context("Failed to load project"), + Err(e) => return Err(e).context("Failed to deploy project"), } eprintln!("Driving frames (mode-gated; --max-cycles {max_cycles})..."); @@ -103,38 +100,3 @@ fn is_profile_stop_error(e: &anyhow::Error) -> bool { .contains("Emulator stopped by profile gate") }) } - -async fn push_project_files( - client: &LpClient, - local_fs: &dyn LpFs, - project_uid: &str, -) -> Result<()> { - let entries = local_fs - .list_dir("/".as_path(), true) - .map_err(|e| anyhow::anyhow!("Failed to list project files: {e:?}"))?; - - for entry in entries { - if entry.as_str().ends_with('/') { - continue; - } - if local_fs.is_dir(entry.as_path()).unwrap_or(false) { - continue; - } - let content = local_fs - .read_file(entry.as_path()) - .map_err(|e| anyhow::anyhow!("Failed to read {}: {e:?}", entry.as_str()))?; - - let relative = if entry.as_str().starts_with('/') { - &entry.as_str()[1..] - } else { - entry.as_str() - }; - - let full_path = format!("/projects/{project_uid}/{relative}"); - client - .fs_write(full_path.as_path(), content) - .await - .with_context(|| format!("Failed to write {full_path}"))?; - } - Ok(()) -} diff --git a/lp-cli/src/commands/upload/handler.rs b/lp-cli/src/commands/upload/handler.rs index e5462b943..180f253d7 100644 --- a/lp-cli/src/commands/upload/handler.rs +++ b/lp-cli/src/commands/upload/handler.rs @@ -7,7 +7,7 @@ use lpfs::{LpFs, LpFsStd}; use std::sync::Arc; use crate::client::{LpClient, client_connect}; -use crate::commands::dev::{push_project_async, validation}; +use crate::commands::dev::{deploy_project_async, validation}; use lpa_client::HostSpecifier; use super::args::UploadArgs; @@ -47,20 +47,9 @@ async fn handle_upload_async(args: UploadArgs) -> Result<()> { let local_fs: Arc = Arc::new(LpFsStd::new(dir)); - if let Err(e) = client.stop_all_projects().await { - eprintln!("Warning: Failed to stop all projects: {e}"); - eprintln!("Continuing with project push..."); - } - - push_project_async(&client, &*local_fs, &project_uid) - .await - .with_context(|| format!("Failed to push project to server (host: {host_spec_str})"))?; - - let project_path = format!("projects/{project_uid}"); - client - .project_load(&project_path) + deploy_project_async(&client, &*local_fs, &project_uid) .await - .with_context(|| format!("Failed to load project on server: {project_path}"))?; + .with_context(|| format!("Failed to deploy project to server (host: {host_spec_str})"))?; println!("Project uploaded and loaded successfully."); Ok(()) diff --git a/lp-core/lpc-shared/src/project/builder.rs b/lp-core/lpc-shared/src/project/builder.rs index 166acb949..6e3b38a0e 100644 --- a/lp-core/lpc-shared/src/project/builder.rs +++ b/lp-core/lpc-shared/src/project/builder.rs @@ -4,6 +4,7 @@ use alloc::{format, rc::Rc, string::String, vec, vec::Vec}; use core::cell::RefCell; use lp_collection::VecMap; use lpc_model::GlslOpts; +use lpc_model::nodes::clock::ClockDef; use lpc_model::nodes::fixture::{ColorOrder, FixtureDef, MappingConfig, PathSpec, RingOrder}; use lpc_model::nodes::output::{OutputDef, OutputDriverOptionsConfig}; use lpc_model::nodes::shader::{ShaderDef, ShaderSlotDef}; @@ -21,6 +22,7 @@ use lpfs::lp_path::LpPathBuf; pub struct ProjectBuilder { fs: Rc>, name: String, + clock_id: u32, texture_id: u32, shader_id: u32, output_id: u32, @@ -77,6 +79,7 @@ impl ProjectBuilder { Self { fs, name: String::from("Test Project"), + clock_id: 1, texture_id: 1, shader_id: 1, output_id: 1, @@ -150,6 +153,22 @@ impl ProjectBuilder { } } + /// Add a clock node with defaults. + pub fn clock_basic(&mut self) -> LpPathBuf { + let id = self.clock_id; + self.clock_id += 1; + + let node_name = numbered_node_name("clock", id); + let path = artifact_path_for_node(&node_name); + let toml = authored_node_toml(&slot_shape_registry(), &NodeDef::Clock(ClockDef::default())); + + self.write_file_helper(path.as_str(), toml.as_bytes()) + .expect("Failed to write clock artifact"); + self.register_node(node_name, path.clone()); + + path + } + /// Add a texture node with defaults (16x16) pub fn texture_basic(&mut self) -> LpPathBuf { self.texture().add(self) @@ -489,7 +508,6 @@ fn affine2d_from_matrix(matrix: [[f32; 4]; 4]) -> Affine2d { #[cfg(test)] mod tests { use super::*; - use lp_collection::VecMap; use lpc_model::NodeDef; use lpfs::LpFsMemory; diff --git a/lp-fw/README.md b/lp-fw/README.md index c5b2084f1..99bfadc6d 100644 --- a/lp-fw/README.md +++ b/lp-fw/README.md @@ -1,9 +1,87 @@ -# LightPlayer Firmware +# LightPlayer Firmware And Local Runtimes -This directory contains the firmware for LightPlayer, the bare-metal, no_std, `lp-server` -implementations that run on various microcontrollers. +This directory contains LightPlayer firmware and firmware-shaped runtime targets. +The core product path is still embedded GLSL JIT execution: shaders are compiled +and run on the target device at runtime. Host and browser runtimes exist to make +local development, Studio simulation, and non-embedded deployments practical; +they are not replacements for on-device shader compilation. -## Running on Device +## Crates + +| Crate | Target | Purpose | +|---|---|---| +| [`fw-esp32`](./fw-esp32/) | ESP32-C6 bare metal | Reference embedded firmware target. Runs `lp-server` on device. | +| [`fw-emu`](./fw-emu/) | RV32 bare-metal emulator | Firmware image used by emulator-oriented validation. | +| [`fw-host`](./fw-host/) | Host OS | Local host runtime that can run an in-memory `LpServer` outside `lp-cli`. Useful for Studio, local services, and host deployments. | +| [`fw-browser`](./fw-browser/) | `wasm32-unknown-unknown` browser/Web Worker | Browser runtime proof for Studio project simulation and browser-local testing. | +| [`fw-core`](./fw-core/) | shared | Shared firmware support code. | +| [`fw-tests`](./fw-tests/) | host test harness | Firmware/emulator integration tests. | +| [`fw-checks`](./fw-checks/) | host checks | Firmware validation/check helper crate. | + +## Target Roles + +### Embedded Firmware + +`fw-esp32` and `fw-emu` preserve the embedded product path. They must keep the +GLSL compiler and runtime execution available on the target. Do not feature-gate +the compiler out of these targets to work around build, size, or `no_std` +issues. + +### Host Runtime + +`fw-host` is the host-OS LightPlayer runtime target. It owns reusable local +server lifecycle that should not live only in `lp-cli`. The Studio link layer can +use this target through `lpa-link` `host-process` support to create local runtime +instances and connect an `lpa-client` to them. + +Useful checks: + +```bash +cargo check -p fw-host +cargo test -p fw-host +cargo check -p lpa-link --features host-process +cargo test -p lpa-link --features host-process +``` + +### Browser Runtime + +`fw-browser` is the browser/Web Worker runtime target for Studio simulation and +project testing. It builds to wasm, initializes the browser `lpvm-wasm` runtime, +owns an in-memory `LpServer`/filesystem/virtual hardware runtime, accepts +`lpc_wire` client frames over a structured worker envelope, and can load/tick a +project without exposing direct shader APIs to JavaScript. + +Useful checks: + +```bash +cargo check -p fw-browser --target wasm32-unknown-unknown +cargo test -p fw-browser --target wasm32-unknown-unknown --no-run +just fw-browser-build +``` + +To manually run the browser smoke page: + +```bash +just fw-browser-smoke +``` + +Then open: + +```text +http://127.0.0.1:2819/smoke.html +``` + +Success means the page shows `ok` and +`document.documentElement.dataset.smoke == "ok"`. The current page writes a +small project through worker messages, loads it, ticks the runtime, and verifies +increasing output bytes through project-read `OutputChannels` resources. + +`just fw-browser-test` is the intended automated `wasm-bindgen-test` path, but it +requires a working browser/WebDriver environment. If it fails locally because no +headless browser is available, treat that as browser-runner provisioning rather +than proof that `fw-browser` failed to compile. + +## Running On Device ### ESP32-C6 @@ -14,16 +92,53 @@ just demo-esp32 ``` This will: -1. Ensure the RISC-V 32-bit target is installed -2. Build and flash the firmware to the connected ESP32-C6 device -3. Run the firmware on the device + +1. Ensure the RISC-V 32-bit target is installed. +2. Build and flash the firmware to the connected ESP32-C6 device. +3. Run the firmware on the device. The command is equivalent to: + +```bash +cd lp-fw/fw-esp32 +cargo run --target riscv32imac-unknown-none-elf --release --features esp32c6 +``` + +Requirements: + +- ESP32-C6 device connected via USB. +- `cargo-espflash` or `espflash` installed. +- RISC-V 32-bit target installed, usually handled by the just recipe. + +For linked ESP32 builds, size measurements, and bloat analysis, run from +`lp-fw/fw-esp32/` or through a just recipe that changes into that directory so +the crate-local linker configuration is active. + +### Studio Firmware Package + +Studio browser flashing consumes prebuilt ESP32-C6 firmware assets rather than +building from an ELF in the browser. Generate the current browser-flashable +package with: + ```bash -cd lp-fw/fw-esp32 && cargo run --target riscv32imac-unknown-none-elf --release --features esp32c6 +just studio-firmware-package-esp32c6 ``` -**Requirements:** -- ESP32-C6 device connected via USB -- `cargo-espflash` or `espflash` installed (usually installed automatically by cargo-espflash) -- RISC-V 32-bit target installed (handled automatically by the just command) \ No newline at end of file +The recipe builds `fw-esp32` with `esp32c6,server` under the `release-esp32` +profile, then runs `espflash save-image --merge --skip-padding` to emit a merged +binary image and manifest under: + +```text +lp-app/lp-studio-web/public/firmware/esp32c6/ +``` + +The package is generated output and is gitignored. `just studio-web-build` +depends on this package so release/static Studio builds have the firmware assets +available for the browser provisioning flow. + +## Workspace Notes + +This workspace mixes host crates, browser wasm crates, and RV32 bare-metal +firmware crates. Do not use `cargo build --workspace` or +`cargo test --workspace` on the host target. Prefer targeted checks or the +repo-level just recipes documented in the root `AGENTS.md`. diff --git a/lp-fw/esp-println-fork/.cargo/config.toml b/lp-fw/esp-println-fork/.cargo/config.toml new file mode 100644 index 000000000..18da0116b --- /dev/null +++ b/lp-fw/esp-println-fork/.cargo/config.toml @@ -0,0 +1,2 @@ +[unstable] +build-std = [ "core" ] diff --git a/lp-fw/fw-browser/.gitignore b/lp-fw/fw-browser/.gitignore new file mode 100644 index 000000000..94fe832e6 --- /dev/null +++ b/lp-fw/fw-browser/.gitignore @@ -0,0 +1,2 @@ +/pkg/ +/www/pkg/ diff --git a/lp-fw/fw-browser/Cargo.toml b/lp-fw/fw-browser/Cargo.toml new file mode 100644 index 000000000..946e18104 --- /dev/null +++ b/lp-fw/fw-browser/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "fw-browser" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "Browser/Web Worker LightPlayer firmware runtime" + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +fw-core = { path = "../fw-core", features = ["std"] } +lpa-server = { path = "../../lp-app/lpa-server" } +lpc-hardware = { path = "../../lp-core/lpc-hardware" } +lpc-model = { path = "../../lp-core/lpc-model" } +lpc-shared = { path = "../../lp-core/lpc-shared" } +lpc-wire = { path = "../../lp-core/lpc-wire" } +lpfs = { path = "../../lp-base/lpfs", features = ["std"] } +lpvm-wasm = { path = "../../lp-shader/lpvm-wasm" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +wasm-bindgen = "0.2" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/lp-fw/fw-browser/README.md b/lp-fw/fw-browser/README.md new file mode 100644 index 000000000..de01225c4 --- /dev/null +++ b/lp-fw/fw-browser/README.md @@ -0,0 +1,74 @@ +# fw-browser + +`fw-browser` is the browser/Web Worker LightPlayer runtime target. + +It exists for Studio simulation and browser-local project testing. It is not the +embedded product path and it is not a replacement for ESP32 runtime shader +compilation. The browser runtime still uses the real shader frontend and +`lpvm-wasm` browser backend to compile and execute shaders in the browser, but +shader work happens behind `LpServer` and project loading rather than through +direct public shader calls. + +## Relationship To Other Crates + +- `lpa-server` owns projects, filesystem protocol handling, and render ticks. +- `fw-core` provides shared runtime drain/tick helpers. +- `lpvm-wasm` is used by `lpc-engine`'s wasm32 graphics backend to execute + shaders through browser `WebAssembly` APIs. +- `lpa-link` `browser-worker` models browser runtime instances and scoped + logs/status for Studio. +- Future Studio UI code should consume this through a browser-local link/session + boundary rather than reaching directly into shader runtime details. + +## Worker Boundary + +The wasm-bindgen exports are intentionally small and firmware-shaped: + +- initialize browser builtin exports +- create a named runtime instance +- send structured envelope JSON to the runtime +- tick the runtime deterministically +- drain structured output envelope JSON +- read runtime count + +`lpa-link` owns the Studio browser-worker wrapper under its +`browser_worker` provider. The `fw-browser/www/fw-browser-worker.js` file is a +standalone smoke-page wrapper for this crate's local browser smoke test. + +Input envelopes currently include `protocol_in`, `tick`, `start`, `stop`, and +`drain`. `protocol_in` carries a whole `lpc_wire` client JSON frame. Output +envelopes currently include `status`, `log`, and `protocol_out`. `protocol_out` +carries a whole `lpc_wire` server JSON frame. + +Automated smoke coverage should load/tick projects through this boundary and +inspect canonical project-read `OutputChannels` resources rather than reaching +directly into shader or output-provider internals. + +## Validation + +```bash +cargo check -p fw-browser --target wasm32-unknown-unknown +cargo test -p fw-browser --target wasm32-unknown-unknown --no-run +just fw-browser-build +``` + +To manually run the browser smoke page: + +```bash +just fw-browser-smoke +``` + +Then open: + +```text +http://127.0.0.1:2819/smoke.html +``` + +Success means the page reports `ok`, sets +`document.documentElement.dataset.smoke == "ok"`, writes a small project through +worker protocol messages, loads it, ticks the worker-owned firmware runtime, and +observes increasing `OutputChannels` bytes through project-read resources. + +`just fw-browser-test` runs the Rust-native `wasm-bindgen-test` path. It requires +a working browser/WebDriver environment, so local failures caused by missing or +broken browser automation should be treated as runner provisioning issues. diff --git a/lp-fw/fw-browser/src/envelope.rs b/lp-fw/fw-browser/src/envelope.rs new file mode 100644 index 000000000..6349693de --- /dev/null +++ b/lp-fw/fw-browser/src/envelope.rs @@ -0,0 +1,45 @@ +//! Structured worker envelope types. +//! +//! The worker envelope is intentionally separate from `lpc_wire`: it carries +//! protocol frames, logs, and lifecycle/status messages over browser +//! `postMessage` without pretending the browser worker is a serial port. + +use serde::{Deserialize, Serialize}; + +/// Message sent from JavaScript into one browser firmware runtime. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum BrowserInputEnvelope { + /// Queue one complete `lpc_wire::ClientMessage` JSON frame. + ProtocolIn { frame: String }, + /// Advance the runtime by a deterministic amount of time. + Tick { delta_ms: Option }, + /// Mark the runtime as running for future autorun support. + Start, + /// Mark the runtime as stopped for future autorun support. + Stop, + /// Return queued output envelopes without ticking. + Drain, +} + +/// Message emitted by one browser firmware runtime. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum BrowserOutputEnvelope { + /// Runtime lifecycle or health status. + Status { + runtime_id: u32, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + /// Firmware log line surfaced outside the worker. + Log { + runtime_id: u32, + level: String, + target: String, + message: String, + }, + /// One complete `lpc_wire::WireServerMessage` JSON frame. + ProtocolOut { frame: String }, +} diff --git a/lp-fw/fw-browser/src/executor.rs b/lp-fw/fw-browser/src/executor.rs new file mode 100644 index 000000000..bba663403 --- /dev/null +++ b/lp-fw/fw-browser/src/executor.rs @@ -0,0 +1,25 @@ +//! Tiny executor for browser runtime futures. +//! +//! The server/transport traits are async, but the wasm export boundary is +//! synchronous today. These futures complete immediately in this runtime, so a +//! no-op waker is enough until the browser target needs genuinely async IO. + +/// Run an immediately-ready firmware future to completion. +pub(crate) fn block_on(future: F) -> F::Output { + use core::pin::pin; + use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + let waker = unsafe { + static VTABLE: RawWakerVTable = + RawWakerVTable::new(|data| RawWaker::new(data, &VTABLE), |_| {}, |_| {}, |_| {}); + Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) + }; + let mut cx = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => return output, + Poll::Pending => {} + } + } +} diff --git a/lp-fw/fw-browser/src/lib.rs b/lp-fw/fw-browser/src/lib.rs new file mode 100644 index 000000000..02263c212 --- /dev/null +++ b/lp-fw/fw-browser/src/lib.rs @@ -0,0 +1,23 @@ +//! Browser/Web Worker LightPlayer firmware runtime. +//! +//! JavaScript owns worker creation and `postMessage`; this crate owns the +//! firmware-shaped runtime behind that boundary: `LpServer`, filesystem, +//! virtual hardware/output, tick state, logs, and protocol message routing. + +#![cfg(target_arch = "wasm32")] + +mod envelope; +mod executor; +mod manual_time_provider; +mod runtime; +mod runtime_registry; +mod server_transport; +mod wasm_exports; + +pub use wasm_exports::{ + create_runtime, drain_output_json, fw_browser_init_exports, handle_envelope_json, + runtime_count, tick_runtime, +}; + +#[cfg(test)] +mod tests; diff --git a/lp-fw/fw-browser/src/manual_time_provider.rs b/lp-fw/fw-browser/src/manual_time_provider.rs new file mode 100644 index 000000000..615792487 --- /dev/null +++ b/lp-fw/fw-browser/src/manual_time_provider.rs @@ -0,0 +1,36 @@ +//! Manual browser time source. +//! +//! Browser firmware tests and Studio previews need deterministic ticks, so time +//! advances only when the embedding code sends a `tick` envelope. + +use std::cell::RefCell; +use std::rc::Rc; + +use lpc_shared::time::TimeProvider; + +/// Shared deterministic millisecond clock for one browser firmware runtime. +#[derive(Clone)] +pub(crate) struct ManualTimeProvider { + now_ms: Rc>, +} + +impl ManualTimeProvider { + /// Create a manual clock starting at zero. + pub(crate) fn new() -> Self { + Self { + now_ms: Rc::new(RefCell::new(0)), + } + } + + /// Move the clock forward by the requested tick amount. + pub(crate) fn advance(&self, delta_ms: u32) { + let mut now = self.now_ms.borrow_mut(); + *now = now.saturating_add(u64::from(delta_ms)); + } +} + +impl TimeProvider for ManualTimeProvider { + fn now_ms(&self) -> u64 { + *self.now_ms.borrow() + } +} diff --git a/lp-fw/fw-browser/src/runtime.rs b/lp-fw/fw-browser/src/runtime.rs new file mode 100644 index 000000000..b6cea152f --- /dev/null +++ b/lp-fw/fw-browser/src/runtime.rs @@ -0,0 +1,171 @@ +//! Browser-owned firmware runtime. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use fw_core::{drain_client_messages, tick_server_frame}; +use lpa_server::{ButtonService, Graphics, LpGraphics, LpServer, RadioService}; +use lpc_hardware::{HardwareSystem, HwRegistry, default_esp32c6_hardware_manifest}; +use lpc_model::AsLpPath; +use lpc_shared::output::MemoryOutputProvider; +use lpc_shared::time::TimeProvider; +use lpc_wire::{ClientMessage, json}; +use lpfs::LpFsMemory; + +use crate::envelope::{BrowserInputEnvelope, BrowserOutputEnvelope}; +use crate::executor::block_on; +use crate::manual_time_provider::ManualTimeProvider; +use crate::server_transport::BrowserServerTransport; + +/// One in-browser LightPlayer firmware instance. +/// +/// The runtime owns the same major pieces as local firmware: server, filesystem, +/// virtual hardware services, output provider, protocol transport, and clock. +pub(crate) struct BrowserFirmwareRuntime { + id: u32, + label: String, + server: LpServer, + transport: BrowserServerTransport, + time: ManualTimeProvider, + last_tick_ms: u64, + running: bool, + outbox: Vec, +} + +impl BrowserFirmwareRuntime { + /// Build a memory-backed browser firmware runtime. + pub(crate) fn new(id: u32, label: &str) -> Result { + let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new_permissive())); + let hardware = Rc::new(HardwareSystem::with_virtual_drivers(Rc::new( + HwRegistry::new(default_esp32c6_hardware_manifest()), + ))); + let button_service: Rc = hardware.clone(); + let radio_service: Rc = hardware; + let graphics: Arc = Arc::new(Graphics::new()); + let time = ManualTimeProvider::new(); + let time_provider: Rc = Rc::new(time.clone()); + let server = LpServer::new_with_hardware_services( + output_provider, + Box::new(LpFsMemory::new()), + "/projects/".as_path(), + None, + Some(time_provider), + Some(button_service), + Some(radio_service), + graphics, + ); + + let mut runtime = Self { + id, + label: label.to_string(), + server, + transport: BrowserServerTransport::new(), + time, + last_tick_ms: 0, + running: false, + outbox: Vec::new(), + }; + runtime.status("booting", Some("browser firmware runtime created")); + runtime.log("info", "fw-browser runtime booted"); + runtime.status("ready", None); + Ok(runtime) + } + + /// Numeric handle used by the wasm runtime registry. + pub(crate) fn id(&self) -> u32 { + self.id + } + + /// Apply one browser input envelope to the runtime. + pub(crate) fn handle_envelope(&mut self, envelope: BrowserInputEnvelope) -> Result<(), String> { + match envelope { + BrowserInputEnvelope::ProtocolIn { frame } => { + let msg: ClientMessage = json::from_str(&frame) + .map_err(|error| format!("parse protocol_in frame: {error}"))?; + self.transport.push_incoming(msg); + self.log("debug", "queued protocol_in frame"); + Ok(()) + } + BrowserInputEnvelope::Tick { delta_ms } => self.tick(delta_ms.unwrap_or(16).max(1)), + BrowserInputEnvelope::Start => { + self.running = true; + self.status("running", None); + Ok(()) + } + BrowserInputEnvelope::Stop => { + self.running = false; + self.status("stopped", None); + Ok(()) + } + BrowserInputEnvelope::Drain => Ok(()), + } + } + + /// Advance server time, process queued protocol messages, and tick projects. + pub(crate) fn tick(&mut self, delta_ms: u32) -> Result<(), String> { + self.time.advance(delta_ms); + let frame_start_ms = self.time.now_ms(); + let drained = block_on(drain_client_messages(&mut self.transport)); + if let Some(error) = &drained.error { + self.log("warn", &format!("transport receive error: {error}")); + } + let incoming_count = drained.message_count(); + let tick = block_on(tick_server_frame( + &mut self.server, + &mut self.transport, + &self.time, + frame_start_ms, + self.last_tick_ms, + drained.messages, + )); + self.last_tick_ms = frame_start_ms; + if let Some(error) = tick.server_error { + self.status("error", Some(&format!("server tick error: {error}"))); + } + + self.log( + "trace", + &format!( + "tick delta={}ms incoming={} responses={} frame={}us", + tick.delta_ms, incoming_count, tick.response_count, tick.frame_time_us + ), + ); + self.flush_protocol_out()?; + Ok(()) + } + + /// Serialize and clear all queued runtime output envelopes. + pub(crate) fn drain_output_json(&mut self) -> Result { + self.flush_protocol_out()?; + let messages = core::mem::take(&mut self.outbox); + serde_json::to_string(&messages).map_err(|error| format!("serialize envelopes: {error}")) + } + + fn flush_protocol_out(&mut self) -> Result<(), String> { + for msg in self.transport.take_outgoing() { + let frame = json::to_string(&msg) + .map_err(|error| format!("serialize protocol_out frame: {error}"))?; + self.outbox + .push(BrowserOutputEnvelope::ProtocolOut { frame }); + } + Ok(()) + } + + fn status(&mut self, status: &str, message: Option<&str>) { + self.outbox.push(BrowserOutputEnvelope::Status { + runtime_id: self.id, + status: status.to_string(), + message: message.map(str::to_string), + }); + } + + fn log(&mut self, level: &str, message: &str) { + self.outbox.push(BrowserOutputEnvelope::Log { + runtime_id: self.id, + level: level.to_string(), + target: "fw-browser".to_string(), + message: format!("{}: {message}", self.label), + }); + } +} diff --git a/lp-fw/fw-browser/src/runtime_registry.rs b/lp-fw/fw-browser/src/runtime_registry.rs new file mode 100644 index 000000000..a91020219 --- /dev/null +++ b/lp-fw/fw-browser/src/runtime_registry.rs @@ -0,0 +1,42 @@ +//! Registry for browser firmware runtimes. +//! +//! The wasm boundary uses numeric runtime ids so one page can create multiple +//! browser firmware instances without exposing Rust references to JavaScript. + +use std::cell::RefCell; + +use crate::runtime::BrowserFirmwareRuntime; + +thread_local! { + static RUNTIMES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Create a runtime and return its stable id for later wasm calls. +pub(crate) fn create_runtime(label: &str) -> Result { + RUNTIMES.with(|runtimes| { + let mut runtimes = runtimes.borrow_mut(); + let id = runtimes.len() as u32 + 1; + runtimes.push(BrowserFirmwareRuntime::new(id, label)?); + Ok(id) + }) +} + +/// Return the number of runtimes currently held by this wasm instance. +pub(crate) fn runtime_count() -> u32 { + RUNTIMES.with(|runtimes| runtimes.borrow().len() as u32) +} + +/// Borrow a runtime by id for one wasm export call. +pub(crate) fn with_runtime_mut( + runtime_id: u32, + f: impl FnOnce(&mut BrowserFirmwareRuntime) -> Result, +) -> Result { + RUNTIMES.with(|runtimes| { + let mut runtimes = runtimes.borrow_mut(); + let runtime = runtimes + .iter_mut() + .find(|runtime| runtime.id() == runtime_id) + .ok_or_else(|| format!("runtime {runtime_id} not found"))?; + f(runtime) + }) +} diff --git a/lp-fw/fw-browser/src/server_transport.rs b/lp-fw/fw-browser/src/server_transport.rs new file mode 100644 index 000000000..d61f4a406 --- /dev/null +++ b/lp-fw/fw-browser/src/server_transport.rs @@ -0,0 +1,65 @@ +//! In-memory server transport for browser worker protocol frames. + +use lpc_shared::transport::ServerTransport; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage}; + +/// Queue-backed transport between `BrowserFirmwareRuntime` and `LpServer`. +pub(crate) struct BrowserServerTransport { + incoming: Vec, + outgoing: Vec, + closed: bool, +} + +impl BrowserServerTransport { + /// Create an empty in-memory server transport. + pub(crate) fn new() -> Self { + Self { + incoming: Vec::new(), + outgoing: Vec::new(), + closed: false, + } + } + + /// Queue a client protocol message for the next runtime tick. + pub(crate) fn push_incoming(&mut self, msg: ClientMessage) { + self.incoming.push(msg); + } + + /// Drain server protocol messages emitted during recent ticks. + pub(crate) fn take_outgoing(&mut self) -> Vec { + core::mem::take(&mut self.outgoing) + } +} + +impl ServerTransport for BrowserServerTransport { + async fn send(&mut self, msg: WireServerMessage) -> Result<(), TransportError> { + if self.closed { + return Err(TransportError::ConnectionLost); + } + self.outgoing.push(msg); + Ok(()) + } + + async fn receive(&mut self) -> Result, TransportError> { + if self.closed { + return Err(TransportError::ConnectionLost); + } + Ok(if self.incoming.is_empty() { + None + } else { + Some(self.incoming.remove(0)) + }) + } + + async fn receive_all(&mut self) -> Result, TransportError> { + if self.closed { + return Err(TransportError::ConnectionLost); + } + Ok(core::mem::take(&mut self.incoming)) + } + + async fn close(&mut self) -> Result<(), TransportError> { + self.closed = true; + Ok(()) + } +} diff --git a/lp-fw/fw-browser/src/tests.rs b/lp-fw/fw-browser/src/tests.rs new file mode 100644 index 000000000..dee4ec28b --- /dev/null +++ b/lp-fw/fw-browser/src/tests.rs @@ -0,0 +1,302 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use lpc_model::{AsLpPath, AsLpPathBuf, NodeId}; +use lpc_shared::ProjectBuilder; +use lpc_wire::{ + ClientRequest, FsRequest, NodeReadQuery, ProjectReadQuery, ProjectReadRequest, + ProjectReadResult, ReadLevel, ResourcePayloadRead, ResourceReadQuery, RuntimeReadQuery, + WireChannelSampleFormat, WireRuntimeBufferMetadataPayload, WireServerMessage, + WireServerMsgBody, WireTreeDelta, json, messages::ClientMessage, +}; +use lpfs::{LpFs, LpFsMemory}; +use serde::Serialize; +use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + +use crate::envelope::BrowserOutputEnvelope; +use crate::{create_runtime, fw_browser_init_exports, handle_envelope_json, tick_runtime}; + +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +fn runtime_serves_protocol_messages_after_tick() { + fw_browser_init_exports(wasm_bindgen::exports()); + + let runtime_id = create_runtime("wasm-bindgen-test").expect("create runtime"); + let client = ClientMessage { + id: 7, + msg: ClientRequest::ListAvailableProjects, + }; + let frame = json::to_string(&client).expect("client frame"); + let input = serde_json::to_string(&BrowserInputEnvelopeForTest::ProtocolIn { frame }) + .expect("input envelope"); + + let initial = handle_envelope_json(runtime_id, &input).expect("handle protocol_in"); + assert!(initial.contains("queued protocol_in frame")); + + let output = tick_runtime(runtime_id, 16).expect("tick runtime"); + assert!(output.contains("protocol_out")); + assert!(output.contains("listAvailableProjects")); +} + +#[wasm_bindgen_test] +fn runtime_loads_project_and_renders_output_after_ticks() { + fw_browser_init_exports(wasm_bindgen::exports()); + + let runtime_id = create_runtime("project-render-test").expect("create runtime"); + let project_fs = build_smoke_project(); + let mut next_id = 1; + + for (path, content) in collect_project_files(&project_fs.borrow()) { + let full_path = format!("/projects/smoke/{path}").as_path_buf(); + let response = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::Filesystem(FsRequest::Write { + path: full_path, + data: content, + }), + 1, + ) + .into_iter() + .next() + .expect("fs write response"); + + match response.msg { + WireServerMsgBody::Filesystem(lpc_wire::FsResponse::Write { error, .. }) => { + assert_eq!(error, None); + } + other => panic!("unexpected fs write response: {other:?}"), + } + } + + let load_response = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::LoadProject { + path: "smoke".to_string(), + }, + 16, + ) + .into_iter() + .next() + .expect("load project response"); + + let project_handle = match load_response.msg { + WireServerMsgBody::LoadProject { handle } => handle, + other => panic!("unexpected load response: {other:?}"), + }; + + let nodes_response = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::ProjectRequest { + handle: project_handle, + request: ProjectReadRequest { + since: None, + queries: vec![ProjectReadQuery::Nodes(NodeReadQuery { + level: ReadLevel::Detail, + nodes: Default::default(), + include_slots: false, + })], + probes: Vec::new(), + }, + }, + 16, + ) + .into_iter() + .next() + .expect("project nodes response"); + + let output_id = output_node_id(nodes_response); + + let mut red_values = Vec::new(); + for _ in 0..3 { + let response = send_protocol_request( + runtime_id, + next_request_id(&mut next_id), + ClientRequest::ProjectRequest { + handle: project_handle, + request: ProjectReadRequest { + since: None, + queries: vec![ + ProjectReadQuery::Runtime(RuntimeReadQuery), + ProjectReadQuery::Resources(ResourceReadQuery { + level: ReadLevel::Detail, + payloads: ResourcePayloadRead::All, + }), + ], + probes: Vec::new(), + }, + }, + 40, + ) + .into_iter() + .next() + .expect("project resource response"); + + let sample = read_output_sample(response, output_id); + assert!(sample.runtime_frame_num > 0); + assert_eq!(sample.green, 0); + assert_eq!(sample.blue, 0); + assert!(sample.red > 0); + red_values.push(sample.red); + } + + assert!( + red_values.windows(2).all(|pair| pair[1] > pair[0]), + "output red channel should increase across ticks: {red_values:?}" + ); +} + +fn next_request_id(next_id: &mut u64) -> u64 { + let id = *next_id; + *next_id += 1; + id +} + +fn send_protocol_request( + runtime_id: u32, + id: u64, + msg: ClientRequest, + delta_ms: u32, +) -> Vec { + let client = ClientMessage { id, msg }; + let frame = json::to_string(&client).expect("client frame"); + let input = serde_json::to_string(&BrowserInputEnvelopeForTest::ProtocolIn { frame }) + .expect("input envelope"); + + handle_envelope_json(runtime_id, &input).expect("handle protocol_in"); + collect_protocol_out(&tick_runtime(runtime_id, delta_ms).expect("tick runtime")) +} + +fn collect_protocol_out(envelopes_json: &str) -> Vec { + let envelopes: Vec = + serde_json::from_str(envelopes_json).expect("output envelopes"); + envelopes + .into_iter() + .filter_map(|envelope| match envelope { + BrowserOutputEnvelope::ProtocolOut { frame } => { + Some(json::from_str(&frame).expect("server frame")) + } + _ => None, + }) + .collect() +} + +fn build_smoke_project() -> Rc> { + let fs = Rc::new(RefCell::new(LpFsMemory::new())); + let mut builder = ProjectBuilder::new(fs.clone()); + builder.clock_basic(); + let texture_path = builder.texture().width(2).height(2).add(&mut builder); + builder.shader_basic(&texture_path); + let output_path = builder.output_basic(); + builder.fixture_basic(&output_path, &texture_path); + builder.build(); + fs +} + +fn collect_project_files(fs: &LpFsMemory) -> Vec<(String, Vec)> { + let entries = fs + .list_dir("/".as_path(), true) + .expect("project files list"); + + let mut files = Vec::new(); + for entry in entries { + if entry.as_str().ends_with('/') || fs.is_dir(entry.as_path()).unwrap_or(false) { + continue; + } + + let content = fs.read_file(entry.as_path()).expect("project file read"); + let relative_path = entry.as_str().trim_start_matches('/').to_string(); + files.push((relative_path, content)); + } + files +} + +fn output_node_id(response: WireServerMessage) -> NodeId { + let WireServerMsgBody::ProjectRequest { response } = response.msg else { + panic!("unexpected project-read response"); + }; + let ProjectReadResult::Nodes(nodes) = response + .results + .first() + .expect("node result should be present") + else { + panic!("first project-read result should be nodes"); + }; + + let mut available_paths = Vec::new(); + for delta in &nodes.tree_deltas { + if let WireTreeDelta::Created { id, path, .. } = delta { + let path = path.to_string(); + available_paths.push(path.clone()); + if path.ends_with("/output.output") { + return *id; + } + } + } + + panic!("output node not found; available paths: {available_paths:?}"); +} + +fn read_output_sample(response: WireServerMessage, output_id: NodeId) -> OutputSample { + let WireServerMsgBody::ProjectRequest { response } = response.msg else { + panic!("unexpected project-read response"); + }; + + let runtime_frame_num = match response.results.first() { + Some(ProjectReadResult::Runtime(runtime)) => runtime.project.frame_num, + other => panic!("first project-read result should be runtime: {other:?}"), + }; + let ProjectReadResult::Resources(resources) = response + .results + .get(1) + .expect("resource result should be present") + else { + panic!("second project-read result should be resources"); + }; + + let payload = resources + .runtime_buffer_payloads + .iter() + .find(|payload| { + resources.summaries.iter().any(|summary| { + summary.resource_ref == payload.resource_ref && summary.owner == Some(output_id) + }) && matches!( + payload.metadata, + WireRuntimeBufferMetadataPayload::OutputChannels { + sample_format: WireChannelSampleFormat::U16, + .. + } + ) + }) + .unwrap_or_else(|| { + panic!( + "output payload not found; summaries: {:?}; payloads: {:?}", + resources.summaries, resources.runtime_buffer_payloads + ) + }); + + assert!(payload.bytes.len() >= 6); + OutputSample { + red: u16::from_le_bytes([payload.bytes[0], payload.bytes[1]]), + green: u16::from_le_bytes([payload.bytes[2], payload.bytes[3]]), + blue: u16::from_le_bytes([payload.bytes[4], payload.bytes[5]]), + runtime_frame_num, + } +} + +#[derive(Debug)] +struct OutputSample { + red: u16, + green: u16, + blue: u16, + runtime_frame_num: u64, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum BrowserInputEnvelopeForTest { + ProtocolIn { frame: String }, +} diff --git a/lp-fw/fw-browser/src/wasm_exports.rs b/lp-fw/fw-browser/src/wasm_exports.rs new file mode 100644 index 000000000..994ed1b15 --- /dev/null +++ b/lp-fw/fw-browser/src/wasm_exports.rs @@ -0,0 +1,54 @@ +//! wasm-bindgen exports used by `fw-browser-worker.js`. + +use lpvm_wasm::rt_browser::init_host_exports; +use wasm_bindgen::prelude::*; + +use crate::envelope::BrowserInputEnvelope; +use crate::runtime_registry; + +/// Initialize LPVM browser host exports. +/// +/// Call this once after wasm-bindgen initialization, passing the embedding +/// module's `wasm_bindgen::exports()`. +#[wasm_bindgen] +pub fn fw_browser_init_exports(exports: JsValue) { + init_host_exports(exports); +} + +/// Create a browser-local firmware runtime and return its runtime id. +#[wasm_bindgen] +pub fn create_runtime(label: &str) -> Result { + runtime_registry::create_runtime(label) +} + +/// Number of live browser firmware runtimes. +#[wasm_bindgen] +pub fn runtime_count() -> u32 { + runtime_registry::runtime_count() +} + +/// Handle one input envelope encoded as JSON and return output envelopes JSON. +#[wasm_bindgen] +pub fn handle_envelope_json(runtime_id: u32, envelope_json: &str) -> Result { + let envelope: BrowserInputEnvelope = + serde_json::from_str(envelope_json).map_err(|error| format!("parse envelope: {error}"))?; + runtime_registry::with_runtime_mut(runtime_id, |runtime| { + runtime.handle_envelope(envelope)?; + runtime.drain_output_json() + }) +} + +/// Tick a runtime by `delta_ms` and return output envelopes JSON. +#[wasm_bindgen] +pub fn tick_runtime(runtime_id: u32, delta_ms: u32) -> Result { + runtime_registry::with_runtime_mut(runtime_id, |runtime| { + runtime.tick(delta_ms.max(1))?; + runtime.drain_output_json() + }) +} + +/// Drain pending output envelopes without ticking. +#[wasm_bindgen] +pub fn drain_output_json(runtime_id: u32) -> Result { + runtime_registry::with_runtime_mut(runtime_id, |runtime| runtime.drain_output_json()) +} diff --git a/lp-fw/fw-browser/www/fw-browser-worker.js b/lp-fw/fw-browser/www/fw-browser-worker.js new file mode 100644 index 000000000..e536d945a --- /dev/null +++ b/lp-fw/fw-browser/www/fw-browser-worker.js @@ -0,0 +1,69 @@ +import init, { + create_runtime, + drain_output_json, + fw_browser_init_exports, + handle_envelope_json, + tick_runtime, +} from './pkg/fw_browser.js'; + +let runtimeId = null; +let booted = false; + +self.onmessage = async (event) => { + try { + const message = event.data || {}; + switch (message.kind) { + case 'boot': + await boot(message.label || 'browser-worker'); + break; + case 'protocol_in': + requireBooted(); + postMany(handle_envelope_json(runtimeId, JSON.stringify(message))); + break; + case 'tick': + requireBooted(); + postMany(tick_runtime(runtimeId, message.delta_ms || 16)); + break; + case 'drain': + requireBooted(); + postMany(drain_output_json(runtimeId)); + break; + case 'start': + case 'stop': + requireBooted(); + postMany(handle_envelope_json(runtimeId, JSON.stringify(message))); + break; + default: + throw new Error(`unknown worker message kind: ${message.kind}`); + } + } catch (error) { + self.postMessage({ + kind: 'status', + status: 'error', + message: String(error?.stack || error), + }); + } +}; + +async function boot(label) { + if (!booted) { + self.postMessage({ kind: 'status', status: 'booting' }); + const exports = await init(); + fw_browser_init_exports(exports); + runtimeId = create_runtime(label); + booted = true; + postMany(drain_output_json(runtimeId)); + } +} + +function requireBooted() { + if (!booted || runtimeId == null) { + throw new Error('worker runtime has not booted'); + } +} + +function postMany(envelopesJson) { + for (const envelope of JSON.parse(envelopesJson)) { + self.postMessage(envelope); + } +} diff --git a/lp-fw/fw-browser/www/smoke-project/clock.toml b/lp-fw/fw-browser/www/smoke-project/clock.toml new file mode 100644 index 000000000..3e4ef317c --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/clock.toml @@ -0,0 +1 @@ +kind = "Clock" diff --git a/lp-fw/fw-browser/www/smoke-project/fixture.toml b/lp-fw/fw-browser/www/smoke-project/fixture.toml new file mode 100644 index 000000000..6026a4e9e --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/fixture.toml @@ -0,0 +1,40 @@ +kind = "Fixture" +color_order = "rgb" +brightness = 255 +gamma_correction = false +sampling = "direct" +transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + +[bindings.input] +source = "bus#visual.out" + +[bindings.output] +target = "bus#control.out" + +[render_size] +width = 10 +height = 10 + +[mapping] +kind = "PathPoints" +sample_diameter = 2.0 + +[mapping.paths.0] +kind = "RingArray" +center = [0.5, 0.5] +diameter = 1.0 +start_ring_inclusive = 0 +end_ring_exclusive = 9 +offset_angle = 0.0 +order = "inner_first" + +[mapping.paths.0.ring_lamp_counts] +0 = 1 +1 = 8 +2 = 12 +3 = 16 +4 = 24 +5 = 32 +6 = 40 +7 = 48 +8 = 60 diff --git a/lp-fw/fw-browser/www/smoke-project/output.toml b/lp-fw/fw-browser/www/smoke-project/output.toml new file mode 100644 index 000000000..533d08df6 --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/output.toml @@ -0,0 +1,12 @@ +kind = "Output" +endpoint = "ws281x:rmt:D10" + +[bindings.input] +source = "bus#control.out" + +[options] +white_point = [1.0, 1.0, 1.0] +brightness = 1.0 +interpolation_enabled = false +dithering_enabled = false +lut_enabled = false diff --git a/lp-fw/fw-browser/www/smoke-project/project.toml b/lp-fw/fw-browser/www/smoke-project/project.toml new file mode 100644 index 000000000..91c1c6d07 --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/project.toml @@ -0,0 +1,14 @@ +kind = "Project" +name = "fw-browser smoke" + +[nodes.output] +ref = "./output.toml" + +[nodes.clock] +ref = "./clock.toml" + +[nodes.shader] +ref = "./shader.toml" + +[nodes.fixture] +ref = "./fixture.toml" diff --git a/lp-fw/fw-browser/www/smoke-project/shader.glsl b/lp-fw/fw-browser/www/smoke-project/shader.glsl new file mode 100644 index 000000000..b41425d03 --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/shader.glsl @@ -0,0 +1,18 @@ +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +const float TAU = 6.28318; + +vec3 palette(float t) { + return 0.5 + 0.5 * cos(TAU * (t + vec3(0.0, 0.33, 0.66))); +} + +vec4 render(vec2 pos) { + vec2 uv = pos / outputSize; + float waves = sin(uv.x * 16.0 + time * 2.1) * sin(uv.y * 14.0 - time * 1.7); + float cross = sin((uv.x + uv.y) * 12.0 + waves * 2.3 + time * 1.3); + float phase = uv.x * 0.55 + uv.y * 0.35 + waves * 0.12 + time * 0.08; + float light = mix(0.38, 1.0, 0.5 + 0.5 * cross); + + return vec4(palette(phase) * light + vec3(0.025), 1.0); +} diff --git a/lp-fw/fw-browser/www/smoke-project/shader.toml b/lp-fw/fw-browser/www/smoke-project/shader.toml new file mode 100644 index 000000000..0f1252052 --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/shader.toml @@ -0,0 +1,18 @@ +kind = "Shader" +source = { path = "shader.glsl" } +render_order = 0 + +[bindings.output] +target = "bus#visual.out" + +[glsl_opts] +add_sub = "saturating" +mul = "saturating" +div = "saturating" + +[consumed.time] +kind = "value" +value = "f32" +default = 0.0 +label = "Time" +description = "Project clock time in seconds" diff --git a/lp-fw/fw-browser/www/smoke.html b/lp-fw/fw-browser/www/smoke.html new file mode 100644 index 000000000..e1029a4bb --- /dev/null +++ b/lp-fw/fw-browser/www/smoke.html @@ -0,0 +1,669 @@ + + + + + + fw-browser smoke + + + +
+

fw-browser smoke

+
booting
+
+
+
+

Render product

+
+ +
+
+
+

Output channels

+
+ +
+
+
+

Boot checklist

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+
+

Worker log

+
starting
+
+
+ + + diff --git a/lp-fw/fw-core/README.md b/lp-fw/fw-core/README.md new file mode 100644 index 000000000..9552e99cc --- /dev/null +++ b/lp-fw/fw-core/README.md @@ -0,0 +1,47 @@ +# fw-core + +`fw-core` contains shared firmware support code used by firmware targets. + +It is `no_std` by default and provides reusable pieces for embedded/server +firmware, including serial transport helpers, message routing, test-message +serialization, target-specific logging support, and small runtime loop helpers. + +## Relationship To Other Crates + +- `fw-esp32` uses `fw-core` with the `esp32` feature for ESP32-C6 firmware. +- `fw-emu` uses `fw-core` with the `emu` feature for RV32 emulator firmware. +- `lpa-server`, `lpc-shared`, `lpc-model`, and `lpc-wire` provide the server, + shared transport, model, and wire concepts that firmware hosts. + +`fw-core` should contain reusable firmware plumbing. Target-specific hardware +setup, board drivers, flash layout, emulator process behavior, and host/browser +runtime lifecycle belong in their target crates. + +## Runtime Helpers + +`fw_core::runtime` owns target-neutral server loop pieces: + +- drain available client messages from a `ServerTransport` +- tick `LpServer` with a computed frame delta +- record last-frame timing on the server + +Targets still decide how to boot, yield, sleep, schedule autorun, expose logs, +and manage hardware. This keeps `fw-core` useful without turning it into a +browser, host, or ESP32 abstraction layer. + +## Features + +- `std`: enables host-side support for tests and logging dependencies. +- `emu`: enables emulator-specific logging/serialization support. +- `esp32`: enables ESP32-specific firmware support. + +## Validation + +```bash +cargo check -p fw-core +cargo test -p fw-core +``` + +When changing code that affects firmware behavior, also run the relevant target +checks from the root `AGENTS.md`, especially `fw-esp32` and `fw-emu` target +checks. diff --git a/lp-fw/fw-core/src/lib.rs b/lp-fw/fw-core/src/lib.rs index 42bbdb4f5..fbdb8184f 100644 --- a/lp-fw/fw-core/src/lib.rs +++ b/lp-fw/fw-core/src/lib.rs @@ -9,11 +9,15 @@ pub mod log; pub mod message_router; +pub mod runtime; pub mod serial; pub mod test_messages; pub mod transport; pub use message_router::MessageRouter; +pub use runtime::{ + DrainedClientMessages, ServerTickOutcome, drain_client_messages, tick_server_frame, +}; pub use test_messages::{ TestCommand, TestResponse, deserialize_command, parse_message_line, serialize_command, serialize_response, diff --git a/lp-fw/fw-core/src/runtime.rs b/lp-fw/fw-core/src/runtime.rs new file mode 100644 index 000000000..d6a6ee887 --- /dev/null +++ b/lp-fw/fw-core/src/runtime.rs @@ -0,0 +1,160 @@ +//! Shared firmware runtime loop helpers. +//! +//! Target crates still own boot, hardware setup, scheduling, and yielding. This +//! module only provides the target-neutral parts of a LightPlayer firmware loop: +//! draining client messages and ticking `LpServer` through a `ServerTransport`. + +extern crate alloc; + +use alloc::vec::Vec; + +use lpa_server::{LpServer, ServerError}; +use lpc_shared::time::TimeProvider; +use lpc_shared::transport::ServerTransport; +use lpc_wire::{TransportError, WireMessage}; + +/// Result of draining currently available client messages from a transport. +#[derive(Debug)] +pub struct DrainedClientMessages { + pub messages: Vec, + pub receive_calls: u32, + pub error: Option, +} + +impl DrainedClientMessages { + #[must_use] + pub fn message_count(&self) -> usize { + self.messages.len() + } +} + +/// Result of one server tick/send step. +#[derive(Debug, Clone)] +pub struct ServerTickOutcome { + pub delta_ms: u32, + pub response_count: usize, + pub frame_time_us: u64, + pub server_error: Option, +} + +/// Drain all currently available client messages from `transport`. +/// +/// A receive error is returned alongside any messages already collected. This +/// lets target loops decide whether a specific error is fatal. +pub async fn drain_client_messages(transport: &mut T) -> DrainedClientMessages { + let mut messages = Vec::new(); + let mut receive_calls = 0; + + loop { + receive_calls += 1; + match transport.receive().await { + Ok(Some(msg)) => messages.push(WireMessage::Client(msg)), + Ok(None) => { + return DrainedClientMessages { + messages, + receive_calls, + error: None, + }; + } + Err(error) => { + return DrainedClientMessages { + messages, + receive_calls, + error: Some(error), + }; + } + } + } +} + +/// Tick the server, send responses through `transport`, and record frame time. +pub async fn tick_server_frame( + server: &mut LpServer, + transport: &mut T, + time_provider: &P, + frame_start_ms: u64, + last_tick_ms: u64, + incoming_messages: Vec, +) -> ServerTickOutcome +where + T: ServerTransport, + P: TimeProvider, +{ + let delta_time = time_provider.elapsed_ms(last_tick_ms); + let delta_ms = delta_time.min(u32::MAX as u64) as u32; + let delta_ms = delta_ms.max(1); + + match server + .tick_and_send(delta_ms, incoming_messages, transport) + .await + { + Ok(response_count) => { + let frame_time_us = elapsed_us(time_provider, frame_start_ms); + server.set_last_frame_time(frame_time_us); + ServerTickOutcome { + delta_ms, + response_count, + frame_time_us, + server_error: None, + } + } + Err(error) => { + let frame_time_us = elapsed_us(time_provider, frame_start_ms); + server.set_last_frame_time(frame_time_us); + ServerTickOutcome { + delta_ms, + response_count: 0, + frame_time_us, + server_error: Some(error), + } + } + } +} + +fn elapsed_us(time_provider: &P, start_ms: u64) -> u64 { + time_provider.elapsed_ms(start_ms).saturating_mul(1000) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::FakeTransport; + use lpc_shared::time::TimeProvider; + use lpc_wire::{ClientMessage, ClientRequest}; + + #[test] + fn drain_client_messages_collects_until_empty() { + let mut transport = FakeTransport::new(); + transport.queue_message(ClientMessage { + id: 1, + msg: ClientRequest::ListAvailableProjects, + }); + transport.queue_message(ClientMessage { + id: 2, + msg: ClientRequest::ListLoadedProjects, + }); + + let drained = pollster::block_on(drain_client_messages(&mut transport)); + + assert_eq!(drained.message_count(), 2); + assert_eq!(drained.receive_calls, 3); + assert!(drained.error.is_none()); + } + + #[test] + fn mock_time_provider_reports_elapsed_ms() { + let time = MockTimeProvider { now_ms: 42 }; + + assert_eq!(time.elapsed_ms(40), 2); + } + + struct MockTimeProvider { + now_ms: u64, + } + + impl TimeProvider for MockTimeProvider { + fn now_ms(&self) -> u64 { + self.now_ms + } + } +} diff --git a/lp-fw/fw-emu/.cargo/config.toml b/lp-fw/fw-emu/.cargo/config.toml new file mode 100644 index 000000000..2cfa8ba7c --- /dev/null +++ b/lp-fw/fw-emu/.cargo/config.toml @@ -0,0 +1,10 @@ +[target.'cfg(target_arch = "riscv32")'] +rustflags = [ + "-C", "target-feature=-c", + "-C", "force-frame-pointers", + "-C", "force-unwind-tables=yes", + "-C", "panic=unwind", +] + +[unstable] +build-std = ["core", "alloc"] diff --git a/lp-fw/fw-emu/README.md b/lp-fw/fw-emu/README.md new file mode 100644 index 000000000..5703e42bb --- /dev/null +++ b/lp-fw/fw-emu/README.md @@ -0,0 +1,37 @@ +# fw-emu + +`fw-emu` is the RV32 firmware image used by the LightPlayer emulator tests. + +It preserves the embedded shape of the product while running under the +repository's RISC-V emulator infrastructure. This makes it possible to validate +real firmware behavior, shader compilation, server behavior, and panic recovery +without requiring physical ESP32 hardware for every test. + +## Relationship To Other Crates + +- `fw-core` provides shared firmware transport/logging plumbing and runtime loop + helpers with the `emu` feature. +- `lpa-server` runs inside the firmware image. +- `lp-riscv-emu`, `lp-riscv-emu-guest`, and related crates provide the emulator + host/guest infrastructure. +- `fw-tests` contains host-side tests that build and exercise this firmware. + +`fw-emu` is not a host runtime like `fw-host`; it is still firmware, just running +inside the emulator. + +## Validation + +Build/check the emulator firmware: + +```bash +cargo check -p fw-emu --target riscv32imac-unknown-none-elf --profile release-emu +``` + +Run firmware emulator tests that exercise real shader compilation and execution: + +```bash +cargo test -p fw-tests --test scene_render_emu --test profile_alloc_emu +``` + +Do not use host workspace-wide cargo commands for this target. Use the targeted +commands or root just recipes described in `AGENTS.md`. diff --git a/lp-fw/fw-emu/src/server_loop.rs b/lp-fw/fw-emu/src/server_loop.rs index 68ea4ab01..1b6037a22 100644 --- a/lp-fw/fw-emu/src/server_loop.rs +++ b/lp-fw/fw-emu/src/server_loop.rs @@ -5,17 +5,15 @@ use crate::serial::SyscallSerialIo; use crate::time::SyscallTimeProvider; -use alloc::vec::Vec; use core::future::Future; use core::pin::pin; use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; use fw_core::transport::SerialTransport; +use fw_core::{drain_client_messages, tick_server_frame}; use log; use lp_riscv_emu_guest::sys_yield; use lpa_server::LpServer; use lpc_shared::time::TimeProvider; -use lpc_shared::transport::ServerTransport; -use lpc_wire::WireMessage; /// Block on a future until completion. Uses sys_yield when pending. fn block_on(future: F) -> F::Output { @@ -54,54 +52,30 @@ pub fn run_server_loop( frame_start ); - // Collect incoming messages (non-blocking) - let mut incoming_messages = Vec::new(); - let mut receive_calls = 0; - loop { - receive_calls += 1; - match block_on(transport.receive()) { - Ok(Some(msg)) => { - log::debug!( - "run_server_loop: Received message id={} on receive call #{}", - msg.id, - receive_calls - ); - incoming_messages.push(WireMessage::Client(msg)); - } - Ok(None) => { - if receive_calls > 1 { - log::trace!( - "run_server_loop: No more messages after {} receive calls", - receive_calls - ); - } - // No more messages available - break; - } - Err(e) => { - log::warn!("run_server_loop: Transport error: {:?}", e); - // Transport error - break and continue - break; - } - } + let drained = block_on(drain_client_messages(&mut transport)); + if let Some(error) = drained.error { + log::warn!("run_server_loop: Transport error: {error:?}"); } log::trace!( "run_server_loop: Collected {} messages this loop iteration", - incoming_messages.len() + drained.messages.len() ); - // Calculate delta time since last tick - let delta_time = time_provider.elapsed_ms(last_tick); - let delta_ms = delta_time.min(u32::MAX as u64) as u32; - - match block_on(server.tick_and_send(delta_ms.max(1), incoming_messages, &mut transport)) { - Ok(response_count) => { - log::trace!("run_server_loop: Server sent {response_count} response(s)"); - } - Err(e) => { - log::warn!("run_server_loop: Server tick error: {:?}", e); - // Server error - continue - } + let tick = block_on(tick_server_frame( + &mut server, + &mut transport, + &time_provider, + frame_start, + last_tick, + drained.messages, + )); + if let Some(error) = tick.server_error { + log::warn!("run_server_loop: Server tick error: {error:?}"); + } else { + log::trace!( + "run_server_loop: Server sent {} response(s)", + tick.response_count + ); } last_tick = frame_start; diff --git a/lp-fw/fw-esp32/README.md b/lp-fw/fw-esp32/README.md new file mode 100644 index 000000000..575db3d6b --- /dev/null +++ b/lp-fw/fw-esp32/README.md @@ -0,0 +1,52 @@ +# fw-esp32 + +`fw-esp32` is the reference embedded LightPlayer firmware target for ESP32-C6. + +This is the main bare-metal product path: GLSL shaders are compiled on the +device at runtime and executed from RAM. Do not replace this with host/browser +precompilation, and do not feature-gate the compiler out of the embedded +compile/execute path to solve build, size, or `no_std` issues. + +## Responsibilities + +- ESP32-C6 boot and board initialization. +- USB/JTAG serial transport. +- Flash-backed or memory-backed LightPlayer filesystem. +- `lp-server` hosting on device. +- LED output through RMT/WS281x drivers. +- Root-owned hardware capabilities such as buttons and ESP-NOW radio support. +- Firmware check and test harness modes behind feature flags. + +Shared firmware plumbing belongs in `fw-core`. Host-local runtime lifecycle +belongs in `fw-host`. Browser Studio simulation belongs in `fw-browser`. + +## Common Commands + +Run on a connected ESP32-C6: + +```bash +just demo-esp32 +``` + +Target check from the workspace root: + +```bash +cargo check -p fw-esp32 --target riscv32imac-unknown-none-elf --profile release-esp32 --features esp32c6,server +``` + +For linked firmware builds, size measurements, or bloat analysis, run from this +crate directory so the crate-local linker configuration is active: + +```bash +cd lp-fw/fw-esp32 +cargo build --target riscv32imac-unknown-none-elf --profile release-esp32 --features esp32c6,server +rust-size ../../target/riscv32imac-unknown-none-elf/release-esp32/fw-esp32 +``` + +## Feature Notes + +The default feature set targets ESP32-C6 with server and radio support. Many +`test_*` features select focused firmware harnesses for hardware validation, +profiling, or smoke tests. Keep feature additions honest: test and check modes +may narrow behavior for a harness, but the normal firmware path must preserve +runtime shader compilation on device. diff --git a/lp-fw/fw-esp32/src/serial/io_task.rs b/lp-fw/fw-esp32/src/serial/io_task.rs index 661e55644..0ddd27267 100644 --- a/lp-fw/fw-esp32/src/serial/io_task.rs +++ b/lp-fw/fw-esp32/src/serial/io_task.rs @@ -91,15 +91,6 @@ const WRITE_TIMEOUT: Duration = Duration::from_millis(1000); /// large enough to avoid excessive syscalls. Resource snapshots can be 10KB+. const WRITE_CHUNK_SIZE: usize = 256; -/// Async write with timeout. Returns false if the write timed out or errored. -async fn timed_write(tx: &mut W, data: &[u8]) -> bool { - use embassy_futures::select::{Either, select}; - match select(Timer::after(WRITE_TIMEOUT), Write::write(tx, data)).await { - Either::First(_) => false, - Either::Second(result) => result.is_ok(), - } -} - /// Write all data in chunks with per-chunk timeout. Prevents large messages /// (e.g. resource snapshots) from timing out mid-write and corrupting the stream /// by concatenating with the next message. Uses write_all per chunk to @@ -216,7 +207,7 @@ async fn drain_outgoing_server_json_chunks(tx: &mut W, connected: bool match receiver.try_receive() { Ok(chunk) if connected => { if !timed_write_all(tx, chunk.bytes()).await { - let _ = timed_write(tx, b"\n").await; + let _ = timed_write_all(tx, b"\n").await; break; } } @@ -232,7 +223,10 @@ async fn drain_outgoing_messages(router: &MessageRouter, tx: &mut W, c loop { match receiver.try_receive() { Ok(msg) if connected => { - if !timed_write(tx, msg.as_bytes()).await { + if !timed_write_all(tx, b"\n").await { + break; + } + if !timed_write_all(tx, msg.as_bytes()).await { break; } } @@ -278,7 +272,7 @@ async fn drain_outgoing_server_msg(tx: &mut W, connected: bool) { // If a timeout interrupts a JSON frame before the trailing newline, separate the // next frame so host parsers can recover instead of concatenating two `M!` messages. - let _ = timed_write(tx, b"\n").await; + let _ = timed_write_all(tx, b"\n").await; } #[cfg(feature = "server")] @@ -299,7 +293,7 @@ async fn timed_write_full_server_msg( ) -> bool { let mut buf = [0u8; 4 * 1024]; let mut writer = StackJsonWriter::new(&mut buf); - if writer.write(b"M!").is_err() { + if writer.write(b"\nM!").is_err() { return false; } if ser_write_json::ser::to_writer(&mut writer, &msg).is_err() { diff --git a/lp-fw/fw-esp32/src/server_loop.rs b/lp-fw/fw-esp32/src/server_loop.rs index 8d54457d4..6778de0e1 100644 --- a/lp-fw/fw-esp32/src/server_loop.rs +++ b/lp-fw/fw-esp32/src/server_loop.rs @@ -153,7 +153,9 @@ pub async fn run_server_loop( // Send heartbeat message periodically // See prior art: fw-esp32/src/tests/test_usb.rs heartbeat_task() // This implementation uses proper ServerMessage types with M! prefix - if current_time.saturating_sub(heartbeat_last_sent) >= HEARTBEAT_INTERVAL_MS { + if response_count > 0 { + heartbeat_last_sent = current_time; + } else if current_time.saturating_sub(heartbeat_last_sent) >= HEARTBEAT_INTERVAL_MS { // Get loaded projects from server let loaded_projects = server.project_manager().list_loaded_projects(); diff --git a/lp-fw/fw-esp32/src/tests/test_gpio_calibrate.rs b/lp-fw/fw-esp32/src/tests/test_gpio_calibrate.rs index c38cedf26..ed9ede29c 100644 --- a/lp-fw/fw-esp32/src/tests/test_gpio_calibrate.rs +++ b/lp-fw/fw-esp32/src/tests/test_gpio_calibrate.rs @@ -1,6 +1,6 @@ //! Host-driven GPIO calibration test mode. //! -//! This mode keeps firmware deliberately small: the host asks for one GPIO action at a time and +//! This mode keeps firmware deliberately small: the host asks for one GPIO ux at a time and //! owns all calibration state. extern crate alloc; diff --git a/lp-fw/fw-esp32/src/transport.rs b/lp-fw/fw-esp32/src/transport.rs index 937a90472..26ec64cfc 100644 --- a/lp-fw/fw-esp32/src/transport.rs +++ b/lp-fw/fw-esp32/src/transport.rs @@ -161,7 +161,7 @@ impl ServerTransport for StreamingMessageRouterTransport { return Ok(Some(msg)); } Err(e) => { - log::warn!("StreamingMessageRouterTransport: Failed to parse: {e}"); + log::debug!("StreamingMessageRouterTransport: Failed to parse: {e}"); continue; } } @@ -198,7 +198,7 @@ where { let mut writer = ChunkedJsonWriter::new(channel); writer - .write_all(b"M!") + .write_all(b"\nM!") .map_err(|_| TransportError::ConnectionLost)?; let mut json = JsonWriter::new(writer); diff --git a/lp-fw/fw-host/Cargo.toml b/lp-fw/fw-host/Cargo.toml new file mode 100644 index 000000000..24b1d35a0 --- /dev/null +++ b/lp-fw/fw-host/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "fw-host" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +fw-core = { path = "../fw-core", features = ["std"] } +lpa-client = { path = "../../lp-app/lpa-client" } +lpa-server = { path = "../../lp-app/lpa-server" } +lpc-hardware = { path = "../../lp-core/lpc-hardware", features = ["std"] } +lpc-model = { path = "../../lp-core/lpc-model", features = ["std"] } +lpc-shared = { path = "../../lp-core/lpc-shared", features = ["std"] } +lpc-wire = { path = "../../lp-core/lpc-wire", features = ["std"] } +lpfs = { path = "../../lp-base/lpfs", features = ["std"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } + +[lints] +workspace = true diff --git a/lp-fw/fw-host/README.md b/lp-fw/fw-host/README.md new file mode 100644 index 000000000..9220338aa --- /dev/null +++ b/lp-fw/fw-host/README.md @@ -0,0 +1,44 @@ +# fw-host + +`fw-host` is the host-OS LightPlayer runtime target. + +It extracts local runtime/server lifecycle out of `lp-cli` so Studio and other +host applications can create local LightPlayer runtime instances without owning +server internals directly. + +## Relationship To Other Crates + +- `lpa-server` hosts projects and serves the LightPlayer wire API. +- `lpa-client` consumes the client-side connection created by the runtime. +- `lpa-link` `host-process` uses `fw-host` to create runtime instances and expose + them as low-level link sessions. +- `fw-core` provides the shared transport drain and server tick helpers used by + the host runtime loop. +- `lpc-*` and `lpfs` provide the model, hardware, shared transport, wire, and + filesystem pieces used by the hosted server. + +`fw-host` is not embedded firmware. It is a valid runtime target for host +deployments and local development, but it must not replace the ESP32 on-device +GLSL JIT product path. + +## Current Scope + +The current implementation provides an in-memory runtime suitable for M1 Studio +foundation work: + +- start a local memory-backed `LpServer` +- produce a local client transport pair +- shut down cleanly +- run multiple memory runtimes concurrently + +Persistent host projects, process supervision, external TCP/UDP outputs, and +packaged host deployments are future productization work. + +## Validation + +```bash +cargo check -p fw-host +cargo test -p fw-host +cargo check -p lpa-link --features host-process +cargo test -p lpa-link --features host-process +``` diff --git a/lp-fw/fw-host/src/host_runtime.rs b/lp-fw/fw-host/src/host_runtime.rs new file mode 100644 index 000000000..677f9414d --- /dev/null +++ b/lp-fw/fw-host/src/host_runtime.rs @@ -0,0 +1,168 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use lpa_client::{ClientTransport, create_local_transport_pair}; +use lpa_server::{ButtonService, Graphics, LpGraphics, LpServer, RadioService}; +use lpc_hardware::{HardwareSystem, HwRegistry, default_esp32c6_hardware_manifest}; +use lpc_model::AsLpPath; +use lpc_shared::output::MemoryOutputProvider; +use lpfs::LpFsMemory; +use tokio::sync::Mutex; + +use crate::host_runtime_error::HostRuntimeError; +use crate::server_loop::run_server_loop_async; + +pub struct HostRuntime { + server_handle: Option>, + client_transport: Arc>>, + closed: Arc, +} + +impl HostRuntime { + pub fn start_memory() -> Result { + let (client_transport, server_transport) = create_local_transport_pair(); + let client_transport: Arc>> = + Arc::new(Mutex::new(Box::new(client_transport))); + let closed = Arc::new(AtomicBool::new(false)); + let closed_for_thread = Arc::clone(&closed); + + let server_handle = thread::Builder::new() + .name("fw-host-runtime".to_string()) + .spawn(move || { + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("{}", HostRuntimeError::RuntimeCreateFailed(error)); + closed_for_thread.store(true, Ordering::Relaxed); + return; + } + }; + + let server = create_memory_server(); + runtime.block_on(async { + let local_set = tokio::task::LocalSet::new(); + let _ = local_set + .run_until(run_server_loop_async(server, server_transport)) + .await; + }); + closed_for_thread.store(true, Ordering::Relaxed); + }) + .map_err(HostRuntimeError::SpawnFailed)?; + + Ok(Self { + server_handle: Some(server_handle), + client_transport, + closed, + }) + } + + pub fn client_transport(&self) -> Arc>> { + Arc::clone(&self.client_transport) + } + + pub async fn close(&mut self) -> Result<(), HostRuntimeError> { + if self.closed.swap(true, Ordering::Relaxed) { + return Ok(()); + } + + { + let mut transport = self.client_transport.lock().await; + transport + .close() + .await + .map_err(|error| HostRuntimeError::Transport(error.to_string()))?; + } + + if let Some(handle) = self.server_handle.take() { + let start = Instant::now(); + loop { + if handle.is_finished() { + handle + .join() + .map_err(|_| HostRuntimeError::ServerThreadPanicked)?; + return Ok(()); + } + + if start.elapsed() > Duration::from_secs(1) { + return Err(HostRuntimeError::ServerThreadStopTimedOut); + } + + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + Ok(()) + } +} + +impl Drop for HostRuntime { + fn drop(&mut self) { + self.closed.store(true, Ordering::Relaxed); + if let Some(handle) = self.server_handle.take() { + let start = Instant::now(); + while !handle.is_finished() && start.elapsed() <= Duration::from_millis(100) { + thread::yield_now(); + } + if handle.is_finished() { + let _ = handle.join(); + } + } + } +} + +fn create_memory_server() -> LpServer { + let output_provider = Rc::new(RefCell::new(MemoryOutputProvider::new_permissive())); + let hardware = Rc::new(HardwareSystem::with_virtual_drivers(Rc::new( + HwRegistry::new(default_esp32c6_hardware_manifest()), + ))); + let button_service: Rc = hardware.clone(); + let radio_service: Rc = hardware; + let graphics: Arc = Arc::new(Graphics::new()); + + LpServer::new_with_hardware_services( + output_provider, + Box::new(LpFsMemory::new()), + "/projects/".as_path(), + None, + None, + Some(button_service), + Some(radio_service), + graphics, + ) +} + +#[cfg(test)] +mod tests { + use lpa_client::TokioLpClient; + + use super::*; + + #[tokio::test] + async fn memory_runtime_serves_client_requests_and_shuts_down() { + let mut runtime = HostRuntime::start_memory().unwrap(); + let client = TokioLpClient::new_shared(runtime.client_transport()); + + let projects = client.project_list_available().await.unwrap(); + + assert!(projects.is_empty()); + runtime.close().await.unwrap(); + } + + #[tokio::test] + async fn multiple_memory_runtimes_can_run_concurrently() { + let mut runtime_a = HostRuntime::start_memory().unwrap(); + let mut runtime_b = HostRuntime::start_memory().unwrap(); + let client_a = TokioLpClient::new_shared(runtime_a.client_transport()); + let client_b = TokioLpClient::new_shared(runtime_b.client_transport()); + + assert!(client_a.project_list_available().await.unwrap().is_empty()); + assert!(client_b.project_list_available().await.unwrap().is_empty()); + + runtime_a.close().await.unwrap(); + runtime_b.close().await.unwrap(); + } +} diff --git a/lp-fw/fw-host/src/host_runtime_error.rs b/lp-fw/fw-host/src/host_runtime_error.rs new file mode 100644 index 000000000..ca09902e4 --- /dev/null +++ b/lp-fw/fw-host/src/host_runtime_error.rs @@ -0,0 +1,26 @@ +use std::fmt::{self, Display}; + +#[derive(Debug)] +pub enum HostRuntimeError { + SpawnFailed(std::io::Error), + RuntimeCreateFailed(std::io::Error), + ServerThreadPanicked, + ServerThreadStopTimedOut, + Transport(String), +} + +impl Display for HostRuntimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SpawnFailed(error) => write!(f, "failed to spawn host runtime thread: {error}"), + Self::RuntimeCreateFailed(error) => { + write!(f, "failed to create host runtime tokio runtime: {error}") + } + Self::ServerThreadPanicked => f.write_str("host runtime thread panicked"), + Self::ServerThreadStopTimedOut => f.write_str("host runtime thread did not stop"), + Self::Transport(error) => write!(f, "host runtime transport error: {error}"), + } + } +} + +impl std::error::Error for HostRuntimeError {} diff --git a/lp-fw/fw-host/src/lib.rs b/lp-fw/fw-host/src/lib.rs new file mode 100644 index 000000000..ecaf019c2 --- /dev/null +++ b/lp-fw/fw-host/src/lib.rs @@ -0,0 +1,8 @@ +//! Host-OS LightPlayer runtime support. + +pub mod host_runtime; +pub mod host_runtime_error; +mod server_loop; + +pub use host_runtime::HostRuntime; +pub use host_runtime_error::HostRuntimeError; diff --git a/lp-fw/fw-host/src/server_loop.rs b/lp-fw/fw-host/src/server_loop.rs new file mode 100644 index 000000000..61b0770bf --- /dev/null +++ b/lp-fw/fw-host/src/server_loop.rs @@ -0,0 +1,71 @@ +use std::time::{Duration, Instant}; + +use fw_core::{drain_client_messages, tick_server_frame}; +use lpa_server::LpServer; +use lpc_shared::time::TimeProvider; +use lpc_shared::transport::ServerTransport; +use lpc_wire::TransportError; + +use crate::HostRuntimeError; + +const TARGET_FRAME_TIME_MS: u32 = 16; + +pub async fn run_server_loop_async( + mut server: LpServer, + mut transport: T, +) -> Result<(), HostRuntimeError> { + let time_provider = HostLoopTimeProvider::new(); + let mut last_tick_ms = time_provider.now_ms(); + + loop { + let frame_start = Instant::now(); + let frame_start_ms = time_provider.now_ms(); + let drained = drain_client_messages(&mut transport).await; + if let Some(error) = drained.error { + match error { + TransportError::ConnectionLost => return Ok(()), + error => eprintln!("Host runtime transport error: {error}"), + } + } + + let tick = tick_server_frame( + &mut server, + &mut transport, + &time_provider, + frame_start_ms, + last_tick_ms, + drained.messages, + ) + .await; + if let Some(error) = tick.server_error { + eprintln!("Host runtime server error: {error}"); + } + + last_tick_ms = frame_start_ms; + let frame_duration = frame_start.elapsed(); + if frame_duration < Duration::from_millis(TARGET_FRAME_TIME_MS as u64) { + tokio::time::sleep(Duration::from_millis(TARGET_FRAME_TIME_MS as u64) - frame_duration) + .await; + } else { + tokio::task::yield_now().await; + } + } +} + +struct HostLoopTimeProvider { + start: Instant, +} + +impl HostLoopTimeProvider { + fn new() -> Self { + Self { + start: Instant::now(), + } + } +} + +impl TimeProvider for HostLoopTimeProvider { + fn now_ms(&self) -> u64 { + self.start.elapsed().as_millis().min(u64::MAX as u128) as u64 + } +} diff --git a/lp-fw/fw-tests/README.md b/lp-fw/fw-tests/README.md index b75bbc557..358758fe4 100644 --- a/lp-fw/fw-tests/README.md +++ b/lp-fw/fw-tests/README.md @@ -1,6 +1,23 @@ # Firmware Tests -Integration tests for firmware functionality, including USB serial communication tests. +Integration tests for firmware functionality, including firmware emulator +rendering and USB serial communication tests. + +## Firmware Emulator Render Tests + +`scene_render_emu` builds and runs `fw-emu`, writes a project through the wire +protocol, loads it, advances simulated time, and verifies output channel bytes +through canonical project-read `OutputChannels` resources. + +```bash +cargo test -p fw-tests --test scene_render_emu +``` + +Browser firmware smoke coverage currently lives with `fw-browser`: the +`fw-browser` wasm test covers project load/tick/output through the runtime API, +and `lp-fw/fw-browser/www/smoke.html` creates a real Web Worker and verifies the +same project-read output path through `postMessage`. A future CI browser runner +can move or mirror the Web Worker smoke here if that becomes easier to maintain. ## USB Serial Tests diff --git a/lp-fw/fw-tests/tests/scene_render_emu.rs b/lp-fw/fw-tests/tests/scene_render_emu.rs index a5f195437..a98f2258d 100644 --- a/lp-fw/fw-tests/tests/scene_render_emu.rs +++ b/lp-fw/fw-tests/tests/scene_render_emu.rs @@ -1,8 +1,283 @@ -//! Firmware scene render coverage placeholder. +//! Integration test for fw-emu that loads a scene and renders frames. //! -//! The previous test depended on the removed project detail sync path. M3 will -//! rebuild this coverage on top of canonical slot/resource sync. +//! This exercises the firmware server path over the emulated serial transport: +//! project files are written through the wire protocol, the project is loaded by +//! firmware, and output channel bytes are inspected through the canonical +//! project-read resource API. -#[test] -#[ignore = "awaits M3 canonical project sync rebuild"] -fn scene_render_emu_awaits_canonical_project_sync() {} +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; + +use fw_tests::transport_emu_serial::SerialEmuClientTransport; +use lp_riscv_elf::load_elf; +use lp_riscv_emu::{ + LogLevel, Riscv32Emulator, TimeMode, + test_util::{BinaryBuildConfig, ensure_binary_built}, +}; +use lp_riscv_inst::Gpr; +use lpa_client::TokioLpClient; +use lpc_model::{AsLpPath, NodeId}; +use lpc_shared::ProjectBuilder; +use lpc_wire::{ + NodeReadQuery, ProjectReadQuery, ProjectReadRequest, ProjectReadResult, ReadLevel, + ResourcePayloadRead, ResourceReadQuery, RuntimeReadQuery, WireChannelSampleFormat, + WireRuntimeBufferMetadataPayload, WireTreeDelta, +}; +use lpfs::{LpFs, LpFsMemory}; + +#[tokio::test] +#[test_log::test] +async fn test_scene_render_fw_emu() { + log::info!("Building fw-emu..."); + let fw_emu_path = ensure_binary_built( + BinaryBuildConfig::new("fw-emu") + .with_target("riscv32imac-unknown-none-elf") + .with_profile("release-emu") + .with_backtrace_support(true), + ) + .expect("Failed to build fw-emu"); + + log::info!("Starting emulator..."); + let elf_data = std::fs::read(&fw_emu_path).expect("Failed to read fw-emu ELF"); + let load_info = load_elf(&elf_data).expect("Failed to load ELF"); + let ram_size = load_info.ram.len(); + let mut emulator = Riscv32Emulator::new(load_info.code, load_info.ram) + .with_log_level(LogLevel::Instructions) + .with_time_mode(TimeMode::Simulated(0)) + .with_allow_unaligned_access(true); + + let sp_value = 0x80000000u32.wrapping_add((ram_size as u32).wrapping_sub(16)); + emulator.set_register(Gpr::Sp, sp_value as i32); + emulator.set_pc(load_info.entry_point); + + let emulator = Arc::new(Mutex::new(emulator)); + let transport = SerialEmuClientTransport::new(emulator.clone()) + .with_backtrace(load_info.symbol_map.clone(), load_info.code_end); + let client = TokioLpClient::new(Box::new(transport)); + + let fs = Rc::new(RefCell::new(LpFsMemory::new())); + let mut builder = ProjectBuilder::new(fs.clone()); + builder.clock_basic(); + let texture_path = builder.texture().width(2).height(2).add(&mut builder); + builder.shader_basic(&texture_path); + let output_path = builder.output_basic(); + builder.fixture_basic(&output_path, &texture_path); + builder.build(); + + log::info!("Syncing project files..."); + let project_dir = "project"; + for (path, content) in collect_project_files(&fs.borrow()) { + let full_path = format!("/projects/{project_dir}/{path}"); + log::info!(" {full_path}"); + client + .fs_write(full_path.as_path(), content) + .await + .expect("Failed to write project file"); + } + + log::info!("Loading project..."); + let project_handle = client + .project_load(project_dir) + .await + .expect("Failed to load project"); + + let shader_id = read_node_id_for_suffix(&client, project_handle, "/shader.shader").await; + let output_id = read_node_id_for_suffix(&client, project_handle, "/output.output").await; + + log::info!("Shader node: {shader_id:?}; output node: {output_id:?}"); + + let mut red_values = Vec::new(); + for _ in 0..3 { + emulator.lock().unwrap().advance_time(40); + let sample = read_output_sample(&client, project_handle, output_id).await; + + assert!( + sample.runtime_frame_num > 0, + "firmware should have ticked at least one project frame" + ); + assert_eq!( + sample.green, 0, + "output green channel should stay zero; sample: {sample:?}" + ); + assert_eq!( + sample.blue, 0, + "output blue channel should stay zero; sample: {sample:?}" + ); + assert!( + sample.red > 0, + "output red channel should be nonzero after time advances; sample: {sample:?}" + ); + + red_values.push(sample.red); + } + + assert!( + red_values.windows(2).all(|pair| pair[1] > pair[0]), + "output red channel should increase as simulated time advances; values: {red_values:?}" + ); +} + +async fn read_node_id_for_suffix( + client: &TokioLpClient, + handle: lpc_wire::WireProjectHandle, + suffix: &str, +) -> NodeId { + let response = client + .project_read( + handle, + ProjectReadRequest { + since: None, + queries: vec![ProjectReadQuery::Nodes(NodeReadQuery { + level: ReadLevel::Detail, + nodes: Default::default(), + include_slots: false, + })], + probes: Vec::new(), + }, + ) + .await + .expect("Failed to read project nodes"); + + let ProjectReadResult::Nodes(nodes) = response + .results + .first() + .expect("project read should include node result") + else { + panic!( + "project read returned non-node result: {:?}", + response.results + ); + }; + + let mut available_paths = Vec::new(); + for delta in &nodes.tree_deltas { + if let WireTreeDelta::Created { + id, + path: node_path, + .. + } = delta + { + let node_path = node_path.to_string(); + available_paths.push(node_path.clone()); + if node_path.ends_with(suffix) { + return *id; + } + } + } + + panic!("node path ending in {suffix} not found; available paths: {available_paths:?}"); +} + +async fn read_output_sample( + client: &TokioLpClient, + handle: lpc_wire::WireProjectHandle, + output_id: NodeId, +) -> OutputSample { + let response = client + .project_read( + handle, + ProjectReadRequest { + since: None, + queries: vec![ + ProjectReadQuery::Runtime(RuntimeReadQuery), + ProjectReadQuery::Resources(ResourceReadQuery { + level: ReadLevel::Detail, + payloads: ResourcePayloadRead::All, + }), + ], + probes: Vec::new(), + }, + ) + .await + .expect("Failed to read output resources"); + + let runtime_frame_num = match response.results.first() { + Some(ProjectReadResult::Runtime(runtime)) => runtime.project.frame_num, + other => panic!("project read returned non-runtime result: {other:?}"), + }; + + let ProjectReadResult::Resources(resources) = response + .results + .get(1) + .expect("project read should include resource result") + else { + panic!( + "project read returned non-resource result: {:?}", + response.results + ); + }; + + let payload = resources + .runtime_buffer_payloads + .iter() + .find(|payload| { + resources + .summaries + .iter() + .any(|summary| { + summary.resource_ref == payload.resource_ref && summary.owner == Some(output_id) + }) + && matches!( + payload.metadata, + WireRuntimeBufferMetadataPayload::OutputChannels { + sample_format: WireChannelSampleFormat::U16, + .. + } + ) + }) + .unwrap_or_else(|| { + panic!( + "output channel payload for {output_id:?} not found; summaries: {:?}; payloads: {:?}", + resources.summaries, resources.runtime_buffer_payloads + ) + }); + + assert_eq!( + payload.bytes.len() % 2, + 0, + "U16 output payload should contain whole samples" + ); + assert!( + payload.bytes.len() >= 6, + "output payload should contain at least one RGB pixel; got {} bytes", + payload.bytes.len() + ); + + OutputSample { + red: u16::from_le_bytes([payload.bytes[0], payload.bytes[1]]), + green: u16::from_le_bytes([payload.bytes[2], payload.bytes[3]]), + blue: u16::from_le_bytes([payload.bytes[4], payload.bytes[5]]), + runtime_frame_num, + } +} + +fn collect_project_files(fs: &LpFsMemory) -> Vec<(String, Vec)> { + let entries = fs + .list_dir("/".as_path(), true) + .expect("Failed to list project files"); + + let mut files = Vec::new(); + for entry in entries { + if entry.as_str().ends_with('/') || fs.is_dir(entry.as_path()).unwrap_or(false) { + continue; + } + + let content = fs + .read_file(entry.as_path()) + .expect("Failed to read project file"); + let relative_path = entry.as_str().trim_start_matches('/').to_string(); + + files.push((relative_path, content)); + } + + files +} + +#[derive(Debug)] +struct OutputSample { + red: u16, + green: u16, + blue: u16, + runtime_frame_num: u64, +} diff --git a/lp-shader/lps-filetests/src/mutation.rs b/lp-shader/lps-filetests/src/mutation.rs index 552396e62..ef8b26366 100644 --- a/lp-shader/lps-filetests/src/mutation.rs +++ b/lp-shader/lps-filetests/src/mutation.rs @@ -50,7 +50,7 @@ fn annotation_color(kind: Option<&str>) -> &'static str { } } -/// A single mutation action to be applied to a file. +/// A single mutation ux to be applied to a file. #[derive(Debug, Clone)] pub enum MutationAction { /// Add an annotation before a run directive. @@ -74,7 +74,7 @@ pub enum MutationAction { } impl MutationAction { - /// Get the path for this action. + /// Get the path for this ux. fn path(&self) -> &PathBuf { match self { MutationAction::AddAnnotation { path, .. } => path, @@ -82,7 +82,7 @@ impl MutationAction { } } - /// Get the line number for this action. + /// Get the line number for this ux. fn line(&self) -> usize { match self { MutationAction::AddAnnotation { line, .. } => *line, @@ -116,7 +116,7 @@ impl MutationPlan { self.actions.len() } - /// Add an action to the plan. + /// Add an ux to the plan. pub fn push(&mut self, action: MutationAction) { self.actions.push(action); } @@ -137,7 +137,7 @@ impl MutationPlan { let mut sorted: Vec<_> = self.actions.iter().collect(); sorted.sort_by_key(|a| (a.path(), a.line())); - // Group by action type + // Group by ux type let mut add_actions: Vec<&MutationAction> = Vec::new(); let mut remove_actions: Vec<&MutationAction> = Vec::new(); @@ -268,7 +268,7 @@ impl MutationPlan { } } - /// Determine the annotation kind from the first action (for header coloring). + /// Determine the annotation kind from the first ux (for header coloring). fn annotation_kind(&self) -> Option<&str> { self.actions.first().and_then(|a| match a { MutationAction::AddAnnotation { annotation, .. } => { @@ -346,7 +346,7 @@ impl MutationPlan { let action = actions[i]; let line = action.line(); - // Check if this is a remove action + // Check if this is a remove ux if let MutationAction::RemoveAnnotation { target_name, .. } = action { // Collect all RemoveAnnotation actions at this line let mut targets: Vec<&Target> = Vec::new(); diff --git a/lp-shader/lpvm-wasm/src/rt_browser/marshal.rs b/lp-shader/lpvm-wasm/src/rt_browser/marshal.rs index 2bd262bf8..8191288ae 100644 --- a/lp-shader/lpvm-wasm/src/rt_browser/marshal.rs +++ b/lp-shader/lpvm-wasm/src/rt_browser/marshal.rs @@ -5,7 +5,7 @@ use std::format; use js_sys::{Array, ArrayBuffer, Reflect, Uint8Array, WebAssembly}; use lpir::FloatMode; use lps_shared::layout::{type_alignment, type_size}; -use lps_shared::{LayoutRules, LpsType}; +use lps_shared::{LayoutRules, LpsTexture2DDescriptor, LpsTexture2DValue, LpsType}; use lpvm::{LpsValueF32, glsl_component_count}; use wasm_bindgen::JsValue; @@ -301,6 +301,13 @@ fn collect_js_q32_words( } Ok(()) } + Texture2D => { + for _ in 0..4 { + out.push(js_num_as_i32(&slots[*off])?); + *off += 1; + } + Ok(()) + } Array { element, len } => { for _ in 0..*len { collect_js_q32_words(element, slots, fm, off, out)?; @@ -498,6 +505,17 @@ fn decode_lps_from_js_slots( } Ok((LpsValueF32::Mat4x4(m), 16)) } + Texture2D => Ok(( + LpsValueF32::Texture2D(LpsTexture2DValue::from_guest_descriptor( + LpsTexture2DDescriptor { + ptr: js_num_as_i32(&slots[off])? as u32, + width: js_num_as_i32(&slots[off + 1])? as u32, + height: js_num_as_i32(&slots[off + 2])? as u32, + row_stride: js_num_as_i32(&slots[off + 3])? as u32, + }, + )), + 4, + )), Array { element, len } => { let mut elems = Vec::with_capacity(*len as usize); let mut o = off; diff --git a/scratch/2026-06-22-studio-pages-deployment/notes.md b/scratch/2026-06-22-studio-pages-deployment/notes.md new file mode 100644 index 000000000..10b1b8242 --- /dev/null +++ b/scratch/2026-06-22-studio-pages-deployment/notes.md @@ -0,0 +1,106 @@ +# Notes: Studio Pages Deployment + +Canonical planning target: +`/Users/yona/.photomancer/planning/lightplayer/2026-06-22-studio-pages-deployment/` + +This temporary copy lives in repo `scratch/` because the current sandbox could +read `~/.photomancer/planning` but could not write through its Dropbox-backed +symlink target. + +## Initial Understanding + +Deploy `lpa-studio-web` under the owned `lightplayer.app` domain using GitHub +Pages, while automating as much setup/deployment work as possible and isolating +human-owned DNS/GitHub confirmation steps. + +Desired channels: + +- Production Studio: `https://lightplayer.app/` +- Manual beta Studio: `https://beta.lightplayer.app/` +- Demo: `https://demo.lightplayer.app/` + +## Current Repo State + +- `just web-demo-build` builds `lp-app/web-demo/www/`. +- `just web-demo-deploy` pushes demo files to the `gh-pages` branch. +- `just studio-web-build` builds release `lpa-studio-web`, release + `fw-browser`, and an ESP32-C6 firmware image into + `lp-app/lpa-studio-web/public/`. +- `just studio-web-dev-build` writes debug/story artifacts into the same + `public/pkg/` directory. +- A release comparison showed release wasm-bindgen output near `6.3M` before + the `3.0M` firmware image. The current `public/` tree is about `30M` because + it contains debug/dev-generated wasm. + +## Files And Surfaces Inspected + +- `AGENTS.md` +- `agent-context.toml` +- `justfile` +- `.github/workflows/main-push.yml` +- `.github/workflows/pre-merge.yml` +- `lp-app/lpa-studio-web/README.md` +- `lp-app/lpa-studio-web/Dioxus.toml` +- `lp-app/lpa-studio-web/public/index.html` +- `lp-app/lpa-studio-web/scripts/studio-firmware-manifest.mjs` +- `lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs` +- `lp-app/lpa-link/README.md` +- `lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs` +- `lp-app/lpa-link/src/providers/browser_serial_esp32/browser_esp32_flash.rs` +- `lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial_esp32_options.rs` + +## Important Findings + +- Browser serial snippets ultimately import the app-served controller at + `/lpa-link/browser_esp32_device_controller.js`. +- Because of root-anchored browser assets, first deployment should prefer + root-hosted hostnames (`lightplayer.app`, `beta.lightplayer.app`, + `demo.lightplayer.app`) instead of path-hosted channels like + `lightplayer.app/beta/`. +- `Dioxus.toml` has `base_path = "/"`, matching root-hosted hostnames. +- Browser serial provisioning currently uses pinned CDN ESM: + `https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm`. +- Production Studio includes a generated firmware image, so CI deployment needs + the ESP32 build context and `espflash`, or firmware packaging needs to become + a separate release artifact. First pass keeps firmware bundled. +- Local `gh` is version `2.87.3`. `gh repo create` can create extra Pages repos, + and `gh api` can call Pages REST endpoints. `gh repo edit` does not expose + Pages settings as direct flags. +- Network was restricted in this turn, so remote branch and GitHub API checks + could not be refreshed. + +## Human vs Agent Boundary + +Agent-automatable work: + +- Build clean release-only deploy directories. +- Generate version/channel metadata. +- Add CI workflows for production auto-deploy and manual beta/demo deploys. +- Add smoke checks for generated static sites. +- Use `gh repo create` and `gh api` helper scripts for one-time Pages setup + where permissions allow. +- Document exact DNS records and GitHub settings. + +Human-owned work: + +- DNS registrar changes for `lightplayer.app`, `www`, `beta`, and `demo`. +- Any GitHub organization/repository setting blocked by token permissions. +- Verifying custom-domain ownership and HTTPS certificate provisioning. +- Deciding whether `serial-debug.html` ships in production or only beta. + +## Questions / Assumptions + +| # | Question | Context | Suggested answer | +|---|---|---|---| +| Q1 | Should production use the source repo's Pages site? | Shortest path for auto-deploy after main merges. | Yes | +| Q2 | Should beta/demo use separate repos? | Each custom domain attaches to a Pages site; separate repos keep subdomains simple. | Yes | +| Q3 | Should `serial-debug.html` ship in production? | Useful for hardware debugging, but lower-level than Studio. | Ship in beta, decide before production | +| Q4 | Should first production depend on jsDelivr for `esptool-js`? | Current app already uses a pinned ESM CDN URL. | Yes for first rollout | +| Q5 | Should path-hosted channels be deferred? | Root-anchored assets make path hosting fragile today. | Yes | + +## Future Work + +- Make browser asset/module paths base-path aware. +- Self-host `esptool-js` and dependencies for production/offline robustness. +- Add asset fingerprinting or service-worker-aware caching if needed. +- Mirror firmware images to release assets if Pages bandwidth becomes a concern. diff --git a/scratch/2026-06-22-studio-pages-deployment/plan.md b/scratch/2026-06-22-studio-pages-deployment/plan.md new file mode 100644 index 000000000..e9ff23207 --- /dev/null +++ b/scratch/2026-06-22-studio-pages-deployment/plan.md @@ -0,0 +1,330 @@ +--- +kind: plan +size: sm +depth: small +status: active +repo: lightplayer +created: 2026-06-22 +adr: expected +--- + +# Studio GitHub Pages Deployment + +## Planning Size + +This is a small implementation plan because the first rollout can be done as a +single coherent pass: add static deploy packaging, add CI workflows, add helper +automation for GitHub setup, document human DNS/setup steps, and validate the +generated release bundles. + +The plan intentionally avoids path-hosted beta/demo support in the first pass. +The current browser serial snippets depend on root-hosted assets such as +`/lpa-link/browser_esp32_device_controller.js`, so root-hosted hostnames keep +the deploy path simple and correct. + +## Goal + +Deploy LightPlayer Studio to GitHub Pages under `lightplayer.app` with: + +- production auto-deploy after merges to `main`; +- a manually deployable beta Studio channel; +- a manually deployable demo channel for the existing GLSL web demo; +- as much setup as practical automated through repository scripts and `gh`; +- a clear human checklist for DNS and GitHub Pages confirmation steps. + +## Acceptance Criteria + +- `just studio-web-deploy-dir` or equivalent produces a clean release-only + static directory for Studio, without stale debug wasm from `studio-dev`. +- The Studio deploy directory includes `index.html`, `pkg/`, `lpa-link/`, + `firmware/esp32c6/`, and generated `version.json`. +- A local smoke command serves the generated Studio directory and verifies that + the browser shell loads at `/`. +- Production CI builds and deploys Studio from `main` using GitHub Pages from + Actions. +- Manual CI can deploy beta Studio from a selected ref. +- Manual CI can deploy the GLSL web demo. +- Helper scripts document and, where possible, execute one-time `gh` + repository/Page setup for beta/demo repos. +- Documentation cleanly separates automated steps from human DNS and GitHub UI + confirmation. +- Existing `web-demo-deploy` remains available until the new demo channel is + verified. + +## Out Of Scope + +- Do not make the GLSL compiler optional or remove compiler code from firmware + or browser builds to reduce deployment size. +- Do not implement path-hosted deployments such as `lightplayer.app/beta/` in + this first pass. +- Do not self-host `esptool-js` in the first pass unless CDN use blocks + deployment validation. +- Do not add auto-mutating Git hooks. +- Do not migrate historical repo-local plans or roadmaps. + +## Expected URLs + +- Production Studio: `https://lightplayer.app/` +- Optional `www` alias: `https://www.lightplayer.app/` +- Beta Studio: `https://beta.lightplayer.app/` +- GLSL web demo: `https://demo.lightplayer.app/` + +## Deployment Topology + +Use root-hosted GitHub Pages sites: + +- Source repo `light-player/lightplayer` hosts production Studio from Actions + and owns the `lightplayer.app` custom domain. +- New repo `light-player/lightplayer-beta-pages` hosts beta Studio and owns + `beta.lightplayer.app`. +- New repo `light-player/lightplayer-demo-pages` hosts the web demo and owns + `demo.lightplayer.app`. + +The source repo workflows build artifacts. For beta/demo, the workflow publishes +the generated static files to the corresponding Pages repo. This keeps each +channel eligible for its own custom domain while preserving source-of-truth +build logic in `light-player/lightplayer`. + +## Files Expected To Change + +- `justfile` +- `.github/workflows/deploy-studio-pages.yml` +- `.github/workflows/deploy-pages-channel.yml` or separate beta/demo workflows +- `scripts/pages/` or similar setup/deploy helper scripts +- `lp-app/lpa-studio-web/scripts/` for deploy metadata and smoke checks +- `lp-app/lpa-studio-web/README.md` +- `lp-app/web-demo/README.md` if present, or another nearby demo doc +- `docs/adr/-studio-pages-deployment.md` + +## Documentation Expected To Change + +- Add a Studio deployment section to `lp-app/lpa-studio-web/README.md`. +- Add or update web demo deployment notes near `lp-app/web-demo/`. +- Add an ADR because this chooses a lasting public hosting topology and + deployment channel model. +- Add a human setup checklist, either in the ADR or a concise + `docs/deploy/studio-pages.md` if the checklist becomes too detailed. + +## Repo Constraints + +- Preserve the on-device GLSL JIT compiler. Do not solve web/firmware bundle + size by disabling shader compilation in firmware or the default runtime path. +- Do not run `cargo build --workspace` or `cargo test --workspace`. +- Linked ESP32 firmware builds must run from `lp-fw/fw-esp32/` or through an + existing just recipe that changes into that directory. +- `studio-web-build` must keep using release builds for deploy output. +- If non-generated files under `lp-app/lpa-studio-web/` change, run + `just studio-story-baselines-if-needed` before committing. + +## Implementation Steps + +1. Restore the canonical planning artifact location. + + Move this temporary plan from: + + ```text + scratch/2026-06-22-studio-pages-deployment/ + ``` + + to: + + ```text + /Users/yona/.photomancer/planning/lightplayer/2026-06-22-studio-pages-deployment/ + ``` + + when the agent has write access to the personal planning workspace. + +2. Add clean deploy-directory recipes. + + Add recipes or scripts that build into a clean staging directory instead of + relying on whatever was last written to `lp-app/lpa-studio-web/public/pkg`. + Suggested commands: + + ```bash + just studio-web-deploy-dir + just web-demo-deploy-dir + ``` + + These should: + + - remove and recreate a staging directory under ignored output, such as + `target/pages/studio`; + - run the release Studio build path; + - copy only deployable assets; + - generate `version.json` with channel, source ref, source SHA, build time, + app version, and dirty state; + - print total size and the largest files. + +3. Add local smoke validation. + + Add a smoke script or just recipe, for example: + + ```bash + just studio-web-smoke + just web-demo-smoke + ``` + + Studio smoke should serve the generated deploy directory and use a browser + check to verify that: + + - `/` loads without startup-blocking console errors; + - the Dioxus shell renders a stable root marker or known text; + - `version.json` is fetchable; + - `pkg/lpa-studio-web_bg.wasm`, `pkg/fw_browser_bg.wasm`, and + `firmware/esp32c6/manifest.json` are present. + +4. Add production GitHub Pages workflow. + + Add `.github/workflows/deploy-studio-pages.yml`: + + - Trigger on `push` to `main` and `workflow_dispatch`. + - Use Pages permissions: + + ```yaml + permissions: + contents: read + pages: write + id-token: write + ``` + + - Use `actions/configure-pages`, `actions/upload-pages-artifact`, and + `actions/deploy-pages`. + - Install the pinned Rust toolchain from `rust-toolchain.toml`. + - Add `wasm32-unknown-unknown` and `riscv32imac-unknown-none-elf`. + - Install `just`, `wasm-bindgen-cli --version 0.2.114`, and `espflash`. + - Run the release deploy-dir recipe and smoke check. + - Upload the Studio deploy directory. + +5. Add manually deployable beta/demo workflows. + + Add a manual workflow with inputs: + + - `channel`: `beta` or `demo`; + - `ref`: default current branch/ref; + - `dry_run`: optional boolean. + + For beta: + + - build Studio from the selected ref; + - generate `version.json` with `channel = "beta"`; + - publish to `light-player/lightplayer-beta-pages`. + + For demo: + + - run the web demo deploy-dir recipe; + - generate `version.json` with `channel = "demo"`; + - publish to `light-player/lightplayer-demo-pages`. + + Keep production deployment separate from this manual workflow so a beta/demo + dispatch cannot accidentally overwrite production. + +6. Add GitHub setup automation. + + Add `scripts/pages/setup-pages-repos.sh` or similar. It should be safe and + idempotent, printing each action before running it. + + Automatable with `gh`: + + ```bash + gh repo create light-player/lightplayer-beta-pages --public \ + --description "LightPlayer beta Studio GitHub Pages site" + + gh repo create light-player/lightplayer-demo-pages --public \ + --description "LightPlayer web demo GitHub Pages site" + ``` + + Use `gh api` rather than `gh repo edit` for Pages-specific endpoints because + this `gh` version does not expose Pages settings as first-class flags. + + The implementation should verify the exact REST payloads against GitHub + during implementation, because Pages API behavior can depend on whether the + repo already has Pages enabled and whether it uses branch source or Actions + source. + +7. Write the human setup checklist. + + Include: + + - Confirm `light-player/lightplayer` Pages source is GitHub Actions. + - Set production custom domain to `lightplayer.app`. + - Create DNS records: + - apex `A` records to GitHub Pages; + - apex `AAAA` records if desired; + - `www` CNAME to `light-player.github.io`; + - `beta` CNAME to `light-player.github.io`; + - `demo` CNAME to `light-player.github.io`. + - Wait for GitHub Pages DNS check and TLS certificate provisioning. + - Enable "Enforce HTTPS". + - Decide whether production should include `serial-debug.html`; if not, + add a packaging option that excludes it from production but keeps it in + beta. + +8. Validate and clean up. + + Run focused validation: + + ```bash + just studio-web-deploy-dir + just studio-web-smoke + just web-demo-deploy-dir + just web-demo-smoke + cargo check -p lpa-studio-web --target wasm32-unknown-unknown + cargo check -p lpa-link --features browser-serial-esp32 --target wasm32-unknown-unknown + ``` + + If non-generated Studio UI files changed, also run: + + ```bash + just studio-story-baselines-if-needed + ``` + + Check for stale debug artifacts, generated files accidentally staged from + `public/pkg`, and Pages helper scripts that contain hard-coded personal + paths. + +## GitHub CLI Automation Boundary + +`gh` can likely automate: + +- creating `lightplayer-beta-pages` and `lightplayer-demo-pages`; +- setting repo descriptions/homepage metadata; +- pushing deploy branches or static commits when CI has a token; +- invoking REST endpoints for Pages source/custom-domain setup; +- querying Pages build/custom-domain status; +- creating PRs for the workflow/script changes. + +`gh` cannot replace: + +- DNS registrar changes for `lightplayer.app`; +- waiting for DNS propagation and certificate issuance; +- organization policy or token-scope changes if the current token lacks access; +- final browser/hardware sanity checks on real Web Serial hardware. + +The setup helper should run as far as it can, then print the remaining human +checklist with exact DNS records and GitHub URLs. + +## ADR Candidate + +Create: + +```text +docs/adr/2026-06-22-studio-pages-deployment.md +``` + +Decision: + +- Host production, beta, and demo as root-hosted GitHub Pages sites with + separate custom hostnames. +- Use source-repo Actions for production and manual source-repo workflows for + beta/demo publication. +- Defer path-hosted channels until browser asset paths are base-path aware. + +## Definition Of Done + +- Production Studio can be deployed automatically from `main`. +- Beta Studio and demo can be deployed manually from CI. +- The setup helper reports what it completed and what remains human-owned. +- DNS/human checklist is clear enough to execute without rereading the CI + workflow. +- Local release bundle size is reported and does not include stale debug wasm. +- The existing web demo remains reachable until the new demo subdomain is live. diff --git a/scratch/style-lab/holographic-daw.html b/scratch/style-lab/holographic-daw.html new file mode 100644 index 000000000..a0f46ecf4 --- /dev/null +++ b/scratch/style-lab/holographic-daw.html @@ -0,0 +1,595 @@ + + + + + + LightPlayer Style Lab - Holographic DAW + + + +
+
+
+ +
+ LightPlayer Studio + Aurora Rack / ESP32-C6 live JIT +
+
+
+ + + + +
+
+ USB ready + JIT 2.7 ms + Heap 58 K +
+
+ +
+ + +
+
+
+
Shaderfragment.glsl
+
GLSLLPIRRV32
+
+
vec3 palette(float t) {
+  return 0.5 + 0.5 * cos(6.28318 *
+    (vec3(0.00, 0.32, 0.67) + t));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+  vec2 p = fragCoord / iResolution.xy;
+  float wave = sin((p.x + time * 0.18) * 18.0);
+  float bloom = smoothstep(0.30, 0.96, wave * p.y);
+  vec3 rgb = palette(p.x + time * 0.07);
+  fragColor = vec4(mix(rgb * 0.18, rgb, bloom), 1.0);
+}
+
+ +
+
TextureLED preview
64 x 32
+
+ + +
+
+
+ + + +
+
RuntimeRecent activity
+
+
infocompiled fragment.glsl to RV32 native
+
debugpool allocator reused 9 registers
+
warntexture slot rainbow LUT is simulated
+
+
+
+
+ + diff --git a/scratch/style-lab/index.html b/scratch/style-lab/index.html new file mode 100644 index 000000000..e53883232 --- /dev/null +++ b/scratch/style-lab/index.html @@ -0,0 +1,122 @@ + + + + + + LightPlayer Style Lab + + + +
+
+

LightPlayer Style Lab

+

Standalone HTML sketches for comparing dense, responsive LED art control-surface directions at small, medium, and large widths.

+
+
+ + Prismatic Console +

Glass, rainbow edge light, dark DAW density.

+ Closest to fancy LED program energy. +
+ + Lab Instrument +

Measurement rig, oscilloscope, practical status surfaces.

+ Most technical and no-nonsense. +
+ + Holographic DAW +

Creative workstation with punchier color and timeline rhythm.

+ Most music-tool adjacent. +
+ + Industrial Surface +

Physical console cues, amber status lights, hard utility.

+ Least glass, most hardware. +
+ + Iridescent Minimal +

Mostly neutral surfaces with selective rainbow feedback.

+ Most likely production baseline. +
+
+
+ + diff --git a/scratch/style-lab/industrial-surface.html b/scratch/style-lab/industrial-surface.html new file mode 100644 index 000000000..93e6e8ecb --- /dev/null +++ b/scratch/style-lab/industrial-surface.html @@ -0,0 +1,590 @@ + + + + + + LightPlayer Style Lab - Industrial Surface + + + +
+
+
+ +
+ LightPlayer Studio + Aurora Rack / ESP32-C6 live JIT +
+
+
+ + + + +
+
+ USB ready + JIT 2.7 ms + Heap 58 K +
+
+ +
+ + +
+
+
+
Shaderfragment.glsl
+
GLSLLPIRRV32
+
+
vec3 palette(float t) {
+  return 0.5 + 0.5 * cos(6.28318 *
+    (vec3(0.00, 0.32, 0.67) + t));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+  vec2 p = fragCoord / iResolution.xy;
+  float wave = sin((p.x + time * 0.18) * 18.0);
+  float bloom = smoothstep(0.30, 0.96, wave * p.y);
+  vec3 rgb = palette(p.x + time * 0.07);
+  fragColor = vec4(mix(rgb * 0.18, rgb, bloom), 1.0);
+}
+
+ +
+
TextureLED preview
64 x 32
+
+ + +
+
+
+ + + +
+
RuntimeRecent activity
+
+
infocompiled fragment.glsl to RV32 native
+
debugpool allocator reused 9 registers
+
warntexture slot rainbow LUT is simulated
+
+
+
+
+ + diff --git a/scratch/style-lab/iridescent-minimal.html b/scratch/style-lab/iridescent-minimal.html new file mode 100644 index 000000000..b44feb483 --- /dev/null +++ b/scratch/style-lab/iridescent-minimal.html @@ -0,0 +1,773 @@ + + + + + + LightPlayer Style Lab - Iridescent Minimal + + + +
+
+
+ +
+ LightPlayer Studio + Aurora Rack / ESP32-C6 live JIT +
+
+
+ + + + +
+
+ USB ready + JIT 2.7 ms + Heap 58 K +
+
+ +
+ + +
+
+
+
Shaderfragment.glsl
+
GLSLLPIRRV32
+
+
vec3 palette(float t) {
+  return 0.5 + 0.5 * cos(6.28318 *
+    (vec3(0.00, 0.32, 0.67) + t));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+  vec2 p = fragCoord / iResolution.xy;
+  float wave = sin((p.x + time * 0.18) * 18.0);
+  float bloom = smoothstep(0.30, 0.96, wave * p.y);
+  vec3 rgb = palette(p.x + time * 0.07);
+  fragColor = vec4(mix(rgb * 0.18, rgb, bloom), 1.0);
+}
+
+ +
+
TextureLED preview
64 x 32
+
+ + +
+
+
+ + + +
+
RuntimeRecent activity
+
+
infocompiled fragment.glsl to RV32 native
+
debugpool allocator reused 9 registers
+
warntexture slot rainbow LUT is simulated
+
+
+
+
+ + diff --git a/scratch/style-lab/lab-instrument.html b/scratch/style-lab/lab-instrument.html new file mode 100644 index 000000000..318ddb7fc --- /dev/null +++ b/scratch/style-lab/lab-instrument.html @@ -0,0 +1,589 @@ + + + + + + LightPlayer Style Lab - Lab Instrument + + + +
+
+
+ +
+ LightPlayer Studio + Aurora Rack / ESP32-C6 live JIT +
+
+
+ + + + +
+
+ USB ready + JIT 2.7 ms + Heap 58 K +
+
+ +
+ + +
+
+
+
Shaderfragment.glsl
+
GLSLLPIRRV32
+
+
vec3 palette(float t) {
+  return 0.5 + 0.5 * cos(6.28318 *
+    (vec3(0.00, 0.32, 0.67) + t));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+  vec2 p = fragCoord / iResolution.xy;
+  float wave = sin((p.x + time * 0.18) * 18.0);
+  float bloom = smoothstep(0.30, 0.96, wave * p.y);
+  vec3 rgb = palette(p.x + time * 0.07);
+  fragColor = vec4(mix(rgb * 0.18, rgb, bloom), 1.0);
+}
+
+ +
+
TextureLED preview
64 x 32
+
+ + +
+
+
+ + + +
+
RuntimeRecent activity
+
+
infocompiled fragment.glsl to RV32 native
+
debugpool allocator reused 9 registers
+
warntexture slot rainbow LUT is simulated
+
+
+
+
+ + diff --git a/scratch/style-lab/prismatic-console.html b/scratch/style-lab/prismatic-console.html new file mode 100644 index 000000000..f077b0527 --- /dev/null +++ b/scratch/style-lab/prismatic-console.html @@ -0,0 +1,659 @@ + + + + + + LightPlayer Style Lab - Prismatic Console + + + +
+
+
+ +
+ LightPlayer Studio + Aurora Rack / ESP32-C6 live JIT +
+
+
+ + + + +
+
+ USB ready + JIT 2.7 ms + Heap 58 K +
+
+ +
+ + +
+
+
+
+ Shader + fragment.glsl +
+
+ GLSL + LPIR + RV32 +
+
+
vec3 palette(float t) {
+  return 0.5 + 0.5 * cos(6.28318 *
+    (vec3(0.00, 0.32, 0.67) + t));
+}
+
+void mainImage(out vec4 fragColor, in vec2 fragCoord) {
+  vec2 p = fragCoord / iResolution.xy;
+  float wave = sin((p.x + time * 0.18) * 18.0);
+  float bloom = smoothstep(0.30, 0.96, wave * p.y);
+  vec3 rgb = palette(p.x + time * 0.07);
+  fragColor = vec4(mix(rgb * 0.18, rgb, bloom), 1.0);
+}
+
+ +
+
+
+ Texture + LED preview +
+ 64 x 32 +
+
+ + +
+
+
+ + + +
+
+
+ Runtime + Recent activity +
+
+
+
infocompiled fragment.glsl to RV32 native
+
debugpool allocator reused 9 registers
+
warntexture slot rainbow LUT is simulated
+
+
+
+
+ + diff --git a/scripts/agent-context.sh b/scripts/agent-context.sh new file mode 100755 index 000000000..4c80c95f5 --- /dev/null +++ b/scripts/agent-context.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: scripts/agent-context.sh [--planning-root PATH] + +Prints shell-friendly agent context values: + repo_root=... + repo_slug=... + planning_root=... + repo_planning_root=... + skills_root=... + +The repo context is read from agent-context.toml when present. By default this +uses PHOTOMANCER_PLANNING_ROOT and PHOTOMANCER_SKILLS_ROOT, falling back to: + ~/.photomancer/planning +USAGE +} + +planning_root_override="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --planning-root) + if [[ $# -lt 2 ]]; then + printf 'Missing value for --planning-root\n' >&2 + exit 2 + fi + planning_root_override="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac +done + +repo_root="$(git rev-parse --show-toplevel)" +context_file="$repo_root/agent-context.toml" + +read_context_key() { + local key="$1" + + if [[ ! -f "$context_file" ]]; then + return 0 + fi + + sed -n "s/^[[:space:]]*$key[[:space:]]*=[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "$context_file" | + head -n 1 +} + +repo_slug="$(read_context_key repo_slug)" +planning_root_env="$(read_context_key planning_root_env)" +skills_root_env="$(read_context_key skills_root_env)" +default_skills_subdir="$(read_context_key default_skills_subdir)" + +if [[ -z "$repo_slug" ]]; then + repo_slug="$(basename "$repo_root")" +fi + +if [[ -z "$planning_root_env" ]]; then + planning_root_env="PHOTOMANCER_PLANNING_ROOT" +fi + +if [[ -z "$skills_root_env" ]]; then + skills_root_env="PHOTOMANCER_SKILLS_ROOT" +fi + +if [[ -z "$default_skills_subdir" ]]; then + default_skills_subdir="skills" +fi + +planning_root="$planning_root_override" +if [[ -z "$planning_root" ]]; then + planning_root="${!planning_root_env:-}" +fi +if [[ -z "$planning_root" && ( -d "$HOME/.photomancer/planning" || -L "$HOME/.photomancer/planning" ) ]]; then + planning_root="$HOME/.photomancer/planning" +fi + +if [[ -z "$planning_root" ]]; then + printf '%s is not set and ~/.photomancer/planning does not exist. Set it or pass --planning-root PATH.\n' "$planning_root_env" >&2 + exit 1 +fi + +if [[ ! -d "$planning_root" ]]; then + printf 'Planning root does not exist: %s\n' "$planning_root" >&2 + exit 1 +fi + +skills_root="${!skills_root_env:-$planning_root/$default_skills_subdir}" +repo_planning_root="$planning_root/$repo_slug" + +printf 'repo_root=%q\n' "$repo_root" +printf 'repo_slug=%q\n' "$repo_slug" +printf 'planning_root_env=%q\n' "$planning_root_env" +printf 'planning_root=%q\n' "$planning_root" +printf 'repo_planning_root=%q\n' "$repo_planning_root" +printf 'skills_root_env=%q\n' "$skills_root_env" +printf 'skills_root=%q\n' "$skills_root" diff --git a/scripts/dev-init.sh b/scripts/dev-init.sh index df397c011..761e26655 100755 --- a/scripts/dev-init.sh +++ b/scripts/dev-init.sh @@ -95,6 +95,12 @@ if ! check_command "just"; then MISSING_TOOLS=1 fi +if ! check_command "oxipng"; then + echo " Install oxipng for Studio story image baselines: cargo install oxipng" + echo " Or via package manager: brew install oxipng" + MISSING_TOOLS=1 +fi + # Check Rust version if ! check_rust_version; then MISSING_TOOLS=1 @@ -122,6 +128,11 @@ if cargo install just; then else echo -e "${YELLOW}⚠${NC} just update skipped (try: cargo install just)" fi +if cargo install oxipng; then + echo -e "${GREEN}✓${NC} oxipng updated" +else + echo -e "${YELLOW}⚠${NC} oxipng update skipped (try: cargo install oxipng or brew install oxipng)" +fi echo "" # Install RISC-V target diff --git a/scripts/pages/prepare-pages-artifact.mjs b/scripts/pages/prepare-pages-artifact.mjs new file mode 100755 index 000000000..4e6a1cb66 --- /dev/null +++ b/scripts/pages/prepare-pages-artifact.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { cp, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../.."); +const args = parseArgs(process.argv.slice(2)); +const kind = requiredArg(args, "kind"); +const outDir = path.resolve(repoRoot, requiredArg(args, "out")); +const channel = args.channel ?? "local"; +const domain = args.domain ?? ""; + +const configs = { + studio: { + app: "lightplayer-studio", + sourceDir: path.join(repoRoot, "lp-app/lpa-studio-web/public"), + entries: ["index.html", "pkg", "lpa-link", "firmware", "serial-debug.html"], + required: [ + "index.html", + "pkg/lpa-studio-web.js", + "pkg/lpa-studio-web_bg.wasm", + "pkg/fw_browser.js", + "pkg/fw_browser_bg.wasm", + "lpa-link/browser_esp32_device_controller.js", + "firmware/esp32c6/manifest.json", + ], + }, + "web-demo": { + app: "lightplayer-web-demo", + sourceDir: path.join(repoRoot, "lp-app/web-demo/www"), + entries: ["index.html", "rainbow-default.glsl", "pkg"], + required: ["index.html", "rainbow-default.glsl", "pkg/web_demo.js", "pkg/web_demo_bg.wasm"], + }, +}; + +const config = configs[kind]; +if (!config) { + throw new Error(`unknown artifact kind: ${kind}`); +} + +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); + +for (const entry of config.entries) { + const source = path.join(config.sourceDir, entry); + if (!existsSync(source)) { + continue; + } + await cp(source, path.join(outDir, entry), { recursive: true }); +} + +for (const required of config.required) { + const file = path.join(outDir, required); + if (!existsSync(file)) { + throw new Error(`missing required deploy asset: ${required}`); + } +} + +await writeFile(path.join(outDir, ".nojekyll"), ""); +if (domain) { + await writeFile(path.join(outDir, "CNAME"), `${domain}\n`); +} +await writeFile(path.join(outDir, "version.json"), `${JSON.stringify(versionInfo(config.app), null, 2)}\n`); + +const files = await listFiles(outDir); +const totalBytes = files.reduce((sum, file) => sum + file.size, 0); +console.log(`Pages artifact: ${path.relative(repoRoot, outDir)}`); +console.log(`Channel: ${channel}`); +console.log(`Total size: ${formatBytes(totalBytes)}`); +console.log("Largest files:"); +for (const file of files.sort((a, b) => b.size - a.size).slice(0, 8)) { + console.log(` ${formatBytes(file.size).padStart(9)} ${path.relative(outDir, file.path)}`); +} + +function versionInfo(app) { + const generatedAt = new Date().toISOString(); + return { + schemaVersion: 1, + app, + channel, + version: commandOrUnknown("scripts/print-app-version.sh"), + source: { + repository: process.env.GITHUB_REPOSITORY ?? "light-player/lightplayer", + ref: process.env.GITHUB_REF_NAME ?? commandOrUnknown("git", ["branch", "--show-current"]), + sha: process.env.GITHUB_SHA ?? commandOrUnknown("git", ["rev-parse", "HEAD"]), + dirty: isDirty(), + }, + build: { + generatedAt, + workflow: process.env.GITHUB_WORKFLOW ?? null, + runId: process.env.GITHUB_RUN_ID ?? null, + runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + }, + }; +} + +async function listFiles(directory) { + const entries = await import("node:fs/promises").then((fs) => + fs.readdir(directory, { withFileTypes: true }), + ); + const files = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(entryPath))); + } else if (entry.isFile()) { + const metadata = await stat(entryPath); + files.push({ path: entryPath, size: metadata.size }); + } + } + return files; +} + +function isDirty() { + if (process.env.GITHUB_ACTIONS === "true") { + return false; + } + try { + execFileSync("git", ["diff", "--quiet", "--ignore-submodules"], { + cwd: repoRoot, + stdio: "ignore", + }); + execFileSync("git", ["diff", "--cached", "--quiet", "--ignore-submodules"], { + cwd: repoRoot, + stdio: "ignore", + }); + return false; + } catch { + return true; + } +} + +function commandOrUnknown(command, args = []) { + try { + return execFileSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + +function formatBytes(bytes) { + const units = ["B", "KiB", "MiB", "GiB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value.startsWith("--")) { + throw new Error(`unexpected argument: ${value}`); + } + const key = value.slice(2); + const next = values[index + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = "true"; + } else { + parsed[key] = next; + index += 1; + } + } + return parsed; +} + +function requiredArg(values, key) { + const value = values[key]; + if (!value) { + throw new Error(`missing required argument: --${key}`); + } + return value; +} diff --git a/scripts/pages/publish-static-repo.sh b/scripts/pages/publish-static-repo.sh new file mode 100755 index 000000000..82f3a11bb --- /dev/null +++ b/scripts/pages/publish-static-repo.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: $0 --repo OWNER/REPO --dir PATH [--message MESSAGE] [--dry-run]" >&2 +} + +target_repo="" +artifact_dir="" +message="" +dry_run=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + target_repo="${2:-}" + shift 2 + ;; + --dir) + artifact_dir="${2:-}" + shift 2 + ;; + --message) + message="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + *) + usage + exit 2 + ;; + esac +done + +if [[ -z "$target_repo" || -z "$artifact_dir" ]]; then + usage + exit 2 +fi + +if [[ ! -d "$artifact_dir" ]]; then + echo "artifact directory does not exist: $artifact_dir" >&2 + exit 1 +fi + +token="${LIGHTPLAYER_PAGES_TOKEN:-${GH_TOKEN:-}}" +if [[ "$dry_run" != true && -z "$token" ]]; then + echo "LIGHTPLAYER_PAGES_TOKEN or GH_TOKEN is required to publish." >&2 + exit 1 +fi + +message="${message:-deploy: pages $(date -u +%Y-%m-%dT%H:%M:%SZ)}" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +clone_url="https://github.com/${target_repo}.git" +if [[ -n "$token" ]]; then + clone_url="https://x-access-token:${token}@github.com/${target_repo}.git" +fi + +git clone --depth 1 "$clone_url" "$tmp_dir/site" >/dev/null 2>&1 + +find "$tmp_dir/site" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + +cp -R "${artifact_dir}/." "$tmp_dir/site/" + +cd "$tmp_dir/site" +git add -A + +if git diff --cached --quiet; then + echo "No changes to publish for ${target_repo}." + exit 0 +fi + +if [[ "$dry_run" == true ]]; then + echo "Dry run: would publish these changes to ${target_repo}:" + git status --short + exit 0 +fi + +git config user.name "lightplayer-pages-bot" +git config user.email "lightplayer-pages-bot@users.noreply.github.com" +git commit -m "$message" +git push origin HEAD:main +echo "Published ${artifact_dir} to ${target_repo}." diff --git a/scripts/pages/setup-pages-repos.sh b/scripts/pages/setup-pages-repos.sh new file mode 100755 index 000000000..b4700c83d --- /dev/null +++ b/scripts/pages/setup-pages-repos.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +owner="${LIGHTPLAYER_PAGES_OWNER:-light-player}" +source_repo="${LIGHTPLAYER_SOURCE_REPO:-lightplayer}" +beta_repo="${LIGHTPLAYER_BETA_PAGES_REPO:-lightplayer-beta-pages}" +demo_repo="${LIGHTPLAYER_DEMO_PAGES_REPO:-lightplayer-demo-pages}" +apply=false + +for arg in "$@"; do + case "$arg" in + --apply) apply=true ;; + --dry-run) apply=false ;; + *) + echo "usage: $0 [--apply|--dry-run]" >&2 + exit 2 + ;; + esac +done + +run() { + printf '+' + printf ' %q' "$@" + printf '\n' + if [[ "$apply" == true ]]; then + "$@" + fi +} + +repo_exists() { + gh repo view "$1" >/dev/null 2>&1 +} + +ensure_repo() { + local repo="$1" + local description="$2" + if repo_exists "${owner}/${repo}"; then + echo "Repository exists: ${owner}/${repo}" + else + run gh repo create "${owner}/${repo}" --public --add-readme --description "$description" + fi +} + +ensure_branch_pages() { + local repo="$1" + local domain="$2" + if [[ "$apply" != true ]]; then + echo "Would configure Pages for ${owner}/${repo}: branch main /, custom domain ${domain}" + return + fi + + if gh api "repos/${owner}/${repo}/pages" >/dev/null 2>&1; then + gh api -X PUT "repos/${owner}/${repo}/pages" \ + -f cname="$domain" \ + -f source[branch]=main \ + -f source[path]=/ >/dev/null + else + gh api -X POST "repos/${owner}/${repo}/pages" \ + -f source[branch]=main \ + -f source[path]=/ >/dev/null + gh api -X PUT "repos/${owner}/${repo}/pages" \ + -f cname="$domain" >/dev/null + fi +} + +if ! command -v gh >/dev/null 2>&1; then + echo "gh is required." >&2 + exit 1 +fi + +echo "Mode: $([[ "$apply" == true ]] && echo apply || echo dry-run)" +echo "Owner: ${owner}" + +ensure_repo "$beta_repo" "LightPlayer beta Studio GitHub Pages site" +ensure_repo "$demo_repo" "LightPlayer web demo GitHub Pages site" +ensure_branch_pages "$beta_repo" "beta.lightplayer.app" +ensure_branch_pages "$demo_repo" "demo.lightplayer.app" + +cat < existsSync(candidate)) ?? null; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value.startsWith("--")) { + throw new Error(`unexpected argument: ${value}`); + } + const key = value.slice(2); + const next = values[index + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = "true"; + } else { + parsed[key] = next; + index += 1; + } + } + return parsed; +} + +function requiredArg(values, key) { + const value = values[key]; + if (!value) { + throw new Error(`missing required argument: --${key}`); + } + return value; +}