From c13b58cbc237674206103f17ed87de95a2a9a7ef Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 11:04:31 -0700 Subject: [PATCH 01/62] feat: add studio link runtime foundation Adds lpa-link, fw-host, and fw-browser runtime proof crates for the Studio foundation. Includes browser smoke/test recipes and records the link/runtime boundary in docs/adr/2026-06-17-studio-link-and-local-runtimes.md. Plan: /Users/yona/Dropbox/Documents/PersonalNotes/Planning/lightplayer/2026-06-17-lp-studio-foundation/00-m0-prereqs/plan.md --- Cargo.lock | 97 +++++++++ Cargo.toml | 5 + ...26-06-17-studio-link-and-local-runtimes.md | 85 ++++++++ justfile | 37 ++++ lp-app/lpa-link/Cargo.toml | 24 ++ lp-app/lpa-link/src/lib.rs | 28 +++ lp-app/lpa-link/src/link_connection.rs | 72 ++++++ lp-app/lpa-link/src/link_diagnostic.rs | 34 +++ lp-app/lpa-link/src/link_endpoint.rs | 38 ++++ lp-app/lpa-link/src/link_endpoint_id.rs | 26 +++ lp-app/lpa-link/src/link_endpoint_status.rs | 11 + lp-app/lpa-link/src/link_error.rs | 48 ++++ lp-app/lpa-link/src/link_log_entry.rs | 36 +++ lp-app/lpa-link/src/link_management.rs | 20 ++ lp-app/lpa-link/src/link_provider.rs | 19 ++ lp-app/lpa-link/src/link_provider_id.rs | 26 +++ lp-app/lpa-link/src/link_session.rs | 18 ++ lp-app/lpa-link/src/link_session_id.rs | 26 +++ lp-app/lpa-link/src/providers/fake.rs | 204 +++++++++++++++++ .../lpa-link/src/providers/local_browser.rs | 173 +++++++++++++++ lp-app/lpa-link/src/providers/local_host.rs | 206 ++++++++++++++++++ lp-app/lpa-link/src/providers/mod.rs | 5 + lp-fw/fw-browser/.gitignore | 2 + lp-fw/fw-browser/Cargo.toml | 25 +++ lp-fw/fw-browser/src/lib.rs | 173 +++++++++++++++ lp-fw/fw-browser/www/smoke.html | 48 ++++ lp-fw/fw-host/Cargo.toml | 23 ++ lp-fw/fw-host/src/host_runtime.rs | 168 ++++++++++++++ lp-fw/fw-host/src/host_runtime_error.rs | 26 +++ lp-fw/fw-host/src/lib.rs | 8 + lp-fw/fw-host/src/server_loop.rs | 56 +++++ lp-shader/lpvm-wasm/src/rt_browser/marshal.rs | 20 +- 32 files changed, 1786 insertions(+), 1 deletion(-) create mode 100644 docs/adr/2026-06-17-studio-link-and-local-runtimes.md create mode 100644 lp-app/lpa-link/Cargo.toml create mode 100644 lp-app/lpa-link/src/lib.rs create mode 100644 lp-app/lpa-link/src/link_connection.rs create mode 100644 lp-app/lpa-link/src/link_diagnostic.rs create mode 100644 lp-app/lpa-link/src/link_endpoint.rs create mode 100644 lp-app/lpa-link/src/link_endpoint_id.rs create mode 100644 lp-app/lpa-link/src/link_endpoint_status.rs create mode 100644 lp-app/lpa-link/src/link_error.rs create mode 100644 lp-app/lpa-link/src/link_log_entry.rs create mode 100644 lp-app/lpa-link/src/link_management.rs create mode 100644 lp-app/lpa-link/src/link_provider.rs create mode 100644 lp-app/lpa-link/src/link_provider_id.rs create mode 100644 lp-app/lpa-link/src/link_session.rs create mode 100644 lp-app/lpa-link/src/link_session_id.rs create mode 100644 lp-app/lpa-link/src/providers/fake.rs create mode 100644 lp-app/lpa-link/src/providers/local_browser.rs create mode 100644 lp-app/lpa-link/src/providers/local_host.rs create mode 100644 lp-app/lpa-link/src/providers/mod.rs create mode 100644 lp-fw/fw-browser/.gitignore create mode 100644 lp-fw/fw-browser/Cargo.toml create mode 100644 lp-fw/fw-browser/src/lib.rs create mode 100644 lp-fw/fw-browser/www/smoke.html create mode 100644 lp-fw/fw-host/Cargo.toml create mode 100644 lp-fw/fw-host/src/host_runtime.rs create mode 100644 lp-fw/fw-host/src/host_runtime_error.rs create mode 100644 lp-fw/fw-host/src/lib.rs create mode 100644 lp-fw/fw-host/src/server_loop.rs diff --git a/Cargo.lock b/Cargo.lock index 0fde21ea6..8e8d03fa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,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" @@ -2813,6 +2819,18 @@ dependencies = [ "slab", ] +[[package]] +name = "fw-browser" +version = "40.0.0" +dependencies = [ + "js-sys", + "lps-frontend", + "lpvm", + "lpvm-wasm", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "fw-checks" version = "40.0.0" @@ -2907,6 +2925,20 @@ dependencies = [ "unwinding", ] +[[package]] +name = "fw-host" +version = "40.0.0" +dependencies = [ + "lpa-client", + "lpa-server", + "lpc-hardware", + "lpc-model", + "lpc-shared", + "lpc-wire", + "lpfs", + "tokio", +] + [[package]] name = "fw-tests" version = "40.0.0" @@ -4070,6 +4102,16 @@ dependencies = [ "tokio-tungstenite", ] +[[package]] +name = "lpa-link" +version = "40.0.0" +dependencies = [ + "fw-host", + "lpa-client", + "serde", + "tokio", +] + [[package]] name = "lpa-server" version = "40.0.0" @@ -4558,6 +4600,16 @@ dependencies = [ "paste", ] +[[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" @@ -5170,6 +5222,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" @@ -7159,6 +7217,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" diff --git a/Cargo.toml b/Cargo.toml index 0320754ed..19829ef4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,10 @@ members = [ "lp-core/lpc-shared", "lp-app/lpa-server", "lp-app/lpa-client", + "lp-app/lpa-link", + "lp-fw/fw-browser", "lp-fw/fw-core", + "lp-fw/fw-host", "lp-fw/fw-checks", "lp-cli", "lp-fw/fw-esp32", @@ -68,7 +71,9 @@ default-members = [ "lp-core/lpc-shared", "lp-app/lpa-server", "lp-app/lpa-client", + "lp-app/lpa-link", "lp-fw/fw-core", + "lp-fw/fw-host", "lp-fw/fw-checks", "lp-fw/fw-tests", "lp-cli", 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/justfile b/justfile index cf4b59fb4..138cb0a7c 100644 --- a/justfile +++ b/justfile @@ -121,6 +121,43 @@ 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 + # ============================================================================ # Build commands - Workspace-wide # ============================================================================ diff --git a/lp-app/lpa-link/Cargo.toml b/lp-app/lpa-link/Cargo.toml new file mode 100644 index 000000000..bdc1ea4e9 --- /dev/null +++ b/lp-app/lpa-link/Cargo.toml @@ -0,0 +1,24 @@ +[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 } +serde = { workspace = true, features = ["derive"] } +tokio = { version = "1", features = ["sync"], optional = true } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } + +[features] +default = [] +local-browser = [] +local-host = ["dep:fw-host", "dep:lpa-client", "dep:tokio"] + +[lints] +workspace = true diff --git a/lp-app/lpa-link/src/lib.rs b/lp-app/lpa-link/src/lib.rs new file mode 100644 index 000000000..dd129bfba --- /dev/null +++ b/lp-app/lpa-link/src/lib.rs @@ -0,0 +1,28 @@ +//! App-side links to LightPlayer runtimes and devices. + +pub mod link_connection; +pub mod link_diagnostic; +pub mod link_endpoint; +pub mod link_endpoint_id; +pub mod link_endpoint_status; +pub mod link_error; +pub mod link_log_entry; +pub mod link_management; +pub mod link_provider; +pub mod link_provider_id; +pub mod link_session; +pub mod link_session_id; +pub mod providers; + +pub use link_connection::{LinkConnection, LinkConnectionKind}; +pub use link_diagnostic::{LinkDiagnostic, LinkDiagnosticSeverity}; +pub use link_endpoint::LinkEndpoint; +pub use link_endpoint_id::LinkEndpointId; +pub use link_endpoint_status::LinkEndpointStatus; +pub use link_error::LinkError; +pub use link_log_entry::{LinkLogEntry, LinkLogLevel}; +pub use link_management::LinkManagement; +pub use link_provider::LinkProvider; +pub use link_provider_id::LinkProviderId; +pub use link_session::LinkSession; +pub use link_session_id::LinkSessionId; diff --git a/lp-app/lpa-link/src/link_connection.rs b/lp-app/lpa-link/src/link_connection.rs new file mode 100644 index 000000000..65066ecfb --- /dev/null +++ b/lp-app/lpa-link/src/link_connection.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LinkEndpointId, LinkSessionId}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkConnectionKind { + Fake, + PendingImplementation { kind: String }, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct LinkConnection { + pub endpoint_id: LinkEndpointId, + pub session_id: LinkSessionId, + pub kind: LinkConnectionKind, + #[cfg(feature = "local-host")] + #[serde(skip)] + pub local_host_transport: + Option>>>, +} + +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(feature = "local-host")] + local_host_transport: 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(feature = "local-host")] + local_host_transport: None, + } + } + + #[cfg(feature = "local-host")] + pub fn local_host( + endpoint_id: impl Into, + session_id: impl Into, + transport: std::sync::Arc>>, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::PendingImplementation { + kind: "local-host".to_string(), + }, + local_host_transport: Some(transport), + } + } + + #[cfg(feature = "local-host")] + pub fn local_host_transport( + &self, + ) -> Option>>> { + self.local_host_transport.clone() + } +} diff --git a/lp-app/lpa-link/src/link_diagnostic.rs b/lp-app/lpa-link/src/link_diagnostic.rs new file mode 100644 index 000000000..af7a4efa3 --- /dev/null +++ b/lp-app/lpa-link/src/link_diagnostic.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LinkEndpointId, LinkSessionId}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkDiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkDiagnostic { + pub endpoint_id: LinkEndpointId, + pub session_id: Option, + pub severity: LinkDiagnosticSeverity, + pub message: String, +} + +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/link_endpoint.rs b/lp-app/lpa-link/src/link_endpoint.rs new file mode 100644 index 000000000..e83aa1e5a --- /dev/null +++ b/lp-app/lpa-link/src/link_endpoint.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LinkEndpointId, LinkEndpointStatus, LinkManagement, LinkProviderId}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkEndpoint { + pub id: LinkEndpointId, + pub provider_id: LinkProviderId, + pub label: String, + pub status: LinkEndpointStatus, + pub management: LinkManagement, +} + +impl LinkEndpoint { + pub fn new( + id: impl Into, + provider_id: impl Into, + label: impl Into, + ) -> Self { + Self { + id: id.into(), + provider_id: provider_id.into(), + label: label.into(), + status: LinkEndpointStatus::Available, + management: LinkManagement::default(), + } + } + + pub fn with_status(mut self, status: LinkEndpointStatus) -> Self { + self.status = status; + self + } + + pub fn with_management(mut self, management: LinkManagement) -> Self { + self.management = management; + self + } +} diff --git a/lp-app/lpa-link/src/link_endpoint_id.rs b/lp-app/lpa-link/src/link_endpoint_id.rs new file mode 100644 index 000000000..dee01d86d --- /dev/null +++ b/lp-app/lpa-link/src/link_endpoint_id.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[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) + } +} diff --git a/lp-app/lpa-link/src/link_endpoint_status.rs b/lp-app/lpa-link/src/link_endpoint_status.rs new file mode 100644 index 000000000..6e71b381b --- /dev/null +++ b/lp-app/lpa-link/src/link_endpoint_status.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +#[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/link_error.rs b/lp-app/lpa-link/src/link_error.rs new file mode 100644 index 000000000..2d2069ee8 --- /dev/null +++ b/lp-app/lpa-link/src/link_error.rs @@ -0,0 +1,48 @@ +use std::fmt::{self, Display}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LinkError { + EndpointNotFound { endpoint: String }, + OperationUnsupported { operation: String }, + ConnectionFailed { message: String }, + Closed, + Other { message: String }, +} + +impl LinkError { + pub fn endpoint_not_found(endpoint: impl Into) -> Self { + Self::EndpointNotFound { + endpoint: endpoint.into(), + } + } + + pub fn unsupported(operation: impl Into) -> Self { + Self::OperationUnsupported { + operation: operation.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::OperationUnsupported { operation } => { + write!(f, "link operation unsupported: {operation}") + } + Self::ConnectionFailed { message } => write!(f, "link connection failed: {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/link_log_entry.rs b/lp-app/lpa-link/src/link_log_entry.rs new file mode 100644 index 000000000..af1a0d6e3 --- /dev/null +++ b/lp-app/lpa-link/src/link_log_entry.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LinkEndpointId, LinkSessionId}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum LinkLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkLogEntry { + pub endpoint_id: LinkEndpointId, + pub session_id: Option, + pub level: LinkLogLevel, + pub message: String, +} + +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/link_management.rs b/lp-app/lpa-link/src/link_management.rs new file mode 100644 index 000000000..d4c0d9d65 --- /dev/null +++ b/lp-app/lpa-link/src/link_management.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkManagement { + pub can_reset: bool, + pub can_flash: bool, + pub can_read_fs: bool, + pub can_write_fs: bool, + pub can_read_logs: bool, + pub can_read_diagnostics: bool, +} + +impl LinkManagement { + pub fn diagnostics_only() -> Self { + Self { + can_read_diagnostics: true, + ..Self::default() + } + } +} diff --git a/lp-app/lpa-link/src/link_provider.rs b/lp-app/lpa-link/src/link_provider.rs new file mode 100644 index 000000000..e78a26112 --- /dev/null +++ b/lp-app/lpa-link/src/link_provider.rs @@ -0,0 +1,19 @@ +use crate::{ + LinkEndpoint, LinkEndpointId, LinkEndpointStatus, LinkError, LinkProviderId, LinkSession, +}; + +#[allow(async_fn_in_trait, reason = "Link providers are not object-safe yet")] +pub trait LinkProvider { + type Session: LinkSession; + + fn id(&self) -> &LinkProviderId; + + async fn discover(&mut self) -> Result, LinkError>; + + async fn status( + &mut self, + endpoint_id: &LinkEndpointId, + ) -> Result; + + async fn connect(&mut self, endpoint_id: &LinkEndpointId) -> Result; +} diff --git a/lp-app/lpa-link/src/link_provider_id.rs b/lp-app/lpa-link/src/link_provider_id.rs new file mode 100644 index 000000000..b828ab24f --- /dev/null +++ b/lp-app/lpa-link/src/link_provider_id.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub struct LinkProviderId(String); + +impl LinkProviderId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for LinkProviderId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for LinkProviderId { + fn from(value: String) -> Self { + Self::new(value) + } +} diff --git a/lp-app/lpa-link/src/link_session.rs b/lp-app/lpa-link/src/link_session.rs new file mode 100644 index 000000000..07d31d9bf --- /dev/null +++ b/lp-app/lpa-link/src/link_session.rs @@ -0,0 +1,18 @@ +use crate::{ + LinkConnection, LinkDiagnostic, LinkEndpointId, LinkError, LinkLogEntry, LinkSessionId, +}; + +#[allow(async_fn_in_trait, reason = "Link sessions are not object-safe yet")] +pub trait LinkSession { + fn id(&self) -> &LinkSessionId; + + fn endpoint_id(&self) -> &LinkEndpointId; + + fn logs(&self) -> Vec; + + fn diagnostics(&self) -> Vec; + + async fn connection(&mut self) -> Result; + + async fn close(&mut self) -> Result<(), LinkError>; +} diff --git a/lp-app/lpa-link/src/link_session_id.rs b/lp-app/lpa-link/src/link_session_id.rs new file mode 100644 index 000000000..fe6278cc7 --- /dev/null +++ b/lp-app/lpa-link/src/link_session_id.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[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) + } +} diff --git a/lp-app/lpa-link/src/providers/fake.rs b/lp-app/lpa-link/src/providers/fake.rs new file mode 100644 index 000000000..85f007e33 --- /dev/null +++ b/lp-app/lpa-link/src/providers/fake.rs @@ -0,0 +1,204 @@ +use crate::{ + LinkConnection, LinkDiagnostic, LinkDiagnosticSeverity, LinkEndpoint, LinkEndpointId, + LinkEndpointStatus, LinkError, LinkLogEntry, LinkLogLevel, LinkProvider, LinkProviderId, + LinkSession, LinkSessionId, +}; + +#[derive(Clone, Debug)] +pub struct FakeProvider { + id: LinkProviderId, + endpoints: Vec, + next_session_index: u64, +} + +impl FakeProvider { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + endpoints: Vec::new(), + next_session_index: 1, + } + } + + pub fn with_endpoint(mut self, endpoint: LinkEndpoint) -> Self { + self.endpoints.push(endpoint); + 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())) + } +} + +impl LinkProvider for FakeProvider { + type Session = FakeSession; + + fn id(&self) -> &LinkProviderId { + &self.id + } + + 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; + + Ok(FakeSession::new(endpoint.id, session_id)) + } +} + +#[derive(Clone, Debug)] +pub struct FakeSession { + endpoint_id: LinkEndpointId, + id: LinkSessionId, + closed: bool, + logs: Vec, + diagnostics: Vec, +} + +impl FakeSession { + pub fn new(endpoint_id: LinkEndpointId, id: LinkSessionId) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(id.clone()), + LinkLogLevel::Info, + "fake link session opened", + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(id.clone()), + LinkDiagnosticSeverity::Info, + "fake link session ready", + )]; + + Self { + endpoint_id, + id, + closed: false, + logs, + diagnostics, + } + } +} + +impl LinkSession for FakeSession { + fn id(&self) -> &LinkSessionId { + &self.id + } + + fn endpoint_id(&self) -> &LinkEndpointId { + &self.endpoint_id + } + + fn logs(&self) -> Vec { + self.logs.clone() + } + + fn diagnostics(&self) -> Vec { + self.diagnostics.clone() + } + + async fn connection(&mut self) -> Result { + if self.closed { + return Err(LinkError::Closed); + } + + Ok(LinkConnection::fake( + self.endpoint_id.clone(), + self.id.clone(), + )) + } + + async fn close(&mut self) -> Result<(), LinkError> { + self.closed = true; + self.logs.push(LinkLogEntry::new( + self.endpoint_id.clone(), + Some(self.id.clone()), + LinkLogLevel::Info, + "fake link session closed", + )); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LinkManagement; + + #[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 mut 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 = session_a.connection().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 mut session = provider + .connect(&LinkEndpointId::new("fake-a")) + .await + .unwrap(); + + let logs = session.logs(); + let diagnostics = session.diagnostics(); + + 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())); + + session.close().await.unwrap(); + assert!(session.connection().await.is_err()); + } + + fn fake_provider() -> FakeProvider { + let provider_id = LinkProviderId::new("fake"); + FakeProvider::new(provider_id.clone()) + .with_endpoint( + LinkEndpoint::new("fake-a", provider_id.clone(), "Fake A") + .with_management(LinkManagement::diagnostics_only()), + ) + .with_endpoint(LinkEndpoint::new("fake-b", provider_id, "Fake B")) + } +} diff --git a/lp-app/lpa-link/src/providers/local_browser.rs b/lp-app/lpa-link/src/providers/local_browser.rs new file mode 100644 index 000000000..2baa6975b --- /dev/null +++ b/lp-app/lpa-link/src/providers/local_browser.rs @@ -0,0 +1,173 @@ +use crate::{ + LinkConnection, LinkDiagnostic, LinkDiagnosticSeverity, LinkEndpoint, LinkEndpointId, + LinkEndpointStatus, LinkError, LinkLogEntry, LinkLogLevel, LinkManagement, LinkProvider, + LinkProviderId, LinkSession, LinkSessionId, +}; + +#[derive(Clone, Debug)] +pub struct LocalBrowserProvider { + id: LinkProviderId, + endpoints: Vec, + next_endpoint_index: u64, + next_session_index: u64, +} + +impl LocalBrowserProvider { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + endpoints: Vec::new(), + next_endpoint_index: 1, + next_session_index: 1, + } + } + + pub fn create_worker_endpoint(&mut self, label: impl Into) -> LinkEndpointId { + let endpoint_id = LinkEndpointId::new(format!( + "{}-worker-{}", + self.id.as_str(), + self.next_endpoint_index + )); + self.next_endpoint_index += 1; + + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.id.clone(), label) + .with_management(LinkManagement { + can_read_logs: true, + can_read_diagnostics: true, + ..LinkManagement::default() + }); + 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())) + } +} + +impl LinkProvider for LocalBrowserProvider { + type Session = LocalBrowserSession; + + fn id(&self) -> &LinkProviderId { + &self.id + } + + 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; + Ok(LocalBrowserSession::new(endpoint.id, session_id)) + } +} + +#[derive(Clone, Debug)] +pub struct LocalBrowserSession { + endpoint_id: LinkEndpointId, + id: LinkSessionId, + closed: bool, + logs: Vec, + diagnostics: Vec, +} + +impl LocalBrowserSession { + pub fn new(endpoint_id: LinkEndpointId, id: LinkSessionId) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(id.clone()), + LinkLogLevel::Info, + "local browser worker session created", + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(id.clone()), + LinkDiagnosticSeverity::Info, + "local browser worker session pending runtime binding", + )]; + Self { + endpoint_id, + id, + closed: false, + logs, + diagnostics, + } + } +} + +impl LinkSession for LocalBrowserSession { + fn id(&self) -> &LinkSessionId { + &self.id + } + + fn endpoint_id(&self) -> &LinkEndpointId { + &self.endpoint_id + } + + fn logs(&self) -> Vec { + self.logs.clone() + } + + fn diagnostics(&self) -> Vec { + self.diagnostics.clone() + } + + async fn connection(&mut self) -> Result { + if self.closed { + return Err(LinkError::Closed); + } + Ok(LinkConnection::pending( + self.endpoint_id.clone(), + self.id.clone(), + "local-browser", + )) + } + + async fn close(&mut self) -> Result<(), LinkError> { + self.closed = true; + self.logs.push(LinkLogEntry::new( + self.endpoint_id.clone(), + Some(self.id.clone()), + LinkLogLevel::Info, + "local browser worker session closed", + )); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn local_browser_provider_supports_multiple_worker_endpoints() { + let mut provider = LocalBrowserProvider::new("local-browser"); + 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()); + } +} diff --git a/lp-app/lpa-link/src/providers/local_host.rs b/lp-app/lpa-link/src/providers/local_host.rs new file mode 100644 index 000000000..571843c95 --- /dev/null +++ b/lp-app/lpa-link/src/providers/local_host.rs @@ -0,0 +1,206 @@ +use fw_host::HostRuntime; + +use crate::{ + LinkConnection, LinkDiagnostic, LinkDiagnosticSeverity, LinkEndpoint, LinkEndpointId, + LinkEndpointStatus, LinkError, LinkLogEntry, LinkLogLevel, LinkManagement, LinkProvider, + LinkProviderId, LinkSession, LinkSessionId, +}; + +#[derive(Clone, Debug)] +pub struct LocalHostProvider { + id: LinkProviderId, + endpoints: Vec, + next_endpoint_index: u64, + next_session_index: u64, +} + +impl LocalHostProvider { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + endpoints: Vec::new(), + next_endpoint_index: 1, + next_session_index: 1, + } + } + + pub fn create_memory_endpoint(&mut self, label: impl Into) -> LinkEndpointId { + let endpoint_id = LinkEndpointId::new(format!( + "{}-memory-{}", + self.id.as_str(), + self.next_endpoint_index + )); + self.next_endpoint_index += 1; + + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.id.clone(), label) + .with_management(LinkManagement { + can_read_logs: true, + can_read_diagnostics: true, + ..LinkManagement::default() + }); + 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())) + } +} + +impl LinkProvider for LocalHostProvider { + type Session = LocalHostSession; + + fn id(&self) -> &LinkProviderId { + &self.id + } + + 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; + + Ok(LocalHostSession::new(endpoint.id, session_id, runtime)) + } +} + +pub struct LocalHostSession { + endpoint_id: LinkEndpointId, + id: LinkSessionId, + runtime: HostRuntime, + logs: Vec, + diagnostics: Vec, +} + +impl LocalHostSession { + pub fn new(endpoint_id: LinkEndpointId, id: LinkSessionId, runtime: HostRuntime) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(id.clone()), + LinkLogLevel::Info, + "local host runtime started", + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(id.clone()), + LinkDiagnosticSeverity::Info, + "local host runtime ready", + )]; + + Self { + endpoint_id, + id, + runtime, + logs, + diagnostics, + } + } +} + +impl LinkSession for LocalHostSession { + fn id(&self) -> &LinkSessionId { + &self.id + } + + fn endpoint_id(&self) -> &LinkEndpointId { + &self.endpoint_id + } + + fn logs(&self) -> Vec { + self.logs.clone() + } + + fn diagnostics(&self) -> Vec { + self.diagnostics.clone() + } + + async fn connection(&mut self) -> Result { + Ok(LinkConnection::local_host( + self.endpoint_id.clone(), + self.id.clone(), + self.runtime.client_transport(), + )) + } + + async fn close(&mut self) -> Result<(), LinkError> { + self.runtime + .close() + .await + .map_err(|error| LinkError::Other { + message: error.to_string(), + })?; + self.logs.push(LinkLogEntry::new( + self.endpoint_id.clone(), + Some(self.id.clone()), + LinkLogLevel::Info, + "local host runtime stopped", + )); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use lpa_client::LpClient; + + use super::*; + + #[tokio::test] + async fn local_host_connection_serves_client_requests() { + let mut provider = provider_with_two_endpoints(); + let endpoint_id = LinkEndpointId::new("local-host-memory-1"); + let mut session = provider.connect(&endpoint_id).await.unwrap(); + + let connection = session.connection().await.unwrap(); + let transport = connection.local_host_transport().unwrap(); + let client = LpClient::new_shared(transport); + let projects = client.project_list_available().await.unwrap(); + + assert!(projects.is_empty()); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn local_host_provider_supports_multiple_endpoints() { + let mut provider = provider_with_two_endpoints(); + let endpoints = provider.discover().await.unwrap(); + + assert_eq!(endpoints.len(), 2); + + let mut session_a = provider.connect(&endpoints[0].id).await.unwrap(); + let mut 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()); + + session_a.close().await.unwrap(); + session_b.close().await.unwrap(); + } + + fn provider_with_two_endpoints() -> LocalHostProvider { + let mut provider = LocalHostProvider::new("local-host"); + provider.create_memory_endpoint("Local Host A"); + provider.create_memory_endpoint("Local Host B"); + provider + } +} 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..6c91b4089 --- /dev/null +++ b/lp-app/lpa-link/src/providers/mod.rs @@ -0,0 +1,5 @@ +pub mod fake; +#[cfg(feature = "local-browser")] +pub mod local_browser; +#[cfg(feature = "local-host")] +pub mod local_host; 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..a110eafc4 --- /dev/null +++ b/lp-fw/fw-browser/Cargo.toml @@ -0,0 +1,25 @@ +[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 runtime proof" + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +js-sys = "0.3" +lps-frontend = { path = "../../lp-shader/lps-frontend" } +lpvm = { path = "../../lp-shader/lpvm" } +lpvm-wasm = { path = "../../lp-shader/lpvm-wasm" } +wasm-bindgen = "0.2" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/lp-fw/fw-browser/src/lib.rs b/lp-fw/fw-browser/src/lib.rs new file mode 100644 index 000000000..57058178e --- /dev/null +++ b/lp-fw/fw-browser/src/lib.rs @@ -0,0 +1,173 @@ +//! Browser/Web Worker LightPlayer runtime proof. + +#![cfg(target_arch = "wasm32")] + +use std::cell::RefCell; + +use js_sys::{Array, Function, Reflect, Uint8Array}; +use lps_frontend::{compile, lower}; +use lpvm::{LpvmEngine, LpvmModule}; +use lpvm_wasm::rt_browser::{BrowserLpvmEngine, BrowserLpvmInstance, init_host_exports}; +use lpvm_wasm::{FloatMode, WasmOptions}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +const PIXEL_BUF_OFFSET: u32 = 1024; + +thread_local! { + static RUNTIMES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct BrowserRuntime { + id: u32, + label: String, + engine: BrowserLpvmEngine, + instance: Option, + logs: Vec, +} + +#[wasm_bindgen] +pub fn fw_browser_init_exports(exports: JsValue) { + init_host_exports(exports); +} + +#[wasm_bindgen] +pub fn create_runtime(label: &str) -> Result { + let opts = WasmOptions { + float_mode: FloatMode::Q32, + ..Default::default() + }; + let engine = BrowserLpvmEngine::new(opts).map_err(|error| format!("{error}"))?; + + RUNTIMES.with(|runtimes| { + let mut runtimes = runtimes.borrow_mut(); + let id = runtimes.len() as u32 + 1; + runtimes.push(BrowserRuntime { + id, + label: label.to_string(), + engine, + instance: None, + logs: vec![format!("runtime {id} created: {label}")], + }); + Ok(id) + }) +} + +#[wasm_bindgen] +pub fn runtime_count() -> u32 { + RUNTIMES.with(|runtimes| runtimes.borrow().len() as u32) +} + +#[wasm_bindgen] +pub fn compile_shader(runtime_id: u32, source: &str) -> Result<(), String> { + with_runtime_mut(runtime_id, |runtime| { + let naga = compile(source).map_err(|error| format!("parse: {error}"))?; + let (ir, meta) = lower(&naga).map_err(|error| format!("lower: {error}"))?; + let module = runtime + .engine + .compile(&ir, &meta) + .map_err(|error| format!("compile: {error}"))?; + let instance = module + .instantiate() + .map_err(|error| format!("instantiate: {error}"))?; + runtime.instance = Some(instance); + runtime.logs.push("shader compiled".to_string()); + Ok(()) + }) +} + +#[wasm_bindgen] +pub fn render_first_pixel(runtime_id: u32, time_q32: i32) -> Result { + with_runtime_mut(runtime_id, |runtime| { + let instance = runtime + .instance + .as_ref() + .ok_or_else(|| "no shader loaded".to_string())?; + let exports = instance.js_exports(); + let func = Reflect::get(exports, &JsValue::from_str("render_frame")) + .map_err(|error| format!("get render_frame: {error:?}"))?; + let func: Function = func + .dyn_into() + .map_err(|_| "render_frame is not a function".to_string())?; + + let args = Array::new(); + args.push(&JsValue::from_f64(1.0)); + args.push(&JsValue::from_f64(1.0)); + args.push(&JsValue::from_f64(time_q32 as f64)); + args.push(&JsValue::from_f64(PIXEL_BUF_OFFSET as f64)); + func.apply(&JsValue::NULL, &args) + .map_err(|error| format!("render_frame trap: {error:?}"))?; + + let memory = instance + .js_memory() + .ok_or_else(|| "shader has no linear memory export".to_string())?; + let buffer = memory.buffer(); + let bytes = Uint8Array::new_with_byte_offset_and_length(&buffer, PIXEL_BUF_OFFSET, 4); + let mut rgba = [0_u8; 4]; + bytes.copy_to(&mut rgba); + runtime.logs.push(format!("rendered first pixel: {rgba:?}")); + + Ok(format!("{},{},{},{}", rgba[0], rgba[1], rgba[2], rgba[3])) + }) +} + +#[wasm_bindgen] +pub fn logs(runtime_id: u32) -> Result { + with_runtime_mut(runtime_id, |runtime| { + Ok(format!( + "runtime {} ({})\n{}", + runtime.id, + runtime.label, + runtime.logs.join("\n") + )) + }) +} + +fn with_runtime_mut( + runtime_id: u32, + f: impl FnOnce(&mut BrowserRuntime) -> 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) + }) +} + +#[cfg(test)] +mod tests { + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + + use super::*; + + wasm_bindgen_test_configure!(run_in_browser); + + const CONSTANT_RED_SHADER: &str = r#" +vec4 render(vec2 fragCoord, vec2 outputSize, float time) { + return vec4(1.0, 0.0, 0.0, 1.0); +} +"#; + + #[wasm_bindgen_test] + fn compiles_and_renders_constant_shader() { + fw_browser_init_exports(wasm_bindgen::exports()); + + let before_count = runtime_count(); + let runtime_id = create_runtime("wasm-bindgen-test").expect("create runtime"); + assert_eq!(runtime_count(), before_count + 1); + + compile_shader(runtime_id, CONSTANT_RED_SHADER).expect("compile shader"); + let rgba = render_first_pixel(runtime_id, 0).expect("render first pixel"); + + assert_ne!(rgba, "0,0,0,0"); + assert!( + logs(runtime_id) + .expect("runtime logs") + .contains("shader compiled"), + "logs should record shader compilation" + ); + } +} diff --git a/lp-fw/fw-browser/www/smoke.html b/lp-fw/fw-browser/www/smoke.html new file mode 100644 index 000000000..70a1711ae --- /dev/null +++ b/lp-fw/fw-browser/www/smoke.html @@ -0,0 +1,48 @@ + + + + + fw-browser smoke + + +
running
+ + + diff --git a/lp-fw/fw-host/Cargo.toml b/lp-fw/fw-host/Cargo.toml new file mode 100644 index 000000000..da7c73051 --- /dev/null +++ b/lp-fw/fw-host/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "fw-host" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +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/src/host_runtime.rs b/lp-fw/fw-host/src/host_runtime.rs new file mode 100644 index 000000000..3ae3caea8 --- /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::LpClient; + + use super::*; + + #[tokio::test] + async fn memory_runtime_serves_client_requests_and_shuts_down() { + let mut runtime = HostRuntime::start_memory().unwrap(); + let client = LpClient::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 = LpClient::new_shared(runtime_a.client_transport()); + let client_b = LpClient::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..f87f6b464 --- /dev/null +++ b/lp-fw/fw-host/src/server_loop.rs @@ -0,0 +1,56 @@ +use std::time::{Duration, Instant}; + +use lpa_server::LpServer; +use lpc_shared::transport::ServerTransport; +use lpc_wire::{TransportError, WireMessage}; + +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 mut last_tick = Instant::now(); + + loop { + let frame_start = Instant::now(); + let mut incoming_messages = Vec::new(); + + loop { + match transport.receive().await { + Ok(Some(client_msg)) => incoming_messages.push(WireMessage::Client(client_msg)), + Ok(None) => break, + Err(TransportError::ConnectionLost) => return Ok(()), + Err(error) => { + eprintln!("Host runtime transport error: {error}"); + break; + } + } + } + + let delta_time = last_tick.elapsed(); + let delta_ms = delta_time.as_millis().min(u32::MAX as u128) as u32; + let tick_start = Instant::now(); + + if let Err(error) = server + .tick_and_send(delta_ms.max(1), incoming_messages, &mut transport) + .await + { + eprintln!("Host runtime server error: {error}"); + } else { + let frame_time_us = tick_start.elapsed().as_micros() as u64; + server.set_last_frame_time(frame_time_us); + } + + last_tick = frame_start; + 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; + } + } +} 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; From 926d188fff87c0191cccf10395243c70f2aefa02 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 12:18:43 -0700 Subject: [PATCH 02/62] docs: describe studio runtime crates Adds README coverage for lpa-link and firmware/runtime crates introduced or clarified by the Studio M0 foundation. --- lp-app/README.md | 2 + lp-app/lpa-link/README.md | 51 +++++++++++++++++ lp-fw/README.md | 114 +++++++++++++++++++++++++++++++++---- lp-fw/fw-browser/README.md | 59 +++++++++++++++++++ lp-fw/fw-core/README.md | 34 +++++++++++ lp-fw/fw-emu/README.md | 37 ++++++++++++ lp-fw/fw-esp32/README.md | 52 +++++++++++++++++ lp-fw/fw-host/README.md | 42 ++++++++++++++ 8 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 lp-app/lpa-link/README.md create mode 100644 lp-fw/fw-browser/README.md create mode 100644 lp-fw/fw-core/README.md create mode 100644 lp-fw/fw-emu/README.md create mode 100644 lp-fw/fw-esp32/README.md create mode 100644 lp-fw/fw-host/README.md diff --git a/lp-app/README.md b/lp-app/README.md index e6b811f17..69ec3f3f4 100644 --- a/lp-app/README.md +++ b/lp-app/README.md @@ -17,6 +17,8 @@ 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. - `web-demo` — browser demo and tooling for the shader pipeline. ## Boundary diff --git a/lp-app/lpa-link/README.md b/lp-app/lpa-link/README.md new file mode 100644 index 000000000..378e29868 --- /dev/null +++ b/lp-app/lpa-link/README.md @@ -0,0 +1,51 @@ +# lpa-link + +`lpa-link` is the low-level app-side link layer for LightPlayer endpoints. + +It 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. + +## Providers + +- `providers::fake` is a deterministic test provider and future Studio-core + harness. +- `providers::local_host` launches host-local runtime instances through + `fw-host` and returns a connection usable by `lpa-client`. +- `providers::local_browser` models browser/Web Worker runtime instances for + Studio simulation and project testing. + +Provider support is feature-gated: + +```bash +cargo check -p lpa-link +cargo test -p lpa-link +cargo check -p lpa-link --features local-host +cargo test -p lpa-link --features local-host +cargo check -p lpa-link --features local-browser --target wasm32-unknown-unknown +cargo test -p lpa-link --features local-browser +``` + +## Design Notes + +- 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 `local_host`, + `local_browser`, `discover`, `status`, `connect`, and `logs`. +- The model is plural-first. Multiple host or browser runtime instances should + be natural, even if the first Studio UI exposes only one session. +- A `LinkConnection` is a server/client connection, not a project session. + Project sessions belong above this layer. diff --git a/lp-fw/README.md b/lp-fw/README.md index c5b2084f1..3b0b10b6c 100644 --- a/lp-fw/README.md +++ b/lp-fw/README.md @@ -1,9 +1,84 @@ -# 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` `local-host` 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 local-host +cargo test -p lpa-link --features local-host +``` + +### 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, +compiles a tiny shader through the real shader frontend, and renders a test +pixel. + +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"`. + +`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 +89,31 @@ 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 +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 (usually installed automatically by cargo-espflash) -- RISC-V 32-bit target installed (handled automatically by the just command) \ No newline at end of file +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. + +## 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/fw-browser/README.md b/lp-fw/fw-browser/README.md new file mode 100644 index 000000000..82a35635e --- /dev/null +++ b/lp-fw/fw-browser/README.md @@ -0,0 +1,59 @@ +# 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. + +## Relationship To Other Crates + +- `lps-frontend` parses and lowers GLSL. +- `lpvm-wasm` compiles the lowered shader to wasm and runs it through browser + `WebAssembly` APIs. +- `lpa-link` `local-browser` 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. + +## Public Proof Surface + +The current wasm-bindgen exports are intentionally small: + +- initialize browser builtin exports +- create a named runtime instance +- compile a shader into that runtime +- render the first pixel +- read runtime-scoped logs +- read runtime count + +That proves the first browser-local thread without committing to the final +Studio API. + +## 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"`, and renders a red test pixel. + +`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-core/README.md b/lp-fw/fw-core/README.md new file mode 100644 index 000000000..26c7f13b7 --- /dev/null +++ b/lp-fw/fw-core/README.md @@ -0,0 +1,34 @@ +# 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, and target-specific logging support. + +## 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. + +## 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 +``` + +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-emu/README.md b/lp-fw/fw-emu/README.md new file mode 100644 index 000000000..84890fb0f --- /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 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-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-host/README.md b/lp-fw/fw-host/README.md new file mode 100644 index 000000000..3c65bb990 --- /dev/null +++ b/lp-fw/fw-host/README.md @@ -0,0 +1,42 @@ +# 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` `local-host` uses `fw-host` to create runtime instances and expose + them as low-level link sessions. +- `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 local-host +cargo test -p lpa-link --features local-host +``` From 3332390ca1e935f9dfe6dc6b1735dca5d3703f5b Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 13:41:13 -0700 Subject: [PATCH 03/62] test: restore fw emulator scene render coverage --- lp-core/lpc-shared/src/project/builder.rs | 20 +- lp-fw/fw-tests/tests/scene_render_emu.rs | 287 +++++++++++++++++++++- 2 files changed, 300 insertions(+), 7 deletions(-) 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/fw-tests/tests/scene_render_emu.rs b/lp-fw/fw-tests/tests/scene_render_emu.rs index a5f195437..9c0c6e883 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::LpClient; +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 = LpClient::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: &LpClient, + 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: &LpClient, + 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, +} From 83d7ffafd8a363abc5fed7b799ce38c4b27baa9d Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 13:57:29 -0700 Subject: [PATCH 04/62] feat: build browser firmware runtime --- Cargo.lock | 13 +- .../2026-06-17-browser-firmware-runtime.md | 84 +++ lp-app/lpa-link/README.md | 8 +- lp-app/lpa-link/src/link_connection.rs | 16 + .../lpa-link/src/providers/local_browser.rs | 23 +- lp-fw/README.md | 9 +- lp-fw/fw-browser/Cargo.toml | 14 +- lp-fw/fw-browser/README.md | 38 +- lp-fw/fw-browser/src/lib.rs | 695 +++++++++++++++--- lp-fw/fw-browser/www/fw-browser-worker.js | 69 ++ lp-fw/fw-browser/www/smoke-project/clock.toml | 1 + .../fw-browser/www/smoke-project/fixture.toml | 40 + .../fw-browser/www/smoke-project/output.toml | 12 + .../fw-browser/www/smoke-project/project.toml | 14 + .../fw-browser/www/smoke-project/shader.glsl | 6 + .../fw-browser/www/smoke-project/shader.toml | 18 + lp-fw/fw-browser/www/smoke.html | 249 ++++++- lp-fw/fw-core/README.md | 15 +- lp-fw/fw-core/src/lib.rs | 4 + lp-fw/fw-core/src/runtime.rs | 160 ++++ lp-fw/fw-emu/README.md | 4 +- lp-fw/fw-emu/src/server_loop.rs | 66 +- lp-fw/fw-host/Cargo.toml | 1 + lp-fw/fw-host/README.md | 2 + lp-fw/fw-host/src/server_loop.rs | 65 +- lp-fw/fw-tests/README.md | 19 +- 26 files changed, 1417 insertions(+), 228 deletions(-) create mode 100644 docs/adr/2026-06-17-browser-firmware-runtime.md create mode 100644 lp-fw/fw-browser/www/fw-browser-worker.js create mode 100644 lp-fw/fw-browser/www/smoke-project/clock.toml create mode 100644 lp-fw/fw-browser/www/smoke-project/fixture.toml create mode 100644 lp-fw/fw-browser/www/smoke-project/output.toml create mode 100644 lp-fw/fw-browser/www/smoke-project/project.toml create mode 100644 lp-fw/fw-browser/www/smoke-project/shader.glsl create mode 100644 lp-fw/fw-browser/www/smoke-project/shader.toml create mode 100644 lp-fw/fw-core/src/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index 8e8d03fa1..3bbcd2c73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2823,10 +2823,16 @@ dependencies = [ name = "fw-browser" version = "40.0.0" dependencies = [ - "js-sys", - "lps-frontend", - "lpvm", + "fw-core", + "lpa-server", + "lpc-hardware", + "lpc-model", + "lpc-shared", + "lpc-wire", + "lpfs", "lpvm-wasm", + "serde", + "serde_json", "wasm-bindgen", "wasm-bindgen-test", ] @@ -2929,6 +2935,7 @@ dependencies = [ name = "fw-host" version = "40.0.0" dependencies = [ + "fw-core", "lpa-client", "lpa-server", "lpc-hardware", 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..9f2f7685a --- /dev/null +++ b/docs/adr/2026-06-17-browser-firmware-runtime.md @@ -0,0 +1,84 @@ +# 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 local-browser` models endpoint/session identity and reports a +`LocalBrowserWorker` 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. diff --git a/lp-app/lpa-link/README.md b/lp-app/lpa-link/README.md index 378e29868..c0cc3642a 100644 --- a/lp-app/lpa-link/README.md +++ b/lp-app/lpa-link/README.md @@ -25,7 +25,9 @@ details directly in UI code. - `providers::local_host` launches host-local runtime instances through `fw-host` and returns a connection usable by `lpa-client`. - `providers::local_browser` models browser/Web Worker runtime instances for - Studio simulation and project testing. + Studio simulation and project testing. Its connection kind records the + `fw-browser-post-message-v1` envelope; Studio web code owns the actual + JavaScript `Worker` object and postMessage transport binding. Provider support is feature-gated: @@ -49,3 +51,7 @@ cargo test -p lpa-link --features local-browser be natural, even if the first Studio UI exposes only one session. - A `LinkConnection` is a server/client connection, not a project session. Project sessions belong above this layer. +- `local-browser` is worker-shaped but not Rust-owned. The link layer can model + endpoint/session identity, status, logs, diagnostics, and the worker envelope + protocol. The web frontend must still bind that model to an actual module + Worker created from `fw-browser/www/fw-browser-worker.js`. diff --git a/lp-app/lpa-link/src/link_connection.rs b/lp-app/lpa-link/src/link_connection.rs index 65066ecfb..062e6b97a 100644 --- a/lp-app/lpa-link/src/link_connection.rs +++ b/lp-app/lpa-link/src/link_connection.rs @@ -5,6 +5,7 @@ use crate::{LinkEndpointId, LinkSessionId}; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum LinkConnectionKind { Fake, + LocalBrowserWorker { protocol: String }, PendingImplementation { kind: String }, } @@ -47,6 +48,21 @@ impl LinkConnection { } } + pub fn local_browser_worker( + endpoint_id: impl Into, + session_id: impl Into, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::LocalBrowserWorker { + protocol: "fw-browser-post-message-v1".to_string(), + }, + #[cfg(feature = "local-host")] + local_host_transport: None, + } + } + #[cfg(feature = "local-host")] pub fn local_host( endpoint_id: impl Into, diff --git a/lp-app/lpa-link/src/providers/local_browser.rs b/lp-app/lpa-link/src/providers/local_browser.rs index 2baa6975b..65b5b092c 100644 --- a/lp-app/lpa-link/src/providers/local_browser.rs +++ b/lp-app/lpa-link/src/providers/local_browser.rs @@ -99,7 +99,7 @@ impl LocalBrowserSession { endpoint_id.clone(), Some(id.clone()), LinkDiagnosticSeverity::Info, - "local browser worker session pending runtime binding", + "local browser worker session ready; Studio web owns Worker postMessage binding", )]; Self { endpoint_id, @@ -132,10 +132,9 @@ impl LinkSession for LocalBrowserSession { if self.closed { return Err(LinkError::Closed); } - Ok(LinkConnection::pending( + Ok(LinkConnection::local_browser_worker( self.endpoint_id.clone(), self.id.clone(), - "local-browser", )) } @@ -153,6 +152,8 @@ impl LinkSession for LocalBrowserSession { #[cfg(test)] mod tests { + use crate::LinkConnectionKind; + use super::*; #[tokio::test] @@ -170,4 +171,20 @@ mod tests { assert_ne!(session_a.id(), session_b.id()); assert_ne!(session_a.endpoint_id(), session_b.endpoint_id()); } + + #[tokio::test] + async fn local_browser_connection_reports_worker_protocol() { + let mut provider = LocalBrowserProvider::new("local-browser"); + let endpoint_id = provider.create_worker_endpoint("Browser A"); + let mut session = provider.connect(&endpoint_id).await.unwrap(); + + let connection = session.connection().await.unwrap(); + + assert_eq!(connection.endpoint_id, endpoint_id); + assert!(matches!( + connection.kind, + LinkConnectionKind::LocalBrowserWorker { ref protocol } + if protocol == "fw-browser-post-message-v1" + )); + } } diff --git a/lp-fw/README.md b/lp-fw/README.md index 3b0b10b6c..3b82857fb 100644 --- a/lp-fw/README.md +++ b/lp-fw/README.md @@ -47,8 +47,9 @@ cargo test -p lpa-link --features local-host `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, -compiles a tiny shader through the real shader frontend, and renders a test -pixel. +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: @@ -71,7 +72,9 @@ http://127.0.0.1:2819/smoke.html ``` Success means the page shows `ok` and -`document.documentElement.dataset.smoke == "ok"`. +`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 diff --git a/lp-fw/fw-browser/Cargo.toml b/lp-fw/fw-browser/Cargo.toml index a110eafc4..946e18104 100644 --- a/lp-fw/fw-browser/Cargo.toml +++ b/lp-fw/fw-browser/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true license.workspace = true rust-version.workspace = true publish = false -description = "Browser/Web Worker LightPlayer runtime proof" +description = "Browser/Web Worker LightPlayer firmware runtime" [lints] workspace = true @@ -15,10 +15,16 @@ workspace = true crate-type = ["cdylib", "rlib"] [dependencies] -js-sys = "0.3" -lps-frontend = { path = "../../lp-shader/lps-frontend" } -lpvm = { path = "../../lp-shader/lpvm" } +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] diff --git a/lp-fw/fw-browser/README.md b/lp-fw/fw-browser/README.md index 82a35635e..a2f76a14d 100644 --- a/lp-fw/fw-browser/README.md +++ b/lp-fw/fw-browser/README.md @@ -5,31 +5,43 @@ 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. +`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 -- `lps-frontend` parses and lowers GLSL. -- `lpvm-wasm` compiles the lowered shader to wasm and runs it through browser - `WebAssembly` APIs. +- `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` `local-browser` 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. -## Public Proof Surface +## Worker Boundary -The current wasm-bindgen exports are intentionally small: +The wasm-bindgen exports are intentionally small and firmware-shaped: - initialize browser builtin exports - create a named runtime instance -- compile a shader into that runtime -- render the first pixel -- read runtime-scoped logs +- send structured envelope JSON to the runtime +- tick the runtime deterministically +- drain structured output envelope JSON - read runtime count -That proves the first browser-local thread without committing to the final -Studio API. +`fw-browser/www/fw-browser-worker.js` wraps those exports in a module Web Worker +that accepts the same envelope vocabulary over `postMessage`. + +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 @@ -52,7 +64,9 @@ http://127.0.0.1:2819/smoke.html ``` Success means the page reports `ok`, sets -`document.documentElement.dataset.smoke == "ok"`, and renders a red test pixel. +`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 diff --git a/lp-fw/fw-browser/src/lib.rs b/lp-fw/fw-browser/src/lib.rs index 57058178e..e0230e903 100644 --- a/lp-fw/fw-browser/src/lib.rs +++ b/lp-fw/fw-browser/src/lib.rs @@ -1,131 +1,338 @@ -//! Browser/Web Worker LightPlayer runtime proof. +//! Browser/Web Worker LightPlayer firmware runtime. +//! +//! The public wasm surface is a firmware-shaped message boundary. JavaScript +//! owns worker creation and `postMessage`; this crate owns the runtime behind +//! that boundary: `LpServer`, filesystem, virtual hardware/output, tick state, +//! logs, and protocol message routing. #![cfg(target_arch = "wasm32")] use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; -use js_sys::{Array, Function, Reflect, Uint8Array}; -use lps_frontend::{compile, lower}; -use lpvm::{LpvmEngine, LpvmModule}; -use lpvm_wasm::rt_browser::{BrowserLpvmEngine, BrowserLpvmInstance, init_host_exports}; -use lpvm_wasm::{FloatMode, WasmOptions}; -use wasm_bindgen::JsCast; +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_shared::transport::ServerTransport; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage, json}; +use lpfs::LpFsMemory; +use lpvm_wasm::rt_browser::init_host_exports; +use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; -const PIXEL_BUF_OFFSET: u32 = 1024; - thread_local! { - static RUNTIMES: RefCell> = const { RefCell::new(Vec::new()) }; -} - -struct BrowserRuntime { - id: u32, - label: String, - engine: BrowserLpvmEngine, - instance: Option, - logs: Vec, + static RUNTIMES: RefCell> = const { RefCell::new(Vec::new()) }; } +/// 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 { - let opts = WasmOptions { - float_mode: FloatMode::Q32, - ..Default::default() - }; - let engine = BrowserLpvmEngine::new(opts).map_err(|error| format!("{error}"))?; - RUNTIMES.with(|runtimes| { let mut runtimes = runtimes.borrow_mut(); let id = runtimes.len() as u32 + 1; - runtimes.push(BrowserRuntime { - id, - label: label.to_string(), - engine, - instance: None, - logs: vec![format!("runtime {id} created: {label}")], - }); + runtimes.push(BrowserFirmwareRuntime::new(id, label)?); Ok(id) }) } +/// Number of live browser firmware runtimes. #[wasm_bindgen] pub fn runtime_count() -> u32 { RUNTIMES.with(|runtimes| runtimes.borrow().len() as u32) } +/// Handle one input envelope encoded as JSON and return output envelopes JSON. #[wasm_bindgen] -pub fn compile_shader(runtime_id: u32, source: &str) -> Result<(), String> { +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}"))?; with_runtime_mut(runtime_id, |runtime| { - let naga = compile(source).map_err(|error| format!("parse: {error}"))?; - let (ir, meta) = lower(&naga).map_err(|error| format!("lower: {error}"))?; - let module = runtime - .engine - .compile(&ir, &meta) - .map_err(|error| format!("compile: {error}"))?; - let instance = module - .instantiate() - .map_err(|error| format!("instantiate: {error}"))?; - runtime.instance = Some(instance); - runtime.logs.push("shader compiled".to_string()); - Ok(()) + runtime.handle_envelope(envelope)?; + runtime.drain_output_json() }) } +/// Tick a runtime by `delta_ms` and return output envelopes JSON. #[wasm_bindgen] -pub fn render_first_pixel(runtime_id: u32, time_q32: i32) -> Result { +pub fn tick_runtime(runtime_id: u32, delta_ms: u32) -> Result { with_runtime_mut(runtime_id, |runtime| { - let instance = runtime - .instance - .as_ref() - .ok_or_else(|| "no shader loaded".to_string())?; - let exports = instance.js_exports(); - let func = Reflect::get(exports, &JsValue::from_str("render_frame")) - .map_err(|error| format!("get render_frame: {error:?}"))?; - let func: Function = func - .dyn_into() - .map_err(|_| "render_frame is not a function".to_string())?; - - let args = Array::new(); - args.push(&JsValue::from_f64(1.0)); - args.push(&JsValue::from_f64(1.0)); - args.push(&JsValue::from_f64(time_q32 as f64)); - args.push(&JsValue::from_f64(PIXEL_BUF_OFFSET as f64)); - func.apply(&JsValue::NULL, &args) - .map_err(|error| format!("render_frame trap: {error:?}"))?; - - let memory = instance - .js_memory() - .ok_or_else(|| "shader has no linear memory export".to_string())?; - let buffer = memory.buffer(); - let bytes = Uint8Array::new_with_byte_offset_and_length(&buffer, PIXEL_BUF_OFFSET, 4); - let mut rgba = [0_u8; 4]; - bytes.copy_to(&mut rgba); - runtime.logs.push(format!("rendered first pixel: {rgba:?}")); - - Ok(format!("{},{},{},{}", rgba[0], rgba[1], rgba[2], rgba[3])) + runtime.tick(delta_ms.max(1))?; + runtime.drain_output_json() }) } +/// Drain pending output envelopes without ticking. #[wasm_bindgen] -pub fn logs(runtime_id: u32) -> Result { - with_runtime_mut(runtime_id, |runtime| { - Ok(format!( - "runtime {} ({})\n{}", - runtime.id, - runtime.label, - runtime.logs.join("\n") - )) - }) +pub fn drain_output_json(runtime_id: u32) -> Result { + with_runtime_mut(runtime_id, |runtime| runtime.drain_output_json()) +} + +struct BrowserFirmwareRuntime { + id: u32, + label: String, + server: LpServer, + transport: BrowserServerTransport, + time: ManualTimeProvider, + last_tick_ms: u64, + running: bool, + outbox: Vec, +} + +impl BrowserFirmwareRuntime { + 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) + } + + 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(()), + } + } + + 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(()) + } + + 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 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 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), + }); + } +} + +#[derive(Clone)] +struct ManualTimeProvider { + now_ms: Rc>, +} + +impl ManualTimeProvider { + fn new() -> Self { + Self { + now_ms: Rc::new(RefCell::new(0)), + } + } + + 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() + } +} + +struct BrowserServerTransport { + incoming: Vec, + outgoing: Vec, + closed: bool, +} + +impl BrowserServerTransport { + fn new() -> Self { + Self { + incoming: Vec::new(), + outgoing: Vec::new(), + closed: false, + } + } + + fn push_incoming(&mut self, msg: ClientMessage) { + self.incoming.push(msg); + } + + 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(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum BrowserInputEnvelope { + ProtocolIn { frame: String }, + Tick { delta_ms: Option }, + Start, + Stop, + Drain, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum BrowserOutputEnvelope { + Status { + runtime_id: u32, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + Log { + runtime_id: u32, + level: String, + target: String, + message: String, + }, + ProtocolOut { + frame: String, + }, } fn with_runtime_mut( runtime_id: u32, - f: impl FnOnce(&mut BrowserRuntime) -> Result, + f: impl FnOnce(&mut BrowserFirmwareRuntime) -> Result, ) -> Result { RUNTIMES.with(|runtimes| { let mut runtimes = runtimes.borrow_mut(); @@ -137,37 +344,325 @@ fn with_runtime_mut( }) } +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 => {} + } + } +} + #[cfg(test)] mod tests { + 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, messages::ClientMessage, + }; + use lpfs::{LpFs, LpFsMemory}; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; use super::*; wasm_bindgen_test_configure!(run_in_browser); - const CONSTANT_RED_SHADER: &str = r#" -vec4 render(vec2 fragCoord, vec2 outputSize, float time) { - return vec4(1.0, 0.0, 0.0, 1.0); -} -"#; - #[wasm_bindgen_test] - fn compiles_and_renders_constant_shader() { + fn runtime_serves_protocol_messages_after_tick() { fw_browser_init_exports(wasm_bindgen::exports()); - let before_count = runtime_count(); let runtime_id = create_runtime("wasm-bindgen-test").expect("create runtime"); - assert_eq!(runtime_count(), before_count + 1); + 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:?}"), + } + } - compile_shader(runtime_id, CONSTANT_RED_SHADER).expect("compile shader"); - let rgba = render_first_pixel(runtime_id, 0).expect("render first pixel"); + 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_ne!(rgba, "0,0,0,0"); assert!( - logs(runtime_id) - .expect("runtime logs") - .contains("shader compiled"), - "logs should record shader compilation" + 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/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..d953f1d1f --- /dev/null +++ b/lp-fw/fw-browser/www/smoke-project/shader.glsl @@ -0,0 +1,6 @@ +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec4 render(vec2 pos) { + return vec4(mod(time, 1.0), 0.0, 0.0, 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 index 70a1711ae..1e29dfc57 100644 --- a/lp-fw/fw-browser/www/smoke.html +++ b/lp-fw/fw-browser/www/smoke.html @@ -7,42 +7,237 @@
running
diff --git a/lp-fw/fw-core/README.md b/lp-fw/fw-core/README.md index 26c7f13b7..9552e99cc 100644 --- a/lp-fw/fw-core/README.md +++ b/lp-fw/fw-core/README.md @@ -4,7 +4,7 @@ It is `no_std` by default and provides reusable pieces for embedded/server firmware, including serial transport helpers, message routing, test-message -serialization, and target-specific logging support. +serialization, target-specific logging support, and small runtime loop helpers. ## Relationship To Other Crates @@ -17,6 +17,18 @@ serialization, and target-specific logging support. 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. @@ -27,6 +39,7 @@ runtime lifecycle belong in their target crates. ```bash cargo check -p fw-core +cargo test -p fw-core ``` When changing code that affects firmware behavior, also run the relevant target 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/README.md b/lp-fw/fw-emu/README.md index 84890fb0f..5703e42bb 100644 --- a/lp-fw/fw-emu/README.md +++ b/lp-fw/fw-emu/README.md @@ -9,8 +9,8 @@ without requiring physical ESP32 hardware for every test. ## Relationship To Other Crates -- `fw-core` provides shared firmware transport/logging plumbing with the `emu` - feature. +- `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. 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-host/Cargo.toml b/lp-fw/fw-host/Cargo.toml index da7c73051..24b1d35a0 100644 --- a/lp-fw/fw-host/Cargo.toml +++ b/lp-fw/fw-host/Cargo.toml @@ -7,6 +7,7 @@ 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"] } diff --git a/lp-fw/fw-host/README.md b/lp-fw/fw-host/README.md index 3c65bb990..01ed0c995 100644 --- a/lp-fw/fw-host/README.md +++ b/lp-fw/fw-host/README.md @@ -12,6 +12,8 @@ server internals directly. - `lpa-client` consumes the client-side connection created by the runtime. - `lpa-link` `local-host` 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. diff --git a/lp-fw/fw-host/src/server_loop.rs b/lp-fw/fw-host/src/server_loop.rs index f87f6b464..61b0770bf 100644 --- a/lp-fw/fw-host/src/server_loop.rs +++ b/lp-fw/fw-host/src/server_loop.rs @@ -1,8 +1,10 @@ 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, WireMessage}; +use lpc_wire::TransportError; use crate::HostRuntimeError; @@ -12,39 +14,34 @@ pub async fn run_server_loop_async( mut server: LpServer, mut transport: T, ) -> Result<(), HostRuntimeError> { - let mut last_tick = Instant::now(); + let time_provider = HostLoopTimeProvider::new(); + let mut last_tick_ms = time_provider.now_ms(); loop { let frame_start = Instant::now(); - let mut incoming_messages = Vec::new(); - - loop { - match transport.receive().await { - Ok(Some(client_msg)) => incoming_messages.push(WireMessage::Client(client_msg)), - Ok(None) => break, - Err(TransportError::ConnectionLost) => return Ok(()), - Err(error) => { - eprintln!("Host runtime transport error: {error}"); - break; - } + 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 delta_time = last_tick.elapsed(); - let delta_ms = delta_time.as_millis().min(u32::MAX as u128) as u32; - let tick_start = Instant::now(); - - if let Err(error) = server - .tick_and_send(delta_ms.max(1), incoming_messages, &mut transport) - .await - { + 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}"); - } else { - let frame_time_us = tick_start.elapsed().as_micros() as u64; - server.set_last_frame_time(frame_time_us); } - last_tick = frame_start; + 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) @@ -54,3 +51,21 @@ pub async fn run_server_loop_async( } } } + +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 From a9fc782e9c39b7f6ecfd3d621718b846bd79ecbb Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 14:31:56 -0700 Subject: [PATCH 05/62] Improve fw-browser runtime smoke test --- lp-fw/fw-browser/src/envelope.rs | 45 ++ lp-fw/fw-browser/src/executor.rs | 25 + lp-fw/fw-browser/src/lib.rs | 675 +----------------- lp-fw/fw-browser/src/manual_time_provider.rs | 36 + lp-fw/fw-browser/src/runtime.rs | 171 +++++ lp-fw/fw-browser/src/runtime_registry.rs | 42 ++ lp-fw/fw-browser/src/server_transport.rs | 65 ++ lp-fw/fw-browser/src/tests.rs | 302 ++++++++ lp-fw/fw-browser/src/wasm_exports.rs | 54 ++ .../fw-browser/www/smoke-project/shader.glsl | 14 +- lp-fw/fw-browser/www/smoke.html | 554 ++++++++++++-- 11 files changed, 1258 insertions(+), 725 deletions(-) create mode 100644 lp-fw/fw-browser/src/envelope.rs create mode 100644 lp-fw/fw-browser/src/executor.rs create mode 100644 lp-fw/fw-browser/src/manual_time_provider.rs create mode 100644 lp-fw/fw-browser/src/runtime.rs create mode 100644 lp-fw/fw-browser/src/runtime_registry.rs create mode 100644 lp-fw/fw-browser/src/server_transport.rs create mode 100644 lp-fw/fw-browser/src/tests.rs create mode 100644 lp-fw/fw-browser/src/wasm_exports.rs 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 index e0230e903..02263c212 100644 --- a/lp-fw/fw-browser/src/lib.rs +++ b/lp-fw/fw-browser/src/lib.rs @@ -1,668 +1,23 @@ //! Browser/Web Worker LightPlayer firmware runtime. //! -//! The public wasm surface is a firmware-shaped message boundary. JavaScript -//! owns worker creation and `postMessage`; this crate owns the runtime behind -//! that boundary: `LpServer`, filesystem, virtual hardware/output, tick state, -//! logs, and protocol message routing. +//! 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")] -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::Arc; +mod envelope; +mod executor; +mod manual_time_provider; +mod runtime; +mod runtime_registry; +mod server_transport; +mod wasm_exports; -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_shared::transport::ServerTransport; -use lpc_wire::{ClientMessage, TransportError, WireServerMessage, json}; -use lpfs::LpFsMemory; -use lpvm_wasm::rt_browser::init_host_exports; -use serde::{Deserialize, Serialize}; -use wasm_bindgen::prelude::*; - -thread_local! { - static RUNTIMES: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// 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 { - RUNTIMES.with(|runtimes| { - let mut runtimes = runtimes.borrow_mut(); - let id = runtimes.len() as u32 + 1; - runtimes.push(BrowserFirmwareRuntime::new(id, label)?); - Ok(id) - }) -} - -/// Number of live browser firmware runtimes. -#[wasm_bindgen] -pub fn runtime_count() -> u32 { - RUNTIMES.with(|runtimes| runtimes.borrow().len() as u32) -} - -/// 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}"))?; - 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 { - 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 { - with_runtime_mut(runtime_id, |runtime| runtime.drain_output_json()) -} - -struct BrowserFirmwareRuntime { - id: u32, - label: String, - server: LpServer, - transport: BrowserServerTransport, - time: ManualTimeProvider, - last_tick_ms: u64, - running: bool, - outbox: Vec, -} - -impl BrowserFirmwareRuntime { - 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) - } - - 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(()), - } - } - - 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(()) - } - - 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 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 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), - }); - } -} - -#[derive(Clone)] -struct ManualTimeProvider { - now_ms: Rc>, -} - -impl ManualTimeProvider { - fn new() -> Self { - Self { - now_ms: Rc::new(RefCell::new(0)), - } - } - - 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() - } -} - -struct BrowserServerTransport { - incoming: Vec, - outgoing: Vec, - closed: bool, -} - -impl BrowserServerTransport { - fn new() -> Self { - Self { - incoming: Vec::new(), - outgoing: Vec::new(), - closed: false, - } - } - - fn push_incoming(&mut self, msg: ClientMessage) { - self.incoming.push(msg); - } - - 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(()) - } -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum BrowserInputEnvelope { - ProtocolIn { frame: String }, - Tick { delta_ms: Option }, - Start, - Stop, - Drain, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum BrowserOutputEnvelope { - Status { - runtime_id: u32, - status: String, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, - }, - Log { - runtime_id: u32, - level: String, - target: String, - message: String, - }, - ProtocolOut { - frame: String, - }, -} - -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) - }) -} - -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 => {} - } - } -} +pub use wasm_exports::{ + create_runtime, drain_output_json, fw_browser_init_exports, handle_envelope_json, + runtime_count, tick_runtime, +}; #[cfg(test)] -mod tests { - 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, messages::ClientMessage, - }; - use lpfs::{LpFs, LpFsMemory}; - use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; - - use super::*; - - 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 }, - } -} +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/smoke-project/shader.glsl b/lp-fw/fw-browser/www/smoke-project/shader.glsl index d953f1d1f..b41425d03 100644 --- a/lp-fw/fw-browser/www/smoke-project/shader.glsl +++ b/lp-fw/fw-browser/www/smoke-project/shader.glsl @@ -1,6 +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) { - return vec4(mod(time, 1.0), 0.0, 0.0, 1.0); + 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.html b/lp-fw/fw-browser/www/smoke.html index 1e29dfc57..e1029a4bb 100644 --- a/lp-fw/fw-browser/www/smoke.html +++ b/lp-fw/fw-browser/www/smoke.html @@ -2,23 +2,219 @@ + fw-browser smoke + -
running
+
+

fw-browser smoke

+
booting
+
+
+
+

Render product

+
+ +
+
+
+

Output channels

+
+ +
+
+
+

Boot checklist

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

Worker log

+
starting
+
+
From 9e120b0beae4d070ea1a2a55579517bb0e7d0730 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 14:48:46 -0700 Subject: [PATCH 06/62] Document agent planning workflow --- AGENTS.md | 22 ++++++++ agent-context.toml | 5 ++ docs/adr/README.md | 43 ++++++++++++++++ docs/adr/_template.md | 17 +++++++ scripts/agent-context.sh | 106 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 agent-context.toml create mode 100644 docs/adr/README.md create mode 100644 docs/adr/_template.md create mode 100755 scripts/agent-context.sh diff --git a/AGENTS.md b/AGENTS.md index 8779970b5..938616088 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,28 @@ 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. + ## Validation Commands These commands must pass for any change touching the shader pipeline: 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/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/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" From 852f7cf3ab8e6b68a7f48fba1393949235e70eb7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 14:48:49 -0700 Subject: [PATCH 07/62] Add basic3 project example --- examples/basic3/project.json | 4 +++ examples/basic3/src/fixture.fixture/node.json | 31 +++++++++++++++++ examples/basic3/src/main.texture/node.json | 4 +++ examples/basic3/src/rainbow.shader/main.glsl | 33 +++++++++++++++++++ examples/basic3/src/rainbow.shader/node.json | 5 +++ examples/basic3/src/strip.output/node.json | 11 +++++++ 6 files changed, 88 insertions(+) create mode 100644 examples/basic3/project.json create mode 100644 examples/basic3/src/fixture.fixture/node.json create mode 100644 examples/basic3/src/main.texture/node.json create mode 100644 examples/basic3/src/rainbow.shader/main.glsl create mode 100644 examples/basic3/src/rainbow.shader/node.json create mode 100644 examples/basic3/src/strip.output/node.json 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 + } + } +} From de93ce85968ae9fc2bca82017862a8b754d33b09 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 14:48:52 -0700 Subject: [PATCH 08/62] Add firmware crate cargo configs --- lp-fw/esp-println-fork/.cargo/config.toml | 2 ++ lp-fw/fw-emu/.cargo/config.toml | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 lp-fw/esp-println-fork/.cargo/config.toml create mode 100644 lp-fw/fw-emu/.cargo/config.toml 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-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"] From f39cc06cafab168c395cc53cf9a547831ac14643 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 14:48:55 -0700 Subject: [PATCH 09/62] Update IDE module source roots --- .idea/lp2025.iml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml index 27f10b7b8..7a2068665 100644 --- a/.idea/lp2025.iml +++ b/.idea/lp2025.iml @@ -103,6 +103,9 @@ + + + From e809444704449ef6e10fd1e8ff548d09b9a6cee5 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 15:56:35 -0700 Subject: [PATCH 10/62] Route CLI local mode through lpa-link --- Cargo.lock | 2 + lp-app/lpa-link/README.md | 54 +++- lp-app/lpa-link/src/link_connection.rs | 5 +- lp-app/lpa-link/src/link_endpoint.rs | 5 + lp-app/lpa-link/src/link_provider.rs | 4 + lp-app/lpa-link/src/link_session.rs | 5 + lp-app/lpa-link/src/providers/local_host.rs | 9 + lp-cli/Cargo.toml | 2 + lp-cli/src/client/client_connect.rs | 12 +- lp-cli/src/client/local_host.rs | 117 +++++++++ lp-cli/src/client/local_server.rs | 267 -------------------- lp-cli/src/client/mod.rs | 2 +- 12 files changed, 194 insertions(+), 290 deletions(-) create mode 100644 lp-cli/src/client/local_host.rs delete mode 100644 lp-cli/src/client/local_server.rs diff --git a/Cargo.lock b/Cargo.lock index 3bbcd2c73..82ed1683b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3951,6 +3951,7 @@ dependencies = [ "lp-riscv-inst", "lp-shader", "lpa-client", + "lpa-link", "lpa-server", "lpc-engine", "lpc-hardware", @@ -3965,6 +3966,7 @@ dependencies = [ "lpvm-cranelift", "lpvm-native", "notify", + "pollster", "rustc-demangle", "serde", "serde_json", diff --git a/lp-app/lpa-link/README.md b/lp-app/lpa-link/README.md index c0cc3642a..8a7120eda 100644 --- a/lp-app/lpa-link/README.md +++ b/lp-app/lpa-link/README.md @@ -1,11 +1,18 @@ # lpa-link -`lpa-link` is the low-level app-side link layer for LightPlayer endpoints. +`lpa-link` provides the mechanism by which an application like Studio or the CLI +connects to an `lp-server`. -It 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`. +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 @@ -20,14 +27,19 @@ details directly in UI code. ## Providers -- `providers::fake` is a deterministic test provider and future Studio-core - harness. -- `providers::local_host` launches host-local runtime instances through - `fw-host` and returns a connection usable by `lpa-client`. -- `providers::local_browser` models browser/Web Worker runtime instances for - Studio simulation and project testing. Its connection kind records the - `fw-browser-post-message-v1` envelope; Studio web code owns the actual - JavaScript `Worker` object and postMessage transport binding. +| Provider ID | Rust module/type | Runtime or device | Endpoint kind | Management intent | Status | +|---|---|---|---|---|---| +| `fake` | `providers::fake::FakeProvider` | none | test endpoint | diagnostics only | implemented | +| `local-host` | `providers::local_host::LocalHostProvider` | in-process `fw-host` | spawnable host runtime | logs, diagnostics, future local filesystem/runtime controls | implemented | +| `local-browser` | `providers::local_browser::LocalBrowserProvider` | `fw-browser` Web Worker | browser worker runtime | logs, diagnostics, worker lifecycle | model implemented; web code owns the actual Worker binding | +| `serial-esp32-web` | future `providers::serial_esp32_web::SerialEsp32WebProvider` | ESP32 over Web Serial | physical serial device | connect, reset, flash, raw filesystem, diagnostics | future | +| `serial-esp32-host` | future `providers::serial_esp32_host::SerialEsp32HostProvider` | ESP32 over host serial | physical serial device | connect, reset, flash, raw filesystem, diagnostics | future | +| `websocket` | future `providers::websocket::WebSocketProvider` | already-running server | remote endpoint | mostly connect/status; limited management | future | +| `webserver-host` | future `providers::webserver_host::WebserverHostProvider` | 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: @@ -42,13 +54,29 @@ cargo test -p lpa-link --features local-browser ## Design Notes +- **Provider:** source of endpoints and management behavior, such as + `local-host`, `local-browser`, or future ESP32 serial providers. +- **Endpoint:** something a provider can connect to. An endpoint can be physical + hardware or a spawnable runtime target. +- **Session:** live ownership/lifecycle of a connected endpoint or launched + runtime. +- **Connection:** client protocol channel to `lp-server`, consumed by + `lpa-client`. +- **Management:** low-level operations below Studio capabilities: reset, flash, + raw filesystem 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 `local_host`, `local_browser`, `discover`, `status`, `connect`, and `logs`. +- Public provider IDs use kebab-case, such as `local-host` and future + `serial-esp32-web`. Rust modules/types use Rust naming, such as + `providers::serial_esp32_web::SerialEsp32WebProvider`. - The model is plural-first. Multiple host or browser runtime instances should be natural, even if the first Studio UI exposes only one session. +- `local-host` endpoints are spawnable. Calling `connect()` creates a new + in-process `fw-host` runtime instance and returns a session that owns its + lifecycle. - A `LinkConnection` is a server/client connection, not a project session. Project sessions belong above this layer. - `local-browser` is worker-shaped but not Rust-owned. The link layer can model diff --git a/lp-app/lpa-link/src/link_connection.rs b/lp-app/lpa-link/src/link_connection.rs index 062e6b97a..5483146e9 100644 --- a/lp-app/lpa-link/src/link_connection.rs +++ b/lp-app/lpa-link/src/link_connection.rs @@ -5,6 +5,7 @@ use crate::{LinkEndpointId, LinkSessionId}; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum LinkConnectionKind { Fake, + LocalHost, LocalBrowserWorker { protocol: String }, PendingImplementation { kind: String }, } @@ -72,9 +73,7 @@ impl LinkConnection { Self { endpoint_id: endpoint_id.into(), session_id: session_id.into(), - kind: LinkConnectionKind::PendingImplementation { - kind: "local-host".to_string(), - }, + kind: LinkConnectionKind::LocalHost, local_host_transport: Some(transport), } } diff --git a/lp-app/lpa-link/src/link_endpoint.rs b/lp-app/lpa-link/src/link_endpoint.rs index e83aa1e5a..2889c359c 100644 --- a/lp-app/lpa-link/src/link_endpoint.rs +++ b/lp-app/lpa-link/src/link_endpoint.rs @@ -2,6 +2,11 @@ use serde::{Deserialize, Serialize}; use crate::{LinkEndpointId, LinkEndpointStatus, LinkManagement, LinkProviderId}; +/// A provider-visible target that can be connected to. +/// +/// Endpoints are not always physical devices. `local-host`, 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 { pub id: LinkEndpointId, diff --git a/lp-app/lpa-link/src/link_provider.rs b/lp-app/lpa-link/src/link_provider.rs index e78a26112..f746eb85d 100644 --- a/lp-app/lpa-link/src/link_provider.rs +++ b/lp-app/lpa-link/src/link_provider.rs @@ -8,6 +8,10 @@ pub trait LinkProvider { fn id(&self) -> &LinkProviderId; + /// Discover endpoints currently offered by this provider. + /// + /// Providers may return physical endpoints, such as a future ESP32 serial + /// port, or spawnable endpoints, such as `local-host` memory runtimes. async fn discover(&mut self) -> Result, LinkError>; async fn status( diff --git a/lp-app/lpa-link/src/link_session.rs b/lp-app/lpa-link/src/link_session.rs index 07d31d9bf..66929572a 100644 --- a/lp-app/lpa-link/src/link_session.rs +++ b/lp-app/lpa-link/src/link_session.rs @@ -12,6 +12,11 @@ pub trait LinkSession { fn diagnostics(&self) -> Vec; + /// Open or return the client connection associated with this session. + /// + /// The session owns lifecycle below the connection. For `local-host`, that + /// means keeping the in-process `fw-host` runtime alive while the returned + /// transport is in use. async fn connection(&mut self) -> Result; async fn close(&mut self) -> Result<(), LinkError>; diff --git a/lp-app/lpa-link/src/providers/local_host.rs b/lp-app/lpa-link/src/providers/local_host.rs index 571843c95..4f43f007f 100644 --- a/lp-app/lpa-link/src/providers/local_host.rs +++ b/lp-app/lpa-link/src/providers/local_host.rs @@ -24,6 +24,11 @@ impl LocalHostProvider { } } + /// 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-{}", @@ -172,6 +177,10 @@ mod tests { let mut session = provider.connect(&endpoint_id).await.unwrap(); let connection = session.connection().await.unwrap(); + assert!(matches!( + connection.kind, + crate::LinkConnectionKind::LocalHost + )); let transport = connection.local_host_transport().unwrap(); let client = LpClient::new_shared(transport); let projects = client.project_list_available().await.unwrap(); diff --git a/lp-cli/Cargo.toml b/lp-cli/Cargo.toml index d9b030c87..8f77d0758 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 = ["local-host"] } 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..f2d963222 100644 --- a/lp-cli/src/client/client_connect.rs +++ b/lp-cli/src/client/client_connect.rs @@ -21,14 +21,15 @@ use lpa_client::{ClientTransport, HostSpecifier, WebSocketClientTransport}; #[cfg(feature = "serial")] use std::sync::{Arc, Mutex}; -use crate::client::local_server::LocalServerTransport; +use crate::client::local_host::connect_local_host; #[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 `local-host` 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 local_host = connect_local_host()?; + Ok(Box::new(local_host)) } HostSpecifier::WebSocket { url } => { // WebSocketClientTransport::new is async, but client_connect is sync diff --git a/lp-cli/src/client/local_host.rs b/lp-cli/src/client/local_host.rs new file mode 100644 index 000000000..c9d849936 --- /dev/null +++ b/lp-cli/src/client/local_host.rs @@ -0,0 +1,117 @@ +//! CLI local-host transport. +//! +//! This module adapts `lpa-link`'s `local-host` 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::local_host::{LocalHostProvider, LocalHostSession}; +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 LocalHostClientTransport { + transport: Option>>>, + session: LocalHostSession, + closed: bool, +} + +impl LocalHostClientTransport { + fn new(transport: Arc>>, session: LocalHostSession) -> Self { + Self { + transport: Some(transport), + session, + closed: false, + } + } +} + +/// Start a new local-host runtime and return a CLI-compatible transport. +pub fn connect_local_host() -> Result { + let mut provider = LocalHostProvider::new("local-host"); + let endpoint_id = provider.create_memory_endpoint("Local Host"); + let mut session = pollster::block_on(provider.connect(&endpoint_id))?; + let connection = pollster::block_on(session.connection())?; + let transport = connection + .local_host_transport() + .ok_or_else(|| anyhow::anyhow!("local-host connection did not include a transport"))?; + + Ok(LocalHostClientTransport::new(transport, session)) +} + +#[async_trait::async_trait] +impl ClientTransport for LocalHostClientTransport { + 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()); + self.session.close().await.map_err(link_error_to_transport) + } +} + +impl Drop for LocalHostClientTransport { + 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::LpClient; + + use super::*; + + #[tokio::test] + async fn local_host_transport_serves_client_requests() { + let transport = connect_local_host().unwrap(); + let client = LpClient::new(Box::new(transport)); + + let projects = client.project_list_available().await.unwrap(); + + assert!(projects.is_empty()); + } + + #[tokio::test] + async fn close_stops_local_host_transport() { + let mut transport = connect_local_host().unwrap(); + + transport.close().await.unwrap(); + + assert!(matches!( + transport.receive().await, + Err(TransportError::ConnectionLost) + )); + } +} 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..fcba6c97d 100644 --- a/lp-cli/src/client/mod.rs +++ b/lp-cli/src/client/mod.rs @@ -3,7 +3,7 @@ pub use lpa_client::*; // CLI-specific modules pub mod client_connect; -pub mod local_server; +pub mod local_host; pub mod serial_port; // Re-export CLI-specific types From 3203ea41a8cfa14355992d8c495725aaca7a837d Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 17:03:31 -0700 Subject: [PATCH 11/62] feat: add host serial esp32 link provider Hard-rename link provider IDs toward the environment-mechanism-target convention, add host-serial-esp32, and route CLI hardware serial sessions through lpa-link. ADR: docs/adr/2026-06-18-link-provider-id-convention.md Plan: /Users/yona/Dropbox/Documents/PersonalNotes/Planning/lightplayer/2026-06-17-lp-studio-foundation/00c-m0c-serial-esp32-link/plan.md --- Cargo.lock | 2 + .../2026-06-18-link-provider-id-convention.md | 76 ++++ lp-app/lpa-link/Cargo.toml | 13 +- lp-app/lpa-link/README.md | 43 +- lp-app/lpa-link/src/lib.rs | 2 + lp-app/lpa-link/src/link_connection.rs | 62 +-- lp-app/lpa-link/src/link_endpoint.rs | 2 +- lp-app/lpa-link/src/link_provider.rs | 4 +- lp-app/lpa-link/src/link_session.rs | 2 +- .../{local_browser.rs => browser_worker.rs} | 34 +- .../{local_host.rs => host_process.rs} | 42 +- .../src/providers/host_serial_esp32.rs | 382 ++++++++++++++++++ lp-app/lpa-link/src/providers/mod.rs | 26 +- lp-cli/Cargo.toml | 2 +- lp-cli/src/client/client_connect.rs | 20 +- .../client/{local_host.rs => host_process.rs} | 40 +- lp-cli/src/client/host_serial_esp32.rs | 108 +++++ lp-cli/src/client/mod.rs | 3 +- lp-cli/src/client/serial_port.rs | 47 +-- lp-cli/src/commands/fwcheck/handler.rs | 17 +- 20 files changed, 768 insertions(+), 159 deletions(-) create mode 100644 docs/adr/2026-06-18-link-provider-id-convention.md rename lp-app/lpa-link/src/providers/{local_browser.rs => browser_worker.rs} (84%) rename lp-app/lpa-link/src/providers/{local_host.rs => host_process.rs} (83%) create mode 100644 lp-app/lpa-link/src/providers/host_serial_esp32.rs rename lp-cli/src/client/{local_host.rs => host_process.rs} (68%) create mode 100644 lp-cli/src/client/host_serial_esp32.rs diff --git a/Cargo.lock b/Cargo.lock index 82ed1683b..5e39250f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4117,7 +4117,9 @@ version = "40.0.0" dependencies = [ "fw-host", "lpa-client", + "lpc-model", "serde", + "serialport", "tokio", ] 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/lp-app/lpa-link/Cargo.toml b/lp-app/lpa-link/Cargo.toml index bdc1ea4e9..19f4f19ca 100644 --- a/lp-app/lpa-link/Cargo.toml +++ b/lp-app/lpa-link/Cargo.toml @@ -9,7 +9,9 @@ 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 } serde = { workspace = true, features = ["derive"] } +serialport = { version = "4.8", optional = true } tokio = { version = "1", features = ["sync"], optional = true } [dev-dependencies] @@ -17,8 +19,15 @@ tokio = { version = "1", features = ["macros", "rt"] } [features] default = [] -local-browser = [] -local-host = ["dep:fw-host", "dep:lpa-client", "dep:tokio"] +browser-worker = [] +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 index 8a7120eda..b0f571a5d 100644 --- a/lp-app/lpa-link/README.md +++ b/lp-app/lpa-link/README.md @@ -30,12 +30,13 @@ details directly in UI code. | Provider ID | Rust module/type | Runtime or device | Endpoint kind | Management intent | Status | |---|---|---|---|---|---| | `fake` | `providers::fake::FakeProvider` | none | test endpoint | diagnostics only | implemented | -| `local-host` | `providers::local_host::LocalHostProvider` | in-process `fw-host` | spawnable host runtime | logs, diagnostics, future local filesystem/runtime controls | implemented | -| `local-browser` | `providers::local_browser::LocalBrowserProvider` | `fw-browser` Web Worker | browser worker runtime | logs, diagnostics, worker lifecycle | model implemented; web code owns the actual Worker binding | -| `serial-esp32-web` | future `providers::serial_esp32_web::SerialEsp32WebProvider` | ESP32 over Web Serial | physical serial device | connect, reset, flash, raw filesystem, diagnostics | future | -| `serial-esp32-host` | future `providers::serial_esp32_host::SerialEsp32HostProvider` | ESP32 over host serial | physical serial device | connect, reset, flash, raw filesystem, diagnostics | future | -| `websocket` | future `providers::websocket::WebSocketProvider` | already-running server | remote endpoint | mostly connect/status; limited management | future | -| `webserver-host` | future `providers::webserver_host::WebserverHostProvider` | host service owning `fw-host` runtimes | service-managed runtime endpoint | create/stop runtimes, logs, diagnostics | future | +| `host-process` | `providers::host_process::HostProcessProvider` | host process running `fw-host` | spawnable host runtime | logs, diagnostics, future local filesystem/runtime controls | implemented | +| `browser-worker` | `providers::browser_worker::BrowserWorkerProvider` | `fw-browser` Web Worker | browser worker runtime | logs, diagnostics, worker lifecycle | model implemented; web code owns the actual Worker binding | +| `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 | +| `browser-serial-esp32` | future `providers::browser_serial_esp32::BrowserSerialEsp32Provider` | ESP32 over Web Serial | physical serial device | connect, reset, flash, raw filesystem, diagnostics | future | +| `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 @@ -46,16 +47,18 @@ Provider support is feature-gated: ```bash cargo check -p lpa-link cargo test -p lpa-link -cargo check -p lpa-link --features local-host -cargo test -p lpa-link --features local-host -cargo check -p lpa-link --features local-browser --target wasm32-unknown-unknown -cargo test -p lpa-link --features local-browser +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-worker --target wasm32-unknown-unknown +cargo test -p lpa-link --features browser-worker ``` ## Design Notes - **Provider:** source of endpoints and management behavior, such as - `local-host`, `local-browser`, or future ESP32 serial providers. + `host-process`, `browser-worker`, or ESP32 serial providers. - **Endpoint:** something a provider can connect to. An endpoint can be physical hardware or a spawnable runtime target. - **Session:** live ownership/lifecycle of a connected endpoint or launched @@ -67,19 +70,23 @@ cargo test -p lpa-link --features local-browser - 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 `local_host`, - `local_browser`, `discover`, `status`, `connect`, and `logs`. -- Public provider IDs use kebab-case, such as `local-host` and future - `serial-esp32-web`. Rust modules/types use Rust naming, such as - `providers::serial_esp32_web::SerialEsp32WebProvider`. +- 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. -- `local-host` endpoints are spawnable. Calling `connect()` creates a new +- `host-process` endpoints are spawnable. Calling `connect()` creates a new in-process `fw-host` runtime instance and returns a session that owns its lifecycle. - A `LinkConnection` is a server/client connection, not a project session. Project sessions belong above this layer. -- `local-browser` is worker-shaped but not Rust-owned. The link layer can model +- `browser-worker` is worker-shaped but not Rust-owned. The link layer can model endpoint/session identity, status, logs, diagnostics, and the worker envelope protocol. The web frontend must still bind that model to an actual module Worker created from `fw-browser/www/fw-browser-worker.js`. diff --git a/lp-app/lpa-link/src/lib.rs b/lp-app/lpa-link/src/lib.rs index dd129bfba..a2ce7741d 100644 --- a/lp-app/lpa-link/src/lib.rs +++ b/lp-app/lpa-link/src/lib.rs @@ -14,6 +14,8 @@ pub mod link_session; pub mod link_session_id; pub mod providers; +#[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] +pub use link_connection::LinkClientTransport; pub use link_connection::{LinkConnection, LinkConnectionKind}; pub use link_diagnostic::{LinkDiagnostic, LinkDiagnosticSeverity}; pub use link_endpoint::LinkEndpoint; diff --git a/lp-app/lpa-link/src/link_connection.rs b/lp-app/lpa-link/src/link_connection.rs index 5483146e9..a033fa2ed 100644 --- a/lp-app/lpa-link/src/link_connection.rs +++ b/lp-app/lpa-link/src/link_connection.rs @@ -2,11 +2,16 @@ use serde::{Deserialize, Serialize}; use crate::{LinkEndpointId, LinkSessionId}; +#[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] +pub type LinkClientTransport = + std::sync::Arc>>; + #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum LinkConnectionKind { Fake, - LocalHost, - LocalBrowserWorker { protocol: String }, + HostProcess, + BrowserWorker { protocol: String }, + HostSerialEsp32, PendingImplementation { kind: String }, } @@ -15,10 +20,9 @@ pub struct LinkConnection { pub endpoint_id: LinkEndpointId, pub session_id: LinkSessionId, pub kind: LinkConnectionKind, - #[cfg(feature = "local-host")] + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] #[serde(skip)] - pub local_host_transport: - Option>>>, + pub client_transport: Option, } impl LinkConnection { @@ -30,8 +34,8 @@ impl LinkConnection { endpoint_id: endpoint_id.into(), session_id: session_id.into(), kind: LinkConnectionKind::Fake, - #[cfg(feature = "local-host")] - local_host_transport: None, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + client_transport: None, } } @@ -44,44 +48,56 @@ impl LinkConnection { endpoint_id: endpoint_id.into(), session_id: session_id.into(), kind: LinkConnectionKind::PendingImplementation { kind: kind.into() }, - #[cfg(feature = "local-host")] - local_host_transport: None, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + client_transport: None, } } - pub fn local_browser_worker( + 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::LocalBrowserWorker { + kind: LinkConnectionKind::BrowserWorker { protocol: "fw-browser-post-message-v1".to_string(), }, - #[cfg(feature = "local-host")] - local_host_transport: None, + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + client_transport: None, + } + } + + #[cfg(feature = "host-process")] + pub fn host_process( + endpoint_id: impl Into, + session_id: impl Into, + transport: LinkClientTransport, + ) -> Self { + Self { + endpoint_id: endpoint_id.into(), + session_id: session_id.into(), + kind: LinkConnectionKind::HostProcess, + client_transport: Some(transport), } } - #[cfg(feature = "local-host")] - pub fn local_host( + #[cfg(feature = "host-serial-esp32")] + pub fn host_serial_esp32( endpoint_id: impl Into, session_id: impl Into, - transport: std::sync::Arc>>, + transport: LinkClientTransport, ) -> Self { Self { endpoint_id: endpoint_id.into(), session_id: session_id.into(), - kind: LinkConnectionKind::LocalHost, - local_host_transport: Some(transport), + kind: LinkConnectionKind::HostSerialEsp32, + client_transport: Some(transport), } } - #[cfg(feature = "local-host")] - pub fn local_host_transport( - &self, - ) -> Option>>> { - self.local_host_transport.clone() + #[cfg(any(feature = "host-process", feature = "host-serial-esp32"))] + pub fn client_transport(&self) -> Option { + self.client_transport.clone() } } diff --git a/lp-app/lpa-link/src/link_endpoint.rs b/lp-app/lpa-link/src/link_endpoint.rs index 2889c359c..55f2a182c 100644 --- a/lp-app/lpa-link/src/link_endpoint.rs +++ b/lp-app/lpa-link/src/link_endpoint.rs @@ -4,7 +4,7 @@ use crate::{LinkEndpointId, LinkEndpointStatus, LinkManagement, LinkProviderId}; /// A provider-visible target that can be connected to. /// -/// Endpoints are not always physical devices. `local-host`, for example, +/// 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)] diff --git a/lp-app/lpa-link/src/link_provider.rs b/lp-app/lpa-link/src/link_provider.rs index f746eb85d..3d0057c59 100644 --- a/lp-app/lpa-link/src/link_provider.rs +++ b/lp-app/lpa-link/src/link_provider.rs @@ -10,8 +10,8 @@ pub trait LinkProvider { /// Discover endpoints currently offered by this provider. /// - /// Providers may return physical endpoints, such as a future ESP32 serial - /// port, or spawnable endpoints, such as `local-host` memory runtimes. + /// 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>; async fn status( diff --git a/lp-app/lpa-link/src/link_session.rs b/lp-app/lpa-link/src/link_session.rs index 66929572a..cd32ac650 100644 --- a/lp-app/lpa-link/src/link_session.rs +++ b/lp-app/lpa-link/src/link_session.rs @@ -14,7 +14,7 @@ pub trait LinkSession { /// Open or return the client connection associated with this session. /// - /// The session owns lifecycle below the connection. For `local-host`, that + /// The session owns lifecycle below the connection. For `host-process`, that /// means keeping the in-process `fw-host` runtime alive while the returned /// transport is in use. async fn connection(&mut self) -> Result; diff --git a/lp-app/lpa-link/src/providers/local_browser.rs b/lp-app/lpa-link/src/providers/browser_worker.rs similarity index 84% rename from lp-app/lpa-link/src/providers/local_browser.rs rename to lp-app/lpa-link/src/providers/browser_worker.rs index 65b5b092c..d4a72e756 100644 --- a/lp-app/lpa-link/src/providers/local_browser.rs +++ b/lp-app/lpa-link/src/providers/browser_worker.rs @@ -5,14 +5,14 @@ use crate::{ }; #[derive(Clone, Debug)] -pub struct LocalBrowserProvider { +pub struct BrowserWorkerProvider { id: LinkProviderId, endpoints: Vec, next_endpoint_index: u64, next_session_index: u64, } -impl LocalBrowserProvider { +impl BrowserWorkerProvider { pub fn new(id: impl Into) -> Self { Self { id: id.into(), @@ -48,8 +48,8 @@ impl LocalBrowserProvider { } } -impl LinkProvider for LocalBrowserProvider { - type Session = LocalBrowserSession; +impl LinkProvider for BrowserWorkerProvider { + type Session = BrowserWorkerSession; fn id(&self) -> &LinkProviderId { &self.id @@ -74,12 +74,12 @@ impl LinkProvider for LocalBrowserProvider { self.next_session_index )); self.next_session_index += 1; - Ok(LocalBrowserSession::new(endpoint.id, session_id)) + Ok(BrowserWorkerSession::new(endpoint.id, session_id)) } } #[derive(Clone, Debug)] -pub struct LocalBrowserSession { +pub struct BrowserWorkerSession { endpoint_id: LinkEndpointId, id: LinkSessionId, closed: bool, @@ -87,19 +87,19 @@ pub struct LocalBrowserSession { diagnostics: Vec, } -impl LocalBrowserSession { +impl BrowserWorkerSession { pub fn new(endpoint_id: LinkEndpointId, id: LinkSessionId) -> Self { let logs = vec![LinkLogEntry::new( endpoint_id.clone(), Some(id.clone()), LinkLogLevel::Info, - "local browser worker session created", + "browser worker session created", )]; let diagnostics = vec![LinkDiagnostic::new( endpoint_id.clone(), Some(id.clone()), LinkDiagnosticSeverity::Info, - "local browser worker session ready; Studio web owns Worker postMessage binding", + "browser worker session ready; Studio web owns Worker postMessage binding", )]; Self { endpoint_id, @@ -111,7 +111,7 @@ impl LocalBrowserSession { } } -impl LinkSession for LocalBrowserSession { +impl LinkSession for BrowserWorkerSession { fn id(&self) -> &LinkSessionId { &self.id } @@ -132,7 +132,7 @@ impl LinkSession for LocalBrowserSession { if self.closed { return Err(LinkError::Closed); } - Ok(LinkConnection::local_browser_worker( + Ok(LinkConnection::browser_worker( self.endpoint_id.clone(), self.id.clone(), )) @@ -144,7 +144,7 @@ impl LinkSession for LocalBrowserSession { self.endpoint_id.clone(), Some(self.id.clone()), LinkLogLevel::Info, - "local browser worker session closed", + "browser worker session closed", )); Ok(()) } @@ -157,8 +157,8 @@ mod tests { use super::*; #[tokio::test] - async fn local_browser_provider_supports_multiple_worker_endpoints() { - let mut provider = LocalBrowserProvider::new("local-browser"); + async fn browser_worker_provider_supports_multiple_worker_endpoints() { + let mut provider = BrowserWorkerProvider::new("browser-worker"); provider.create_worker_endpoint("Browser A"); provider.create_worker_endpoint("Browser B"); @@ -173,8 +173,8 @@ mod tests { } #[tokio::test] - async fn local_browser_connection_reports_worker_protocol() { - let mut provider = LocalBrowserProvider::new("local-browser"); + async fn browser_worker_connection_reports_worker_protocol() { + let mut provider = BrowserWorkerProvider::new("browser-worker"); let endpoint_id = provider.create_worker_endpoint("Browser A"); let mut session = provider.connect(&endpoint_id).await.unwrap(); @@ -183,7 +183,7 @@ mod tests { assert_eq!(connection.endpoint_id, endpoint_id); assert!(matches!( connection.kind, - LinkConnectionKind::LocalBrowserWorker { ref protocol } + LinkConnectionKind::BrowserWorker { ref protocol } if protocol == "fw-browser-post-message-v1" )); } diff --git a/lp-app/lpa-link/src/providers/local_host.rs b/lp-app/lpa-link/src/providers/host_process.rs similarity index 83% rename from lp-app/lpa-link/src/providers/local_host.rs rename to lp-app/lpa-link/src/providers/host_process.rs index 4f43f007f..d6df9d2a0 100644 --- a/lp-app/lpa-link/src/providers/local_host.rs +++ b/lp-app/lpa-link/src/providers/host_process.rs @@ -7,14 +7,14 @@ use crate::{ }; #[derive(Clone, Debug)] -pub struct LocalHostProvider { +pub struct HostProcessProvider { id: LinkProviderId, endpoints: Vec, next_endpoint_index: u64, next_session_index: u64, } -impl LocalHostProvider { +impl HostProcessProvider { pub fn new(id: impl Into) -> Self { Self { id: id.into(), @@ -55,8 +55,8 @@ impl LocalHostProvider { } } -impl LinkProvider for LocalHostProvider { - type Session = LocalHostSession; +impl LinkProvider for HostProcessProvider { + type Session = HostProcessSession; fn id(&self) -> &LinkProviderId { &self.id @@ -85,11 +85,11 @@ impl LinkProvider for LocalHostProvider { )); self.next_session_index += 1; - Ok(LocalHostSession::new(endpoint.id, session_id, runtime)) + Ok(HostProcessSession::new(endpoint.id, session_id, runtime)) } } -pub struct LocalHostSession { +pub struct HostProcessSession { endpoint_id: LinkEndpointId, id: LinkSessionId, runtime: HostRuntime, @@ -97,19 +97,19 @@ pub struct LocalHostSession { diagnostics: Vec, } -impl LocalHostSession { +impl HostProcessSession { pub fn new(endpoint_id: LinkEndpointId, id: LinkSessionId, runtime: HostRuntime) -> Self { let logs = vec![LinkLogEntry::new( endpoint_id.clone(), Some(id.clone()), LinkLogLevel::Info, - "local host runtime started", + "host process runtime started", )]; let diagnostics = vec![LinkDiagnostic::new( endpoint_id.clone(), Some(id.clone()), LinkDiagnosticSeverity::Info, - "local host runtime ready", + "host process runtime ready", )]; Self { @@ -122,7 +122,7 @@ impl LocalHostSession { } } -impl LinkSession for LocalHostSession { +impl LinkSession for HostProcessSession { fn id(&self) -> &LinkSessionId { &self.id } @@ -140,7 +140,7 @@ impl LinkSession for LocalHostSession { } async fn connection(&mut self) -> Result { - Ok(LinkConnection::local_host( + Ok(LinkConnection::host_process( self.endpoint_id.clone(), self.id.clone(), self.runtime.client_transport(), @@ -158,7 +158,7 @@ impl LinkSession for LocalHostSession { self.endpoint_id.clone(), Some(self.id.clone()), LinkLogLevel::Info, - "local host runtime stopped", + "host process runtime stopped", )); Ok(()) } @@ -171,17 +171,17 @@ mod tests { use super::*; #[tokio::test] - async fn local_host_connection_serves_client_requests() { + async fn host_process_connection_serves_client_requests() { let mut provider = provider_with_two_endpoints(); - let endpoint_id = LinkEndpointId::new("local-host-memory-1"); + let endpoint_id = LinkEndpointId::new("host-process-memory-1"); let mut session = provider.connect(&endpoint_id).await.unwrap(); let connection = session.connection().await.unwrap(); assert!(matches!( connection.kind, - crate::LinkConnectionKind::LocalHost + crate::LinkConnectionKind::HostProcess )); - let transport = connection.local_host_transport().unwrap(); + let transport = connection.client_transport().unwrap(); let client = LpClient::new_shared(transport); let projects = client.project_list_available().await.unwrap(); @@ -190,7 +190,7 @@ mod tests { } #[tokio::test] - async fn local_host_provider_supports_multiple_endpoints() { + async fn host_process_provider_supports_multiple_endpoints() { let mut provider = provider_with_two_endpoints(); let endpoints = provider.discover().await.unwrap(); @@ -206,10 +206,10 @@ mod tests { session_b.close().await.unwrap(); } - fn provider_with_two_endpoints() -> LocalHostProvider { - let mut provider = LocalHostProvider::new("local-host"); - provider.create_memory_endpoint("Local Host A"); - provider.create_memory_endpoint("Local Host B"); + fn provider_with_two_endpoints() -> HostProcessProvider { + let mut provider = HostProcessProvider::new("host-process"); + 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.rs b/lp-app/lpa-link/src/providers/host_serial_esp32.rs new file mode 100644 index 000000000..fef44129e --- /dev/null +++ b/lp-app/lpa-link/src/providers/host_serial_esp32.rs @@ -0,0 +1,382 @@ +use std::sync::Arc; + +use lpa_client::transport_serial::{ + HardwareSerialOptions, SerialLineObserver, create_hardware_serial_transport_pair_with_options, +}; +use tokio::sync::Mutex; + +use crate::{ + LinkClientTransport, LinkConnection, LinkDiagnostic, LinkDiagnosticSeverity, LinkEndpoint, + LinkEndpointId, LinkEndpointStatus, LinkError, LinkLogEntry, LinkLogLevel, LinkManagement, + LinkProvider, LinkProviderId, LinkSession, LinkSessionId, +}; + +#[derive(Clone, Default)] +pub struct HostSerialEsp32Options { + pub baud_rate: Option, + pub reset_after_open: bool, + pub line_observer: Option>, +} + +#[derive(Clone)] +pub struct HostSerialEsp32Provider { + id: LinkProviderId, + endpoints: Vec, + options: HostSerialEsp32Options, + next_session_index: u64, +} + +impl HostSerialEsp32Provider { + pub fn new(id: impl Into) -> Self { + Self::with_options(id, HostSerialEsp32Options::default()) + } + + pub fn with_options(id: impl Into, options: HostSerialEsp32Options) -> Self { + Self { + id: id.into(), + endpoints: Vec::new(), + options, + next_session_index: 1, + } + } + + pub fn set_options(&mut self, options: HostSerialEsp32Options) { + self.options = 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(&self.id, &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(()) + } + + fn endpoint( + &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 upsert_port_endpoint( + &mut self, + endpoint_id: LinkEndpointId, + port_name: String, + label: String, + ) { + let endpoint = LinkEndpoint::new(endpoint_id.clone(), self.id.clone(), label) + .with_management(LinkManagement { + can_reset: true, + can_read_logs: true, + can_read_diagnostics: true, + ..LinkManagement::default() + }); + + 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 { + type Session = HostSerialEsp32Session; + + fn id(&self) -> &LinkProviderId { + &self.id + } + + 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)?.endpoint.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 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 transport: LinkClientTransport = Arc::new(Mutex::new(transport)); + + Ok(HostSerialEsp32Session::new( + endpoint.endpoint.id, + session_id, + endpoint.port_name, + baud_rate, + transport, + )) + } +} + +pub struct HostSerialEsp32Session { + endpoint_id: LinkEndpointId, + id: LinkSessionId, + port_name: String, + baud_rate: u32, + transport: Option, + logs: Vec, + diagnostics: Vec, + closed: bool, +} + +impl HostSerialEsp32Session { + pub fn new( + endpoint_id: LinkEndpointId, + id: LinkSessionId, + port_name: String, + baud_rate: u32, + transport: LinkClientTransport, + ) -> Self { + let logs = vec![LinkLogEntry::new( + endpoint_id.clone(), + Some(id.clone()), + LinkLogLevel::Info, + format!("host serial ESP32 session opened on {port_name}"), + )]; + let diagnostics = vec![LinkDiagnostic::new( + endpoint_id.clone(), + Some(id.clone()), + LinkDiagnosticSeverity::Info, + format!("host serial ESP32 transport ready at {baud_rate} baud"), + )]; + Self { + endpoint_id, + id, + port_name, + baud_rate, + transport: Some(transport), + logs, + diagnostics, + closed: false, + } + } +} + +impl LinkSession for HostSerialEsp32Session { + fn id(&self) -> &LinkSessionId { + &self.id + } + + fn endpoint_id(&self) -> &LinkEndpointId { + &self.endpoint_id + } + + fn logs(&self) -> Vec { + self.logs.clone() + } + + fn diagnostics(&self) -> Vec { + self.diagnostics.clone() + } + + async fn connection(&mut self) -> Result { + if self.closed { + return Err(LinkError::Closed); + } + let Some(transport) = &self.transport else { + return Err(LinkError::Closed); + }; + Ok(LinkConnection::host_serial_esp32( + self.endpoint_id.clone(), + self.id.clone(), + transport.clone(), + )) + } + + async fn close(&mut self) -> Result<(), LinkError> { + if self.closed { + return Ok(()); + } + self.closed = true; + if let Some(transport) = self.transport.take() { + transport + .lock() + .await + .close() + .await + .map_err(|error| LinkError::other(error.to_string()))?; + } + self.logs.push(LinkLogEntry::new( + self.endpoint_id.clone(), + Some(self.id.clone()), + LinkLogLevel::Info, + format!( + "host serial ESP32 session closed on {} at {} baud", + self.port_name, self.baud_rate + ), + )); + Ok(()) + } +} + +#[derive(Clone, Debug)] +struct HostSerialEsp32Endpoint { + endpoint: LinkEndpoint, + port_name: String, +} + +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(provider_id: &LinkProviderId, port_name: &str) -> LinkEndpointId { + LinkEndpointId::new(format!( + "{}:{}", + provider_id.as_str(), + 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") +} + +#[cfg(test)] +mod tests { + use lpc_model::DEFAULT_SERIAL_BAUD_RATE; + + use super::*; + + #[test] + fn explicit_port_endpoint_records_metadata() { + let mut provider = HostSerialEsp32Provider::new("host-serial-esp32"); + + 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.endpoint.management.can_reset); + assert!(endpoint.endpoint.management.can_read_logs); + assert!(endpoint.endpoint.management.can_read_diagnostics); + assert!(!endpoint.endpoint.management.can_flash); + } + + #[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("host-serial-esp32"); + + 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 index 6c91b4089..fcdbc082f 100644 --- a/lp-app/lpa-link/src/providers/mod.rs +++ b/lp-app/lpa-link/src/providers/mod.rs @@ -1,5 +1,23 @@ +//! Link provider implementations. +//! +//! Provider IDs 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(feature = "browser-worker")] +pub mod browser_worker; pub mod fake; -#[cfg(feature = "local-browser")] -pub mod local_browser; -#[cfg(feature = "local-host")] -pub mod local_host; +#[cfg(feature = "host-process")] +pub mod host_process; +#[cfg(feature = "host-serial-esp32")] +pub mod host_serial_esp32; diff --git a/lp-cli/Cargo.toml b/lp-cli/Cargo.toml index 8f77d0758..d3036e4de 100644 --- a/lp-cli/Cargo.toml +++ b/lp-cli/Cargo.toml @@ -26,7 +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 = ["local-host"] } +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"] } diff --git a/lp-cli/src/client/client_connect.rs b/lp-cli/src/client/client_connect.rs index f2d963222..7bfe7faa7 100644 --- a/lp-cli/src/client/client_connect.rs +++ b/lp-cli/src/client/client_connect.rs @@ -14,21 +14,21 @@ 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_host::connect_local_host; +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 a `local-host` link session backed by an in-process +/// For `Local`, creates a `host-process` link session backed by an in-process /// `fw-host` runtime. /// /// # Arguments @@ -61,8 +61,8 @@ use crate::client::serial_port::detect_serial_port; pub fn client_connect(spec: HostSpecifier) -> Result> { match spec { HostSpecifier::Local => { - let local_host = connect_local_host()?; - Ok(Box::new(local_host)) + 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/local_host.rs b/lp-cli/src/client/host_process.rs similarity index 68% rename from lp-cli/src/client/local_host.rs rename to lp-cli/src/client/host_process.rs index c9d849936..f4c84999d 100644 --- a/lp-cli/src/client/local_host.rs +++ b/lp-cli/src/client/host_process.rs @@ -1,26 +1,26 @@ -//! CLI local-host transport. +//! CLI host-process transport. //! -//! This module adapts `lpa-link`'s `local-host` provider to the CLI's current +//! 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::local_host::{LocalHostProvider, LocalHostSession}; +use lpa_link::providers::host_process::{HostProcessProvider, HostProcessSession}; 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 LocalHostClientTransport { +pub struct HostProcessClientTransport { transport: Option>>>, - session: LocalHostSession, + session: HostProcessSession, closed: bool, } -impl LocalHostClientTransport { - fn new(transport: Arc>>, session: LocalHostSession) -> Self { +impl HostProcessClientTransport { + fn new(transport: Arc>>, session: HostProcessSession) -> Self { Self { transport: Some(transport), session, @@ -29,21 +29,21 @@ impl LocalHostClientTransport { } } -/// Start a new local-host runtime and return a CLI-compatible transport. -pub fn connect_local_host() -> Result { - let mut provider = LocalHostProvider::new("local-host"); - let endpoint_id = provider.create_memory_endpoint("Local Host"); +/// Start a new host-process runtime and return a CLI-compatible transport. +pub fn connect_host_process() -> Result { + let mut provider = HostProcessProvider::new("host-process"); + let endpoint_id = provider.create_memory_endpoint("Host Process"); let mut session = pollster::block_on(provider.connect(&endpoint_id))?; let connection = pollster::block_on(session.connection())?; let transport = connection - .local_host_transport() - .ok_or_else(|| anyhow::anyhow!("local-host connection did not include a transport"))?; + .client_transport() + .ok_or_else(|| anyhow::anyhow!("host-process connection did not include a transport"))?; - Ok(LocalHostClientTransport::new(transport, session)) + Ok(HostProcessClientTransport::new(transport, session)) } #[async_trait::async_trait] -impl ClientTransport for LocalHostClientTransport { +impl ClientTransport for HostProcessClientTransport { async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { if self.closed { return Err(TransportError::ConnectionLost); @@ -77,7 +77,7 @@ impl ClientTransport for LocalHostClientTransport { } } -impl Drop for LocalHostClientTransport { +impl Drop for HostProcessClientTransport { fn drop(&mut self) { drop(self.transport.take()); } @@ -94,8 +94,8 @@ mod tests { use super::*; #[tokio::test] - async fn local_host_transport_serves_client_requests() { - let transport = connect_local_host().unwrap(); + async fn host_process_transport_serves_client_requests() { + let transport = connect_host_process().unwrap(); let client = LpClient::new(Box::new(transport)); let projects = client.project_list_available().await.unwrap(); @@ -104,8 +104,8 @@ mod tests { } #[tokio::test] - async fn close_stops_local_host_transport() { - let mut transport = connect_local_host().unwrap(); + async fn close_stops_host_process_transport() { + let mut transport = connect_host_process().unwrap(); transport.close().await.unwrap(); 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..d19615e89 --- /dev/null +++ b/lp-cli/src/client/host_serial_esp32.rs @@ -0,0 +1,108 @@ +//! 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, HostSerialEsp32Session, +}; +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>>>, + session: HostSerialEsp32Session, + closed: bool, +} + +impl HostSerialEsp32ClientTransport { + fn new( + transport: Arc>>, + session: HostSerialEsp32Session, + ) -> Self { + Self { + transport: Some(transport), + 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("host-serial-esp32", options); + let endpoint_id = provider.create_endpoint_for_port(port_name, format!("ESP32 ({port_name})")); + let mut session = pollster::block_on(provider.connect(&endpoint_id))?; + let connection = pollster::block_on(session.connection())?; + let transport = connection.client_transport().ok_or_else(|| { + anyhow::anyhow!("host-serial-esp32 connection did not include a transport") + })?; + + Ok(HostSerialEsp32ClientTransport::new(transport, 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()); + self.session.close().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/mod.rs b/lp-cli/src/client/mod.rs index fcba6c97d..a59ba4b41 100644 --- a/lp-cli/src/client/mod.rs +++ b/lp-cli/src/client/mod.rs @@ -3,7 +3,8 @@ pub use lpa_client::*; // CLI-specific modules pub mod client_connect; -pub mod local_host; +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..2b6a90c8a 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("host-serial-esp32"); + 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/fwcheck/handler.rs b/lp-cli/src/commands/fwcheck/handler.rs index 5cf2bcf58..a5d95a1e2 100644 --- a/lp-cli/src/commands/fwcheck/handler.rs +++ b/lp-cli/src/commands/fwcheck/handler.rs @@ -5,14 +5,14 @@ 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::transport_serial::SerialLineObserver; use lpa_client::{ClientTransport, LpClient}; +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::client::host_serial_esp32::connect_host_serial_esp32_with_options; use crate::commands::dev::{push_project_async, validation}; use super::args::{FwcheckCli, FwcheckCommand, FwcheckDemoArgs, FwcheckRunArgs, FwcheckTargetArg}; @@ -218,16 +218,13 @@ 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)); From 64d174c667291a847a51dce80a008e49d2b36b76 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 17:42:45 -0700 Subject: [PATCH 12/62] docs: refresh link provider naming for studio --- docs/adr/2026-06-17-browser-firmware-runtime.md | 4 ++-- lp-fw/README.md | 6 +++--- lp-fw/fw-browser/README.md | 2 +- lp-fw/fw-host/README.md | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/adr/2026-06-17-browser-firmware-runtime.md b/docs/adr/2026-06-17-browser-firmware-runtime.md index 9f2f7685a..db9ca7597 100644 --- a/docs/adr/2026-06-17-browser-firmware-runtime.md +++ b/docs/adr/2026-06-17-browser-firmware-runtime.md @@ -35,8 +35,8 @@ Studio can show connection health and raw protocol independently. ticking an `LpServer` frame. Browser Worker lifecycle, host process lifecycle, and ESP32 scheduling remain target-specific. -`lpa-link local-browser` models endpoint/session identity and reports a -`LocalBrowserWorker` connection with protocol `fw-browser-post-message-v1`. +`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. diff --git a/lp-fw/README.md b/lp-fw/README.md index 3b82857fb..ec4e04d5c 100644 --- a/lp-fw/README.md +++ b/lp-fw/README.md @@ -31,7 +31,7 @@ issues. `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` `local-host` support to create local runtime +use this target through `lpa-link` `host-process` support to create local runtime instances and connect an `lpa-client` to them. Useful checks: @@ -39,8 +39,8 @@ Useful checks: ```bash cargo check -p fw-host cargo test -p fw-host -cargo check -p lpa-link --features local-host -cargo test -p lpa-link --features local-host +cargo check -p lpa-link --features host-process +cargo test -p lpa-link --features host-process ``` ### Browser Runtime diff --git a/lp-fw/fw-browser/README.md b/lp-fw/fw-browser/README.md index a2f76a14d..fa023edcd 100644 --- a/lp-fw/fw-browser/README.md +++ b/lp-fw/fw-browser/README.md @@ -15,7 +15,7 @@ direct public shader calls. - `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` `local-browser` models browser runtime instances and scoped +- `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. diff --git a/lp-fw/fw-host/README.md b/lp-fw/fw-host/README.md index 01ed0c995..9220338aa 100644 --- a/lp-fw/fw-host/README.md +++ b/lp-fw/fw-host/README.md @@ -10,7 +10,7 @@ server internals directly. - `lpa-server` hosts projects and serves the LightPlayer wire API. - `lpa-client` consumes the client-side connection created by the runtime. -- `lpa-link` `local-host` uses `fw-host` to create runtime instances and expose +- `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. @@ -39,6 +39,6 @@ packaged host deployments are future productization work. ```bash cargo check -p fw-host cargo test -p fw-host -cargo check -p lpa-link --features local-host -cargo test -p lpa-link --features local-host +cargo check -p lpa-link --features host-process +cargo test -p lpa-link --features host-process ``` From c3078a835bf6b0e5e5d8ad3285e6001264082453 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 18:42:41 -0700 Subject: [PATCH 13/62] feat: add studio foundation slice Add lp-studio-core, lp-studio-runtime, and lp-studio-web for the first local Studio flow. The slice includes the action/effect/event core model, host-process and browser-worker runtime paths, a static Dioxus shell, Studio build recipes, and an ADR for the action/session architecture. Plan: /Users/yona/Dropbox/Documents/PersonalNotes/Planning/lightplayer/2026-06-17-lp-studio-foundation/01-m1-local-studio-foundation/plan.md --- .gitignore | 5 +- .idea/lp2025.iml | 3 + Cargo.lock | 1825 ++++++++++++++++- Cargo.toml | 5 + ...6-18-studio-action-session-architecture.md | 81 + justfile | 31 + lp-app/README.md | 5 + lp-app/lp-studio-core/Cargo.toml | 17 + lp-app/lp-studio-core/README.md | 32 + .../lp-studio-core/src/action_descriptor.rs | 136 ++ .../src/action_history_policy.rs | 41 + lp-app/lp-studio-core/src/action_id.rs | 16 + lp-app/lp-studio-core/src/action_meta.rs | 25 + lp-app/lp-studio-core/src/action_origin.rs | 10 + lp-app/lp-studio-core/src/client_session.rs | 16 + .../lp-studio-core/src/connection_session.rs | 9 + .../lp-studio-core/src/device_capability.rs | 14 + lp-app/lp-studio-core/src/device_id.rs | 26 + lp-app/lp-studio-core/src/device_session.rs | 13 + lp-app/lp-studio-core/src/in_flight_action.rs | 51 + lp-app/lp-studio-core/src/lib.rs | 50 + lp-app/lp-studio-core/src/link_selection.rs | 25 + lp-app/lp-studio-core/src/project_session.rs | 21 + lp-app/lp-studio-core/src/studio_action.rs | 77 + lp-app/lp-studio-core/src/studio_app.rs | 377 ++++ .../lp-studio-core/src/studio_diagnostic.rs | 35 + lp-app/lp-studio-core/src/studio_effect.rs | 36 + lp-app/lp-studio-core/src/studio_event.rs | 56 + lp-app/lp-studio-core/src/studio_heartbeat.rs | 10 + lp-app/lp-studio-core/src/studio_log_entry.rs | 31 + lp-app/lp-studio-core/src/studio_state.rs | 19 + lp-app/lp-studio-runtime/Cargo.toml | 49 + lp-app/lp-studio-runtime/README.md | 35 + .../src/browser_protocol_client.rs | 251 +++ .../src/browser_worker_runtime.rs | 233 +++ .../src/client_session_runtime.rs | 98 + lp-app/lp-studio-runtime/src/demo_project.rs | 68 + .../lp-studio-runtime/src/effect_executor.rs | 14 + lp-app/lp-studio-runtime/src/error.rs | 30 + lp-app/lp-studio-runtime/src/harness.rs | 87 + .../src/host_process_runtime.rs | 240 +++ lp-app/lp-studio-runtime/src/lib.rs | 32 + .../src/project_session_runtime.rs | 127 ++ .../lp-studio-runtime/src/protocol_event.rs | 48 + .../lp-studio-runtime/src/worker_envelope.rs | 32 + lp-app/lp-studio-web/Cargo.toml | 17 + lp-app/lp-studio-web/Dioxus.toml | 9 + lp-app/lp-studio-web/README.md | 24 + lp-app/lp-studio-web/public/index.html | 15 + lp-app/lp-studio-web/src/app.rs | 50 + .../src/components/device_panel.rs | 41 + .../src/components/inventory_view.rs | 51 + .../lp-studio-web/src/components/log_panel.rs | 25 + lp-app/lp-studio-web/src/components/mod.rs | 5 + .../src/components/project_panel.rs | 36 + .../src/components/status_bar.rs | 38 + lp-app/lp-studio-web/src/main.rs | 6 + lp-app/lp-studio-web/src/style.css | 175 ++ 58 files changed, 4857 insertions(+), 77 deletions(-) create mode 100644 docs/adr/2026-06-18-studio-action-session-architecture.md create mode 100644 lp-app/lp-studio-core/Cargo.toml create mode 100644 lp-app/lp-studio-core/README.md create mode 100644 lp-app/lp-studio-core/src/action_descriptor.rs create mode 100644 lp-app/lp-studio-core/src/action_history_policy.rs create mode 100644 lp-app/lp-studio-core/src/action_id.rs create mode 100644 lp-app/lp-studio-core/src/action_meta.rs create mode 100644 lp-app/lp-studio-core/src/action_origin.rs create mode 100644 lp-app/lp-studio-core/src/client_session.rs create mode 100644 lp-app/lp-studio-core/src/connection_session.rs create mode 100644 lp-app/lp-studio-core/src/device_capability.rs create mode 100644 lp-app/lp-studio-core/src/device_id.rs create mode 100644 lp-app/lp-studio-core/src/device_session.rs create mode 100644 lp-app/lp-studio-core/src/in_flight_action.rs create mode 100644 lp-app/lp-studio-core/src/lib.rs create mode 100644 lp-app/lp-studio-core/src/link_selection.rs create mode 100644 lp-app/lp-studio-core/src/project_session.rs create mode 100644 lp-app/lp-studio-core/src/studio_action.rs create mode 100644 lp-app/lp-studio-core/src/studio_app.rs create mode 100644 lp-app/lp-studio-core/src/studio_diagnostic.rs create mode 100644 lp-app/lp-studio-core/src/studio_effect.rs create mode 100644 lp-app/lp-studio-core/src/studio_event.rs create mode 100644 lp-app/lp-studio-core/src/studio_heartbeat.rs create mode 100644 lp-app/lp-studio-core/src/studio_log_entry.rs create mode 100644 lp-app/lp-studio-core/src/studio_state.rs create mode 100644 lp-app/lp-studio-runtime/Cargo.toml create mode 100644 lp-app/lp-studio-runtime/README.md create mode 100644 lp-app/lp-studio-runtime/src/browser_protocol_client.rs create mode 100644 lp-app/lp-studio-runtime/src/browser_worker_runtime.rs create mode 100644 lp-app/lp-studio-runtime/src/client_session_runtime.rs create mode 100644 lp-app/lp-studio-runtime/src/demo_project.rs create mode 100644 lp-app/lp-studio-runtime/src/effect_executor.rs create mode 100644 lp-app/lp-studio-runtime/src/error.rs create mode 100644 lp-app/lp-studio-runtime/src/harness.rs create mode 100644 lp-app/lp-studio-runtime/src/host_process_runtime.rs create mode 100644 lp-app/lp-studio-runtime/src/lib.rs create mode 100644 lp-app/lp-studio-runtime/src/project_session_runtime.rs create mode 100644 lp-app/lp-studio-runtime/src/protocol_event.rs create mode 100644 lp-app/lp-studio-runtime/src/worker_envelope.rs create mode 100644 lp-app/lp-studio-web/Cargo.toml create mode 100644 lp-app/lp-studio-web/Dioxus.toml create mode 100644 lp-app/lp-studio-web/README.md create mode 100644 lp-app/lp-studio-web/public/index.html create mode 100644 lp-app/lp-studio-web/src/app.rs create mode 100644 lp-app/lp-studio-web/src/components/device_panel.rs create mode 100644 lp-app/lp-studio-web/src/components/inventory_view.rs create mode 100644 lp-app/lp-studio-web/src/components/log_panel.rs create mode 100644 lp-app/lp-studio-web/src/components/mod.rs create mode 100644 lp-app/lp-studio-web/src/components/project_panel.rs create mode 100644 lp-app/lp-studio-web/src/components/status_bar.rs create mode 100644 lp-app/lp-studio-web/src/main.rs create mode 100644 lp-app/lp-studio-web/src/style.css diff --git a/.gitignore b/.gitignore index 6969e8294..db7699892 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ perf.data* .DS_Store .builtins-source-hash traces/ -profiles/ \ No newline at end of file +profiles/ +lp-app/lp-studio-web/public/pkg/ +lp-app/lp-studio-web/public/fw-browser-worker.js +lp-app/lp-studio-web/dist/ diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml index 7a2068665..e11865c7c 100644 --- a/.idea/lp2025.iml +++ b/.idea/lp2025.iml @@ -103,6 +103,9 @@ + + + diff --git a/Cargo.lock b/Cargo.lock index 5e39250f2..74810f313 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" @@ -754,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" @@ -781,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" @@ -794,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" @@ -915,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" @@ -1387,135 +1649,601 @@ dependencies = [ ] [[package]] -name = "darling" -version = "0.23.0" +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "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", +] + +[[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 = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "1a15232302d1933015fcf2d6fe9e286ad36f6e9c205a546089a0f326023bb0d2" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "dioxus-core", + "tracing", ] [[package]] -name = "darling_core" -version = "0.20.11" +name = "dioxus-hooks" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "4534f91cf6305204b948bdec130076ac9ecc7c22faab29475b76870558bf73ea" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", ] [[package]] -name = "darling_core" -version = "0.21.3" +name = "dioxus-html" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "e03d6ad4040b667f2b2eefcb678840e630938c09bf9ec39b04ea4d1d96d90d44" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.117", + "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 = "darling_core" -version = "0.23.0" +name = "dioxus-html-internal-macro" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "584e2772127ab00f0d5e1d4d9795f39fecebc828ece0b7a02349d438bc1b1ce7" dependencies = [ - "ident_case", + "convert_case 0.8.0", "proc-macro2", "quote", - "strsim", "syn 2.0.117", ] [[package]] -name = "darling_macro" -version = "0.20.11" +name = "dioxus-interpreter-js" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "11999d6eb5bb179a9512dad30e5de408aab66f2cb65de9098c9fbe02927e2978" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "darling_macro" -version = "0.21.3" +name = "dioxus-logger" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "a28ccdfe36d2cb830a2784e40f7e6f7199805a2c6da99bd65b1ca308f11aed28" dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "dioxus-rsx" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2106afda239a4c7c22ffa1ca19117011225fc1c735c139c0a5b765996aa8bb1d" dependencies = [ - "darling_core 0.23.0", + "proc-macro2", + "proc-macro2-diagnostics", "quote", + "rustversion", "syn 2.0.117", ] [[package]] -name = "data-encoding" -version = "2.10.0" +name = "dioxus-signals" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "3705754f5e043deec9fc7af0d159f18e5b21c02c47d255c7e477f31368f0b6d2" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] [[package]] -name = "debugid" -version = "0.8.0" +name = "dioxus-stores" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +checksum = "64bec7b21c86b1360ec965a07a53a2c96b7caee3465049e1c299a45024e87614" dependencies = [ - "uuid", + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", ] [[package]] -name = "delegate" -version = "0.13.5" +name = "dioxus-stores-macro" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +checksum = "40a5875e9f890f27b1cc3e5b56c1e23601211470315a1fb8627c4ca4f3b2be9a" dependencies = [ + "convert_case 0.8.0", "proc-macro2", "quote", "syn 2.0.117", ] [[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", -] - -[[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]] @@ -1616,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" @@ -2557,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" @@ -2721,6 +3464,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -2992,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" @@ -3034,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]] @@ -3045,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]] @@ -3098,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" @@ -3278,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" @@ -3332,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" @@ -3539,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" @@ -3572,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" @@ -3582,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" @@ -3749,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" @@ -3766,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" @@ -3786,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" @@ -3916,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" @@ -3975,7 +4951,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", - "tungstenite", + "tungstenite 0.21.0", "unicode-width 0.2.2", ] @@ -4087,6 +5063,43 @@ dependencies = [ "lpvm-wasm", ] +[[package]] +name = "lp-studio-core" +version = "40.0.0" +dependencies = [ + "lpa-link", + "lpc-wire", + "serde", +] + +[[package]] +name = "lp-studio-runtime" +version = "40.0.0" +dependencies = [ + "js-sys", + "lp-studio-core", + "lpa-client", + "lpa-link", + "lpc-model", + "lpc-wire", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "lp-studio-web" +version = "40.0.0" +dependencies = [ + "dioxus", + "lp-studio-core", + "lp-studio-runtime", +] + [[package]] name = "lpa-client" version = "40.0.0" @@ -4536,6 +5549,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" @@ -4545,6 +5564,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" @@ -4554,6 +5584,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" @@ -4563,6 +5637,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" @@ -4611,6 +5691,22 @@ 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" @@ -4670,6 +5766,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" @@ -4874,6 +5987,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" @@ -5502,6 +6621,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" @@ -5547,12 +6672,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" @@ -5601,6 +6754,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" @@ -5629,10 +6837,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" @@ -5643,6 +6861,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" @@ -5657,6 +6885,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" @@ -5838,6 +7069,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" @@ -5847,6 +7122,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" @@ -6002,6 +7291,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" @@ -6145,6 +7469,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" @@ -6175,6 +7508,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" @@ -6219,6 +7563,28 @@ dependencies = [ "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 = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6239,6 +7605,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" @@ -6388,12 +7766,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", ] @@ -6590,6 +7998,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" @@ -6625,6 +8067,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" @@ -6735,6 +8186,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" @@ -6813,6 +8294,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" @@ -6822,7 +8313,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]] @@ -6885,6 +8391,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" @@ -6947,6 +8498,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" @@ -6966,13 +8534,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" @@ -7020,6 +8622,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" @@ -7056,6 +8664,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" @@ -7145,6 +8759,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" @@ -7320,6 +8965,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" @@ -7844,6 +9502,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" @@ -8673,6 +10340,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" @@ -8720,7 +10393,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 19829ef4e..12263ce65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ members = [ "lp-app/lpa-server", "lp-app/lpa-client", "lp-app/lpa-link", + "lp-app/lp-studio-core", + "lp-app/lp-studio-runtime", + "lp-app/lp-studio-web", "lp-fw/fw-browser", "lp-fw/fw-core", "lp-fw/fw-host", @@ -72,6 +75,8 @@ default-members = [ "lp-app/lpa-server", "lp-app/lpa-client", "lp-app/lpa-link", + "lp-app/lp-studio-core", + "lp-app/lp-studio-runtime", "lp-fw/fw-core", "lp-fw/fw-host", "lp-fw/fw-checks", 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..f53b02786 --- /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 + +Accepted + +## 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/justfile b/justfile index 138cb0a7c..1d0bdcf01 100644 --- a/justfile +++ b/justfile @@ -158,6 +158,37 @@ fw-browser-smoke: fw-browser-build cd lp-fw/fw-browser/www python3 -m http.server "${port}" --bind 127.0.0.1 +# ============================================================================ +# Studio web app +# ============================================================================ + +studio-web-build: install-wasm32-target fw-browser-build + #!/usr/bin/env bash + set -euo pipefail + echo "Building lp-studio-web for wasm32..." + cargo build -p lp-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/lp-studio-web/public/pkg + wasm-bindgen target/wasm32-unknown-unknown/release/lp-studio-web.wasm \ + --out-dir lp-app/lp-studio-web/public/pkg --target web + echo "Copying fw-browser worker assets..." + cp lp-fw/fw-browser/www/fw-browser-worker.js lp-app/lp-studio-web/public/fw-browser-worker.js + cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lp-studio-web/public/pkg/fw_browser.js + cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lp-studio-web/public/pkg/fw_browser_bg.wasm + echo "Artifacts: lp-app/lp-studio-web/public/ (index.html, fw-browser-worker.js, pkg/)" + +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/lp-studio-web/public + python3 -m http.server "${port}" --bind 127.0.0.1 + # ============================================================================ # Build commands - Workspace-wide # ============================================================================ diff --git a/lp-app/README.md b/lp-app/README.md index 69ec3f3f4..03ca04830 100644 --- a/lp-app/README.md +++ b/lp-app/README.md @@ -19,6 +19,11 @@ logic. server or firmware target. - `lpa-link` — low-level endpoint/link layer for discovery, status, management, diagnostics, logs, and opening server/client connections. +- `lp-studio-core` — UI-independent Studio state, documented actions, effects, + events, diagnostics, capabilities, and sessions. +- `lp-studio-runtime` — Studio effect executor and runtime adapters for + host-process and browser-worker flows. +- `lp-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/lp-studio-core/Cargo.toml b/lp-app/lp-studio-core/Cargo.toml new file mode 100644 index 000000000..737e39224 --- /dev/null +++ b/lp-app/lp-studio-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lp-studio-core" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "UI-independent LightPlayer Studio state, actions, effects, and sessions" + +[dependencies] +lpa-link = { path = "../lpa-link" } +lpc-wire = { path = "../../lp-core/lpc-wire" } +serde = { workspace = true, features = ["derive"] } + +[lints] +workspace = true diff --git a/lp-app/lp-studio-core/README.md b/lp-app/lp-studio-core/README.md new file mode 100644 index 000000000..b15a090b8 --- /dev/null +++ b/lp-app/lp-studio-core/README.md @@ -0,0 +1,32 @@ +# lp-studio-core + +`lp-studio-core` owns the UI-independent LightPlayer Studio domain model. + +It defines Studio state, documented actions, action metadata, effects, runtime +events, diagnostics, capabilities, and session records. UI code, runtime code, +tests, harnesses, and future agents should all speak through this vocabulary. + +## Boundaries + +- This crate owns state transitions and effect descriptions. +- This crate does not perform I/O, spawn runtimes, talk to browser workers, open + serial ports, or render UI. +- `lp-studio-runtime` executes effects and emits events. +- `lp-studio-web` renders state and dispatches actions. +- `lpa-link` remains the lower-level link/session/connection layer below Studio + capabilities. + +Actions are documented program objects. Their descriptors provide labels, +summaries, categories, and history policy so generic UI help and future agents +can inspect the available action surface. + +M1 does not implement undo/redo. It only classifies action history behavior so +future undo can attach to successful project edit transactions instead of every +operational action. + +## Validation + +```bash +cargo check -p lp-studio-core +cargo test -p lp-studio-core +``` diff --git a/lp-app/lp-studio-core/src/action_descriptor.rs b/lp-app/lp-studio-core/src/action_descriptor.rs new file mode 100644 index 000000000..4a770d73d --- /dev/null +++ b/lp-app/lp-studio-core/src/action_descriptor.rs @@ -0,0 +1,136 @@ +use crate::{ActionHistoryPolicy, StudioActionType}; + +/// High-level grouping for UI help and future agent tool presentation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActionCategory { + Device, + Runtime, + Project, + Navigation, +} + +/// Human and machine-readable description of an action type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionDescriptor { + pub action_type: StudioActionType, + pub label: &'static str, + pub summary: &'static str, + pub category: ActionCategory, + pub history_policy: ActionHistoryPolicy, +} + +impl ActionDescriptor { + pub fn for_type(action_type: StudioActionType) -> Self { + match action_type { + StudioActionType::SelectLinkProvider => Self::new( + action_type, + "Select link provider", + "Choose which low-level link provider Studio should use.", + ActionCategory::Device, + ActionHistoryPolicy::Ephemeral, + ), + StudioActionType::DiscoverDevices => Self::new( + action_type, + "Discover devices", + "Ask the selected provider for available endpoints.", + ActionCategory::Device, + ActionHistoryPolicy::Never, + ), + StudioActionType::ConnectDevice => Self::new( + action_type, + "Connect device", + "Open a link session and client connection for an endpoint.", + ActionCategory::Device, + ActionHistoryPolicy::Never, + ), + StudioActionType::DisconnectDevice => Self::new( + action_type, + "Disconnect device", + "Close the current link/device session.", + ActionCategory::Device, + ActionHistoryPolicy::Never, + ), + StudioActionType::LoadDemoProject => Self::new( + action_type, + "Load demo project", + "Write and load the built-in Studio demo project.", + ActionCategory::Project, + ActionHistoryPolicy::Never, + ), + StudioActionType::RefreshStatus => Self::new( + action_type, + "Refresh status", + "Read lightweight runtime status from the current connection.", + ActionCategory::Runtime, + ActionHistoryPolicy::Never, + ), + StudioActionType::ReadProjectInventory => Self::new( + action_type, + "Read project inventory", + "Read effective project inventory from the loaded project.", + ActionCategory::Project, + ActionHistoryPolicy::Never, + ), + StudioActionType::SelectProjectNode => Self::new( + action_type, + "Select project node", + "Select a project node in the Studio read model.", + ActionCategory::Navigation, + ActionHistoryPolicy::Ephemeral, + ), + } + } + + pub fn catalog() -> Vec { + StudioActionType::all() + .into_iter() + .map(Self::for_type) + .collect() + } + + fn new( + action_type: StudioActionType, + label: &'static str, + summary: &'static str, + category: ActionCategory, + history_policy: ActionHistoryPolicy, + ) -> Self { + Self { + action_type, + label, + summary, + category, + history_policy, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operational_actions_are_not_undoable() { + for action_type in [ + StudioActionType::DiscoverDevices, + StudioActionType::ConnectDevice, + StudioActionType::DisconnectDevice, + StudioActionType::LoadDemoProject, + StudioActionType::RefreshStatus, + StudioActionType::ReadProjectInventory, + ] { + assert!( + ActionDescriptor::for_type(action_type) + .history_policy + .never() + ); + } + } + + #[test] + fn navigation_actions_are_ephemeral() { + let descriptor = ActionDescriptor::for_type(StudioActionType::SelectProjectNode); + + assert!(descriptor.history_policy.ephemeral()); + } +} diff --git a/lp-app/lp-studio-core/src/action_history_policy.rs b/lp-app/lp-studio-core/src/action_history_policy.rs new file mode 100644 index 000000000..7c0a5a882 --- /dev/null +++ b/lp-app/lp-studio-core/src/action_history_policy.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +/// Future undo/history treatment for an action. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum ActionHistoryPolicy { + /// The action is operational or read-only and should never enter undo history. + Never, + /// The action is local and temporary, such as selection or panel state. + Ephemeral, + /// Future shape for confirmed domain edits that can be inverted. + UndoableEdit { + scope: UndoScope, + label: String, + merge_key: Option, + }, + /// Separates undo groups for a scope without being undoable itself. + Barrier { scope: UndoScope }, +} + +impl ActionHistoryPolicy { + pub fn never(&self) -> bool { + matches!(self, Self::Never) + } + + pub fn ephemeral(&self) -> bool { + matches!(self, Self::Ephemeral) + } +} + +/// Scope a future undo entry applies to. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum UndoScope { + Studio, + Project { + project_id: String, + }, + Artifact { + project_id: String, + artifact: String, + }, +} diff --git a/lp-app/lp-studio-core/src/action_id.rs b/lp-app/lp-studio-core/src/action_id.rs new file mode 100644 index 000000000..e2a2ca767 --- /dev/null +++ b/lp-app/lp-studio-core/src/action_id.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize, +)] +pub struct ActionId(u64); + +impl ActionId { + pub fn new(value: u64) -> Self { + Self(value) + } + + pub fn get(self) -> u64 { + self.0 + } +} diff --git a/lp-app/lp-studio-core/src/action_meta.rs b/lp-app/lp-studio-core/src/action_meta.rs new file mode 100644 index 000000000..fc2665e31 --- /dev/null +++ b/lp-app/lp-studio-core/src/action_meta.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ActionHistoryPolicy, ActionId, ActionOrigin}; + +/// Per-dispatch metadata attached to a Studio action. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct ActionMeta { + pub action_id: ActionId, + pub origin: ActionOrigin, + pub history_policy: ActionHistoryPolicy, +} + +impl ActionMeta { + pub fn new( + action_id: ActionId, + origin: ActionOrigin, + history_policy: ActionHistoryPolicy, + ) -> Self { + Self { + action_id, + origin, + history_policy, + } + } +} diff --git a/lp-app/lp-studio-core/src/action_origin.rs b/lp-app/lp-studio-core/src/action_origin.rs new file mode 100644 index 000000000..f8d615a90 --- /dev/null +++ b/lp-app/lp-studio-core/src/action_origin.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// Source that initiated a Studio action. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum ActionOrigin { + User, + Agent, + Harness, + System, +} diff --git a/lp-app/lp-studio-core/src/client_session.rs b/lp-app/lp-studio-core/src/client_session.rs new file mode 100644 index 000000000..2a573732f --- /dev/null +++ b/lp-app/lp-studio-core/src/client_session.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct ClientSession { + pub connected: bool, + pub label: String, +} + +impl ClientSession { + pub fn connected(label: impl Into) -> Self { + Self { + connected: true, + label: label.into(), + } + } +} diff --git a/lp-app/lp-studio-core/src/connection_session.rs b/lp-app/lp-studio-core/src/connection_session.rs new file mode 100644 index 000000000..7aea9df7f --- /dev/null +++ b/lp-app/lp-studio-core/src/connection_session.rs @@ -0,0 +1,9 @@ +use lpa_link::{LinkConnectionKind, LinkEndpointId, LinkSessionId}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct ConnectionSession { + pub endpoint_id: LinkEndpointId, + pub session_id: LinkSessionId, + pub kind: LinkConnectionKind, +} diff --git a/lp-app/lp-studio-core/src/device_capability.rs b/lp-app/lp-studio-core/src/device_capability.rs new file mode 100644 index 000000000..fc2f196e4 --- /dev/null +++ b/lp-app/lp-studio-core/src/device_capability.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub enum DeviceCapability { + Connect, + UseBrowserWorker, + UseHostProcess, + ReadHeartbeat, + ListProjects, + LoadProject, + ReadProjectInventory, + ReadLogs, + ReadDiagnostics, +} diff --git a/lp-app/lp-studio-core/src/device_id.rs b/lp-app/lp-studio-core/src/device_id.rs new file mode 100644 index 000000000..37472cb8d --- /dev/null +++ b/lp-app/lp-studio-core/src/device_id.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] +pub struct DeviceId(String); + +impl DeviceId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for DeviceId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl From for DeviceId { + fn from(value: String) -> Self { + Self::new(value) + } +} diff --git a/lp-app/lp-studio-core/src/device_session.rs b/lp-app/lp-studio-core/src/device_session.rs new file mode 100644 index 000000000..92d42bb72 --- /dev/null +++ b/lp-app/lp-studio-core/src/device_session.rs @@ -0,0 +1,13 @@ +use lpa_link::{LinkEndpointId, LinkProviderId, LinkSessionId}; +use serde::{Deserialize, Serialize}; + +use crate::{DeviceCapability, DeviceId}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct DeviceSession { + pub device_id: DeviceId, + pub provider_id: LinkProviderId, + pub endpoint_id: LinkEndpointId, + pub session_id: LinkSessionId, + pub capabilities: Vec, +} diff --git a/lp-app/lp-studio-core/src/in_flight_action.rs b/lp-app/lp-studio-core/src/in_flight_action.rs new file mode 100644 index 000000000..14d7f0bb0 --- /dev/null +++ b/lp-app/lp-studio-core/src/in_flight_action.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ActionId, StudioActionType}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct InFlightAction { + pub action_id: ActionId, + pub action_type: StudioActionTypeName, + pub label: String, +} + +impl InFlightAction { + pub fn new( + action_id: ActionId, + action_type: StudioActionType, + label: impl Into, + ) -> Self { + Self { + action_id, + action_type: StudioActionTypeName::from(action_type), + label: label.into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum StudioActionTypeName { + SelectLinkProvider, + DiscoverDevices, + ConnectDevice, + DisconnectDevice, + LoadDemoProject, + RefreshStatus, + ReadProjectInventory, + SelectProjectNode, +} + +impl From for StudioActionTypeName { + fn from(value: StudioActionType) -> Self { + match value { + StudioActionType::SelectLinkProvider => Self::SelectLinkProvider, + StudioActionType::DiscoverDevices => Self::DiscoverDevices, + StudioActionType::ConnectDevice => Self::ConnectDevice, + StudioActionType::DisconnectDevice => Self::DisconnectDevice, + StudioActionType::LoadDemoProject => Self::LoadDemoProject, + StudioActionType::RefreshStatus => Self::RefreshStatus, + StudioActionType::ReadProjectInventory => Self::ReadProjectInventory, + StudioActionType::SelectProjectNode => Self::SelectProjectNode, + } + } +} diff --git a/lp-app/lp-studio-core/src/lib.rs b/lp-app/lp-studio-core/src/lib.rs new file mode 100644 index 000000000..091f119b4 --- /dev/null +++ b/lp-app/lp-studio-core/src/lib.rs @@ -0,0 +1,50 @@ +//! UI-independent LightPlayer Studio domain model. + +pub mod action_descriptor; +pub mod action_history_policy; +pub mod action_id; +pub mod action_meta; +pub mod action_origin; +pub mod client_session; +pub mod connection_session; +pub mod device_capability; +pub mod device_id; +pub mod device_session; +pub mod in_flight_action; +pub mod link_selection; +pub mod project_session; +pub mod studio_action; +pub mod studio_app; +pub mod studio_diagnostic; +pub mod studio_effect; +pub mod studio_event; +pub mod studio_heartbeat; +pub mod studio_log_entry; +pub mod studio_state; + +pub use action_descriptor::{ActionCategory, ActionDescriptor}; +pub use action_history_policy::{ActionHistoryPolicy, UndoScope}; +pub use action_id::ActionId; +pub use action_meta::ActionMeta; +pub use action_origin::ActionOrigin; +pub use client_session::ClientSession; +pub use connection_session::ConnectionSession; +pub use device_capability::DeviceCapability; +pub use device_id::DeviceId; +pub use device_session::DeviceSession; +pub use in_flight_action::InFlightAction; +pub use link_selection::LinkSelection; +pub use project_session::ProjectSession; +pub use studio_action::{StudioAction, StudioActionKind, StudioActionType}; +pub use studio_app::StudioApp; +pub use studio_diagnostic::{StudioDiagnostic, StudioDiagnosticSeverity}; +pub use studio_effect::StudioEffect; +pub use studio_event::StudioEvent; +pub use studio_heartbeat::StudioHeartbeat; +pub use studio_log_entry::{StudioLogEntry, StudioLogLevel}; +pub use studio_state::StudioState; + +pub const BROWSER_WORKER_PROVIDER_ID: &str = "browser-worker"; +pub const HOST_PROCESS_PROVIDER_ID: &str = "host-process"; +pub const HOST_SERIAL_ESP32_PROVIDER_ID: &str = "host-serial-esp32"; +pub const STUDIO_DEMO_PROJECT_ID: &str = "studio-demo"; diff --git a/lp-app/lp-studio-core/src/link_selection.rs b/lp-app/lp-studio-core/src/link_selection.rs new file mode 100644 index 000000000..c837fc831 --- /dev/null +++ b/lp-app/lp-studio-core/src/link_selection.rs @@ -0,0 +1,25 @@ +use lpa_link::{LinkEndpoint, LinkProviderId}; +use serde::{Deserialize, Serialize}; + +use crate::BROWSER_WORKER_PROVIDER_ID; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct LinkSelection { + pub selected_provider_id: LinkProviderId, + pub endpoints: Vec, +} + +impl LinkSelection { + pub fn new(selected_provider_id: LinkProviderId) -> Self { + Self { + selected_provider_id, + endpoints: Vec::new(), + } + } +} + +impl Default for LinkSelection { + fn default() -> Self { + Self::new(LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID)) + } +} diff --git a/lp-app/lp-studio-core/src/project_session.rs b/lp-app/lp-studio-core/src/project_session.rs new file mode 100644 index 000000000..3846c07fa --- /dev/null +++ b/lp-app/lp-studio-core/src/project_session.rs @@ -0,0 +1,21 @@ +use lpc_wire::{WireProjectHandle, WireProjectInventoryReadResponse}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct ProjectSession { + pub project_id: String, + pub handle: WireProjectHandle, + pub inventory: Option, + pub selected_node_id: Option, +} + +impl ProjectSession { + pub fn new(project_id: impl Into, handle: WireProjectHandle) -> Self { + Self { + project_id: project_id.into(), + handle, + inventory: None, + selected_node_id: None, + } + } +} diff --git a/lp-app/lp-studio-core/src/studio_action.rs b/lp-app/lp-studio-core/src/studio_action.rs new file mode 100644 index 000000000..f49e8db8e --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_action.rs @@ -0,0 +1,77 @@ +use lpa_link::{LinkEndpointId, LinkProviderId}; +use serde::{Deserialize, Serialize}; + +use crate::{ActionDescriptor, ActionMeta}; + +/// Payload-free kind used for descriptors, help, and future agent tools. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum StudioActionType { + SelectLinkProvider, + DiscoverDevices, + ConnectDevice, + DisconnectDevice, + LoadDemoProject, + RefreshStatus, + ReadProjectInventory, + SelectProjectNode, +} + +impl StudioActionType { + pub fn all() -> Vec { + vec![ + Self::SelectLinkProvider, + Self::DiscoverDevices, + Self::ConnectDevice, + Self::DisconnectDevice, + Self::LoadDemoProject, + Self::RefreshStatus, + Self::ReadProjectInventory, + Self::SelectProjectNode, + ] + } +} + +/// Payload-bearing Studio action. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum StudioActionKind { + SelectLinkProvider { provider_id: LinkProviderId }, + DiscoverDevices, + ConnectDevice { endpoint_id: LinkEndpointId }, + DisconnectDevice, + LoadDemoProject, + RefreshStatus, + ReadProjectInventory, + SelectProjectNode { node_id: Option }, +} + +impl StudioActionKind { + pub fn action_type(&self) -> StudioActionType { + match self { + Self::SelectLinkProvider { .. } => StudioActionType::SelectLinkProvider, + Self::DiscoverDevices => StudioActionType::DiscoverDevices, + Self::ConnectDevice { .. } => StudioActionType::ConnectDevice, + Self::DisconnectDevice => StudioActionType::DisconnectDevice, + Self::LoadDemoProject => StudioActionType::LoadDemoProject, + Self::RefreshStatus => StudioActionType::RefreshStatus, + Self::ReadProjectInventory => StudioActionType::ReadProjectInventory, + Self::SelectProjectNode { .. } => StudioActionType::SelectProjectNode, + } + } + + pub fn descriptor(&self) -> ActionDescriptor { + ActionDescriptor::for_type(self.action_type()) + } +} + +/// One dispatchable Studio action plus metadata. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct StudioAction { + pub meta: ActionMeta, + pub kind: StudioActionKind, +} + +impl StudioAction { + pub fn new(meta: ActionMeta, kind: StudioActionKind) -> Self { + Self { meta, kind } + } +} diff --git a/lp-app/lp-studio-core/src/studio_app.rs b/lp-app/lp-studio-core/src/studio_app.rs new file mode 100644 index 000000000..2680b1d85 --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_app.rs @@ -0,0 +1,377 @@ +use lpa_link::{LinkEndpointId, LinkProviderId}; + +use crate::{ + ActionDescriptor, ActionId, ActionMeta, ActionOrigin, ClientSession, ConnectionSession, + DeviceId, DeviceSession, InFlightAction, ProjectSession, STUDIO_DEMO_PROJECT_ID, StudioAction, + StudioActionKind, StudioDiagnostic, StudioEffect, StudioEvent, StudioState, +}; + +pub struct StudioApp { + state: StudioState, + next_action_id: u64, +} + +impl StudioApp { + pub fn new() -> Self { + Self { + state: StudioState::default(), + next_action_id: 1, + } + } + + pub fn state(&self) -> &StudioState { + &self.state + } + + pub fn dispatch_kind( + &mut self, + kind: StudioActionKind, + origin: ActionOrigin, + ) -> Vec { + let action_type = kind.action_type(); + let descriptor = ActionDescriptor::for_type(action_type); + let action_id = self.next_action_id(); + let action = StudioAction::new( + ActionMeta::new(action_id, origin, descriptor.history_policy.clone()), + kind, + ); + self.dispatch(action) + } + + pub fn dispatch(&mut self, action: StudioAction) -> Vec { + let descriptor = action.kind.descriptor(); + let mut effects = Vec::new(); + match action.kind { + StudioActionKind::SelectLinkProvider { provider_id } => { + self.state.link_selection.selected_provider_id = provider_id; + self.state.link_selection.endpoints.clear(); + } + StudioActionKind::DiscoverDevices => { + self.mark_in_flight(action.meta.action_id, descriptor); + effects.push(StudioEffect::DiscoverEndpoints { + action_id: action.meta.action_id, + provider_id: self.state.link_selection.selected_provider_id.clone(), + }); + } + StudioActionKind::ConnectDevice { endpoint_id } => { + self.mark_in_flight(action.meta.action_id, descriptor); + effects.push(StudioEffect::ConnectEndpoint { + action_id: action.meta.action_id, + endpoint_id, + }); + } + StudioActionKind::DisconnectDevice => { + self.mark_in_flight(action.meta.action_id, descriptor); + if let Some(session) = &self.state.device_session { + effects.push(StudioEffect::DisconnectSession { + action_id: action.meta.action_id, + session_id: session.session_id.clone(), + }); + } else { + self.finish_action(action.meta.action_id); + self.state + .diagnostics + .push(StudioDiagnostic::info("No device session is connected.")); + } + } + StudioActionKind::LoadDemoProject => { + self.mark_in_flight(action.meta.action_id, descriptor); + effects.push(StudioEffect::SeedDemoProject { + action_id: action.meta.action_id, + project_id: STUDIO_DEMO_PROJECT_ID.to_string(), + }); + } + StudioActionKind::RefreshStatus => { + self.mark_in_flight(action.meta.action_id, descriptor); + effects.push(StudioEffect::RefreshStatus { + action_id: action.meta.action_id, + }); + } + StudioActionKind::ReadProjectInventory => { + self.mark_in_flight(action.meta.action_id, descriptor); + if let Some(project) = &self.state.project_session { + effects.push(StudioEffect::ReadProjectInventory { + action_id: action.meta.action_id, + handle: project.handle, + }); + } else { + self.finish_action(action.meta.action_id); + self.state.diagnostics.push(StudioDiagnostic::error( + Some(action.meta.action_id), + "No project is loaded.", + )); + } + } + StudioActionKind::SelectProjectNode { node_id } => { + if let Some(project) = &mut self.state.project_session { + project.selected_node_id = node_id; + } + } + } + effects + } + + pub fn apply_event(&mut self, event: StudioEvent) -> Vec { + let mut effects = Vec::new(); + match event { + StudioEvent::EndpointsDiscovered { + action_id, + provider_id, + endpoints, + } => { + self.finish_action(action_id); + self.state.link_selection.selected_provider_id = provider_id; + self.state.link_selection.endpoints = endpoints; + } + StudioEvent::DeviceConnected { + action_id, + provider_id, + endpoint_id, + session_id, + connection_kind, + capabilities, + } => { + self.finish_action(action_id); + let device_id = device_id_for(&provider_id, &endpoint_id); + self.state.device_session = Some(DeviceSession { + device_id, + provider_id, + endpoint_id: endpoint_id.clone(), + session_id: session_id.clone(), + capabilities, + }); + self.state.connection_session = Some(ConnectionSession { + endpoint_id, + session_id, + kind: connection_kind, + }); + self.state.client_session = Some(ClientSession::connected("lp-server")); + } + StudioEvent::DeviceDisconnected { + action_id, + session_id: _, + } => { + self.finish_action(action_id); + self.state.device_session = None; + self.state.connection_session = None; + self.state.client_session = None; + self.state.project_session = None; + } + StudioEvent::DemoProjectSeeded { + action_id, + project_id, + } => { + effects.push(StudioEffect::LoadProject { + action_id, + project_id, + }); + } + StudioEvent::ProjectLoaded { + action_id, + project_id, + handle, + } => { + self.state.project_session = Some(ProjectSession::new(project_id, handle)); + effects.push(StudioEffect::ReadProjectInventory { action_id, handle }); + } + StudioEvent::ProjectInventoryRead { + action_id, + inventory, + } => { + self.finish_action(action_id); + if let Some(project) = &mut self.state.project_session { + project.inventory = Some(inventory); + } + } + StudioEvent::LoadedProjectsRefreshed { + action_id, + projects: _, + } => { + self.finish_action(action_id); + } + StudioEvent::HeartbeatReceived { heartbeat } => { + self.state.heartbeat = Some(heartbeat); + } + StudioEvent::LogReceived { entry } => { + self.state.logs.push(entry); + } + StudioEvent::DiagnosticRaised { diagnostic } => { + self.state.diagnostics.push(diagnostic); + } + StudioEvent::ActionFailed { action_id, message } => { + self.finish_action(action_id); + self.state + .diagnostics + .push(StudioDiagnostic::error(Some(action_id), message)); + } + } + effects + } + + fn next_action_id(&mut self) -> ActionId { + let action_id = ActionId::new(self.next_action_id); + self.next_action_id += 1; + action_id + } + + fn mark_in_flight(&mut self, action_id: ActionId, descriptor: ActionDescriptor) { + self.state.in_flight.push(InFlightAction::new( + action_id, + descriptor.action_type, + descriptor.label, + )); + } + + fn finish_action(&mut self, action_id: ActionId) { + self.state + .in_flight + .retain(|in_flight| in_flight.action_id != action_id); + } +} + +impl Default for StudioApp { + fn default() -> Self { + Self::new() + } +} + +fn device_id_for(provider_id: &LinkProviderId, endpoint_id: &LinkEndpointId) -> DeviceId { + DeviceId::new(format!("{}:{}", provider_id.as_str(), endpoint_id.as_str())) +} + +#[cfg(test)] +mod tests { + use lpa_link::{ + LinkConnectionKind, LinkEndpoint, LinkEndpointId, LinkProviderId, LinkSessionId, + }; + use lpc_wire::{WireProjectHandle, WireProjectInventoryReadResponse}; + + use crate::{ActionOrigin, BROWSER_WORKER_PROVIDER_ID, DeviceCapability}; + + use super::*; + + #[test] + fn discover_devices_produces_effect_and_tracks_in_flight() { + let mut app = StudioApp::new(); + + let effects = app.dispatch_kind(StudioActionKind::DiscoverDevices, ActionOrigin::User); + + assert_eq!(effects.len(), 1); + assert!(matches!(effects[0], StudioEffect::DiscoverEndpoints { .. })); + assert_eq!(app.state().in_flight.len(), 1); + } + + #[test] + fn discovered_endpoints_update_state_and_finish_action() { + let mut app = StudioApp::new(); + let action_id = ActionId::new(7); + let endpoint = LinkEndpoint::new( + "browser-worker-worker-1", + BROWSER_WORKER_PROVIDER_ID, + "Browser runtime", + ); + + app.apply_event(StudioEvent::EndpointsDiscovered { + action_id, + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + endpoints: vec![endpoint.clone()], + }); + + assert_eq!(app.state().link_selection.endpoints, vec![endpoint]); + } + + #[test] + fn device_connected_populates_sessions() { + let mut app = StudioApp::new(); + + app.apply_event(StudioEvent::DeviceConnected { + action_id: ActionId::new(1), + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + endpoint_id: LinkEndpointId::new("browser-worker-worker-1"), + session_id: LinkSessionId::new("session-1"), + connection_kind: LinkConnectionKind::BrowserWorker { + protocol: "fw-browser-post-message-v1".to_string(), + }, + capabilities: vec![DeviceCapability::Connect], + }); + + assert!(app.state().device_session.is_some()); + assert!(app.state().connection_session.is_some()); + assert!(app.state().client_session.is_some()); + } + + #[test] + fn demo_seed_event_loads_project_and_project_load_reads_inventory() { + let mut app = StudioApp::new(); + let action_id = ActionId::new(9); + + let load_effects = app.apply_event(StudioEvent::DemoProjectSeeded { + action_id, + project_id: STUDIO_DEMO_PROJECT_ID.to_string(), + }); + + assert!(matches!(load_effects[0], StudioEffect::LoadProject { .. })); + + let read_effects = app.apply_event(StudioEvent::ProjectLoaded { + action_id, + project_id: STUDIO_DEMO_PROJECT_ID.to_string(), + handle: WireProjectHandle::new(3), + }); + + assert!(matches!( + read_effects[0], + StudioEffect::ReadProjectInventory { .. } + )); + assert!(app.state().project_session.is_some()); + } + + #[test] + fn inventory_event_updates_project_session() { + let mut app = StudioApp::new(); + let action_id = ActionId::new(4); + app.apply_event(StudioEvent::ProjectLoaded { + action_id, + project_id: STUDIO_DEMO_PROJECT_ID.to_string(), + handle: WireProjectHandle::new(1), + }); + + app.apply_event(StudioEvent::ProjectInventoryRead { + action_id, + inventory: WireProjectInventoryReadResponse::default(), + }); + + assert!( + app.state() + .project_session + .as_ref() + .and_then(|project| project.inventory.as_ref()) + .is_some() + ); + } + + #[test] + fn node_selection_is_state_only() { + let mut app = StudioApp::new(); + app.apply_event(StudioEvent::ProjectLoaded { + action_id: ActionId::new(1), + project_id: STUDIO_DEMO_PROJECT_ID.to_string(), + handle: WireProjectHandle::new(1), + }); + + let effects = app.dispatch_kind( + StudioActionKind::SelectProjectNode { + node_id: Some("node-a".to_string()), + }, + ActionOrigin::User, + ); + + assert!(effects.is_empty()); + assert_eq!( + app.state() + .project_session + .as_ref() + .and_then(|project| project.selected_node_id.as_deref()), + Some("node-a") + ); + } +} diff --git a/lp-app/lp-studio-core/src/studio_diagnostic.rs b/lp-app/lp-studio-core/src/studio_diagnostic.rs new file mode 100644 index 000000000..d774d08c8 --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_diagnostic.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +use crate::ActionId; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum StudioDiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct StudioDiagnostic { + pub action_id: Option, + pub severity: StudioDiagnosticSeverity, + pub message: String, +} + +impl StudioDiagnostic { + pub fn info(message: impl Into) -> Self { + Self { + action_id: None, + severity: StudioDiagnosticSeverity::Info, + message: message.into(), + } + } + + pub fn error(action_id: Option, message: impl Into) -> Self { + Self { + action_id, + severity: StudioDiagnosticSeverity::Error, + message: message.into(), + } + } +} diff --git a/lp-app/lp-studio-core/src/studio_effect.rs b/lp-app/lp-studio-core/src/studio_effect.rs new file mode 100644 index 000000000..ebb5c3437 --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_effect.rs @@ -0,0 +1,36 @@ +use lpa_link::{LinkEndpointId, LinkProviderId, LinkSessionId}; +use lpc_wire::WireProjectHandle; +use serde::{Deserialize, Serialize}; + +use crate::ActionId; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum StudioEffect { + DiscoverEndpoints { + action_id: ActionId, + provider_id: LinkProviderId, + }, + ConnectEndpoint { + action_id: ActionId, + endpoint_id: LinkEndpointId, + }, + DisconnectSession { + action_id: ActionId, + session_id: LinkSessionId, + }, + SeedDemoProject { + action_id: ActionId, + project_id: String, + }, + LoadProject { + action_id: ActionId, + project_id: String, + }, + RefreshStatus { + action_id: ActionId, + }, + ReadProjectInventory { + action_id: ActionId, + handle: WireProjectHandle, + }, +} diff --git a/lp-app/lp-studio-core/src/studio_event.rs b/lp-app/lp-studio-core/src/studio_event.rs new file mode 100644 index 000000000..a9485c63b --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_event.rs @@ -0,0 +1,56 @@ +use lpa_link::{LinkConnectionKind, LinkEndpoint, LinkEndpointId, LinkProviderId, LinkSessionId}; +use lpc_wire::{LoadedProject, WireProjectHandle, WireProjectInventoryReadResponse}; +use serde::{Deserialize, Serialize}; + +use crate::{ActionId, DeviceCapability, StudioDiagnostic, StudioHeartbeat, StudioLogEntry}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum StudioEvent { + EndpointsDiscovered { + action_id: ActionId, + provider_id: LinkProviderId, + endpoints: Vec, + }, + DeviceConnected { + action_id: ActionId, + provider_id: LinkProviderId, + endpoint_id: LinkEndpointId, + session_id: LinkSessionId, + connection_kind: LinkConnectionKind, + capabilities: Vec, + }, + DeviceDisconnected { + action_id: ActionId, + session_id: LinkSessionId, + }, + DemoProjectSeeded { + action_id: ActionId, + project_id: String, + }, + ProjectLoaded { + action_id: ActionId, + project_id: String, + handle: WireProjectHandle, + }, + ProjectInventoryRead { + action_id: ActionId, + inventory: WireProjectInventoryReadResponse, + }, + LoadedProjectsRefreshed { + action_id: ActionId, + projects: Vec, + }, + HeartbeatReceived { + heartbeat: StudioHeartbeat, + }, + LogReceived { + entry: StudioLogEntry, + }, + DiagnosticRaised { + diagnostic: StudioDiagnostic, + }, + ActionFailed { + action_id: ActionId, + message: String, + }, +} diff --git a/lp-app/lp-studio-core/src/studio_heartbeat.rs b/lp-app/lp-studio-core/src/studio_heartbeat.rs new file mode 100644 index 000000000..0e24b46f1 --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_heartbeat.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct StudioHeartbeat { + pub fps_avg: f32, + pub frame_count: u64, + pub loaded_project_count: usize, + pub uptime_ms: u64, + pub free_memory_bytes: Option, +} diff --git a/lp-app/lp-studio-core/src/studio_log_entry.rs b/lp-app/lp-studio-core/src/studio_log_entry.rs new file mode 100644 index 000000000..12af24762 --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_log_entry.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum StudioLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct StudioLogEntry { + pub level: StudioLogLevel, + pub target: String, + pub message: String, +} + +impl StudioLogEntry { + pub fn new( + level: StudioLogLevel, + target: impl Into, + message: impl Into, + ) -> Self { + Self { + level, + target: target.into(), + message: message.into(), + } + } +} diff --git a/lp-app/lp-studio-core/src/studio_state.rs b/lp-app/lp-studio-core/src/studio_state.rs new file mode 100644 index 000000000..2a9cbc9ba --- /dev/null +++ b/lp-app/lp-studio-core/src/studio_state.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + ClientSession, ConnectionSession, DeviceSession, InFlightAction, LinkSelection, ProjectSession, + StudioDiagnostic, StudioHeartbeat, StudioLogEntry, +}; + +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] +pub struct StudioState { + pub link_selection: LinkSelection, + pub device_session: Option, + pub connection_session: Option, + pub client_session: Option, + pub project_session: Option, + pub heartbeat: Option, + pub logs: Vec, + pub diagnostics: Vec, + pub in_flight: Vec, +} diff --git a/lp-app/lp-studio-runtime/Cargo.toml b/lp-app/lp-studio-runtime/Cargo.toml new file mode 100644 index 000000000..f5885089a --- /dev/null +++ b/lp-app/lp-studio-runtime/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "lp-studio-runtime" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "LightPlayer Studio effect executor and runtime adapters" + +[dependencies] +lp-studio-core = { path = "../lp-studio-core" } +lpa-client = { path = "../lpa-client", optional = true } +lpa-link = { path = "../lpa-link" } +lpc-model = { path = "../../lp-core/lpc-model" } +lpc-wire = { path = "../../lp-core/lpc-wire" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["std"] } +tokio = { version = "1", features = ["sync", "time"], optional = true } +js-sys = { version = "0.3", optional = true } +serde-wasm-bindgen = { version = "0.6", 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 = [ + "ErrorEvent", + "MessageEvent", + "Window", + "Worker", + "WorkerOptions", + "WorkerType", +] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "time"] } + +[features] +default = [] +host-process = ["dep:lpa-client", "lpa-link/host-process", "dep:tokio"] +browser-worker = [ + "lpa-link/browser-worker", + "dep:js-sys", + "dep:serde-wasm-bindgen", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] + +[lints] +workspace = true diff --git a/lp-app/lp-studio-runtime/README.md b/lp-app/lp-studio-runtime/README.md new file mode 100644 index 000000000..6a743b1ed --- /dev/null +++ b/lp-app/lp-studio-runtime/README.md @@ -0,0 +1,35 @@ +# lp-studio-runtime + +`lp-studio-runtime` executes `lp-studio-core` effects and turns lower-level +runtime/link/client facts back into Studio events. + +## Boundaries + +- `lp-studio-core` owns state transitions. +- `lp-studio-runtime` owns I/O, runtime adapters, demo project seeding, and + client protocol flow. +- `lp-studio-web` owns Dioxus components and browser presentation. + +The host-process path is: + +```text +StudioEffect -> lpa-link host-process -> fw-host -> lpc-wire protocol +``` + +The browser-worker path is: + +```text +StudioEffect -> lpa-link browser-worker model -> JavaScript Worker -> fw-browser +``` + +Demo project loading uses the same server protocol on both paths: write files +under `/projects/studio-demo/...`, then call `LoadProject` with +`studio-demo`. + +## Validation + +```bash +cargo check -p lp-studio-runtime --features host-process +cargo test -p lp-studio-runtime --features host-process +cargo check -p lp-studio-runtime --target wasm32-unknown-unknown --features browser-worker +``` diff --git a/lp-app/lp-studio-runtime/src/browser_protocol_client.rs b/lp-app/lp-studio-runtime/src/browser_protocol_client.rs new file mode 100644 index 000000000..8fba08680 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/browser_protocol_client.rs @@ -0,0 +1,251 @@ +use js_sys::Promise; +use lpc_model::AsLpPathBuf; +use lpc_wire::{ + ClientRequest, WireProjectCommandResponse, WireServerMessage, WireServerMsgBody, json, + messages::ClientMessage, +}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use lp_studio_core::{StudioEffect, StudioEvent}; + +use crate::browser_worker_runtime::BrowserWorkerStudioRuntime; +use crate::protocol_event::{inventory_request, server_event}; +use crate::worker_envelope::{BrowserInputEnvelope, BrowserOutputEnvelope}; +use crate::{StudioRuntimeError, demo_project}; + +pub struct BrowserProtocolClient { + runtime: BrowserWorkerStudioRuntime, + next_request_id: u64, +} + +impl BrowserProtocolClient { + pub fn new(runtime: BrowserWorkerStudioRuntime) -> Self { + Self { + runtime, + next_request_id: 1, + } + } + + pub async fn seed_demo_project( + &mut self, + action_id: lp_studio_core::ActionId, + project_id: &str, + ) -> Result, StudioRuntimeError> { + let mut events = Vec::new(); + for file in demo_project::demo_project_files() { + let path = format!("/projects/{project_id}/{}", file.relative_path); + let response = self + .send_request(ClientRequest::Filesystem(lpc_wire::FsRequest::Write { + path: path.as_str().as_path_buf(), + data: file.bytes.to_vec(), + })) + .await?; + events.extend(response.events); + demo_project::ensure_write_response(&response.response.msg) + .map_err(StudioRuntimeError::Protocol)?; + } + events.push(StudioEvent::DemoProjectSeeded { + action_id, + project_id: project_id.to_string(), + }); + Ok(events) + } + + pub async fn execute_project_effect( + &mut self, + effect: StudioEffect, + ) -> Result, StudioRuntimeError> { + match effect { + StudioEffect::LoadProject { + action_id, + project_id, + } => self.load_project(action_id, &project_id).await, + StudioEffect::ReadProjectInventory { action_id, handle } => { + self.read_inventory(action_id, handle).await + } + StudioEffect::RefreshStatus { action_id } => { + self.refresh_loaded_projects(action_id).await + } + _ => Ok(Vec::new()), + } + } + + async fn load_project( + &mut self, + action_id: lp_studio_core::ActionId, + project_id: &str, + ) -> Result, StudioRuntimeError> { + let exchange = self + .send_request(ClientRequest::LoadProject { + path: project_id.to_string(), + }) + .await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::LoadProject { handle } => { + events.push(StudioEvent::ProjectLoaded { + action_id, + project_id: project_id.to_string(), + handle, + }); + Ok(events) + } + other => Err(StudioRuntimeError::Protocol(format!( + "unexpected load project response: {other:?}" + ))), + } + } + + async fn read_inventory( + &mut self, + action_id: lp_studio_core::ActionId, + handle: lpc_wire::WireProjectHandle, + ) -> Result, StudioRuntimeError> { + let exchange = self.send_request(inventory_request(handle)).await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::ProjectCommand { + response: + WireProjectCommandResponse::ReadInventory { + response: inventory, + }, + } => { + events.push(StudioEvent::ProjectInventoryRead { + action_id, + inventory, + }); + Ok(events) + } + other => Err(StudioRuntimeError::Protocol(format!( + "unexpected inventory response: {other:?}" + ))), + } + } + + async fn refresh_loaded_projects( + &mut self, + action_id: lp_studio_core::ActionId, + ) -> Result, StudioRuntimeError> { + let exchange = self.send_request(ClientRequest::ListLoadedProjects).await?; + let mut events = exchange.events; + if let WireServerMsgBody::ListLoadedProjects { projects } = exchange.response.msg { + events.push(StudioEvent::LoadedProjectsRefreshed { + action_id, + projects, + }); + } + Ok(events) + } + + async fn send_request( + &mut self, + request: ClientRequest, + ) -> Result { + let request_id = self.next_request_id(); + let frame = json::to_string(&ClientMessage { + id: request_id, + msg: request, + }) + .map_err(|error| StudioRuntimeError::Protocol(error.to_string()))?; + self.runtime + .post(&BrowserInputEnvelope::ProtocolIn { frame })?; + + let mut events = Vec::new(); + for _ in 0..240 { + self.runtime + .post(&BrowserInputEnvelope::Tick { delta_ms: Some(16) })?; + sleep_ms(4).await?; + for output in self.runtime.take_outputs() { + match output { + BrowserOutputEnvelope::ProtocolOut { frame } => { + let response: WireServerMessage = json::from_str(&frame) + .map_err(|error| StudioRuntimeError::Protocol(error.to_string()))?; + if response.id == request_id { + return Ok(BrowserExchange { response, events }); + } + if response.id == 0 { + if let Some(event) = server_event(response) { + events.push(event); + } + } + } + output => { + if let Some(event) = worker_output_to_event(output) { + events.push(event); + } + } + } + } + } + Err(StudioRuntimeError::Browser( + "timed out waiting for worker protocol response".to_string(), + )) + } + + fn next_request_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id += 1; + id + } +} + +pub struct BrowserExchange { + pub response: WireServerMessage, + pub events: Vec, +} + +pub async fn sleep_ms(ms: i32) -> Result<(), StudioRuntimeError> { + 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| StudioRuntimeError::Browser(format!("{error:?}"))) +} + +fn worker_output_to_event(output: BrowserOutputEnvelope) -> Option { + match output { + BrowserOutputEnvelope::Status { + status, message, .. + } => Some(StudioEvent::LogReceived { + entry: lp_studio_core::StudioLogEntry::new( + lp_studio_core::StudioLogLevel::Info, + "fw-browser", + message.unwrap_or(status), + ), + }), + BrowserOutputEnvelope::Log { + level, + target, + message, + .. + } => Some(StudioEvent::LogReceived { + entry: lp_studio_core::StudioLogEntry::new( + parse_worker_log_level(&level), + target, + message, + ), + }), + BrowserOutputEnvelope::ProtocolOut { .. } => None, + } +} + +fn parse_worker_log_level(level: &str) -> lp_studio_core::StudioLogLevel { + match level { + "trace" => lp_studio_core::StudioLogLevel::Trace, + "debug" => lp_studio_core::StudioLogLevel::Debug, + "warn" => lp_studio_core::StudioLogLevel::Warn, + "error" => lp_studio_core::StudioLogLevel::Error, + _ => lp_studio_core::StudioLogLevel::Info, + } +} diff --git a/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs b/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs new file mode 100644 index 000000000..e4d3f3aee --- /dev/null +++ b/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs @@ -0,0 +1,233 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use lp_studio_core::{ + ActionOrigin, BROWSER_WORKER_PROVIDER_ID, DeviceCapability, StudioActionKind, StudioApp, + StudioEvent, StudioLogEntry, StudioLogLevel, +}; +use lpa_link::providers::browser_worker::BrowserWorkerProvider; +use lpa_link::{LinkConnectionKind, LinkEndpointId, LinkProvider, LinkProviderId, LinkSession}; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use web_sys::{MessageEvent, Worker, WorkerOptions, WorkerType}; + +use crate::browser_protocol_client::BrowserProtocolClient; +use crate::worker_envelope::{BrowserInputEnvelope, BrowserOutputEnvelope}; +use crate::{StudioRuntimeError, demo_project}; + +pub struct BrowserWorkerStudioRuntime { + worker: Worker, + outputs: Rc>>, +} + +impl BrowserWorkerStudioRuntime { + pub fn new(worker_url: &str) -> Result { + let options = WorkerOptions::new(); + options.set_type(WorkerType::Module); + let worker = Worker::new_with_options(worker_url, &options) + .map_err(|error| StudioRuntimeError::Browser(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: "lp-studio-runtime".to_string(), + message: format!("failed to parse worker message: {error}"), + }), + } + }); + worker.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + on_message.forget(); + Ok(Self { worker, outputs }) + } + + pub async fn boot(&mut self, label: &str) -> Result, StudioRuntimeError> { + self.post(&BrowserInputEnvelope::Boot { + label: label.to_string(), + })?; + let mut events = Vec::new(); + for _ in 0..200 { + crate::browser_protocol_client::sleep_ms(25).await?; + for output in self.take_outputs() { + let ready = matches!( + &output, + BrowserOutputEnvelope::Status { status, .. } if status == "ready" + ); + if let Some(event) = output_to_event(output) { + events.push(event); + } + if ready { + return Ok(events); + } + } + } + Err(StudioRuntimeError::Browser( + "timed out waiting for browser worker boot".to_string(), + )) + } + + pub fn post(&self, envelope: &BrowserInputEnvelope) -> Result<(), StudioRuntimeError> { + let value = serde_wasm_bindgen::to_value(envelope) + .map_err(|error| StudioRuntimeError::Browser(error.to_string()))?; + self.worker + .post_message(&value) + .map_err(|error| StudioRuntimeError::Browser(format!("{error:?}"))) + } + + pub fn take_outputs(&mut self) -> Vec { + core::mem::take(&mut *self.outputs.borrow_mut()) + } + + pub fn take_studio_events(&mut self) -> Vec { + self.take_outputs() + .into_iter() + .filter_map(output_to_event) + .collect() + } +} + +pub async fn run_browser_worker_demo(worker_url: &str) -> Result { + let mut app = StudioApp::new(); + app.dispatch_kind( + StudioActionKind::SelectLinkProvider { + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + }, + ActionOrigin::System, + ); + + let mut provider = BrowserWorkerProvider::new(BROWSER_WORKER_PROVIDER_ID); + let endpoint_id = provider.create_worker_endpoint("Browser firmware runtime"); + let endpoints = provider + .discover() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let discover_effects = + app.dispatch_kind(StudioActionKind::DiscoverDevices, ActionOrigin::Harness); + let action_id = discover_effects + .first() + .and_then(|effect| match effect { + lp_studio_core::StudioEffect::DiscoverEndpoints { action_id, .. } => Some(*action_id), + _ => None, + }) + .unwrap_or_default(); + app.apply_event(StudioEvent::EndpointsDiscovered { + action_id, + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + endpoints, + }); + + let mut session = provider + .connect(&endpoint_id) + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let connection = session + .connection() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let mut runtime = BrowserWorkerStudioRuntime::new(worker_url)?; + for event in runtime.boot("Studio browser runtime").await? { + app.apply_event(event); + } + let connect_effects = app.dispatch_kind( + StudioActionKind::ConnectDevice { + endpoint_id: LinkEndpointId::new(endpoint_id.as_str()), + }, + ActionOrigin::Harness, + ); + let connect_action_id = connect_effects + .first() + .and_then(|effect| match effect { + lp_studio_core::StudioEffect::ConnectEndpoint { action_id, .. } => Some(*action_id), + _ => None, + }) + .unwrap_or_default(); + app.apply_event(StudioEvent::DeviceConnected { + action_id: connect_action_id, + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + endpoint_id, + session_id: session.id().clone(), + connection_kind: match connection.kind { + LinkConnectionKind::BrowserWorker { protocol } => { + LinkConnectionKind::BrowserWorker { protocol } + } + other => other, + }, + capabilities: browser_worker_capabilities(), + }); + + let mut client = BrowserProtocolClient::new(runtime); + let load_effects = app.dispatch_kind(StudioActionKind::LoadDemoProject, ActionOrigin::Harness); + let load_action_id = load_effects + .first() + .and_then(|effect| match effect { + lp_studio_core::StudioEffect::SeedDemoProject { action_id, .. } => Some(*action_id), + _ => None, + }) + .unwrap_or_default(); + for event in client + .seed_demo_project(load_action_id, demo_project::DEMO_PROJECT_ID) + .await? + { + let effects = app.apply_event(event); + for effect in effects { + for event in client.execute_project_effect(effect).await? { + let follow_up = app.apply_event(event); + for effect in follow_up { + for event in client.execute_project_effect(effect).await? { + app.apply_event(event); + } + } + } + } + } + Ok(app) +} + +fn browser_worker_capabilities() -> Vec { + vec![ + DeviceCapability::Connect, + DeviceCapability::UseBrowserWorker, + DeviceCapability::ReadHeartbeat, + DeviceCapability::ListProjects, + DeviceCapability::LoadProject, + DeviceCapability::ReadProjectInventory, + DeviceCapability::ReadLogs, + DeviceCapability::ReadDiagnostics, + ] +} + +fn output_to_event(output: BrowserOutputEnvelope) -> Option { + match output { + BrowserOutputEnvelope::Status { + status, message, .. + } => Some(StudioEvent::LogReceived { + entry: StudioLogEntry::new( + StudioLogLevel::Info, + "fw-browser", + message.unwrap_or(status), + ), + }), + BrowserOutputEnvelope::Log { + level, + target, + message, + .. + } => Some(StudioEvent::LogReceived { + entry: StudioLogEntry::new(parse_worker_log_level(&level), target, message), + }), + BrowserOutputEnvelope::ProtocolOut { .. } => None, + } +} + +fn parse_worker_log_level(level: &str) -> StudioLogLevel { + match level { + "trace" => StudioLogLevel::Trace, + "debug" => StudioLogLevel::Debug, + "warn" => StudioLogLevel::Warn, + "error" => StudioLogLevel::Error, + _ => StudioLogLevel::Info, + } +} diff --git a/lp-app/lp-studio-runtime/src/client_session_runtime.rs b/lp-app/lp-studio-runtime/src/client_session_runtime.rs new file mode 100644 index 000000000..7d3f33efa --- /dev/null +++ b/lp-app/lp-studio-runtime/src/client_session_runtime.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; +use std::time::Duration; + +use lpa_client::ClientTransport; +use lpc_wire::{ClientRequest, WireServerMessage, WireServerMsgBody, messages::ClientMessage}; +use tokio::sync::Mutex; +use tokio::time::timeout; + +use lp_studio_core::{StudioEvent, StudioLogEntry, StudioLogLevel}; + +use crate::StudioRuntimeError; +pub use crate::protocol_event::inventory_request; +use crate::protocol_event::server_event; + +pub type SharedClientTransport = Arc>>; + +pub struct ClientSessionRuntime { + transport: SharedClientTransport, + next_request_id: u64, +} + +impl ClientSessionRuntime { + pub fn new(transport: SharedClientTransport) -> Self { + Self { + transport, + next_request_id: 1, + } + } + + pub async fn send_request( + &mut self, + request: ClientRequest, + ) -> Result { + let request_id = self.next_request_id(); + self.send_request_with_id(request_id, request).await + } + + pub async fn send_request_with_id( + &mut self, + request_id: u64, + request: ClientRequest, + ) -> Result { + let msg = ClientMessage { + id: request_id, + msg: request, + }; + + let mut transport = self.transport.lock().await; + transport + .send(msg) + .await + .map_err(|error| StudioRuntimeError::Transport(error.to_string()))?; + + let mut events = Vec::new(); + loop { + let response = timeout(Duration::from_secs(60), transport.receive()) + .await + .map_err(|_| StudioRuntimeError::Transport("request timed out".to_string()))? + .map_err(|error| StudioRuntimeError::Transport(error.to_string()))?; + + if response.id == request_id { + if let WireServerMsgBody::Error { error } = &response.msg { + return Err(StudioRuntimeError::Protocol(error.clone())); + } + return Ok(ClientExchange { response, events }); + } + + if response.id == 0 { + if let Some(event) = server_event(response) { + events.push(event); + } + continue; + } + + events.push(StudioEvent::LogReceived { + entry: StudioLogEntry::new( + StudioLogLevel::Warn, + "lp-studio-runtime", + format!( + "Ignoring uncorrelated server response id={} while waiting for id={request_id}", + response.id + ), + ), + }); + } + } + + pub fn next_request_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id += 1; + id + } +} + +pub struct ClientExchange { + pub response: WireServerMessage, + pub events: Vec, +} diff --git a/lp-app/lp-studio-runtime/src/demo_project.rs b/lp-app/lp-studio-runtime/src/demo_project.rs new file mode 100644 index 000000000..450d12d52 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/demo_project.rs @@ -0,0 +1,68 @@ +use lpc_model::AsLpPathBuf; +use lpc_wire::{ClientRequest, FsRequest, WireServerMsgBody, messages::ClientMessage}; + +pub const DEMO_PROJECT_ID: &str = lp_studio_core::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_write_messages(first_id: u64, project_id: &str) -> Vec { + demo_project_files() + .iter() + .enumerate() + .map(|(index, file)| { + let path = format!("/projects/{project_id}/{}", file.relative_path).as_path_buf(); + ClientMessage { + id: first_id + index as u64, + msg: ClientRequest::Filesystem(FsRequest::Write { + path, + data: file.bytes.to_vec(), + }), + } + }) + .collect() +} + +pub fn ensure_write_response(body: &WireServerMsgBody) -> Result<(), String> { + match body { + WireServerMsgBody::Filesystem(lpc_wire::FsResponse::Write { error, .. }) => { + if let Some(error) = error { + Err(error.clone()) + } else { + Ok(()) + } + } + other => Err(format!("unexpected filesystem response: {other:?}")), + } +} diff --git a/lp-app/lp-studio-runtime/src/effect_executor.rs b/lp-app/lp-studio-runtime/src/effect_executor.rs new file mode 100644 index 000000000..5c303fd0c --- /dev/null +++ b/lp-app/lp-studio-runtime/src/effect_executor.rs @@ -0,0 +1,14 @@ +use lp_studio_core::{StudioEffect, StudioEvent}; + +use crate::StudioRuntimeError; + +#[allow( + async_fn_in_trait, + reason = "Studio runtime executors are not object-safe yet" +)] +pub trait EffectExecutor { + async fn execute_effect( + &mut self, + effect: StudioEffect, + ) -> Result, StudioRuntimeError>; +} diff --git a/lp-app/lp-studio-runtime/src/error.rs b/lp-app/lp-studio-runtime/src/error.rs new file mode 100644 index 000000000..371d9ebc9 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/error.rs @@ -0,0 +1,30 @@ +use std::fmt::{self, Display}; + +#[derive(Debug)] +pub enum StudioRuntimeError { + Link(String), + Transport(String), + Protocol(String), + MissingClient, + MissingSession, + UnsupportedProvider(String), + Browser(String), +} + +impl Display for StudioRuntimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Link(message) => write!(f, "link error: {message}"), + Self::Transport(message) => write!(f, "transport error: {message}"), + Self::Protocol(message) => write!(f, "protocol error: {message}"), + Self::MissingClient => f.write_str("no Studio client session is connected"), + Self::MissingSession => f.write_str("no Studio device session is connected"), + Self::UnsupportedProvider(provider) => { + write!(f, "unsupported Studio runtime provider: {provider}") + } + Self::Browser(message) => write!(f, "browser runtime error: {message}"), + } + } +} + +impl std::error::Error for StudioRuntimeError {} diff --git a/lp-app/lp-studio-runtime/src/harness.rs b/lp-app/lp-studio-runtime/src/harness.rs new file mode 100644 index 000000000..579c06342 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/harness.rs @@ -0,0 +1,87 @@ +use lp_studio_core::{ + ActionOrigin, HOST_PROCESS_PROVIDER_ID, StudioActionKind, StudioApp, StudioEffect, +}; +use lpa_link::LinkProviderId; + +use crate::effect_executor::EffectExecutor; +use crate::{HostProcessStudioRuntime, StudioRuntimeError}; + +pub struct RuntimeHarness { + app: StudioApp, + runtime: HostProcessStudioRuntime, +} + +impl RuntimeHarness { + pub fn host_process() -> Self { + let mut app = StudioApp::new(); + app.dispatch_kind( + StudioActionKind::SelectLinkProvider { + provider_id: LinkProviderId::new(HOST_PROCESS_PROVIDER_ID), + }, + ActionOrigin::Harness, + ); + Self { + app, + runtime: HostProcessStudioRuntime::new(), + } + } + + pub fn app(&self) -> &StudioApp { + &self.app + } + + pub fn runtime_mut(&mut self) -> &mut HostProcessStudioRuntime { + &mut self.runtime + } + + pub async fn dispatch( + &mut self, + action: StudioActionKind, + origin: ActionOrigin, + ) -> Result<(), StudioRuntimeError> { + let effects = self.app.dispatch_kind(action, origin); + self.drain_effects(effects).await + } + + async fn drain_effects( + &mut self, + mut effects: Vec, + ) -> Result<(), StudioRuntimeError> { + while let Some(effect) = effects.pop() { + let events = self.runtime.execute_effect(effect).await?; + for event in events { + effects.extend(self.app.apply_event(event)); + } + } + Ok(()) + } +} + +pub async fn run_host_process_demo() -> Result { + let mut harness = RuntimeHarness::host_process(); + harness + .dispatch(StudioActionKind::DiscoverDevices, ActionOrigin::Harness) + .await?; + let endpoint_id = harness + .app() + .state() + .link_selection + .endpoints + .first() + .ok_or_else(|| { + StudioRuntimeError::Link("host-process discovery returned no endpoints".to_string()) + })? + .id + .clone(); + harness + .dispatch( + StudioActionKind::ConnectDevice { endpoint_id }, + ActionOrigin::Harness, + ) + .await?; + harness + .dispatch(StudioActionKind::LoadDemoProject, ActionOrigin::Harness) + .await?; + harness.runtime.close().await?; + Ok(harness.app) +} diff --git a/lp-app/lp-studio-runtime/src/host_process_runtime.rs b/lp-app/lp-studio-runtime/src/host_process_runtime.rs new file mode 100644 index 000000000..2463b1979 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/host_process_runtime.rs @@ -0,0 +1,240 @@ +use lp_studio_core::{ + DeviceCapability, HOST_PROCESS_PROVIDER_ID, StudioEffect, StudioEvent, StudioLogEntry, + StudioLogLevel, +}; +use lpa_link::providers::host_process::{HostProcessProvider, HostProcessSession}; +use lpa_link::{LinkEndpointId, LinkProvider, LinkProviderId, LinkSession}; + +use crate::StudioRuntimeError; +use crate::client_session_runtime::ClientSessionRuntime; +use crate::effect_executor::EffectExecutor; +use crate::project_session_runtime::ProjectSessionRuntime; + +pub struct HostProcessStudioRuntime { + provider: HostProcessProvider, + session: Option, + client: Option, +} + +impl HostProcessStudioRuntime { + pub fn new() -> Self { + let mut provider = HostProcessProvider::new(HOST_PROCESS_PROVIDER_ID); + provider.create_memory_endpoint("Studio host runtime"); + Self { + provider, + session: None, + client: None, + } + } + + pub async fn close(&mut self) -> Result<(), StudioRuntimeError> { + if let Some(session) = &mut self.session { + session + .close() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + } + self.session = None; + self.client = None; + Ok(()) + } + + async fn discover( + &mut self, + action_id: lp_studio_core::ActionId, + provider_id: LinkProviderId, + ) -> Result, StudioRuntimeError> { + if provider_id.as_str() != HOST_PROCESS_PROVIDER_ID { + return Err(StudioRuntimeError::UnsupportedProvider( + provider_id.as_str().to_string(), + )); + } + let endpoints = self + .provider + .discover() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + Ok(vec![StudioEvent::EndpointsDiscovered { + action_id, + provider_id, + endpoints, + }]) + } + + async fn connect( + &mut self, + action_id: lp_studio_core::ActionId, + endpoint_id: LinkEndpointId, + ) -> Result, StudioRuntimeError> { + let mut session = self + .provider + .connect(&endpoint_id) + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let connection = session + .connection() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let transport = connection + .client_transport() + .ok_or(StudioRuntimeError::MissingClient)?; + let session_id = session.id().clone(); + let logs = session.logs(); + let diagnostics = session.diagnostics(); + let connection_kind = connection.kind.clone(); + self.client = Some(ClientSessionRuntime::new(transport)); + self.session = Some(session); + + let mut events = Vec::new(); + for log in logs { + events.push(StudioEvent::LogReceived { + entry: StudioLogEntry::new(map_log_level(log.level), "lpa-link", log.message), + }); + } + for diagnostic in diagnostics { + events.push(StudioEvent::DiagnosticRaised { + diagnostic: lp_studio_core::StudioDiagnostic::info(diagnostic.message), + }); + } + events.push(StudioEvent::DeviceConnected { + action_id, + provider_id: LinkProviderId::new(HOST_PROCESS_PROVIDER_ID), + endpoint_id, + session_id, + connection_kind, + capabilities: host_process_capabilities(), + }); + Ok(events) + } + + fn project_runtime(&mut self) -> Result, StudioRuntimeError> { + let client = self + .client + .as_mut() + .ok_or(StudioRuntimeError::MissingClient)?; + Ok(ProjectSessionRuntime::new(client)) + } +} + +impl Default for HostProcessStudioRuntime { + fn default() -> Self { + Self::new() + } +} + +impl EffectExecutor for HostProcessStudioRuntime { + async fn execute_effect( + &mut self, + effect: StudioEffect, + ) -> Result, StudioRuntimeError> { + match effect { + StudioEffect::DiscoverEndpoints { + action_id, + provider_id, + } => self.discover(action_id, provider_id).await, + StudioEffect::ConnectEndpoint { + action_id, + endpoint_id, + } => self.connect(action_id, endpoint_id).await, + StudioEffect::DisconnectSession { + action_id, + session_id, + } => { + self.close().await?; + Ok(vec![StudioEvent::DeviceDisconnected { + action_id, + session_id, + }]) + } + StudioEffect::SeedDemoProject { + action_id, + project_id, + } => { + self.project_runtime()? + .seed_demo_project(action_id, &project_id) + .await + } + StudioEffect::LoadProject { + action_id, + project_id, + } => { + self.project_runtime()? + .load_project(action_id, &project_id) + .await + } + StudioEffect::RefreshStatus { action_id } => { + self.project_runtime()? + .refresh_loaded_projects(action_id) + .await + } + StudioEffect::ReadProjectInventory { action_id, handle } => { + self.project_runtime()? + .read_inventory(action_id, handle) + .await + } + } + } +} + +fn host_process_capabilities() -> Vec { + vec![ + DeviceCapability::Connect, + DeviceCapability::UseHostProcess, + DeviceCapability::ReadHeartbeat, + DeviceCapability::ListProjects, + DeviceCapability::LoadProject, + DeviceCapability::ReadProjectInventory, + DeviceCapability::ReadLogs, + DeviceCapability::ReadDiagnostics, + ] +} + +fn map_log_level(level: lpa_link::LinkLogLevel) -> StudioLogLevel { + match level { + lpa_link::LinkLogLevel::Trace => StudioLogLevel::Trace, + lpa_link::LinkLogLevel::Debug => StudioLogLevel::Debug, + lpa_link::LinkLogLevel::Info => StudioLogLevel::Info, + lpa_link::LinkLogLevel::Warn => StudioLogLevel::Warn, + lpa_link::LinkLogLevel::Error => StudioLogLevel::Error, + } +} + +#[cfg(test)] +mod tests { + use lp_studio_core::{ActionOrigin, StudioActionKind}; + + use crate::demo_project; + use crate::harness::RuntimeHarness; + + #[tokio::test] + async fn host_process_harness_loads_demo_project() { + let mut harness = RuntimeHarness::host_process(); + harness + .dispatch(StudioActionKind::DiscoverDevices, ActionOrigin::Harness) + .await + .unwrap(); + let endpoint_id = harness.app().state().link_selection.endpoints[0].id.clone(); + harness + .dispatch( + StudioActionKind::ConnectDevice { endpoint_id }, + ActionOrigin::Harness, + ) + .await + .unwrap(); + harness + .dispatch(StudioActionKind::LoadDemoProject, ActionOrigin::Harness) + .await + .unwrap(); + + let project = harness + .app() + .state() + .project_session + .as_ref() + .expect("project session"); + let inventory = project.inventory.as_ref().expect("project inventory"); + assert!(!inventory.nodes.is_empty()); + assert_eq!(project.project_id, demo_project::DEMO_PROJECT_ID); + harness.runtime_mut().close().await.unwrap(); + } +} diff --git a/lp-app/lp-studio-runtime/src/lib.rs b/lp-app/lp-studio-runtime/src/lib.rs new file mode 100644 index 000000000..c4bd3e4e3 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/lib.rs @@ -0,0 +1,32 @@ +//! LightPlayer Studio runtime and effect executors. + +pub mod demo_project; +pub mod effect_executor; +pub mod error; +pub mod protocol_event; +pub mod worker_envelope; + +#[cfg(feature = "host-process")] +pub mod client_session_runtime; +#[cfg(feature = "host-process")] +pub mod harness; +#[cfg(feature = "host-process")] +pub mod host_process_runtime; +#[cfg(feature = "host-process")] +pub mod project_session_runtime; + +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +pub mod browser_protocol_client; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +pub mod browser_worker_runtime; + +pub use effect_executor::EffectExecutor; +pub use error::StudioRuntimeError; + +#[cfg(feature = "host-process")] +pub use harness::{RuntimeHarness, run_host_process_demo}; +#[cfg(feature = "host-process")] +pub use host_process_runtime::HostProcessStudioRuntime; + +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +pub use browser_worker_runtime::{BrowserWorkerStudioRuntime, run_browser_worker_demo}; diff --git a/lp-app/lp-studio-runtime/src/project_session_runtime.rs b/lp-app/lp-studio-runtime/src/project_session_runtime.rs new file mode 100644 index 000000000..771a086f6 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/project_session_runtime.rs @@ -0,0 +1,127 @@ +use lpc_model::AsLpPathBuf; +use lpc_wire::{ClientRequest, WireProjectCommandResponse, WireServerMsgBody}; + +use lp_studio_core::{StudioEvent, StudioLogEntry, StudioLogLevel}; + +use crate::client_session_runtime::ClientSessionRuntime; +use crate::protocol_event::inventory_request; +use crate::{StudioRuntimeError, demo_project}; + +pub struct ProjectSessionRuntime<'a> { + client: &'a mut ClientSessionRuntime, +} + +impl<'a> ProjectSessionRuntime<'a> { + pub fn new(client: &'a mut ClientSessionRuntime) -> Self { + Self { client } + } + + pub async fn seed_demo_project( + &mut self, + action_id: lp_studio_core::ActionId, + project_id: &str, + ) -> Result, StudioRuntimeError> { + let mut events = Vec::new(); + for file in demo_project::demo_project_files() { + let path = format!("/projects/{project_id}/{}", file.relative_path); + let exchange = self + .client + .send_request(ClientRequest::Filesystem(lpc_wire::FsRequest::Write { + path: path.as_str().as_path_buf(), + data: file.bytes.to_vec(), + })) + .await?; + events.extend(exchange.events); + demo_project::ensure_write_response(&exchange.response.msg) + .map_err(StudioRuntimeError::Protocol)?; + } + events.push(StudioEvent::DemoProjectSeeded { + action_id, + project_id: project_id.to_string(), + }); + Ok(events) + } + + pub async fn load_project( + &mut self, + action_id: lp_studio_core::ActionId, + project_id: &str, + ) -> Result, StudioRuntimeError> { + let exchange = self + .client + .send_request(ClientRequest::LoadProject { + path: project_id.to_string(), + }) + .await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::LoadProject { handle } => { + events.push(StudioEvent::ProjectLoaded { + action_id, + project_id: project_id.to_string(), + handle, + }); + Ok(events) + } + other => Err(StudioRuntimeError::Protocol(format!( + "unexpected load project response: {other:?}" + ))), + } + } + + pub async fn read_inventory( + &mut self, + action_id: lp_studio_core::ActionId, + handle: lpc_wire::WireProjectHandle, + ) -> Result, StudioRuntimeError> { + let exchange = self.client.send_request(inventory_request(handle)).await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::ProjectCommand { + response: + WireProjectCommandResponse::ReadInventory { + response: inventory, + }, + } => { + events.push(StudioEvent::ProjectInventoryRead { + action_id, + inventory, + }); + Ok(events) + } + other => Err(StudioRuntimeError::Protocol(format!( + "unexpected inventory response: {other:?}" + ))), + } + } + + pub async fn refresh_loaded_projects( + &mut self, + action_id: lp_studio_core::ActionId, + ) -> Result, StudioRuntimeError> { + let exchange = self + .client + .send_request(ClientRequest::ListLoadedProjects) + .await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::ListLoadedProjects { projects } => { + events.push(StudioEvent::LoadedProjectsRefreshed { + action_id, + projects, + }); + Ok(events) + } + other => { + events.push(StudioEvent::LogReceived { + entry: StudioLogEntry::new( + StudioLogLevel::Warn, + "lp-studio-runtime", + format!("unexpected status response: {other:?}"), + ), + }); + Ok(events) + } + } + } +} diff --git a/lp-app/lp-studio-runtime/src/protocol_event.rs b/lp-app/lp-studio-runtime/src/protocol_event.rs new file mode 100644 index 000000000..478f5cd15 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/protocol_event.rs @@ -0,0 +1,48 @@ +use lp_studio_core::{StudioEvent, StudioHeartbeat, StudioLogEntry, StudioLogLevel}; +use lpc_wire::server::api::LogLevel; +use lpc_wire::{ + ClientRequest, WireProjectCommand, WireProjectInventoryReadRequest, WireServerMessage, + WireServerMsgBody, +}; + +pub fn inventory_request(handle: lpc_wire::WireProjectHandle) -> ClientRequest { + ClientRequest::ProjectCommand { + handle, + command: WireProjectCommand::ReadInventory { + request: WireProjectInventoryReadRequest, + }, + } +} + +pub fn server_event(response: WireServerMessage) -> Option { + match response.msg { + WireServerMsgBody::Heartbeat { + fps, + frame_count, + loaded_projects, + uptime_ms, + memory, + } => Some(StudioEvent::HeartbeatReceived { + heartbeat: StudioHeartbeat { + fps_avg: fps.avg, + frame_count, + loaded_project_count: loaded_projects.len(), + uptime_ms, + free_memory_bytes: memory.map(|memory| memory.free_bytes), + }, + }), + WireServerMsgBody::Log { level, message } => Some(StudioEvent::LogReceived { + entry: StudioLogEntry::new(log_level(level), "lp-server", message), + }), + _ => None, + } +} + +fn log_level(level: LogLevel) -> StudioLogLevel { + match level { + LogLevel::Debug => StudioLogLevel::Debug, + LogLevel::Info => StudioLogLevel::Info, + LogLevel::Warn => StudioLogLevel::Warn, + LogLevel::Error => StudioLogLevel::Error, + } +} diff --git a/lp-app/lp-studio-runtime/src/worker_envelope.rs b/lp-app/lp-studio-runtime/src/worker_envelope.rs new file mode 100644 index 000000000..713852ecb --- /dev/null +++ b/lp-app/lp-studio-runtime/src/worker_envelope.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BrowserInputEnvelope { + Boot { label: 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/lp-studio-web/Cargo.toml b/lp-app/lp-studio-web/Cargo.toml new file mode 100644 index 000000000..7f96227b4 --- /dev/null +++ b/lp-app/lp-studio-web/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lp-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" + +[dependencies] +dioxus = { version = "0.7", features = ["web"] } +lp-studio-core = { path = "../lp-studio-core" } +lp-studio-runtime = { path = "../lp-studio-runtime", features = ["browser-worker"] } + +[lints] +workspace = true diff --git a/lp-app/lp-studio-web/Dioxus.toml b/lp-app/lp-studio-web/Dioxus.toml new file mode 100644 index 000000000..25dcca621 --- /dev/null +++ b/lp-app/lp-studio-web/Dioxus.toml @@ -0,0 +1,9 @@ +[application] +name = "lp-studio-web" +default_platform = "web" +asset_dir = "public" +out_dir = "dist" + +[web.app] +title = "LightPlayer Studio" +base_path = "/" diff --git a/lp-app/lp-studio-web/README.md b/lp-app/lp-studio-web/README.md new file mode 100644 index 000000000..ad71e9d8b --- /dev/null +++ b/lp-app/lp-studio-web/README.md @@ -0,0 +1,24 @@ +# lp-studio-web + +`lp-studio-web` is the first static browser shell for LightPlayer Studio. + +It renders `lp-studio-core` state and drives the browser-local `browser-worker` +runtime path from `lp-studio-runtime`. It does not own Studio domain behavior and +does not use Dioxus server functions. + +## Run + +```bash +just studio-web-build +just studio-web +``` + +`studio-web-build` builds the Dioxus web app with Cargo, packages it with +wasm-bindgen, and prepares the `fw-browser` worker assets used by the Studio demo +flow. + +## Boundary + +- `lp-studio-core` owns actions, state, effects, diagnostics, and sessions. +- `lp-studio-runtime` owns browser worker protocol flow and demo project loading. +- `lp-studio-web` owns Dioxus components and static presentation. diff --git a/lp-app/lp-studio-web/public/index.html b/lp-app/lp-studio-web/public/index.html new file mode 100644 index 000000000..b92bdcd32 --- /dev/null +++ b/lp-app/lp-studio-web/public/index.html @@ -0,0 +1,15 @@ + + + + + + LightPlayer Studio + + +
+ + + diff --git a/lp-app/lp-studio-web/src/app.rs b/lp-app/lp-studio-web/src/app.rs new file mode 100644 index 000000000..fc79f678c --- /dev/null +++ b/lp-app/lp-studio-web/src/app.rs @@ -0,0 +1,50 @@ +use dioxus::prelude::*; +use lp_studio_core::StudioApp; +use lp_studio_runtime::run_browser_worker_demo; + +use crate::components::device_panel::DevicePanel; +use crate::components::inventory_view::InventoryView; +use crate::components::log_panel::LogPanel; +use crate::components::project_panel::ProjectPanel; +use crate::components::status_bar::StatusBar; + +const STYLE: &str = include_str!("style.css"); +const WORKER_URL: &str = "./fw-browser-worker.js"; + +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn App() -> Element { + let mut studio = use_signal(StudioApp::new); + let mut running = use_signal(|| false); + let mut error = use_signal(|| Option::::None); + + let state = studio.read().state().clone(); + let is_running = *running.read(); + let error_text = error.read().clone(); + let start_demo = move |_| { + if *running.read() { + return; + } + running.set(true); + error.set(None); + spawn(async move { + match run_browser_worker_demo(WORKER_URL).await { + Ok(app) => studio.set(app), + Err(runtime_error) => error.set(Some(runtime_error.to_string())), + } + running.set(false); + }); + }; + + rsx! { + style { "{STYLE}" } + main { class: "studio-shell", + StatusBar { state: state.clone(), running: is_running, error: error_text.clone() } + section { class: "studio-grid", + DevicePanel { state: state.clone(), running: is_running, on_start_demo: start_demo } + ProjectPanel { state: state.clone() } + InventoryView { state: state.clone() } + LogPanel { state } + } + } + } +} diff --git a/lp-app/lp-studio-web/src/components/device_panel.rs b/lp-app/lp-studio-web/src/components/device_panel.rs new file mode 100644 index 000000000..31519552d --- /dev/null +++ b/lp-app/lp-studio-web/src/components/device_panel.rs @@ -0,0 +1,41 @@ +use dioxus::prelude::*; +use lp_studio_core::StudioState; + +#[component] +pub fn DevicePanel( + state: StudioState, + running: bool, + on_start_demo: EventHandler, +) -> Element { + let provider = state + .link_selection + .selected_provider_id + .as_str() + .to_string(); + let endpoint_count = state.link_selection.endpoints.len(); + let session = state + .device_session + .as_ref() + .map(|session| session.session_id.as_str().to_string()) + .unwrap_or_else(|| "none".to_string()); + rsx! { + section { class: "panel device-panel", + div { class: "panel-heading", + h2 { "Device" } + button { + disabled: running, + onclick: move |event| on_start_demo.call(event), + if running { "Running" } else { "Start demo" } + } + } + dl { + dt { "Provider" } + dd { "{provider}" } + dt { "Endpoints" } + dd { "{endpoint_count}" } + dt { "Session" } + dd { "{session}" } + } + } + } +} diff --git a/lp-app/lp-studio-web/src/components/inventory_view.rs b/lp-app/lp-studio-web/src/components/inventory_view.rs new file mode 100644 index 000000000..fd75ad3a4 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/inventory_view.rs @@ -0,0 +1,51 @@ +use dioxus::prelude::*; +use lp_studio_core::StudioState; + +#[component] +pub fn InventoryView(state: StudioState) -> Element { + let inventory = state + .project_session + .as_ref() + .and_then(|project| project.inventory.as_ref()); + let node_count = inventory + .map(|inventory| inventory.nodes.len()) + .unwrap_or(0); + let def_count = inventory.map(|inventory| inventory.defs.len()).unwrap_or(0); + let asset_count = inventory + .map(|inventory| inventory.assets.len()) + .unwrap_or(0); + rsx! { + section { class: "panel inventory-panel", + div { class: "panel-heading", + h2 { "Inventory" } + span { class: "mini-count", "{node_count} nodes" } + } + div { class: "inventory-stats", + div { strong { "{node_count}" } span { "Nodes" } } + div { strong { "{def_count}" } span { "Definitions" } } + div { strong { "{asset_count}" } span { "Assets" } } + } + ul { class: "inventory-list", + if let Some(inventory) = inventory { + for node in inventory.nodes.iter().take(8) { + { + let label = if node.key.is_root() { + "root".to_string() + } else { + node.key + .segments + .iter() + .map(|segment| segment.slot.to_string()) + .collect::>() + .join(" / ") + }; + rsx! { li { "{label}" } } + } + } + } else { + li { "No project inventory yet." } + } + } + } + } +} diff --git a/lp-app/lp-studio-web/src/components/log_panel.rs b/lp-app/lp-studio-web/src/components/log_panel.rs new file mode 100644 index 000000000..18c667812 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/log_panel.rs @@ -0,0 +1,25 @@ +use dioxus::prelude::*; +use lp_studio_core::StudioState; + +#[component] +pub fn LogPanel(state: StudioState) -> Element { + rsx! { + section { class: "panel log-panel", + div { class: "panel-heading", + h2 { "Logs" } + span { class: "mini-count", "{state.logs.len()}" } + } + ul { class: "log-list", + for diagnostic in state.diagnostics.iter().rev().take(4) { + li { class: "diagnostic-line", "{diagnostic.severity:?}: {diagnostic.message}" } + } + for entry in state.logs.iter().rev().take(10) { + li { "{entry.level:?} {entry.target}: {entry.message}" } + } + if state.logs.is_empty() && state.diagnostics.is_empty() { + li { "No runtime logs yet." } + } + } + } + } +} diff --git a/lp-app/lp-studio-web/src/components/mod.rs b/lp-app/lp-studio-web/src/components/mod.rs new file mode 100644 index 000000000..3e816b90b --- /dev/null +++ b/lp-app/lp-studio-web/src/components/mod.rs @@ -0,0 +1,5 @@ +pub mod device_panel; +pub mod inventory_view; +pub mod log_panel; +pub mod project_panel; +pub mod status_bar; diff --git a/lp-app/lp-studio-web/src/components/project_panel.rs b/lp-app/lp-studio-web/src/components/project_panel.rs new file mode 100644 index 000000000..86645a615 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/project_panel.rs @@ -0,0 +1,36 @@ +use dioxus::prelude::*; +use lp_studio_core::StudioState; + +#[component] +pub fn ProjectPanel(state: StudioState) -> Element { + let project_id = state + .project_session + .as_ref() + .map(|project| project.project_id.clone()) + .unwrap_or_else(|| "not loaded".to_string()); + let handle = state + .project_session + .as_ref() + .map(|project| project.handle.id().to_string()) + .unwrap_or_else(|| "-".to_string()); + let selected = state + .project_session + .as_ref() + .and_then(|project| project.selected_node_id.clone()) + .unwrap_or_else(|| "none".to_string()); + rsx! { + section { class: "panel", + div { class: "panel-heading", + h2 { "Project" } + } + dl { + dt { "Project" } + dd { "{project_id}" } + dt { "Handle" } + dd { "{handle}" } + dt { "Selection" } + dd { "{selected}" } + } + } + } +} diff --git a/lp-app/lp-studio-web/src/components/status_bar.rs b/lp-app/lp-studio-web/src/components/status_bar.rs new file mode 100644 index 000000000..cc6b99fc1 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/status_bar.rs @@ -0,0 +1,38 @@ +use dioxus::prelude::*; +use lp_studio_core::StudioState; + +#[component] +pub fn StatusBar(state: StudioState, running: bool, error: Option) -> Element { + let status = if running { + "Starting" + } else if state.project_session.is_some() { + "Ready" + } else if state.client_session.is_some() { + "Connected" + } else { + "Idle" + }; + let heartbeat = state + .heartbeat + .as_ref() + .map(|heartbeat| { + format!( + "{:.0} fps | frame {}", + heartbeat.fps_avg, heartbeat.frame_count + ) + }) + .unwrap_or_else(|| "waiting for heartbeat".to_string()); + rsx! { + header { class: "status-bar", + div { + h1 { "LightPlayer Studio" } + p { "Browser firmware runtime" } + } + div { class: "status-pill", "{status}" } + div { class: "status-metric", "{heartbeat}" } + if let Some(error) = error { + div { class: "status-error", "{error}" } + } + } + } +} diff --git a/lp-app/lp-studio-web/src/main.rs b/lp-app/lp-studio-web/src/main.rs new file mode 100644 index 000000000..8d4bab02a --- /dev/null +++ b/lp-app/lp-studio-web/src/main.rs @@ -0,0 +1,6 @@ +mod app; +mod components; + +fn main() { + dioxus::launch(app::App); +} diff --git a/lp-app/lp-studio-web/src/style.css b/lp-app/lp-studio-web/src/style.css new file mode 100644 index 000000000..1558e3ed0 --- /dev/null +++ b/lp-app/lp-studio-web/src/style.css @@ -0,0 +1,175 @@ +:root { + color: #17201b; + background: #f5f7f4; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button { + min-height: 34px; + border: 1px solid #1d4d3a; + border-radius: 6px; + background: #1f7a57; + color: white; + font: inherit; + font-weight: 650; + padding: 0 14px; +} + +button:disabled { + background: #84948d; + border-color: #84948d; +} + +.studio-shell { + min-height: 100vh; +} + +.status-bar { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 18px 22px; + border-bottom: 1px solid #ccd5cf; + background: #ffffff; +} + +.status-bar h1, +.panel h2 { + margin: 0; + letter-spacing: 0; +} + +.status-bar h1 { + font-size: 20px; +} + +.status-bar p { + margin: 2px 0 0; + color: #66726b; + font-size: 13px; +} + +.status-pill, +.status-metric, +.status-error, +.mini-count { + border-radius: 999px; + padding: 5px 10px; + background: #e9eee9; + font-size: 13px; + white-space: nowrap; +} + +.status-error { + grid-column: 1 / -1; + background: #ffe8e0; + color: #8b2c1c; +} + +.studio-grid { + display: grid; + grid-template-columns: minmax(260px, 0.8fr) minmax(300px, 1.2fr); + gap: 14px; + padding: 14px; +} + +.panel { + min-width: 0; + border: 1px solid #d5ded8; + border-radius: 8px; + background: #ffffff; + padding: 14px; +} + +.panel-heading { + display: flex; + min-height: 36px; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.panel h2 { + font-size: 15px; +} + +dl { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px 12px; + margin: 0; +} + +dt { + color: #65736c; + font-size: 13px; +} + +dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.inventory-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.inventory-stats div { + border: 1px solid #e2e7e2; + border-radius: 6px; + padding: 10px; +} + +.inventory-stats strong { + display: block; + font-size: 20px; +} + +.inventory-stats span, +.log-list, +.inventory-list { + color: #58645d; + font-size: 13px; +} + +.inventory-list, +.log-list { + display: grid; + gap: 6px; + margin: 0; + padding-left: 18px; +} + +.log-panel { + grid-column: 1 / -1; +} + +.diagnostic-line { + color: #8b2c1c; +} + +@media (max-width: 760px) { + .status-bar, + .studio-grid { + grid-template-columns: 1fr; + } + + .status-pill, + .status-metric { + justify-self: start; + } +} From 6c991d5ff6d095607364b8f295809d9c29409e24 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 19:25:21 -0700 Subject: [PATCH 14/62] feat: add studio storybook workflow Add a feature-gated Dioxus storybook for the Studio UI, a local PNG capture command named studio-story-pngs, and documentation for the workflow. ADR: docs/adr/2026-06-18-studio-native-storybook.md Plan: /Users/yona/Dropbox/Documents/PersonalNotes/Planning/lightplayer/2026-06-17-lp-studio-foundation/01a-m1a-studio-storybook/plan.md --- .gitignore | 1 + Cargo.lock | 4 + .../adr/2026-06-18-studio-native-storybook.md | 45 ++++ justfile | 39 +++ lp-app/lp-studio-web/Cargo.toml | 7 + lp-app/lp-studio-web/README.md | 45 +++- .../scripts/studio-story-pngs.mjs | 123 +++++++++ lp-app/lp-studio-web/src/app.rs | 8 + .../src/components/device_panel_stories.rs | 54 ++++ .../src/components/inventory_view_stories.rs | 28 ++ .../src/components/log_panel_stories.rs | 44 ++++ lp-app/lp-studio-web/src/components/mod.rs | 10 + .../src/components/project_panel_stories.rs | 39 +++ .../src/components/status_bar_stories.rs | 56 ++++ lp-app/lp-studio-web/src/main.rs | 2 + lp-app/lp-studio-web/src/stories/mod.rs | 6 + lp-app/lp-studio-web/src/stories/story.rs | 24 ++ .../lp-studio-web/src/stories/story_book.rs | 180 +++++++++++++ .../src/stories/story_fixtures.rs | 232 +++++++++++++++++ .../src/stories/story_registry.rs | 41 +++ lp-app/lp-studio-web/src/style.css | 243 ++++++++++++++++-- 21 files changed, 1206 insertions(+), 25 deletions(-) create mode 100644 docs/adr/2026-06-18-studio-native-storybook.md create mode 100644 lp-app/lp-studio-web/scripts/studio-story-pngs.mjs create mode 100644 lp-app/lp-studio-web/src/components/device_panel_stories.rs create mode 100644 lp-app/lp-studio-web/src/components/inventory_view_stories.rs create mode 100644 lp-app/lp-studio-web/src/components/log_panel_stories.rs create mode 100644 lp-app/lp-studio-web/src/components/project_panel_stories.rs create mode 100644 lp-app/lp-studio-web/src/components/status_bar_stories.rs create mode 100644 lp-app/lp-studio-web/src/stories/mod.rs create mode 100644 lp-app/lp-studio-web/src/stories/story.rs create mode 100644 lp-app/lp-studio-web/src/stories/story_book.rs create mode 100644 lp-app/lp-studio-web/src/stories/story_fixtures.rs create mode 100644 lp-app/lp-studio-web/src/stories/story_registry.rs diff --git a/.gitignore b/.gitignore index db7699892..97ed2c0c9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ profiles/ lp-app/lp-studio-web/public/pkg/ lp-app/lp-studio-web/public/fw-browser-worker.js lp-app/lp-studio-web/dist/ +lp-app/lp-studio-web/story-pngs/ diff --git a/Cargo.lock b/Cargo.lock index 74810f313..dd031f48c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5098,6 +5098,10 @@ dependencies = [ "dioxus", "lp-studio-core", "lp-studio-runtime", + "lpa-link", + "lpc-model", + "lpc-wire", + "web-sys", ] [[package]] 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..a89f60c68 --- /dev/null +++ b/docs/adr/2026-06-18-studio-native-storybook.md @@ -0,0 +1,45 @@ +# 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 `lp-studio-core::StudioState` 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 + `StudioState` 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 a gitignored + `lp-app/lp-studio-web/story-pngs/` directory. + +## 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. diff --git a/justfile b/justfile index 1d0bdcf01..50ee7f46f 100644 --- a/justfile +++ b/justfile @@ -162,6 +162,45 @@ fw-browser-smoke: fw-browser-build # 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 lp-studio-web for wasm32 debug with stories..." + cargo build -p lp-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/lp-studio-web/public/pkg + wasm-bindgen target/wasm32-unknown-unknown/debug/lp-studio-web.wasm \ + --out-dir lp-app/lp-studio-web/public/pkg --target web + echo "Copying fw-browser worker assets..." + cp lp-fw/fw-browser/www/fw-browser-worker.js lp-app/lp-studio-web/public/fw-browser-worker.js + cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lp-studio-web/public/pkg/fw_browser.js + cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lp-studio-web/public/pkg/fw_browser_bg.wasm + echo "Artifacts: lp-app/lp-studio-web/public/ (debug build)" + +studio-story-pngs: studio-web-dev-build + #!/usr/bin/env bash + set -euo pipefail + node lp-app/lp-studio-web/scripts/studio-story-pngs.mjs + +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/lp-studio-web/public + python3 -m http.server "${port}" --bind 127.0.0.1 + studio-web-build: install-wasm32-target fw-browser-build #!/usr/bin/env bash set -euo pipefail diff --git a/lp-app/lp-studio-web/Cargo.toml b/lp-app/lp-studio-web/Cargo.toml index 7f96227b4..a60f442c0 100644 --- a/lp-app/lp-studio-web/Cargo.toml +++ b/lp-app/lp-studio-web/Cargo.toml @@ -8,10 +8,17 @@ rust-version.workspace = true publish = false description = "Static Dioxus web shell for LightPlayer Studio" +[features] +stories = ["dep:lpa-link", "dep:lpc-model", "dep:lpc-wire", "dep:web-sys"] + [dependencies] dioxus = { version = "0.7", features = ["web"] } +lpa-link = { path = "../lpa-link", optional = true } +lpc-model = { path = "../../lp-core/lpc-model", optional = true } +lpc-wire = { path = "../../lp-core/lpc-wire", optional = true } lp-studio-core = { path = "../lp-studio-core" } lp-studio-runtime = { path = "../lp-studio-runtime", features = ["browser-worker"] } +web-sys = { version = "0.3", optional = true, features = ["Location", "Window"] } [lints] workspace = true diff --git a/lp-app/lp-studio-web/README.md b/lp-app/lp-studio-web/README.md index ad71e9d8b..696598fa6 100644 --- a/lp-app/lp-studio-web/README.md +++ b/lp-app/lp-studio-web/README.md @@ -9,13 +9,48 @@ does not use Dioxus server functions. ## Run ```bash -just studio-web-build -just studio-web +just studio-dev ``` -`studio-web-build` builds the Dioxus web app with Cargo, packages it with -wasm-bindgen, and prepares the `fw-browser` worker assets used by the Studio demo -flow. +`studio-dev` builds debug wasm artifacts for `lp-studio-web` and `fw-browser`, +packages them with wasm-bindgen, prepares the worker assets, and serves +`http://127.0.0.1:2820/`. + +Use `just studio-web-build` or `just studio-web` when you want the release/static +build path. + +## Stories + +`lp-studio-web` has a native Dioxus storybook for isolated component states. +Stories live next to the components they exercise, using sibling files such as +`device_panel_stories.rs`. + +Run the dev server and open the storybook: + +```bash +just studio-dev +``` + +Then visit `http://127.0.0.1:2820/#/stories`. + +Add new stories by: + +1. adding a `*_stories.rs` sibling module for the component +2. adding one or more `StoryDescriptor` values +3. adding a `render_story` match arm for each stable story id +4. registering the module in `stories/story_registry.rs` + +Use `stories/story_fixtures.rs` for fake but domain-shaped `StudioState` +fixtures. Stories should render real components, not duplicate mock markup. + +Generate local PNGs for quick review: + +```bash +just studio-story-pngs +``` + +PNGs are written to `lp-app/lp-studio-web/story-pngs/`, which is gitignored. +They are local review artifacts, not committed visual baselines. ## Boundary diff --git a/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs b/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs new file mode 100644 index 000000000..24a06728f --- /dev/null +++ b/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import { mkdir, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +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/lp-studio-web/public"); +const outputDir = path.resolve( + repoRoot, + process.env.STUDIO_STORY_PNGS_DIR ?? "lp-app/lp-studio-web/story-pngs", +); +const port = process.env.STUDIO_STORY_PNGS_PORT ?? "2822"; +const baseUrl = `http://127.0.0.1:${port}/`; +const chrome = process.env.CHROME_BIN ?? findChrome(); + +if (!chrome) { + console.error( + "Could not find Google Chrome. Set CHROME_BIN=/path/to/chrome to generate story PNGs.", + ); + process.exit(1); +} + +await rm(outputDir, { recursive: true, force: true }); +await mkdir(outputDir, { 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."); + } + + for (const storyId of storyIds) { + const file = path.join(outputDir, `${storyId.replaceAll("/", "__")}.png`); + await runChrome([ + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + "--window-size=1080,760", + "--virtual-time-budget=3000", + `--screenshot=${file}`, + `${baseUrl}?story-png=1#/stories/${storyId}`, + ]); + console.log(`wrote ${path.relative(repoRoot, file)}`); + } + + console.log(`Story PNGs: ${path.relative(repoRoot, outputDir)}`); +} finally { + if (server.exitCode === null) { + server.kill("SIGTERM"); + } + await Promise.race([serverExited, delay(1_000)]); +} + +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 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) { + const child = spawn(chrome, 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(`Chrome exited with ${code}: ${stderr.trim()}`); + } + return stdout; +} + +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/lp-studio-web/src/app.rs b/lp-app/lp-studio-web/src/app.rs index fc79f678c..30d74247b 100644 --- a/lp-app/lp-studio-web/src/app.rs +++ b/lp-app/lp-studio-web/src/app.rs @@ -13,6 +13,14 @@ const WORKER_URL: &str = "./fw-browser-worker.js"; #[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 mut studio = use_signal(StudioApp::new); let mut running = use_signal(|| false); let mut error = use_signal(|| Option::::None); diff --git a/lp-app/lp-studio-web/src/components/device_panel_stories.rs b/lp-app/lp-studio-web/src/components/device_panel_stories.rs new file mode 100644 index 000000000..fd74b48e4 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/device_panel_stories.rs @@ -0,0 +1,54 @@ +use dioxus::prelude::*; + +use crate::components::device_panel::DevicePanel; +use crate::stories::story::StoryDescriptor; +use crate::stories::story_fixtures::{ + studio_state_connected, studio_state_connecting, studio_state_idle, studio_state_long_content, +}; + +pub const STORIES: &[StoryDescriptor] = &[ + StoryDescriptor::new( + "device/idle", + "DevicePanel", + "Idle", + "No endpoint has been discovered.", + ), + StoryDescriptor::new( + "device/starting", + "DevicePanel", + "Starting", + "Endpoint discovery has started the local worker.", + ), + StoryDescriptor::new( + "device/connected", + "DevicePanel", + "Connected", + "A browser-worker session is connected.", + ), + StoryDescriptor::new( + "device/long-session", + "DevicePanel", + "Long Session", + "Long session identifiers should wrap cleanly.", + ), +]; + +pub fn render_story(id: &str) -> Option { + match id { + "device/idle" => Some(device_story(studio_state_idle(), false)), + "device/starting" => Some(device_story(studio_state_connecting(), true)), + "device/connected" => Some(device_story(studio_state_connected(), false)), + "device/long-session" => Some(device_story(studio_state_long_content(), false)), + _ => None, + } +} + +fn device_story(state: lp_studio_core::StudioState, running: bool) -> Element { + rsx! { + DevicePanel { + state, + running, + on_start_demo: move |_| {} + } + } +} diff --git a/lp-app/lp-studio-web/src/components/inventory_view_stories.rs b/lp-app/lp-studio-web/src/components/inventory_view_stories.rs new file mode 100644 index 000000000..68b8d72c0 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/inventory_view_stories.rs @@ -0,0 +1,28 @@ +use dioxus::prelude::*; + +use crate::components::inventory_view::InventoryView; +use crate::stories::story::StoryDescriptor; +use crate::stories::story_fixtures::{studio_state_idle, studio_state_ready}; + +pub const STORIES: &[StoryDescriptor] = &[ + StoryDescriptor::new( + "inventory/empty", + "InventoryView", + "Empty", + "No project inventory has been read.", + ), + StoryDescriptor::new( + "inventory/demo", + "InventoryView", + "Demo Project", + "A populated demo inventory with nodes, definitions, and an asset.", + ), +]; + +pub fn render_story(id: &str) -> Option { + match id { + "inventory/empty" => Some(rsx! { InventoryView { state: studio_state_idle() } }), + "inventory/demo" => Some(rsx! { InventoryView { state: studio_state_ready() } }), + _ => None, + } +} diff --git a/lp-app/lp-studio-web/src/components/log_panel_stories.rs b/lp-app/lp-studio-web/src/components/log_panel_stories.rs new file mode 100644 index 000000000..a061ac741 --- /dev/null +++ b/lp-app/lp-studio-web/src/components/log_panel_stories.rs @@ -0,0 +1,44 @@ +use dioxus::prelude::*; + +use crate::components::log_panel::LogPanel; +use crate::stories::story::StoryDescriptor; +use crate::stories::story_fixtures::{ + studio_state_error, studio_state_idle, studio_state_log_heavy, studio_state_ready, +}; + +pub const STORIES: &[StoryDescriptor] = &[ + StoryDescriptor::new( + "logs/empty", + "LogPanel", + "Empty", + "No logs or diagnostics yet.", + ), + StoryDescriptor::new( + "logs/ready", + "LogPanel", + "Ready Logs", + "A short successful runtime log.", + ), + StoryDescriptor::new( + "logs/diagnostic", + "LogPanel", + "Diagnostic", + "A connection diagnostic appears before logs.", + ), + StoryDescriptor::new( + "logs/heavy", + "LogPanel", + "Log Heavy", + "Many log levels and enough entries to exercise truncation.", + ), +]; + +pub fn render_story(id: &str) -> Option { + match id { + "logs/empty" => Some(rsx! { LogPanel { state: studio_state_idle() } }), + "logs/ready" => Some(rsx! { LogPanel { state: studio_state_ready() } }), + "logs/diagnostic" => Some(rsx! { LogPanel { state: studio_state_error() } }), + "logs/heavy" => Some(rsx! { LogPanel { state: studio_state_log_heavy() } }), + _ => None, + } +} diff --git a/lp-app/lp-studio-web/src/components/mod.rs b/lp-app/lp-studio-web/src/components/mod.rs index 3e816b90b..a41c2c91f 100644 --- a/lp-app/lp-studio-web/src/components/mod.rs +++ b/lp-app/lp-studio-web/src/components/mod.rs @@ -1,5 +1,15 @@ pub mod device_panel; +#[cfg(feature = "stories")] +pub mod device_panel_stories; pub mod inventory_view; +#[cfg(feature = "stories")] +pub mod inventory_view_stories; pub mod log_panel; +#[cfg(feature = "stories")] +pub mod log_panel_stories; pub mod project_panel; +#[cfg(feature = "stories")] +pub mod project_panel_stories; pub mod status_bar; +#[cfg(feature = "stories")] +pub mod status_bar_stories; diff --git a/lp-app/lp-studio-web/src/components/project_panel_stories.rs b/lp-app/lp-studio-web/src/components/project_panel_stories.rs new file mode 100644 index 000000000..69309443c --- /dev/null +++ b/lp-app/lp-studio-web/src/components/project_panel_stories.rs @@ -0,0 +1,39 @@ +use dioxus::prelude::*; + +use crate::components::project_panel::ProjectPanel; +use crate::stories::story::StoryDescriptor; +use crate::stories::story_fixtures::{ + studio_state_idle, studio_state_long_content, studio_state_ready, +}; + +pub const STORIES: &[StoryDescriptor] = &[ + StoryDescriptor::new( + "project/not-loaded", + "ProjectPanel", + "Not Loaded", + "No project session exists yet.", + ), + StoryDescriptor::new( + "project/ready", + "ProjectPanel", + "Ready", + "The demo project is loaded.", + ), + StoryDescriptor::new( + "project/long-content", + "ProjectPanel", + "Long Content", + "Long project and selection labels should wrap.", + ), +]; + +pub fn render_story(id: &str) -> Option { + match id { + "project/not-loaded" => Some(rsx! { ProjectPanel { state: studio_state_idle() } }), + "project/ready" => Some(rsx! { ProjectPanel { state: studio_state_ready() } }), + "project/long-content" => { + Some(rsx! { ProjectPanel { state: studio_state_long_content() } }) + } + _ => None, + } +} diff --git a/lp-app/lp-studio-web/src/components/status_bar_stories.rs b/lp-app/lp-studio-web/src/components/status_bar_stories.rs new file mode 100644 index 000000000..11bfd700e --- /dev/null +++ b/lp-app/lp-studio-web/src/components/status_bar_stories.rs @@ -0,0 +1,56 @@ +use dioxus::prelude::*; + +use crate::components::status_bar::StatusBar; +use crate::stories::story::StoryDescriptor; +use crate::stories::story_fixtures::{ + studio_state_connected, studio_state_error, studio_state_idle, studio_state_ready, +}; + +pub const STORIES: &[StoryDescriptor] = &[ + StoryDescriptor::new( + "status/idle", + "StatusBar", + "Idle", + "No runtime is connected.", + ), + StoryDescriptor::new( + "status/starting", + "StatusBar", + "Starting", + "The browser worker is starting.", + ), + StoryDescriptor::new( + "status/ready", + "StatusBar", + "Ready", + "A demo project is loaded with heartbeat data.", + ), + StoryDescriptor::new( + "status/error", + "StatusBar", + "Error", + "Startup failure shown in the status bar.", + ), +]; + +pub fn render_story(id: &str) -> Option { + match id { + "status/idle" => { + Some(rsx! { StatusBar { state: studio_state_idle(), running: false, error: None } }) + } + "status/starting" => { + Some(rsx! { StatusBar { state: studio_state_connected(), running: true, error: None } }) + } + "status/ready" => { + Some(rsx! { StatusBar { state: studio_state_ready(), running: false, error: None } }) + } + "status/error" => Some(rsx! { + StatusBar { + state: studio_state_error(), + running: false, + error: Some("Browser worker did not respond before the startup timeout.".to_string()) + } + }), + _ => None, + } +} diff --git a/lp-app/lp-studio-web/src/main.rs b/lp-app/lp-studio-web/src/main.rs index 8d4bab02a..00ecad8a6 100644 --- a/lp-app/lp-studio-web/src/main.rs +++ b/lp-app/lp-studio-web/src/main.rs @@ -1,5 +1,7 @@ mod app; mod components; +#[cfg(feature = "stories")] +mod stories; fn main() { dioxus::launch(app::App); diff --git a/lp-app/lp-studio-web/src/stories/mod.rs b/lp-app/lp-studio-web/src/stories/mod.rs new file mode 100644 index 000000000..0d30040da --- /dev/null +++ b/lp-app/lp-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_fixtures; +pub mod story_registry; diff --git a/lp-app/lp-studio-web/src/stories/story.rs b/lp-app/lp-studio-web/src/stories/story.rs new file mode 100644 index 000000000..6588059de --- /dev/null +++ b/lp-app/lp-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/lp-studio-web/src/stories/story_book.rs b/lp-app/lp-studio-web/src/stories/story_book.rs new file mode 100644 index 000000000..e9eadecbd --- /dev/null +++ b/lp-app/lp-studio-web/src/stories/story_book.rs @@ -0,0 +1,180 @@ +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-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/lp-studio-web/src/stories/story_fixtures.rs b/lp-app/lp-studio-web/src/stories/story_fixtures.rs new file mode 100644 index 000000000..278c42a3e --- /dev/null +++ b/lp-app/lp-studio-web/src/stories/story_fixtures.rs @@ -0,0 +1,232 @@ +use lp_studio_core::{ + ActionId, BROWSER_WORKER_PROVIDER_ID, ClientSession, ConnectionSession, DeviceCapability, + DeviceId, DeviceSession, ProjectSession, STUDIO_DEMO_PROJECT_ID, StudioDiagnostic, + StudioHeartbeat, StudioLogEntry, StudioLogLevel, StudioState, +}; +use lpa_link::{ + LinkConnectionKind, LinkEndpoint, LinkEndpointId, LinkEndpointStatus, LinkProviderId, + LinkSessionId, +}; +use lpc_model::{ + ArtifactLocation, ArtifactSpec, AssetBodyOrigin, AssetContentType, AssetEntry, AssetLocation, + AssetState, NodeDefEntry, NodeDefLocation, NodeDefState, NodeInvocation, NodeUseLocation, + ProjectInventory, ProjectNode, ProjectNodePlacement, Revision, SlotPath, +}; +use lpc_wire::{WireProjectHandle, WireProjectInventoryReadResponse}; + +pub fn studio_state_idle() -> StudioState { + StudioState::default() +} + +pub fn studio_state_connecting() -> StudioState { + let mut state = StudioState::default(); + state.link_selection.endpoints = + vec![browser_endpoint().with_status(LinkEndpointStatus::Launching)]; + state +} + +pub fn studio_state_connected() -> StudioState { + let mut state = StudioState::default(); + state.link_selection.endpoints = + vec![browser_endpoint().with_status(LinkEndpointStatus::Connected)]; + attach_device_session(&mut state); + state.heartbeat = Some(StudioHeartbeat { + fps_avg: 59.8, + frame_count: 1_284, + loaded_project_count: 0, + uptime_ms: 42_000, + free_memory_bytes: Some(154_112), + }); + state +} + +pub fn studio_state_ready() -> StudioState { + let mut state = studio_state_connected(); + state.project_session = Some(project_session( + Some(demo_inventory()), + Some("nodes[shader]"), + )); + state.logs = vec![ + StudioLogEntry::new(StudioLogLevel::Info, "fw-browser", "runtime ready"), + StudioLogEntry::new( + StudioLogLevel::Debug, + "lp-studio-runtime", + "loaded studio-demo and read inventory", + ), + ]; + state +} + +pub fn studio_state_error() -> StudioState { + let mut state = studio_state_connected(); + state.diagnostics.push(StudioDiagnostic::error( + Some(ActionId::new(42)), + "Browser worker did not respond before the startup timeout.", + )); + state +} + +pub fn studio_state_long_content() -> StudioState { + let mut state = studio_state_ready(); + if let Some(device) = &mut state.device_session { + device.session_id = + LinkSessionId::new("browser-worker-worker-1:session-with-a-very-long-debug-identifier"); + } + if let Some(project) = &mut state.project_session { + project.project_id = + "studio-demo-with-a-long-human-readable-name-for-layout-testing".to_string(); + project.selected_node_id = + Some("nodes[shader] / uniforms[palette] / nested[very.deep.path]".to_string()); + } + state +} + +pub fn studio_state_log_heavy() -> StudioState { + let mut state = studio_state_ready(); + state.logs = (0..18) + .map(|index| { + let level = match index % 5 { + 0 => StudioLogLevel::Trace, + 1 => StudioLogLevel::Debug, + 2 => StudioLogLevel::Info, + 3 => StudioLogLevel::Warn, + _ => StudioLogLevel::Error, + }; + StudioLogEntry::new( + level, + "fw-browser", + format!("tick {index}: queued protocol frame and drained output"), + ) + }) + .collect(); + state.diagnostics.push(StudioDiagnostic::info( + "Inventory refresh completed from story fixture data.", + )); + state +} + +fn attach_device_session(state: &mut StudioState) { + let provider_id = LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID); + let endpoint_id = LinkEndpointId::new("browser-worker-worker-1"); + let session_id = LinkSessionId::new("browser-worker-worker-1:1"); + state.device_session = Some(DeviceSession { + device_id: DeviceId::new("browser-worker:browser-worker-worker-1"), + provider_id: provider_id.clone(), + endpoint_id: endpoint_id.clone(), + session_id: session_id.clone(), + capabilities: vec![ + DeviceCapability::Connect, + DeviceCapability::UseBrowserWorker, + DeviceCapability::ReadHeartbeat, + DeviceCapability::LoadProject, + DeviceCapability::ReadProjectInventory, + DeviceCapability::ReadLogs, + ], + }); + state.connection_session = Some(ConnectionSession { + endpoint_id, + session_id, + kind: LinkConnectionKind::BrowserWorker { + protocol: "fw-browser-post-message-v1".to_string(), + }, + }); + state.client_session = Some(ClientSession::connected("lp-server")); +} + +fn browser_endpoint() -> LinkEndpoint { + LinkEndpoint::new( + "browser-worker-worker-1", + BROWSER_WORKER_PROVIDER_ID, + "Browser runtime worker", + ) +} + +fn project_session( + inventory: Option, + selected_node_id: Option<&str>, +) -> ProjectSession { + let mut session = ProjectSession::new(STUDIO_DEMO_PROJECT_ID, WireProjectHandle::new(1)); + session.inventory = inventory; + session.selected_node_id = selected_node_id.map(str::to_string); + session +} + +fn demo_inventory() -> WireProjectInventoryReadResponse { + let mut inventory = ProjectInventory::new(); + let root = NodeUseLocation::root(); + let clock = child(&root, "nodes[clock]"); + let fixture = child(&root, "nodes[fixture]"); + let output = child(&root, "nodes[output]"); + let shader = child(&root, "nodes[shader]"); + + for (path, revision) in [ + ("/projects/studio-demo/project.toml", 1), + ("/projects/studio-demo/clock.toml", 2), + ("/projects/studio-demo/fixture.toml", 3), + ("/projects/studio-demo/output.toml", 4), + ("/projects/studio-demo/shader.toml", 5), + ] { + let location = def_location(path); + inventory.defs.insert( + location.clone(), + NodeDefEntry::new(location, NodeDefState::NotFound, Revision::new(revision)), + ); + } + + let shader_asset = AssetLocation::artifact(ArtifactLocation::file( + "/projects/studio-demo/shaders/rainbow.glsl", + )); + inventory.assets.insert( + shader_asset.clone(), + AssetEntry::new( + shader_asset.clone(), + AssetContentType::ShaderSource, + AssetState::Available { + origin: AssetBodyOrigin::Committed, + }, + Revision::new(6), + ), + ); + + inventory.tree.insert_node(ProjectNode::root( + root.clone(), + def_location("/projects/studio-demo/project.toml"), + )); + inventory + .tree + .insert_node(project_node(clock, root.clone(), "clock")); + inventory + .tree + .insert_node(project_node(fixture, root.clone(), "fixture")); + inventory + .tree + .insert_node(project_node(output, root.clone(), "output")); + inventory + .tree + .insert_node(project_node(shader.clone(), root, "shader")); + inventory.tree.add_asset_consumer(shader_asset, shader); + + WireProjectInventoryReadResponse::from_inventory(&inventory) +} + +fn project_node(key: NodeUseLocation, parent: NodeUseLocation, name: &str) -> ProjectNode { + let slot = SlotPath::parse(&format!("nodes[{name}]")).expect("valid story fixture slot path"); + ProjectNode::invocation( + key, + parent, + def_location(&format!("/projects/studio-demo/{name}.toml")), + slot, + ProjectNodePlacement::ProjectChild { + name: name.to_string(), + }, + NodeInvocation::path(ArtifactSpec::path(format!("{name}.toml"))), + ) +} + +fn child(parent: &NodeUseLocation, slot: &str) -> NodeUseLocation { + parent.child(SlotPath::parse(slot).expect("valid story fixture slot path")) +} + +fn def_location(path: &str) -> NodeDefLocation { + NodeDefLocation::artifact_root(ArtifactLocation::file(path)) +} diff --git a/lp-app/lp-studio-web/src/stories/story_registry.rs b/lp-app/lp-studio-web/src/stories/story_registry.rs new file mode 100644 index 000000000..fc7a26b70 --- /dev/null +++ b/lp-app/lp-studio-web/src/stories/story_registry.rs @@ -0,0 +1,41 @@ +use dioxus::prelude::*; + +use crate::components::{ + device_panel_stories, inventory_view_stories, log_panel_stories, project_panel_stories, + status_bar_stories, +}; +use crate::stories::story::StoryDescriptor; + +pub const DEFAULT_STORY_ID: &str = "status/idle"; + +pub fn all_stories() -> Vec { + let mut stories = Vec::new(); + stories.extend_from_slice(status_bar_stories::STORIES); + stories.extend_from_slice(device_panel_stories::STORIES); + stories.extend_from_slice(project_panel_stories::STORIES); + stories.extend_from_slice(inventory_view_stories::STORIES); + stories.extend_from_slice(log_panel_stories::STORIES); + stories +} + +pub fn story_by_id(id: &str) -> Option { + all_stories().into_iter().find(|story| story.id == id) +} + +pub fn render_story(id: &str) -> Element { + status_bar_stories::render_story(id) + .or_else(|| device_panel_stories::render_story(id)) + .or_else(|| project_panel_stories::render_story(id)) + .or_else(|| inventory_view_stories::render_story(id)) + .or_else(|| log_panel_stories::render_story(id)) + .unwrap_or_else(|| { + rsx! { + section { class: "panel", + div { class: "panel-heading", + h2 { "Story not found" } + } + p { "No story is registered for `{id}`." } + } + } + }) +} diff --git a/lp-app/lp-studio-web/src/style.css b/lp-app/lp-studio-web/src/style.css index 1558e3ed0..1c502d3d1 100644 --- a/lp-app/lp-studio-web/src/style.css +++ b/lp-app/lp-studio-web/src/style.css @@ -1,7 +1,23 @@ :root { - color: #17201b; - background: #f5f7f4; + color: #ebe7dc; + background: #12110f; + color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + --surface-base: #12110f; + --surface-panel: #1b1a17; + --surface-panel-raised: #22211d; + --surface-control: #24231f; + --border-subtle: #35332d; + --border-strong: #504b40; + --text-primary: #ebe7dc; + --text-secondary: #a7a092; + --text-muted: #7f7769; + --accent-green: #45d483; + --accent-cyan: #58d5ff; + --accent-amber: #f0bd55; + --danger-bg: #35211c; + --danger-text: #ff9b82; } * { @@ -10,26 +26,40 @@ body { margin: 0; + background: var(--surface-base); } button { min-height: 34px; - border: 1px solid #1d4d3a; + border: 1px solid #38a667; border-radius: 6px; - background: #1f7a57; - color: white; + background: #208852; + color: #f5fff8; font: inherit; font-weight: 650; padding: 0 14px; + box-shadow: 0 0 0 1px rgba(69, 212, 131, 0.12), 0 10px 24px rgba(0, 0, 0, 0.24); + cursor: pointer; +} + +button:hover:not(:disabled), +button:focus-visible { + background: #27a863; + border-color: var(--accent-green); + outline: none; } button:disabled { - background: #84948d; - border-color: #84948d; + background: #34322d; + border-color: #4a463d; + color: var(--text-muted); + cursor: wait; + box-shadow: none; } .studio-shell { min-height: 100vh; + background: var(--surface-base); } .status-bar { @@ -38,8 +68,8 @@ button:disabled { align-items: center; gap: 12px; padding: 18px 22px; - border-bottom: 1px solid #ccd5cf; - background: #ffffff; + border-bottom: 1px solid var(--border-subtle); + background: #171612; } .status-bar h1, @@ -50,11 +80,12 @@ button:disabled { .status-bar h1 { font-size: 20px; + color: var(--text-primary); } .status-bar p { margin: 2px 0 0; - color: #66726b; + color: var(--text-secondary); font-size: 13px; } @@ -64,15 +95,30 @@ button:disabled { .mini-count { border-radius: 999px; padding: 5px 10px; - background: #e9eee9; + border: 1px solid var(--border-subtle); + background: var(--surface-control); + color: var(--text-secondary); font-size: 13px; white-space: nowrap; } +.status-pill { + border-color: rgba(69, 212, 131, 0.4); + background: rgba(69, 212, 131, 0.12); + color: #b9ffd3; +} + +.status-metric { + border-color: rgba(88, 213, 255, 0.34); + background: rgba(88, 213, 255, 0.1); + color: #c8f2ff; +} + .status-error { grid-column: 1 / -1; - background: #ffe8e0; - color: #8b2c1c; + border-color: rgba(255, 155, 130, 0.4); + background: var(--danger-bg); + color: var(--danger-text); } .studio-grid { @@ -84,10 +130,11 @@ button:disabled { .panel { min-width: 0; - border: 1px solid #d5ded8; + border: 1px solid var(--border-subtle); border-radius: 8px; - background: #ffffff; + background: var(--surface-panel); padding: 14px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.18); } .panel-heading { @@ -101,6 +148,7 @@ button:disabled { .panel h2 { font-size: 15px; + color: var(--text-primary); } dl { @@ -111,13 +159,14 @@ dl { } dt { - color: #65736c; + color: var(--text-muted); font-size: 13px; } dd { min-width: 0; margin: 0; + color: var(--text-primary); overflow-wrap: anywhere; } @@ -129,20 +178,22 @@ dd { } .inventory-stats div { - border: 1px solid #e2e7e2; + border: 1px solid var(--border-subtle); border-radius: 6px; + background: var(--surface-panel-raised); padding: 10px; } .inventory-stats strong { display: block; + color: var(--accent-amber); font-size: 20px; } .inventory-stats span, .log-list, .inventory-list { - color: #58645d; + color: var(--text-secondary); font-size: 13px; } @@ -159,15 +210,167 @@ dd { } .diagnostic-line { - color: #8b2c1c; + color: var(--danger-text); +} + +.story-book { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + min-height: 100vh; + background: var(--surface-base); +} + +.story-sidebar { + min-width: 0; + border-right: 1px solid var(--border-subtle); + background: #171612; +} + +.story-sidebar-heading { + padding: 18px; + border-bottom: 1px solid var(--border-subtle); +} + +.story-sidebar h1, +.story-toolbar h2, +.story-canvas-meta h3 { + margin: 0; + letter-spacing: 0; +} + +.story-sidebar h1 { + font-size: 18px; +} + +.story-sidebar p, +.story-toolbar p, +.story-canvas-meta p { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 13px; +} + +.story-nav { + display: grid; + gap: 4px; + padding: 10px; +} + +.story-nav-link { + display: grid; + gap: 2px; + min-width: 0; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-primary); + padding: 10px; + text-decoration: none; +} + +.story-nav-link:hover, +.story-nav-link:focus-visible, +.story-nav-link.is-active { + border-color: rgba(69, 212, 131, 0.36); + background: rgba(69, 212, 131, 0.1); + outline: none; +} + +.story-nav-group { + color: var(--text-muted); + font-size: 12px; +} + +.story-stage { + min-width: 0; + padding: 14px; +} + +.story-toolbar { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.story-toolbar h2 { + font-size: 18px; +} + +.story-viewport-controls { + display: flex; + gap: 6px; +} + +.story-viewport-button { + min-height: 30px; + border-color: var(--border-strong); + background: var(--surface-control); + color: var(--text-secondary); + box-shadow: none; +} + +.story-viewport-button:hover, +.story-viewport-button:focus-visible, +.story-viewport-button.is-active { + border-color: rgba(88, 213, 255, 0.5); + background: rgba(88, 213, 255, 0.1); + color: #c8f2ff; +} + +.story-canvas-shell { + min-width: 0; +} + +.story-canvas-meta { + margin: 0 auto 10px; + max-width: 1040px; +} + +.story-frame { + width: 100%; + min-width: 0; + margin: 0 auto; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--surface-base); + padding: 14px; +} + +.story-frame > .status-bar { + border: 1px solid var(--border-subtle); + border-radius: 8px; +} + +.story-frame > .panel, +.story-frame > .log-panel { + grid-column: auto; +} + +.story-png-page { + min-height: 100vh; + padding: 16px; + background: var(--surface-base); } @media (max-width: 760px) { .status-bar, - .studio-grid { + .studio-grid, + .story-book { grid-template-columns: 1fr; } + .story-sidebar { + border-right: 0; + border-bottom: 1px solid var(--border-subtle); + } + + .story-toolbar { + align-items: flex-start; + flex-direction: column; + } + .status-pill, .status-metric { justify-self: start; From b8d45942ca8143d813345a557e3132d95b8b940d Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Wed, 17 Jun 2026 20:06:07 -0700 Subject: [PATCH 15/62] feat: commit studio story image baselines Add committed story-images baselines, check/update commands, and agent guidance for running visual baseline generation when Studio UI files change. Use Chrome DevTools Protocol to capture the marked story canvas content instead of full viewport screenshots. --- .gitignore | 3 +- AGENTS.md | 30 ++ README.md | 4 +- .../adr/2026-06-18-studio-native-storybook.md | 10 +- .../2026-06-18-studio-story-png-baselines.md | 53 ++ justfile | 30 ++ lp-app/lp-studio-web/README.md | 36 +- .../scripts/studio-story-pngs.mjs | 463 +++++++++++++++++- .../lp-studio-web/src/stories/story_book.rs | 1 + .../story-images/device__connected.png | Bin 0 -> 16113 bytes .../story-images/device__idle.png | Bin 0 -> 13416 bytes .../story-images/device__long-session.png | Bin 0 -> 18608 bytes .../story-images/device__starting.png | Bin 0 -> 13106 bytes .../story-images/inventory__demo.png | Bin 0 -> 18597 bytes .../story-images/inventory__empty.png | Bin 0 -> 12925 bytes .../story-images/logs__diagnostic.png | Bin 0 -> 10663 bytes .../story-images/logs__empty.png | Bin 0 -> 7787 bytes .../story-images/logs__heavy.png | Bin 0 -> 44751 bytes .../story-images/logs__ready.png | Bin 0 -> 12281 bytes .../story-images/project__long-content.png | Bin 0 -> 18103 bytes .../story-images/project__not-loaded.png | Bin 0 -> 10675 bytes .../story-images/project__ready.png | Bin 0 -> 11540 bytes .../story-images/status__error.png | Bin 0 -> 15743 bytes .../story-images/status__idle.png | Bin 0 -> 10581 bytes .../story-images/status__ready.png | Bin 0 -> 12422 bytes .../story-images/status__starting.png | Bin 0 -> 12224 bytes scripts/dev-init.sh | 11 + 27 files changed, 611 insertions(+), 30 deletions(-) create mode 100644 docs/adr/2026-06-18-studio-story-png-baselines.md create mode 100644 lp-app/lp-studio-web/story-images/device__connected.png create mode 100644 lp-app/lp-studio-web/story-images/device__idle.png create mode 100644 lp-app/lp-studio-web/story-images/device__long-session.png create mode 100644 lp-app/lp-studio-web/story-images/device__starting.png create mode 100644 lp-app/lp-studio-web/story-images/inventory__demo.png create mode 100644 lp-app/lp-studio-web/story-images/inventory__empty.png create mode 100644 lp-app/lp-studio-web/story-images/logs__diagnostic.png create mode 100644 lp-app/lp-studio-web/story-images/logs__empty.png create mode 100644 lp-app/lp-studio-web/story-images/logs__heavy.png create mode 100644 lp-app/lp-studio-web/story-images/logs__ready.png create mode 100644 lp-app/lp-studio-web/story-images/project__long-content.png create mode 100644 lp-app/lp-studio-web/story-images/project__not-loaded.png create mode 100644 lp-app/lp-studio-web/story-images/project__ready.png create mode 100644 lp-app/lp-studio-web/story-images/status__error.png create mode 100644 lp-app/lp-studio-web/story-images/status__idle.png create mode 100644 lp-app/lp-studio-web/story-images/status__ready.png create mode 100644 lp-app/lp-studio-web/story-images/status__starting.png diff --git a/.gitignore b/.gitignore index 97ed2c0c9..ce209251b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ profiles/ lp-app/lp-studio-web/public/pkg/ lp-app/lp-studio-web/public/fw-browser-worker.js lp-app/lp-studio-web/dist/ -lp-app/lp-studio-web/story-pngs/ +lp-app/lp-studio-web/story-images/.new/ +lp-app/lp-studio-web/story-images/.scratch/ diff --git a/AGENTS.md b/AGENTS.md index 938616088..b4405e44e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -244,6 +244,36 @@ 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/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/docs/adr/2026-06-18-studio-native-storybook.md b/docs/adr/2026-06-18-studio-native-storybook.md index a89f60c68..b1f3ce27b 100644 --- a/docs/adr/2026-06-18-studio-native-storybook.md +++ b/docs/adr/2026-06-18-studio-native-storybook.md @@ -26,8 +26,8 @@ Studio component stories will be native Dioxus code in `lp-studio-web`. - `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 a gitignored - `lp-app/lp-studio-web/story-pngs/` directory. +- `just studio-story-pngs` generates local PNGs into gitignored + `lp-app/lp-studio-web/story-images/.scratch/`. ## Consequences @@ -43,3 +43,9 @@ 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. 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/justfile b/justfile index 50ee7f46f..362c18acb 100644 --- a/justfile +++ b/justfile @@ -191,6 +191,36 @@ studio-story-pngs: studio-web-dev-build set -euo pipefail node lp-app/lp-studio-web/scripts/studio-story-pngs.mjs +studio-story-baselines: studio-web-dev-build + #!/usr/bin/env bash + set -euo pipefail + node lp-app/lp-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/lp-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/lp-studio-web \ + ':!lp-app/lp-studio-web/public/**' \ + ':!lp-app/lp-studio-web/story-images/**')" + untracked="$(git ls-files --others --exclude-standard -- lp-app/lp-studio-web \ + | grep -v '^lp-app/lp-studio-web/public/' \ + | grep -v '^lp-app/lp-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 diff --git a/lp-app/lp-studio-web/README.md b/lp-app/lp-studio-web/README.md index 696598fa6..d8266ebdb 100644 --- a/lp-app/lp-studio-web/README.md +++ b/lp-app/lp-studio-web/README.md @@ -49,8 +49,40 @@ Generate local PNGs for quick review: just studio-story-pngs ``` -PNGs are written to `lp-app/lp-studio-web/story-pngs/`, which is gitignored. -They are local review artifacts, not committed visual baselines. +PNGs are written to `lp-app/lp-studio-web/story-images/.scratch/`, which is +gitignored. + +Update committed visual baselines when intentional Studio UI changes affect +component rendering: + +```bash +just studio-story-baselines +``` + +Baselines are written to `lp-app/lp-studio-web/story-images/` and should be +committed when they change. The baseline set is intentionally small and should +stay curated. Hidden child directories under `story-images/` are scratch space +and are ignored. Story captures are clipped to the story canvas content at the +standard wide story viewport. + +Baseline and check commands require `oxipng` so fresh captures compare against +the committed optimized PNGs. + +Compare fresh story PNGs against the committed baselines without updating them: + +```bash +just studio-story-check +``` + +Fresh check output is written to `lp-app/lp-studio-web/story-images/.new/`, +which is gitignored. For agent and pre-commit-style local flows, use: + +```bash +just studio-story-baselines-if-needed +``` + +That command runs baseline generation only when tracked or untracked +non-generated files under `lp-app/lp-studio-web/` have changed. ## Boundary diff --git a/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs b/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs index 24a06728f..47d7882d3 100644 --- a/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs +++ b/lp-app/lp-studio-web/scripts/studio-story-pngs.mjs @@ -1,21 +1,96 @@ #!/usr/bin/env node -import { mkdir, rm } from "node:fs/promises"; -import { spawn } from "node:child_process"; +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/lp-studio-web/public"); -const outputDir = path.resolve( - repoRoot, - process.env.STUDIO_STORY_PNGS_DIR ?? "lp-app/lp-studio-web/story-pngs", -); +const storyRoot = path.join(repoRoot, "lp-app/lp-studio-web"); +const mode = parseMode(process.argv.slice(2)); const port = process.env.STUDIO_STORY_PNGS_PORT ?? "2822"; 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( @@ -24,8 +99,8 @@ if (!chrome) { process.exit(1); } -await rm(outputDir, { recursive: true, force: true }); -await mkdir(outputDir, { recursive: true }); +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, @@ -43,21 +118,21 @@ try { throw new Error("No story links were discovered from the storybook page."); } - for (const storyId of storyIds) { - const file = path.join(outputDir, `${storyId.replaceAll("/", "__")}.png`); - await runChrome([ - "--headless=new", - "--disable-gpu", - "--hide-scrollbars", - "--window-size=1080,760", - "--virtual-time-budget=3000", - `--screenshot=${file}`, - `${baseUrl}?story-png=1#/stories/${storyId}`, - ]); - console.log(`wrote ${path.relative(repoRoot, file)}`); - } + const files = await captureStories(storyIds, captureDir); + await optimizePngs(files, { required: mode !== "pngs" }); - console.log(`Story PNGs: ${path.relative(repoRoot, outputDir)}`); + 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"); @@ -65,6 +140,41 @@ try { 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/lp-studio-web/story-images/.new" + ); + } + return ( + process.env.STUDIO_STORY_SCRATCH_DIR ?? + process.env.STUDIO_STORY_PNGS_DIR ?? + "lp-app/lp-studio-web/story-images/.scratch" + ); +} + +function baselineDirFromEnv() { + return ( + process.env.STUDIO_STORY_IMAGES_DIR ?? + process.env.STUDIO_STORY_BASELINES_DIR ?? + "lp-app/lp-studio-web/story-images" + ); +} + async function discoverStoryIds() { const html = await runChrome([ "--headless=new", @@ -79,6 +189,271 @@ async function discoverStoryIds() { .sort(); } +async function captureStories(storyIds, directory) { + const files = []; + const browser = await launchCaptureBrowser(); + try { + for (const storyId of storyIds) { + const file = path.join(directory, storyFileName(storyId)); + await browser.capture(storyPngUrl(storyId), storyId, file); + console.log(`wrote ${path.relative(repoRoot, file)}`); + files.push(file); + } + } finally { + await browser.close(); + } + return files; +} + +async function launchCaptureBrowser() { + 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 { 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 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 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) { @@ -95,7 +470,15 @@ async function waitForServer(url) { } async function runChrome(args) { - const child = spawn(chrome, args, { stdio: ["ignore", "pipe", "pipe"] }); + 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) => { @@ -106,11 +489,43 @@ async function runChrome(args) { }); const [code] = await once(child, "exit"); if (code !== 0) { - throw new Error(`Chrome exited with ${code}: ${stderr.trim()}`); + 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"; diff --git a/lp-app/lp-studio-web/src/stories/story_book.rs b/lp-app/lp-studio-web/src/stories/story_book.rs index e9eadecbd..e0a11303b 100644 --- a/lp-app/lp-studio-web/src/stories/story_book.rs +++ b/lp-app/lp-studio-web/src/stories/story_book.rs @@ -106,6 +106,7 @@ fn StoryCanvas( rsx! { div { class: "story-canvas-shell", + "data-story-capture": "1", "data-story-id": "{story_id}", "data-story-label": "{label}", div { class: "story-canvas-meta", diff --git a/lp-app/lp-studio-web/story-images/device__connected.png b/lp-app/lp-studio-web/story-images/device__connected.png new file mode 100644 index 0000000000000000000000000000000000000000..7ac3f78765edc4455cdd8721594fc087d503f108 GIT binary patch literal 16113 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/device__idle.png b/lp-app/lp-studio-web/story-images/device__idle.png new file mode 100644 index 0000000000000000000000000000000000000000..59fd00efcee5f9feb37a64a737b3177e4a2b5ea3 GIT binary patch literal 13416 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 Nm6KMIDuoz_{6C(rWuyQA literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/device__long-session.png b/lp-app/lp-studio-web/story-images/device__long-session.png new file mode 100644 index 0000000000000000000000000000000000000000..f0c9bc41f92fc46d1ec5227ad0de0a6b4112221e GIT binary patch literal 18608 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/device__starting.png b/lp-app/lp-studio-web/story-images/device__starting.png new file mode 100644 index 0000000000000000000000000000000000000000..5825290466e8bf16590b1b3ec779b56194a61ed6 GIT binary patch literal 13106 zcmZX)1yr0p)Ga)vK+yt&I|D^bmkx<^8 z_-`ut%2<@+bm6>-%39&{0)HIJf9IS?bq;YU9M#8DDv;gD>scP%-|JkjFkCCl(ac>Q zPF3uC!t?@uf^>}>wO&d_gd#6UzPACv$&8wcD&A(pg<&;30n%VsA*ctlhu=cxu7LkAqsQ8vxQ zZ2UyUFZ)!~nLZ|xF5KU-69`xZ>QR@eGt~?i=XgDoH%L8mEISEsb`in4{~SUH=p7~0 zOeV1rqgdA2X^J^sYh=~;O|nS}S-+dh`O?UDV2isI(s8qS{o1LOE-{nffAso`++1S~xjL?G+9OCpESVRmEJJ@Xe^~os?#$;k z&n!-?w)XNAad z#W9VxddyV87&V3K?Fk~Mxh9@*A9Y4nw6-uG)93FU%XS|d)W_$YEr-kwKf{x?c&!&k zb1qyKcwT-nABj1PAt@jIoxrc|po&{uy&h6)bpGo#Oo@G!L9F6=Kt)%Mpxv@^yS(n) zmsg#M7$ok)L)ja+hpIQ1W7hcmXxtdU&bjq(h52fM`VI`$K{dWj{3Ri#6bA3fiRF@U}gg42~f zW6pvU?8fi^{T@OkVv(q68U-z4KmEGI*w}3EY;enB99GEUCLjcJOd(!CLz|co9uFV; z%+;o=NKt?vlW6o9p%&j-S^jSQtm6G$*n#sO^sl60MZrYczSf}Zgh&4lPxB~Blper= zFJ=R!C?7Z}0S*esRS`!u5hq+L^f$smAkGto zJ^}apU1+dysxFzEhD&mqsmnKSo}K&7h=t$vCnxfBrk$LQEf14b{JLJ5mKfTIZ-!~; z3dbAsF^=_SDxlL*-(^sXZi(yCR3jJ8 zfY%wrJZV~4)DgAarRnsKp10=Lk)iMH5-DoDO-h)ckbb4&_B@+U()7EP-8VZ8IYNv{ z;_=FdS>ttdnED>){Bkx43er!OI|Xk5eybm~Z3NhshqAvO*b(tnHyvflCJ*L3wI?fl zZuVWXn4xYgP2@F+Q2kXl3@4sU(dq4;^E1B3?bfJ9Hre7o2@RE5O*5*>5W4z7WvT1& zGSr>%#`Ek~fLzy0sn|i$-m|J>--$o+Ly~;t59Y;Y z!^Z+o$2ONE3y(KD#R_Zb8JdsGX&oMWH-FaKTfEQjF49U}1doQ3gbval72=g}2A&b* zI11BDw>&gWt?D2t8DnM^ocf9?Z0OZq_~L`P)E01rtnAeswIBD_4(fET9sr&s1T%HK zOXf$PvhOn;k6T=iJg=5DcMN<}Sa;Mc8ZJxPK7Tnkpl9IGa){K~@in-I7r}&m!q=c) z_ZL^5yxscZytrfRieFnE2D%$B&BVH+dT%hN$i-r!ukgTh{p(&{O*~tl4SHuEv7=uZ zCu`SW`?hfGJb0hxv`Prn1@*-QdJAxN^b2<_-nna6%<*7s6-^c34h%Z08AiF+|EKNA zjOz5e5A%qo1(lM{fj6f|2LIOz*0P6?Bn#T+=1VJFD&Mz)Hk00uMpSuc(o`TMe+nf3 z6QNjjDu=eQJNN6$$fa%-G$ty#u0Ead3b5+a+~dtWG{*}3$!Edy+-)>lzRwLO2GM#V zTX~ZeGo3rGVim!4p$3?F6lT~7-8Cg7JTT;s3*$+2%n9rcH2VI{RXU-ukbs=@dr4(2 zL{u;vc+ywr{x0~Js@mN$pB%?SWI2y+5Oyci>-V$p)CKqNm93|iTeIT#l6{5&G`q(TaOYyE&EF;nWMmu zUN*`W)J6)RYPOyUv2PWFV`W6hoGBD0*USQjk@BB()3V#>J{`$!hUVuM`@g z5hCyFs!GbBCV9ut1Tl>BjIgSwR^K1H76JD?Bol+q`+^f>y*Oo1M`TL^%*yZ=M2ezl zuEmX?P+yh>ya=rp)%v+?eiP2FD)lENQE2{l(OpZas+$F`KNJyj^5%P!w z+J=*JV^afXUcE3KwEv)YPAi?b&Grylx#f=U<3unl%Z&QNb@9;Pq@fJo5{;mUb$M-! zz(fGZ-nc%XHs#{#X>2z2rCW3`v=>a3|BLk@!JBV8j1B1r;DJgOtOUgD4Zv2SA|t>- zS2B(a&aJ7bj)fDYkbH%p_z7L|RUkb56Qe?^2}(VCDZ{+5GVw!O;xn&yl@culw&2WYtGy6|DM>(JxFbUri^Vzpl+{ z{$PiMOtNSduD_7`bv#2fzn<0wt4N-Yk;zHT$LC{9!xMA+E^qRB!^5Vd3KSCGfFgDK z5WE(`&Ck0GI+WH{-PZgo-SIWj4uLACy&OrBuiK}Z;Q+mVIV1Y|TwhrHztpGO8Tb7J zt|P$iZ2TrQin9h;d7Moslgfn-?C&5Upmjh~L>$DQ^(hW9PZp#^^3|&WSxS5}|Df%& z3iL)I`tSOu+do`JSIV3%_Lzbs9BrZ~=wpa6b2P3=+}Gfv`7M094$oRj2B~pS?@2)({)9Gtjn|&?{OW@cC$COxJlGA+;AH0A=<33V zly8jPDf??XJzF@r@f4$I4!=nB!K>e8a)+g12jrW~&Z{`Uui`Zk$^)Y2S6c(uj(ZX1 ze1t#E=aiJ!-vY5}8}D3q*Iz$gc(&!4*f_^m)r;@F@;{tC3p2Mjr?SfA`~=oC*842J z@^$m^VWg@AmYU6uqczKr@@WCjXvormh1dvDoRqYIxgvq^Q|&)QyQLwhsJ8i0SxL|H z7|}Y(SqI-RdNG+u$j;5oVShC1nSAC~ABduS?QH2+HT4nnmA-${Vf}3a1gL5Lixa7VF=X$&YUtQa6G_W3e-JdBrk&npc%w=qRmZH^i z20iTa%}Fak3%i9EBPz@U6#C95jtTSgY1NkbD(S_z*YEN#Eq|$;5K>_~rX2Mxf)Vwj zaP+xWH9TwtEzH3e^hFc5nevykU^a2Z*YK|i&F`zmp-ATmVlq)N33V5WR=8urj+Bod zWUsY=K+fjOl_m-$uLS$H;Ki_VEv@_uKl(a!YPS|1x#;k^k8u^>RNFIsZ=)m%CM`IK zp5`G`9jvsHIy(EwDa3o_&V}jBnI4_cR&eiYz*LWcoY%@J0}|OsaktCo%)@3#7xM- zJ2}G=o}AxSJXTI07+-frdytU}ymfU&uFJV>ryqVextbwI39Cym2NijYNZ^9j%NDQ& z=O~DDXRG$4>?BCpH|d(CzYh*XMoo%Mmcz+smYKpiTS`GguRYU)$@!A^C~MH<6i86? z(++W$IgSu1}pVcrq)|8L@>Yy*1-Yfw6ey`G%pPuJR4!I;q$ zMPPN0LCrvTl#gT>8NgCnP$Drll5&twkhhT;gKKx*q)tlzb;3I+#`5#usFT#8E)^{yO(@1lL0OhriCDUHKg^b=ov6Az% z72l%(SAD!}GA2A_pbwWjjc@Fk2Q-^kUXP3CnkFq#Is20zr$7ox|+}u*{8;_F? zZPwfz1KK{&e0Lh`jwLy5-nTO=v(sV6)Ewg1z3w-8f22{PT{UATB;&Kz)dJCs;wyLA zcVFTqYwu}PpWL365VY;Tu9|xU9(Ef0R~rY7JkY@|*9uUMMUc|=Ti-V`8&W=}YnjEW z+juZ$>i~+=kJ^cfln4l5bG_jkJpGc%lED6Hc$psnxbYB5Y|(^K-l0I)EsWyjE(LmF z!LWAo}*VgAy;&3Re@4h$dv+VySN>28T0qr$s@_20jD1~5o{VJ!RJ z)^Yo#KAI*VvER+jf=>*!(WuJy}cU~wpdJ6=AW55(8IQ?jF|DyXU?s3b6{e`hjOzWygk^M zXuQ>Zn%*4KfQx7fZV#GA#a~`*d4&B+P{IJ6 zuIC^Ba!8AS!Ezd5(P!Ki2<-jqz9o8UHnD=F;nGA4SvM4t>rtCDp59epzi^KhA8f_L=wD2JSkw z@_92*UH9-5NbuO$OAxcCfN0qvAk-c+y4DI6@hOMvnv%ot$b(x0D$xs^rP)O3ug_;Ys4z ziJ9LO+?7BSC&0oI*Ts2{N!f!Gt`>mqy!Wn7%jgkX;3TL=L*3RUtL2R#$!f>()zLQE z`0E`ON=nN7M?aDL8~?y~5&uwFA^?XAL1_uWFXfVH7ATHptl}^oG?7%0cH&q1e=Ty` zRQQe?iFoU4(SeGg6nQu}&qC0|6w>g4&V#RBkd8<>J$+L9aGfQk@kr{>shgPp7J!*MHBfvE~jUu zedpj?G#mR+7v{=^1xraq2mXOARdt#wu9}{!rpMT%33-P(SE^Z+y{xYDD)b_v$l;m( zy~Fz{B_ah%hdwcRuW==ZA?(#|42@UFdq&n&7Y1~G@?zeHO^38l_sz+kHn$eCTMCza zGDnSGi@nwS8}9UxZgWY7Ve#jin(*HdG(!;h0*jl+OjqUXE_j%kDd~k{p}son{q^Xi zJE6!8BE?w_=zQ-5rkp&MBjxwSKm0A-4=8cAjdF2pnlKKD%PadZQ_R9!{H+_QUhW(6 z0057v*;e2VFVf&=VUI<%dWhDX%c~D?Xhvwsn^~(v>&a#xBt&C6XAVhzd@ioQU+P^9 zZvET-2ANhhmOIus7O>|3hIDsUzk7u5%TmcN0dFtzNAjT$)5mK5dri=aPUL)vSMyYG zh<}~de|~c#;=4Gt?YHn6rJo>w{bE4e_t4N13W4uG?QJBZebi@ph<%K$oy!(IQaQ&7M`m7VY;S7fVo+=ZqOFM^S*!_#50wYM{h$Ko zjV6;o_}xZc_BN7rrT)$dnI)^s+t{jhkb8ge8vod#&i|L91lTUQh%o2;&h7MX-PaUm zH5Or0UflMBxyu+?5N^e-AQA|MF_b%{;JkhFtC5iICns*`osA$y);jab@E6;9BKo z*8id)Z~>2ovvUk7VCVL@1!%mv~H@O!&d1E-@DGe=@};xI6g+(pg_)tcK>A_Fan{CZY9 zQ}(%lKyk5cI)6^)IlqFbMC{=1)|*E=MC6sw>I81%s(lRA7Ba92kALqSH4?B@NRcS! zuM~I$u`xIB>IexTQ4~{CC<+ky(&iFX%BDdDEJtp+xtJgsSY6f81sMbsHn~xWo zpmPO4oW;Wy`^joQ(?AK15yNg7v=t(KPnTDvcMyn#D&8A5k(#({HwJ6+dD3*<4*%U5 ze`}6_9QD01Z)vVdM@_4GBVdm>y;jOuJ5qFC#zY>K7#X4c7RIUoej~rSw8%mO!)*!Q zprUP{Mb0i_5yC#5rWGp@?5s5sik|; zW$0JDs_pcL390goIuXPC5#Ylz=f^@+Ye)4}Q%Ba*WnG?_qEbpqrP(yGVQlJ~p@`8? zQomT-ry3$V<>S`62E8V>tC_^51oaBZx7?b2V-!lU#sA#u8j7(RA1-bZ*r_V&8doCZ z8r;div)C}201%GVSj_-xWgAsDjoCpioVk_~R4WmX>tscYNdI+(N1o8qiDmg0RVYV> zNB-wsuQvENq`|#=Z5HAq4)OQ#rqM$NBK&LOrmV?N5P{gfP^^!2$2n}&y&M2}^-oaQ z{FSW&y^LF(LKh{F+7Q+U1-Sp>(*>O5TMX*sVYt_6AJ;AQnF#C*g39A-8s!9O^&nELC-YcW6faN!W&e!@AOc4yA%EF* z+h5I9Whq-^2A$q~iJ*6yu#rBm9AzTsv7mqk>7BynISJvXB57PJdh2rVgDvo?K-z>S@O!Vqhm`#LN}?5 zyevKs-gF$qyVjU@G6l~c;rR4C8@$iANakWPhFnw)VDpJ4*GgoF>)mpYqQGdRyvxd`DMR-rPN{?2FOpH|o+T zT$J2gX`7q*JZiSxd9D-ZGGePeLh^>iqJor@09C1Kkh11M||zWcQVzR${FFc zWaDV{`Jwv){lhngk>l0Yv+EoTadg{n7J~N|?M!0DS)GoDqkFFy@b0~NCuqn!T=$EQ zod)NO@l{7ZUxxTDa5bfr;i8ge-;);)7Jn8O(y7KKJ#{9-^TJqLG(T*(=J6!!JTB)k zJ{SnJarQVXMJ^?5-%05Cd)x+#*k~tH6gRb5w@~8|hIM9_ft_@$f{L|O1_pw+eS9d+ z$3_M`mzjnLNp{m71$)^4q_J=bGV!uaXyqx=;-CbHM)jgd!0p8xN&f1|Ab$G;_1v*M zx$sXRJa<0%(Q_p915NnzYb`LkEx|HA_ixrVo|GFTVy0nFFAl}l z0{9q_&}tu+4N3PNtkZp?S&q>N`_850bmc$OVbAMj_%YA`0Q@7=z)#$F!Y5LXSo{a6 zehlm`-QzR4QF42$pN;$W=aGVFcLL*>%(d^_nhYF}eEjYDyY~z$Z;x$+-k6JXb466$ zT)Cqh?H|8w>lLMPIM;gP#leka{(KM{>lydva%YaFO&~%hFGMWTQS$g*j$!3s%_Fh`Lp_l`;cSuFf72vwKw%OHc}<2Nm@NKih!IEz z=M0=1Afggs`-*aA!$$op-6#@15K-LAhU0s3>^lj6T&i=56n0?f?@9)I>g9%CA~!xB zKM0WZZZk}#IsXb#3E6OsJ`U6ph_xYwB$D z;Qqwtsp4_8ZBxU_Chw4(>ujJZ$bA3x7zeb{%-^X?Z)jz z5WC8RDasLo426&J9SfZE@@GnhvLE(1#E3hL(2`o z!YWx!HI22On|k7Fzlg8`HBi^}ViJT%Md0n!LARJP(v*(a8%E2Oz6V+QGO9{AeO;2F zzosn^L{!bvX1iIyGSYtyQ_IYAewQfjQlXkoKBq8RusEpk}!GrzD9 zph4wQ1`|lKpE!!4DFO`~_mP!6Xvl~q;CCF>5q4TwY>IPBGtlGr%+(BkI$p_wAPdJg z=azkHZ}^%v=1H`ip4q&by<~;~LIynlnZ$_~zB%6~M!UZYKeH9jK6%)C#0-TG29Y7{ zUcN0v^?AB^cp8&wt%p5G{}6&8GNyj{>}!II4Xk7TrE_XQs6bq#!)tadmC_&!<=`w# zxD6wH8`87>iCYJ%szmi_L)5>&McLl%J43bM?XuY!?gAsRCJK!rujGA*Ow4JVHC8IMkhg;h{mmU$$B)Cz+6e6ybTmo}Ap1k)YaMjk{! zC~=D`93Ehp80=_kr?up0Kps^+ZFqBru!!?{KJi?v*syt=8vK*z;Vnmhr7o62asZQN z+zIv2P;>03|DbDRiu!vXBO`xdH8K(2Zsh{gK+2sNX7y5s+u3mkXfO4nv|T_5FqW> z$Oc$!9DoFZbtO*I?M-XK$_<%RIKxW%E+xTl&DG@6!p4f^8=^m8D+Xr&g2o`o z2Vu_Xk9-IV!f|$)A+pRBGTSK`U9l9NepI_xW4;F~3Nko`ByUcdu#~XCNfKcf3Zke~ z=!^$=)7`N^RFPd@FbEzitJQVJpJK${-ybuaB2*p=UOeP2!sWZWAJ{!)5SKUub)geO zcQY4rbC;bnFZ1j*c2|`gMWz{MBJkN4HZg0;?uTWYme2Z0bX}h3wX&zG#O%_i!XI(7 zSfMTYarYRel0&$A*EF9CvyR-R`p%0ne#tk8ah9?}A%v_+L)zAkqCQ%S;#OqCieD4G zQ#MDfXPL3U(tGYtXu4Th$;^lh${`VASN3e9dR;Qbw8&{I+aR$ZuD8b24t>ye!Y6$7QM|O- z*^xdXZSKRNs;1ZN11N&pL%@eaC45@H3kV~y@L@Cbual=P6$;4cFf1-=tQ(fz(Y(Kt z-R+Gh>G$<^alVZjiIa_I9?B}&=8pjY`~zS35~J)3GkhV0|41G@@(V*%gL}#U5yJlp zFZCKQ%KN{qUJ5Vu2GkZ&6q6lob;WIE@qN5pI$d^S3zM6q+DlV*gNnY#gJJ92{O0I; zyYmmHk3Vws*S2@eGR~1+0W8ETu)sx3(JRgT*>f9Hy`{Wd%hf)I7c-8!S%;Qfe+Iul zO(qN^4|BG;fb%(aq$)^AiEd6GRg@w8o@co{7(jrZ+BBQU#?LQD9P}e-z>&GRHm-#t z9tEqK3h$BQ9R1DlL(3QNA{#jZd`qEO54a$&EH`ww7&*HbK%~w2Y`9P({KAbNnz_)7`|g)7==e zGu&td4L6F`nT?Ec4>S1RoR7)7fHFsWyv}cJLL@`@%7$)oXRr^N)Sx~*Yr486+O-SS zVZE37X;~hk0`}Wkzh;1GUsKq8Vpvb@m&z%$<9cE;w63{e8}PJP9GG z2Bs{{wpk9F2@+(Zs7j}Gk>i7Aev#H`n&&4kMJV{6-vy!&(K}NnH@op>K%sca23i0Q zKG&R!M-py>M&j|0got^QXr}sbvYH7RMBZzj-S~y|%IUX&fe**Nwx!*1(v_Sb8X)qE ze*|fA#VG@d8Jqe3KDn26QQWAA*Uo(+%2Bq3mFYcJZ)WJ$eghjehCg2zL|AYqo6PRB zx-{j#j-b|zXe{I_Y_`4TIF}GR%TtWi@><-i%lJfZZjTivAQaAO{T(SMp+?0v8BPIm zyk~`JB@YOE` z@y>r`7zNQgbj^+27wPdc*bgbY`x|wI^HcjQ!oL8}DTb)73gf<@#-)mb?QT)#(#BV9 z&q5o*^E3$9L5TfWNMVqF5=EW3vOvIRz_x+3K`K-u;us<}H@k@l@9OUWZyY7$;LE|5 zYij+UMNpk8pakmT8-`DWPYgbw5UAJl)urK++Xk1X%apPy4&tW=ZAbvR1lP`&$|7V5 zi6)LA9^8g**-}WIVa`LjCwiSF>Y9jvX7HQm9pa z_ISSY182k;cF${Fhlz&C&Q_|Ky}BAE#^I>TRRO+<9IGfQ6{?*@B9$8KE1C!+#3@B} z?CahQw{N`c&p+1O&NogALe87E?R*}aFV64J&X#r<$1ckh%+24W=m|tUPLSh~kFGHe zjz}G^wFq4AJ?@{@tS4=yoL-C!Z$#~d&8Qq!pigAKf4I3~I?`$Ot!ifBVqYT#_(hQc z4NI!1I?uF;RkntHI1v76LCWGrRva~X^4CNe%1)Rhkn$kbW)P^0;1b)vF#aXt_Wu~>IN>)P+`3+N3CAk5iNfK{* zA`PODTKDs5^Rb=P-Be0)Y2ChK`{(CUgondU>imtalQ?`m$n<`<_j-#P#`zfA=-Bm% zPaV^%IduWL^}F`Eg)_1DLjsQBs2W?IHN@glMhDcX_6{^BuUsC=Wa_dGqu5obG`j!H z=hm-It44sG>Nz80uCS?a4R&K}#mZQ|{)kg*uV=7U$ zH;ID{31FW+CZORCK-;|^?qGXicb}DJ%HPDUe;pG?mq#JOXko<&5mmc0zvxE_uHAbT z272J?X#qSMb)lXc-4~w{D1;JJ%AWFF%KsM66}Ca#siP*uvq@1T4ZS`OOzKOs5t7l0 zx@@RV?Q2nELj39&zFPjW;#M{sm32VPPATRx*EG zyYH6xDK>}V9cBX{Oexx;sE3ETbd9-XT#AMVotoT=z4^NoXGCvM`!6F3QT`ox=WQ}d zpq4_1n_n6HIpYx}5cM28-RJr4i?{FAud{?~nV*X~#T6uL1yfTO3gVO_0`DsPNARV1 zj=^^#4>w9_Cr7s7^-s@h27;ac%n}?oOS#^kKPa~QQ?O!MRp5_ef-HFc$i8hD`B0(P zyFTyY>H{O2o`F9|U#A3mqZdg*wXrJ%sR=i*5AD79Q(aVh?E6%npWmMNd!Q)>2X~yg zqRCaL!XeQCFtFx#R76`L^=?II!PbQE>QH%ix^3#7NU}n*(0ttoeLXDr+e?y#B zulF0i60`q?=E=rT0L8BEN!Cq)0%^S6*-x;1s*T`D-srf?Yxd%uk#l}_--jn_Lg3ad zZTJm=3Z4VWn*^g<6#-^)fZuCUpk>{q%!K~#^&;y!W(LW|<^ns-X4zb&Aih#f?eej6 zOuw(&{w4nApG$@by*96!B_=#R$;~B=H@Lr0Yu^9eqnQxbEnn6f#EDW0?iFPv-oo^y zcTt3>_Bz;1W*^169b9OUJls6aZK>C+(vA1}gn2=GBO$U5nLbX=9XGcBgJSQMG;3#! zq4($0AGa6st-Im#x3TgP?2$3deM6yN>N6D9E6U^nKj#0vjvs2?^LpSHyA1Bs)8C|sY)djpCCOvOsp%d2OjB|Ts z8kCn?J6!rLpG&h-FaY00Fv30u^jiGP;3waFx{%GgGzug+$4Fq#8?oQZuAO^lBDyha z&b7(i85MZ57h=E3-AcKQcLs7e+`^yQH0o--+#6F6l${8P3R1po_kwNE@Ov0I59Z_3FFUa=fSa|ty4p671Dpfd6L~&in&Hv?OYujleNXK`eg2Ey0vJ z5O`|~9O=<(s#BcEfC>B4XW+xJYsv{##Raa&PwLyuoEO1#xj*Wa{YqbJi>1}xP&|K< zoQ%d(zCIRiaPOZ&2&*p`k9+346V_Z?s^?}IF>ECS@?;A{?Gqa*@DVtM{X+rE z-c1R)3S1f&R22wJU{qSp#@oBrs?qIn%!I|@vnMRV*FDe{e_aKJg_lr(&&o2Svs_q- zuG9t!+z1$9(R2n@MRIifwnL-OV0|^n2M`t;C~oHchqEJ?hqU%+!g7_hj&aKg(g&(( zOGZL}379V(gtTvuGvvp|mUL1(A;1PWY#2kI-w47=ceR8eZTq=wP;>;UsiFP@JNyos z0yXrpl7b*lr*eG1RB-@+>NX!FcD|(2P!v(hy6=_)JRo}5DOx~Si>%b0UvwDM>t=fM z`!&0R;vrnImwX8-0PwclOyV8ro9;9YC3ZhU0Hc@@w?<}$0Lh!AMu;BFNjliuldTR( z%@N?&`D;fATydGyN{tAxW&AYU6!;x>%6oFDR7jX9sYsZJwteU_g$6cY7u+;uGr|UQ z;4Pk*7K<8Y^fYKt+7=c26E@G)7#wU7>1ULf;Vo1xvC#01!QZLLFfpYyW#Oqge3{XX zAf<7c-aq2|yE-=CLeUqsV6%KxVXF=S3kM4S5r&AQM+^q_7Rt8~kZ6-sp>4#~3~7C) zBwR85Kv>1~K8seMy%eVU6Gm_b!CFmgcS&OZU+WBwx0ac{XF{7vUo{G*Eho&4*7Y|k W-<8!%UrNA?3Xl?)hg6Ch`2Qc&k0UAo literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/inventory__demo.png b/lp-app/lp-studio-web/story-images/inventory__demo.png new file mode 100644 index 0000000000000000000000000000000000000000..a2c20c0a42f91d7e5ea72b2017369ecec40c391e GIT binary patch literal 18597 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/logs__diagnostic.png b/lp-app/lp-studio-web/story-images/logs__diagnostic.png new file mode 100644 index 0000000000000000000000000000000000000000..3b4bd9df50638d6cdf4f4bb48455009f9ee61069 GIT binary patch literal 10663 zcmZvCby%D|)9*ql?(PfA;uLq61r~?m6e#Yl#fv*Ew0Lof6nFP6(iXSk1zM!Ix9C}( z=Y6kp&UbzJBUh50nM{5&$xL!@tfsmm4kjfg006*If-w#&% z!22_`$oOYXL5$rOX&C~1eAx0N)Z|j*Voh?{c0CBVZRU7r9^hKr?kTw=GI32k!L>7Z z9+<~xy)1uXog zK35<5B$ywAFm@^XWtVP_0_yS6B1P@kz+eyTPvrS$pS4W;%TK?^1K-N+u~Hr!_Am-< zZgo$Ne`Nw8N;W(;;yb}(b&GNDk!K01OHJcJ7mxPzHHHV-!tY9fHnY5)38|xBZYWQB zO@+LJz9;_mw|BcUaB+Hz0u6}k%K$p@4dOq)JdKN60RU0D%XV)5-oS?fbJP5U56eo@ z*d^Vw1mre<=k^KVl`=6<^&7MDr_+XouYE4mH@!+X>6+Ps<7_y3HY6Rac3W1oh(DXgr$Iv;(~mS8axN(XfNhEFSTkoLy@)HTK_IX-1Is&ejouH@n3U6*QX z`No4!3Fs#JF(njN;^I$oPh(_zgNbh;HI7eEtC|m0?d#d}6RV4d1{mI@@~6r;a1H0{ zs>xyk^TJ)jO@Gla;gW?~m({i~;Zmp3qy2|q4+fmx1t^4s?C1BtC;#|Bl{%PCz&an^ zDdv(H3vc<(G7OdxY&uQY(+&{LsdwZh!XO%#e^$;i3T%`aYq$8D)=P^6wW_OCo_cA2K4V|OYo`6=FQp+p>LiJdbA&n+ERiA;W%-zI>_)!xdNvS8F0aX%V5X;lC zC-4&Jz?C>#JQuPN;-P#PyTGB0wQQ9%iWGa ziK5|tYc@|01%*~9HU#nRWctK}9q#A*V{F8D9WPOABEh~j)vD-2bipq2dBtQ|ZZhm2 zqZdJtn=$_kikt3RcQcsaU;rK5l)-gOse*mtARVJE&xIrg!g3#Sn!Tx*o=y#x6lvz9 zulyUG_&aQv{JaY@4Cmju8dewQcQ2;{i9PrQW*s71jT^W5&{BPay7)&}Q!=;qYH0M* z$O@*PJwD4e`u_LsAK;uG8w7YU|B8PQX~y{4^`%Bha`);Xwbi>L1s6+|C1o;{fT-tH zbg>J^Y(E`<8^OXrmDJ%=RYlc3aHgW_P&aFck zynk%)Wv*6}*8;q~$A|)3$PmNTWGW4+UpZ*PF_h*DwL)k! zK8I+yeTiGQJ~s|FnV^(I;d7r7A4^690PeBD{)`RD$~(iGL+9yNb_xD-^kK+BD8JG)ewMV^L_b!I1OVJnKqJau={Jpd&vYt#6J{50N|}OqKgkj^und-5ESTO z#8~iiLPcDlzuWaykA>uzlB<5@nxVb;;oP#t#=c|XmSXglpSOF9Xi#x)WhZy=_W8|p z|1&vzm*DH{W7EXMB(L>eqxQcQ?V4HH2cx^p3Q=2)q-Oy$E7X9us~Z&ss`PeQ30CcKEOBcZ=f*!BTJ&Wf8&w0m` z9<3G*UQ=Ke3qNqhK=d2^9%a6$nCh#5xU@{8NcrH?+zem$M}FqYTMbdBKF*Y>uclYt zgL6bL4}FfW^8)gAM0VD0@@}d7Uu`adPD6TOBcw$sm-{oTD+zb&#Ds(468ROcL>6Ra z3pH7YF(WsD#Dvefb9Y~eh#oLX=}`u3{JPGd@}jk9?fS+f{z7_m7z?_j^}#ujTfv>) zCRF}-e}?4qsEe=}x%>%x@Ufp){EO!aK+CBf7cy}9C$}H?yd=F)SC;H7%Fle~8^eK$ zjtr`7!J~}nC&o8B5)BOIxlxNcjTFW|-QNr~Km4@$#INXn%|LOkmY5ZZa-Nk#n=wRA zJz$A%dQJ9oM<&e7EIl;}ZE?t)IB$&GIHNN6a`Wfk2d8NX7Ax(b7r81hxI@1Xl4lce z^Ch`xmGL-f6t^!kt%c$W2$}X824iw!{5+h3Bm1JFK7$grnybD!!S+MON(_q1h=r0E z_uLYDmq8g%nDuR0W>t+Bdi%jOFEC1dC4aim%HQUr!8Dd_D@93~r-I0LLxz%hSqtw` z=8CRP(W=U79x_IAdfW(TY78(IKtFck6REc?(qK@Tj~$5;pDL8t7bTa~V6Ud>52CB^H5)qr?C zU1HRe9=SsMY57yZ%na((FN8Wa{LIYF-@X-0W^&HI&jK-h16s(-6F@>PtZ#YnI7)pd zM847e)S;p^z3Q~hZ(cCG?K%+;sP(qlx5;H;WpjEv9#1w-9(X$x77Qn5;HUXb8WhI* za5Y1S8uE8@@k@0Fw^U`~Hszh!9+g6i%VLD7Ctl=guWK^T>kxNmIL>{@!$WtAPWijx z*Bf|ORYaJmd~f`>Gjj%}RG|3J7Rc5X%z%|Pg4*9Ea%U+3$AQhPkXOmsqIQj~^cKB0o!$?e7emMtwzjMzVaW>zY;mUfwP zCr!gu=3GR-P<13*MRV$Vyyq$OrvQxb$dY1GF=0mI9r8tSXG-36p;E>PhuxcnTT(2} zy&cP;9Ui~;1~s$FT8t#R_18JLr9E7gu1SV4IX9G%5PG|9e24$|JRzCT(dK*WuT}=U zq|R2Ce*@N*XSn|OZrN%`(?L4|;waPWR&B&_l({>M<{J}TbSscbz#&yBEv9?Q13cm) zt9m>elt`mDjAWQWGl?&@P3Tt_k!}0hV2OW7UE+o(mOMtPm29tZxVTX%-#W}Cxs3_O zPfLEQx-yhs69Xh~n8CytW~D5FzJR?=sIqx6Idq5P>-BGOLZ>&MAscS^l^|lSFE5Q{ z*QB1)Txc2(+Eb2ES1x6|-A*N&8mmwZh^RXJfD^j~y_vhVoiKq)-+( zl(IcaesI`9q3X{NlXCySBJ;mm^TK72p{EgdOQo} zB3;He?bnCqTY>Z$XJ`7DvGhVikr2TpwHF^1saWDL=Qdq2Jpx?uwDL z1AHm@K;Vm65$tjeK;la-j6dx{Hc=5MXs}f;rkPkv%I8-!Tf3@FAMw&)ZpfOKcmt`r zBkF-a2L2TL_vcR=;_GVX@c7PR#tyv8$txgLEhH}{Asm-e_J(x%Nu{~%Wl3Yi3P_sB z`54h}q<(rts?D%=vl3W-zLY7oo%Elg$ON{k`=RvUD0BH^QdrNqpY)l6^y?3jZ=L8( zTjtn%sT(2q_IS=ACK<3gMHkMY>w_iE8KpT6u?@e`WVWv?CM_Rna&|fBd43MH!O$~h z-*GLO@_pu&T-24ufnMiqZI?`QaXM@Smzgx4Y$oW?NUGT$yq7 zUGEQ3GF^z=Vu&0nBoDOXTp6j~K_+qVH#K*Z7Sy0#1L?0V$5**ROyV;1Pu) z%pUl!*)_6>uwB4(M=o{((WE16#(vi;LwCCWb@96yS&P#dyaoZP*`BH|tFdI%jN#9@ zZAyAQEV+AUdrK1xbiF=ehY}hDO3aasQ%vun50E!NZX?6Bv4?m^wc2?M0ysQtztb1R z{+6*5dJy`F`lBZe_*s@64BPI)VO3paHlk=xoh{|c5bNhwzcFG43rY4mT$@-&8Xm+a ztJ?BW_2cB%IE1RWj#1!mnL06wNpZz0Go?Mt|G8#W2Rb%zi@kJMZzIvJk$9)b>1M?`Op>adxKo4D{j#V*qjrP=t6?YDR^A5ZX0tE1 zOZ&v$Pb;y?L#ux6QP$63#zrhq{_?TYpSE}cCFbSLad&>}f3}0=y{yH3U$+xR>pk9M z$V`dK6q|DhE{N%6>e+ECa4j*rL)aS2Gp&Wy=s1%x<4u-^vwkvz0u$ZL5N${f(@#%v z;ulP3xxZKXw*8;Cj7Jhy!nWo=`mEuf5(ryRdQg3`2$s!RIgn~d^I1d#q20bf^s7yM^> zdp~0$KQCK&{pAGH-qd}GzV7L4%@(z;FA;#wL-r}{e9Awc643}00ATX@e}|-7B;>;uIslMyupS-Zf#6d)2g+@32e}H4eF4?#|JvH#&|KC5 z0Qz!#F0`b;(uP0j(SpUm4PJE>8Sk`sRd0YUwPR9<^tkm<@;n*^ot~SP(OKJiZ?!fG z&O$?c&6m%O1nB!HQuTH-m=<~C5;oGK9})aC&y}_c$9D8Np?<(VNbvF{;>8HiX)-A| zr$Wz@WAjc6M2{d^{g9ay-KgZl3;qHEGTjR!@^FA~2LE10{GAnaw({(%lx(Ns833?D z$V(3&7=_v8u#KOi0s!Nc&Yu|Q>m`_;vTlih`V$850FWM$hIdgCPP}j@34TJxZ8y^3 z|A%T3_WdZXo8!#iMAC&8!C5}L?_v0}QHfLwYBkuY)_o@oj7}e!?&sb^%sJ?Ku=3R< zN1FKUhT^Vu3MyJcyCo_$=jm;)QN^qYV{1jx+jkl_qzq#p#uo2$7Tg!ztC*L6>4wgl-<@0T;IF>hC}5V|gZ zU2!EM{L9K2@0G8DScpqy9KYCSstAa1hF|)DCo952&VK1$J09V%%%O3h8iJ)PRXBUd zB^=hqIVdTFkb4)u&x?+MFxAtond~zv;g$=H`=>;Wf41@-M4t|T~4A;~&G(aCeacZ2C&kx_C$Fe>t_r~^gWB7a$ zw~#abLU%sJ`y^M^0`p1Dt1a`y(3bJ9pnE*$cePv5yBm_So0SOFe=rAJKWgjJ47=ZP znO#2^s9?3ZxfpXI>zC2Brxr4aG&+4N|I5hBpzt7_;t1m_^HHHEpE3L4CjYUqorSlt zVR8l9K#WvoRi0J(5No>Cm|L$R=6k*7d7QND8E|*flkfTG&O=ISg)HCIO-?lqDyV|; z4vR223;_#PfPQ(UAKpp^1O`(@!RXy! zc!CJPI;B**oxr4@Ihq&|9x-t@Frf-NVI%^VhVBd zbkq%k^OD`wHG(gV$gi!v%R7G);u1=_qQ&M24H1LK1I2d7xDb0DFj69Y6zJUOgNw@e z`8n{i`D;qvv-}I+n({#S-yrXD7YTB(YMh%CwCm+-AF&TG8k`ZR~ zS#G$D*EQAs>g{Fe?<+gU^+Mm=b)gJ%W+E_HTL$#Cny&t|Jv-!~qefBib8G`6frOEE zx5pJjr=E?0UOl)}D_yE%HmCRr%*;-i0Wb>Mv*`5@bM3^6N;YX}uQfvakkFhikCVUYA!GMlGqX%6qBZbWBz-Aa~K1Fa1T(D{rqEF5loOw8r9RE>r>Z96{2m(3(Im=HU>*zFq3wrvWUo8=<@Gw2BW_S1pXxgZ)&2mkr&&l`v$Q50=lrcG9wwxS*th_E2H36H%GT@G z%RRb=vSmH#8S?&k3d=<=?1dlZa0ye_=u*;@VKGxHv*n4irF!ah$!619>U=X2c#k3y zF1_Lbjm7hha=)K+h7-u){V29NYI>YZt+l_w%Nli8s)VNZ#|+H1f*M#b=K z^u|8SpD*soQ_o$pGI}qRZA+PWb|$}ywk#mSb7G#%nZ$i8-f7-l$~9|&hDXx9O0!1& zw6*#k*)U(lVPXQtbv$nFR~ zuQoocdH5Y~$;4+T^Zq_*UQzcw*VXn2LKc8}H-9T7Rn{7yzdPI|swx5fbKFkHvyeX4 ztc=Tpsu6fF=Ke$m{_^GPLvrq|ZzAHeiSPs2d!H&(s`2eqF)pRr?v^&4sfx?tBDx}< zJtuEXVif(U{zpi^)(Z7XE{)g4@rirkuPE8C_n?g6AAAufYgopJjO;p&gQgcW08*-? zAgwZma*T_DmGO?&@BeNs=dbsMxt$(Wo8SWgv&yx*?|1r=I8?bbf@s9RVfK;m0XL}5 zIU-2(1yYnT4#2r3ICyQKC{O3?wrZ_i@=2n0=F11Ne?mc+^1JPM8@;?OuhJ#~a`EqE zT2n9sLd0g`CSp(gbt!)6>a`HBzNRL6C~PnA)dQU@mw&uNiOV$_$uB+KmS2qlocZlaaMZ$K^N6Pw(e8LXZZzSBBm|A@^^K4P#}>9C^{omKCO0 zDCLXoh!cwn!=gMDH?I?Jdiki{f>DA#=%{J$(|VIU=C57^8NHa~q}$rL5EZgK?5Ehb z3UHG%#U{a7F~;|;Ek+-KdNjOQ)WZ0gh>>LuGm34gNF zB^V8eza`)i9()%^6B}eb+PaKczF+66?O6I5O;h5xv)9~P({sLs;t=pa5QLWUf6Rb? z1TE<_3d(xf$iszdP&)xdhioC|xCE%Yqie+MOA0N_25H7;hhb}?v<;y`O&!P4IEAxv z2oD@eBFh+%Abs9Uw)+jC*cMf7Gp53<^LRxB!k)a$BIEq;8(8?thORzANp z<=G}Bq*?taMkPKf_4(n^AycCNM1G!868&rprG+)rxf$RY)MlA(9W}V>)5LbDY{?mc z!*A|rE)GlCbo$#=MqG|%$6!T(Rce1SUlN(lrb+Pu2b-v1`R#aA08+WNqWi#YtZrsv znT0D_foVbL^-{e0td;Z)#%a=ap8_rIb9@vbYVyoJ3haUn=^Z3}BpEnQ_ax08j?S|z zCI`Iph@7{30#%``>@SdvD^ZD&D3QIQy7N%sSKjSB$(<^1DMMYL-YqC~o>E*!dRo+d zj|rsKHf*Aq6_MHMEUCp-xDVt4e5D+-_M~GhvVtEOjj-qm3)NnPIS@EAE;ZTHqc}5^ zo7@(tJxT+G-Tm4o0@m^hJ`0-)V_&x)2Tio^<{;LN`X4D1VEAQFOk3gs!WzB zy<<|#u5ttM2nk@g5haE~Hak^nkv-{W#01gYPU9c;(c%I9n>LI_J{eHX|T4`39ir>Aj=e#V2=(!LXBL1O6ZnFXqEmldR7p zGsA(4clx4nAiuuhm;QDaYKsxc@tc2IUpkN>+%>{jylEj&lMY{mdknr+d2tK6C`D0W zj;vz;d$JdH3zl$}0sFWb;a!BMnSq?m7LQvw2m6I{`t?h%|AMM0@L>a=F4oESJnu<0 z#oi#3H4S64ynJ7&<+s%b#&%#5RqUvT~_aKBgMai$jx>Tn?Mlj)t4)m55jhMo@1b0>RPLx z$TcSS+!>bn2CZuQdY)>;Nykh6zUF#T!lsA$Y%wjBu_Q!AKKmEv_QoE`d1cf1g_#bXBYh0Z;2vZyBqMDmq0md%d+lPhovW;NeUEo7Ba9W)3hOVJ_R*~Dl4-sq<)UbxqK}qXnol+ zKd5MdSI0U0AyPmVPf@{t&FoJPgR-RFq^zx1M^;gyzS0JYa z%_@axe#Mnr5%UX_xXBDQnom(I6=d98&Pz>KS{DjZMK7Na#7Kc|roPc3z<9)+`ui(V zdx*IBy_Jj2leg@|5{60=z^uFOE!N?JIivVPYZ| zmZHlK664Hwa7UIcG!T})CXq-nt!|Px$`16}cDpdn7t!37Zlcjd`DCe5Oqfvahz=}| zfe02rKdxXkd>=C&0T}->YRNn)Z;qp#fqlf?A2FR`Fiq#ja%^7Yz)jx_l(rSAl;PtA z5tJ#wr_zTHsTM4-=EBaKK!gR{?5n@CJFyz)Xw@&UI~eGZT)Suxv-~I9Y~l?yjvTuWXH* z8s2EA_4}Cy0N8gUO$3=zX(yBp_o23f>l>GQ;}>FM7^4Oad}#Sr18LrRo*^?#gdwnL zl1s{b;#i^ia!741V7RpD{?L)#j_}#3x4^FQ+J+8{Mzmy|+O04FN<4YsU&%*GUE9Hs zf=wL9eNIz0k`o@ReJF^aWXjBJ$t6W> zA_nCIr*Ikr>CwYeM#`ZOIMGA|Q@wAqmIrTM3C_%Omshar1_;l{btqD7D=8^}huB9+ zCsjf4@mFvbC@W671OwqJU6fXpK^j3!t#iJW`=~gjI=G?v3n)*ub}btHZrHXoV@E^w z-$Q3Q1}+!U>O0&XOQl6U%%#a)sizXIhJ#AD<)ln_DB&OeFei6G*RAO~pMRsL5jS>j z;fE&jlRbzyCE|M?h2!=8Ny1@s!%S<-!FR{bqt;IzO>9eRwU`rnx(?~_6Y>NklQ;7_ zP~lF?gkzPtSmi7%-;Mm)hw~la!(PRjO|9%EUJP4+`- z3?=5HMV*^=yS#A~m)##wFRTAdwuV6-6^d{#F2}N~%T$k6Ym_uwcsm8)t1`2fq*Ckr zC^sWg)ERB^4oQ^1Y{Qs=c!V8RC6d@TY}BVePL|XrMM}3d&U+E5JUXEzP_Sx1g$tCO zo9aMTKOD3eWOP;Y?Ow!X%dc50<;P2gu}z0KH>v@EXfQIfDw(iuZW+=YM?ybdjbE}$GlI15ZylKH&ozl*02ct ze+Y9tn4lcfXt5dEjhsiNiwsX3Hx-%sO8Fd6_*XD(@~uN>o=w@)@NSt!PsMdVa_oDV zq9xL36Ol3Xr%5M|>$qC2gU9IT`To8i>*)#3`oH99AVQQ_vn)somq5RL_WhH(bQacu zJ06fQIsyqb3zNH>@6@G>Vo%~bdibM3uTeE%P%5z1cHARZJoS}zvxz5=8S$f8_O;#1 z_L`#Abmpd)kOn$}2^WuLwJGXrt}+Fo;^znoZdSJ0oUNKA;#K7}ZAdm9y*q3-qusAJ z|4H^m_T^}gA@*hl10hLsir?qyqqSNPrQqf}nT&WFO|0|^2$tl83CJu@ZR#0`rw3aP@ zlH+f+^I~O0No^|}zPI=1>jYLeRH~mfiKqneSPBRN=)ZJTfQDUvO`dou3J%A#+2mR!#A a>P~<_hP@Xl&P?l1Gb_ofLuzEr!u}7$&T=FG literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/logs__empty.png b/lp-app/lp-studio-web/story-images/logs__empty.png new file mode 100644 index 0000000000000000000000000000000000000000..a46b222257f3cc5fcd0d587ddf4eae3c1fe98278 GIT binary patch literal 7787 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/logs__heavy.png b/lp-app/lp-studio-web/story-images/logs__heavy.png new file mode 100644 index 0000000000000000000000000000000000000000..46e42bac54470a044bf91a7acbaa486fc40900f0 GIT binary patch literal 44751 zcmZs?1yq|&)Gi7XcY-^F;O_1z1b14B6u06~+&y@3ch^!V?oh0_d+`xG+?f-!fn@fdS%9hq{JFcPKHofy`G_;3(_!R&$Fm{wy?*f;C_P>S|$Uu7MQ z>RysmIE$s%!#-u*#nDdP1u?f}cfaia0_UnzKg>si&AP;1Rc%fnqDAyGyz@QYb<#oz zv9_tiI97d-$GLvnY~XIBwz@%_t)VgKLv}iAX zMN30Ai{r4jZ}G=A`tMBZclk*yWnETJw;{5HhGI zACt-HIby;5Ot}?1yJx{J75-kcm_KH$GU}oqYa%-#HLhIWF96l8NAYI<9zoH|o$oj; z;#pP#lYUm$eTT3N>OY0bW}s8ev}zx7NCZ8YIe-Ay_79$$-lmKDyaOmkzDV~=hY$`z znS7xEVqC1x_0@GuTSTX8L*f%h8qI_!1BlK1Bo-;c={IIUDqAD$;8zu>pmSX}Pf-3p z;gP~IVErn>w!q-P!xf8N7m?jJir*V$b?Ac55xf&(jPdyagGDczW2F{_B1v%tBZ{Ak zVCs&6_Vrl{f~5>YpelNdWFBOc`#sT@=NgQS?|(9+sQHfTiPp$lFk8_FF@BaN7&DTcOWw?&O0zrU z*#5bidJ_9>{=P%t9})>4um6^PgVJE{-2Rzr67alGQ>x;gXE7oG&aB9Wd48dfsEm9c z8Ag+gZu^w`7Yw?9HwU<&nP1#dS=8R4L~zc)YB%(fS?UOx8W0Z|tE+ln70GCqRC#{NT89 z{8?F{oBLpXKvVG6Qnsk7KPfqR&S7PVW5NpgRf3yl0%}#YEn!jo$xrLszm(+KRZ|JL zjgmRnss3k;5&<^4DdTa78)6)O?Io*=t}TQsyE>~)(LD3c_~jwm@o8_bF#w0X9S za_UseLP7fw75jsnOy}ltYyFePG1!_} zjRHf~HTHaU-e&z>Lt>-F=lf(L$NBR*mOSfiixgLigk+~}U*p%`OIe{n13skZ%`&Zh z$J}_7);;vt@x=gV{du~57H)N`CQ6EJsQSl;91_aFBOY+x)9+lhu0KJgrLK3tuOe5Sm3wUh;$X#+a>hkrB!mf7HpvTd|=I0{k!`qhQ@j#Kx? z(9q7&>Wh#DhhZxhzSYmb?xRdEH7#j^@7)ORam%IE!6krYQ!onzHwG0AUBYHpYHn=7*v`i6%|%?85qZX$~^v#t#vGB&$c22Nzn|;fytrW zjJ=Xuono@8pjJMs5XVp4uRNSVv0)j|26HFn57;5AHhM7|o5EYppYXMc>2(;il|{#< zHc~4J#cE+fD>rvGi`w1Cv1yR|!er90Dj&getfUhJ)Oe*xLVEJU^Qs zN%zEBhl2$Ux{agJF9Im;SE$7y<{)b_VTs!wCy1+G4e(Q?vh>S=fv*MmA4XY#-rCyx z$vMPXjoBGD97`A9z%7yV3E;0wCSp#M_Wpubz(i~REGibofDNzMd7K_DR)?TlUFD?F zoP-XwlT|GBy@@{t!K)}bDTLlnst=HASY$hinTdqfY7No#5AY`?_o8|ljg-;aR9w3@ zy;1~g{SSKSnv1;Gcy6HHs%Uqz=5Q1r?eATGPiSnn?wab#==SL6b+bH?mk7pIb;_bL zKF6FXSe#?F8ca=nKRy4dNdidJ^*~7>AZI2kroyhk!}glrB6-*okmOvvT9K9su4p*? zs?=o{?)0JXp~W|MK2~r799r&rK@+-%)CJ*EzR!(0Uuq|GhxhIj zYGFK)fRk%-q~5x}p(B`ZV1Pb+w#Kyr`8W=9$*1n0iLR5ItrX<(>4Gwvhhl^%A{{Nl{8uF9bTCHde%VH}?Y-H<%~>()ItXQmmVWN-@*2?QSB@F^4PhsBfI8^NS(xxR-i=4R%) z+}-VbN(=ox)9NLI`fdX&5tf^M;#iGrpu^)Jg5uPh8lRDfjx4v;>3y9ZuNbm1g1(ocP&L}~j^jR0Y{dzs+wb5&($m<^%UL=kYvQM5 zbIeLyft3n^4lO+@)qM01Lp*c-%#&)%@l5_t3)RcN(b0Zo;>K-cm2u>J=oOyICjgj$ z;B9MX$pRAkHaUb5q4jpvL3an>I`-Xn@>xtt=IlZl6uzOcA!L6E{BuvA;}gDc*(C>H zVrY`A4ZBMV4TKNYQD z$uLeB0pqDRev_#H0KTmIzVPJ0C(0mvQgbI4^KoRxqOeD&CJ^@3gt%E+@u7xC+AOaw zy)Q=LC)^6jri|YkewnCA@eeIy81zsUZL&7iM}BeFqc2ij8$h)X?v{Ku_;tCxd`AF~ zWhqA2r?wB)><(oO1?wT-F-^O82K=3rnjzA&9zG@sPbf!cmO?cdiU2O z_(1MuWU%6s9g>E7{u#f~F#tL(w}RLk9~egQ%W>hW#jB_KO-JR>P%-+1P+mBE&Rj`$ zn3DutQ4jZNhN`uVD#)nQ*HVTH!##sQ|A7t3_u=WlgZS5Z`$*b53kg~P74!}Av4kiS zX78s<^6mh`i|Yl`PZ@?HE;$422h6}U5~2uWQH1pA^9lpLOm?Ei*cUo0cf4ID#Z6C; ze$5*+SVFF&AF8(PSSW;IUlWRmwj2tZZ@S zVg!&6nRvF~=mD`N&;e^Yc3{2T@_)X!lLHH2+ncz70BDacPO+q@xL1I)ge0uosZYNl zHWG~VG?j8kFaM_4Wnyzjm~ff|4?3z5_!&3PD0_9&hMzVckiX2M^+^MVlIHhJ(vTHB zfJ@Ki4$eW5jYn-jX?>yu4aSuMzpDo3`8`!CXSzKGCyiZX9-g=Oa~hPaR_2b^ zfQnxwH8ZFjInc{dlIM9eJraTkbE>rfka{@h-)i#p-KkRo0#NWANWI;YkcX4oPl&R5 zfjE5nJYzL2+X27~QEN%22WQUmX}JkR*>}whrbOkTloebX=>X_DB~QBh!$&P;C0wL! z$H-7l?Z@4QV-ZF`mj_JjGx>WS+g{cV>@;;*P2e<1DkMf=CK7_nU05n`>iq2g+BN>e zDJ~iUCpVvzAe9VZ>Ycm2KL~m5rny4d-Lg;q4JaXH=KRBA;kCiyhpo9x5Adr_N}N(h z@^Fn^Ddfc94^*mY2##;J=WA^U;yrfZbU80lQ~rQ4cxg)z^WlX81XL@vsn+x zGcCaiY-~YJb6Q}tp^hqJ>du*0;Y&0$(4>o>GdZ`ze5%9Cy$x_K4b{Ue}u|4DU;w1B1 zqJqSF6y$Yxzy+>j06vXY+wSZA!G2bmU;aBrpSCjuItiZC4iTco34!vXDlE5 z8XBK~M}IkeJmyvy2#}3$IWYy^+letu?*QUUXIq|Mx|_7T>!nu{ zdZlJ1P6H#2hCQx2iEVuaL8W;ItBag}eBI#SjG2Y;UD!Iv0o@$^B6;L+aPM{F!UScu z_cH(CXY?RS^c)J7zttUk#%Hy)x0h`t7@v;CaXD{`0$y zvf9OKX!}0JM%j<}kKcO@Na(R;?tRnpUMi|Zz2EKYF~Jbq=se@9oC73|Gf7BawYVs5o3kzcPNok3dKi-k<#Mq2%H zjVsfuRiEt}M2~(T$rPTL`l0@zcs)mSGW0{Oh{gyV;A8k{jQ|%YN;qmtHj6-e#+SD! zjQ1**$g70-)uF(D9{!IP|H?J-e~$d~P73ncAS3fy_Q;?9lrbfGWj?ie)Pju3yjpul`>}(IA-2rdEs-pai7U= z>X*Oo3;Ntlu7F8UoE`}wJoBXtGX|Nza(B;n69bExo+~21lT@b8gnwWuL7tlds$#3Q zl}Coq0JWKfg#LgE%cn|Muwxgt^Gs7d%n5G2!J(i8UCIE|?!zXXy!<_y*RbwfXm5zU zLC2v#g`Is$BX(#fQNni9n~PAWN6U3k3<1=0shy!CR}x_uTrLFnkNWd4phW+An}+f zhAHQ|_t#gtj4Wk?$kFXCS;D?nY}=wBnOl-V67V>#GTw5pimKBtH;%!l6sD2M8+ouh_klkKe9udUgTd9R0)6&PM+;S-yw{ zDaCJ(l*j&wr%pc7@6Hr27~vbpPhafq?G=2Y{0-a$L5hXZ`OzFe*323Mzz_OV=v&qi zw%)8hFdc0_Y9TFnCckh<#^1vw~N zJNSNSnvZ|wgIc_}U(15%7ub3%cbYVC*DIm?h5^FH>BqelB>aXK#db`&bE_zR&!*w% zq}`8u_WWkD5DO6U=4}#iyPm6SJT2hqaZS(q=2sWqE5QPH6iO(k!wv-!`B>2TOi%ObGOlF037tHvO@$!pF_GWF^u-t;nW8JjeP2V{QP= z)@|2S0CMR@tUR7kj&kEes($M_6#0*;MIi|k!#D=no@YnX7hCXhL{!LVp41?jZ|DGd z>@u5em+Ov$n0RsSB4k?*N%ihrk`e*N9J_=_IkFahX?u3x92Ew$=QTm#D*>GLt&^%~ zde=W4CNXAKOgr=UyVNBa{IS+rvrh<&kS^TFqUkkj=F{kKopmeS=rkQk8?RtY!Yy|` z`O8iYQf6HBZn|_2{EW54-#RB}oy%N0xTB=oV@^fBviS)=)(rPk{)9-I)9^+43OaJ~IeQ^Ex4_HXg zlhp~fZWih`9JZvijggcJv%L2m3m zb^>OJoh>-Uw!b&zpj`P{zphZpZ&N?(qcjavr^A>oj(S$T*LBy3K57>wSRdg7=AjLA za{axRDU9)dzav1GhAPs&_a*N~+&F6THtG*u|AqdP&)E>unY2QJgDVweqpaKGg;&pr zy%Ou%KBb+0-;$92`C{r4Bf-P7e0klU%;|k&$CLdY z$bCdB3;eevm4DM%uG$q;<%VQN|671gKMuEz$6h_2Tsx!dS)HGav_)oyga1w2J-*k@ zWJJrJ_FQZa-}6^bzP0D0W;HK8afcjR2WHfUqBp16D3txjv5?U7>WAO7l!9xf3S2th3!j{ z)^L%1A$}9*j6F$@8jOqrs^DU^NN}^N`?o53*d_}V z)(1$t^}SQE;DTmRnn{Wh(NNoqcz&lTs{rFt zT;5be2)3D?#+st&`a&dc6obqyOD^_FmVHI6+O=`5+ad8#J6-DJ_>0y*XCW+!-U%&x zD;MYLjQXy&2|dvSm(@`~_=U3pL;bk$#G01}X;ZLHkZBPG==@HPYuneJf`#I8x~&0I|e z&MQkb)&)=WTak!RVm$0hWq@xZWJ#^q1a`;j!cB!czh$&kT663AYKthvD@Po6ni30_OKE;BTSjo zISBZ+vg*Sq&tY*G>JLS@AaDzJu-ZcCHwVseW3lKg!_srWd#5ZU=B4mkH^l~JOr5mj z4rkk%zIJD<#T$Hlq%yDwqF2sXGFjS+ z`C{#UMiwMqzOD<0QaW4r&a}Pf*l0HMa?T{OqWc_>wHRymp=A;mG)tI>Bm@=L;T znvEPpHqh6juzqtChBV|nZWG8$u7QifopC-b*MtLOy-&&sYn|$Mn;>`Zby-~RYCO&I zYqyGJLGOcmlq68*LTeke1~2l1Q`xd&t=}hC7t&5c9wV6nXxl?F1La}wkv<_|u#)3p zuqt8HH(gSrJ|k0;2!CJy5oMVCMhmmS_19QNMnlKR-&VCgV5hY~^>5Xc#}Tv6V}S$- zMec5KBcJA`B`abo4K=emy!8f^)PA~<4=;~12YrV(E3wq8IGRiWMfSN_e{1@oZ@NWU zAqOQDq77P#wAGrv%N;3^16e82M5fK7YXhDq*YXi4w?7P=l!jfc{3k6(66uvv* zmB6MY{C7{w$OVstu-le;L@zjcV zpZwdoy_^u)khyyuE(60RBd&hf9NhegJ_@8Gpl5Xt#ycwuEe?ZoFn=Ru7x!)e4?gND z6}3~M3Fpl7bB*jfToJW+W9ccU8XkkpfZH`|)fV*cza9K&Zd<5J7y_9oSIpFa@Ku`- zBNZ>dC$R`tt)U0USrzgL^tbZI#)`R~GNaPnwF0(h#vNp^NbrFrt*T-4I29Qc+tqTr zRa%q@f~Gg$qA43Lnwi-Lk}cz%f%A*EY2>NEiLq&oHlwMJ93Cx5laD)biC`)Fl$hQ~ zyVy`8I%GFeGIG2o;uT#Ddx_ik1?$`i&W1H@0g<)3e(tnb^F_u`Y}f}szn0O+cf`5@ z-do99Niy&^!*T`KBA-6QM2v7taxeKNLSm+WhGggPd*hcrpLon2|L>LQ`PX~guxKJ`5Cl?#!3G3|1Im2MJzy1qQyjzwLQH`vFeaul z-xb<5yf3QR$KIQ>5W2-epSB|ymb)bewkEHk;b+IsCr+yCv&TlJX`zT{LFR#jO)@|> z#M`i#gc<__q6f}+A>Fxa>B>TSu7X&ZLS7!A#g1M-2Tx(r<^d5RrcZ=)4ptq;`nM-KRH^^QZZ{w8Ezh zwQ6;7SxQC&0LaBtJ;5z&7t5kDg$r8;A(A`{pQ_$#L{Xi{=`LJnG3o3tOAovK7vL%^D!+$%Oz%EqBd z%XbTqT~xheFes}WA1jk#OWg=saKUvAK&)J%rp!?1CW^MHY{bKHLFTD9Hcl z8qf{`efTtchmMAbgk*xkd7{#x@WKu{zpU)MUa=U8bz88WCI(P2uf*9dzQNhx4r}|S zMeO9A^)>P9#d_Ybk7H$~#&1+;$t`WwkZ<2;d}S>jAX}_rX3o|}Nr!QqUj`U&wJVLy zsQbU&GWo6mA$&DZhR&2{^C6g@blgPAh`twmubkdk&{9@ew$|M#k&6LcOVHL`uiR)ew`c%y@)d@i)_&bEfV2o+w)RW&|EL<}DYd@A0D^Y+Bu4NRspF{~2mrcE)I|@B z0TK><(RZSvT>t1x+7dnO^tNvp;_0z5yB$e~^5dF`0EMXUuWaE}bhMt&U)t0Qy^}MP zB#NhKkL(p7yFUBn{UKEXi=T-Y8Oq9ajCksuViZ|$rR$h zEN2lLh$wRgn-|GbFQTI1;65b2JU?$?H8I)0?8yJ~Uxu2PgAtfXD95-=AWq<0}6j=)t zz34%CJDu~+D<@5=#C4O#VrKpGANS6or^>%I)!Zn*9TF3aIZFJ)KRL14Kj(lzT!F$P zZ|b=|W4})s$69zE@c@2NoxoA;$vApDHHc$I3(tIFDIa{DC=)yrZSHI0#Y@8ye5AR*~N(yrxY^oma> zqwRG@avRFOf1Du1!^^<>B?D3S)BjrVo|GOql9%+*d_(o5E@O`#eMg83!J8=k%KN!K z0v$S-q!jogk^NZeG1-`)fB0sT5kxaT=g%5 zw0$265{U4K^|AVy@0oir6gRMh1HOhtyO7Qwe?D0b9L4?Gk4xK|YMh-n^C%Qx4CmOL z-4%u&n_&EjyZ_`u^c@&%E-*tCTm&=d1aQ^=h0RDdc zBX+gffsdob?X2&)45vH5&+pONF9XG))AM|1wKwf4zOn55;^9}s&B?Ve8qwL~!JF&` z_p5W3kPqD+zs9dES48V_JN-KKT%T4_B!-^z3@^?USfaLXy9@9M1B&G`T{B;k8R}gQ zUFi5R@bF@c18GTqYiffrxnuYOEZc0Kv-}ne9rv^b^Up0j9wrPY#BaJ>yMJ|gO?X8_ zbK4!wmfG74*H!UX%LjYbw3JI_S+e%@&B?XYcL~H!+-emCc(e3l<+y9v*<7zY7|Q%d z41fSS*zGyDumex2hSO56pZLbt%m_>!iwwKSbIT+CtU8EPL6G68E_(2x#L~8)o`=uW|AhK#5Q8% zv9W%mpEoL02Ln3e=-j>umQ17FO&ez7%^j1LN=8@M`N2 z-_}1~IyHQ|Q`Ine-p)?ru9^~CbeUaPgWyqGSVChyw$;?{y)$L`DwOm-i+Cq2!HQ$E zjqT{7l`&qT!$it%?ldPzvonryrla>`p|VDwJc_7C;*T8rn}p&3qA;uO#W;I56MYf= zf$DJ{84EJn&iBn#tguV*-$Ki3h<@0izER9`dw9<1yn$c4`_RN0#SPhgW!1Y>k3jFz zf!ZZoy9R7O*fI;l6r`O7r`6v*d7c)Cz@~_ajT=@?EDh|-^FP#0FfGNF$9)6&*wIIg zAGv>_)N;GtcqFjn22@8>L**z;WR!VIIxZ|cYY%mPG>slW5|7J|ye+h5egs}x(^iWo z{Xr_L!L+%%&s2vzDKh~ZNvep%;PmiVduK4IACeHqd_`PLzISge)Z;^IS6j`bA!OpZ zi)k&QDYSbfjZbcu%4qd-seO@{wTcOIGe7~H`j(~8)P~3qqR`JuW;CT)s9^cU&Ujyz z_w9WFh9OO`#MRJsKZ@BsI!83M&Ns{Nc6DHRnWL`l)r>%A-!}huqJ0KX%@`?Ot|XT1h)`f!~StH~Ykc z3AD1|n517nLI=X)H(o9lPoRBqkps=-Rb9E=A3AYD;kSeIW*C!S?w>{u1ml%bJ=;35 zjSRXxb$0<=u(~?{`r&7UJ-8y%C%?V^ZR4Qe`**op#^P(OUL|iq)*BuEXGbWGZ_GE5 z4iUtw-gUVgP$&mHBq>o*&iA|a>f9v1}CN4yX?eK<@<%O0a-;Dvf67f;8e8$5UTQ{Yb;1(`F=&ol^ z%JqQARKUPHdDn$bxs=D$^$VFDx_vR#M2H_oSNhAk{<{aW^;R!Ec*z2RPfUM!IGodD z;3+Umos;!#rRQ@{vw?sKxBjk53HA9hDIJDQX9ZxdM5->H(1eFtt`LvS-t02o*0ThR zD-{1Pn_hLtx*Z;xz29A+9&>e(+dd5VIp0dD zm2hQ1_AoAN_2uGb5+BKbTV_y-<7*+l2)H_JoON(e3LPg;Ln1uN`uC=<$$sNQ(og>t z^f&P;G&@IiRJa8r`ukn1;KGIz0ZLf;w@VZ(n04%iOX73aqFh7=UUBr|XM_5_BwL2; znwefvdwJ{G|3<{J$9V@XRM7U$pURhh))Ac@kfI5k5|%e5F736YlW}e z3QXA2+0qGlnBdb9Oj*tu<9SR?W)niuRe~HX-%#~jKEF>WQQV}nYL-$HVPv7xQ{qw? zGN1&WUA8;!Cn?zGClu~M5x-b}>ht{oB(in~e>%d2+EG_r0TwASprIGHP-g@6F>>k) z0|;oUHEQCVNwf)b_2&RdQ;`{A5-23qFqW#@*ftpWS*bL8zikG!JjTulP+&^oJj5)B-cx6c{;>k z>Ek8W!>-6MlK=YC3(m3{xDbD(Q3<%UG;?zk`-z&BF)<>}>>L~B)KacS^41B-4iSCW z+-Y=GA$A_L*B@boRhWs3Mj6#r&X7O&@qC&eWI zB86Qi2$s;5SQVHmwNdM~jkB5+FJyu-+E6)q@2rzc0i0g;;Cu*$xFH|oLtPSc{!Kq@;55t-fK~_>8kF^g;gXbZmKG3PRct^ZuMO(%km}FgNo9HD#EEw9#wYah6nt1nk!P zIOq;#LT*Fk^IB&RsjfQ!7z0FqF%YRCKtPNig^C!(qU~YtmAV^aHqX z2lV6@-*5eA{Hl`Wp?%uUv3z?i+>%65`c(|XoNl7Gw2YX@_c2Z4li`I3vQ zKhA&2vY>20mvS_novoQlSmDn=omFS^w5?P&WGlXF8YvqyJ+ zzxCmLB6c6|FvhxWWyI>nr?*@P z+nJV=#*j{ti`4ISKsQ`uOiB4o*PwVgo6W~NTVs$`tx!43uc!UeR>%L{t&kxuH(1n7 zV}LO1*0}185@Tfr|HB+6Cl-jWC>BeD3Um5PhH5pRT_$6pD2iAi8Oy{70ZFW5DR+KP z|4Y1yW7HeNuM=2RPEYSgE*cpVrrLrgA-C79?M9blg>EIRx%|bBNn~R7_=Ht`ojD_u z&wxdF4Di$a`VVYukiq2GTgpnC1oPTl>_qANb-yyF^^lLfxiZmX>e@mz$5ziS!UuvajGryl`r!!=8rYy!S(^nU8!0rtm(aBToZ_AHJAC}tnWj!@ zk4boQQ7PFRUj245*=b7L^f@>2bK#vd;3>Dxy-~`aNkn`>?h$L=nEbSXO&$(T z4k-vxP6`p=!-N3k$wEbPq0z8hf4$vG=n2Z-S>JeQeCJ!}S35s=TnUTYSV?eWKluD0vfvw?%xZ9VccL<*m9f&LA~Qu~6=shWdikp1!lqnSD$Bmt4ZF#VWv6-}cAS!e2=azNiN}umhtaTex_8t-IQ5l*kT&&Yw?4BKi;@ zj?#L=<3nK)*tdxVlElDX1zw{%He>=A4dqYwje#PQR_1fHwyDn*#Y@=*!=kYA$_^PO zhlRyQ9ia3Qv{G0$(-8;@V2Wi-vHrG@eK#}pN=(Mt=)eLajx*QQ#dXz(?7gFz;JhTq zFOO_J@^3+MIZtk1>!RMuE|_9Gb;b!yu%%F7zaV>y*ch-e`?nIAFkFaF_(v)pDuMLP zD4$%CsXk(TUi)<4m6fp0rAz6n5P4eg%zv5OCzFu_6Z)Gkn1_@wOTcxsGXKhg3YTdyu z`@(#bW)VlsFK1YK!KU}z4?rXDknr5P%P*qZ5p@eM<|+~k)J}DTw;*$Zkdm5B_iN|w z-mq{WAQa~AwQNBfN`fQ4Q#Vc0i&Pb6t)%H`9cmj%@xz8mjeFnAlObs_fREJ87B@Wz64Q#Jjoeu)%{(`}hM0pNktP{rBPi=q#z@HF8th*ON)rrK!pj?=K(kq9 zG%e>!Z7!-xp&bC)dQKG_VkJu9aL^?M)PJ;8^Kl}zaidZI9okkkC2ADop9zd8m!Xa z!!VX38ZX{(vsL}gYI6Kjz|+|qjrSoCT+S|80IuJ_1-ENk`Mqjn4cB zPD%d~s?q6EJ8HZ;y(5%r(+lmV)60FchMOb7XE%kr^ zn&Ffm?F31KSU-EVdsMyy3iDmKlpS3C`vjM)yW~~a>}SbS*qX4^ScdJ z(-inf{;Vv5TeU!AVVB>t=4i#2pjvzMEZ;aPh2TS+9-kd z)&e7tgVzBPVR|^T<&kTTM)no=?qS}IjZXq)+@ z(-AzVk?x_vHb39Yef;^;%x}L5SF6~F6o1xZAucjWhv}pLFBL<(ihj~fO_^@8V|%4q zaqFGLH|1oO54R)oRw&u@fJjnBA`F%xar4I=T5>D33;d7u!M)t{`jtv6Tt8Vkb9N+0 zpZoS+FRH+*IuB-PM=6v)0O3jA6zNdL-xh>q=_1dT=n`gf7a~WaxoVw7TTqjW&$ z?Rt4n=AU19-RWw1K5s66^DNy4DK)4aZ=^djAuAXMqsch5K2>|fVs`HGR$Yo`)@5)Q zwz(a+80?t<0lh!LCgMO^mWSCA6%$%f8Rz9uk->EjWe?MppoACo1Qmz6 zIz=LeO{>$hg_AtuVu4~pv)f_SI}Vmj#!Rf6g$Z-nuc^m1vC{?vG(;N^dln!(4U1T( z7sC}UqlFA8`0B*hmG|Q}DB?ZrmYYwGn`X(Id~_sUh*=Ff`U%<~X}L>d{secka{u6w^$?71-jY%DE0p-TtKo z>=jguI42Er*&O^31sR*@GQTMyRWeO0H+1*lXxufl_#Hkuc70r-@( zH<-x5gFU67Ni={3D}VGcQKE(g=>ciqlYcP^x_bbiRZCH5A7UqOehKEucamD=&9cJM z2QpOpsq%)S5Ws!hR2}!ZdEo4=-f{%eV<`P5uz}?RA$B7kClpKQF~AnE1rhcYLvN7) zsPJ)N;%>`8%LX4h;AV9KG3U@zuEIs@rXzrSxU<0;Lo~Ufw0xwtH)ZzS-h}% z*`-1t8ddR5V(rDbu`BLuF+%GzIRVXNNN-6_Gb$Z1TSQxUrVsOr1-+4w&B6X0Y2-p9tflu#c+lnL zw4*LQvk`53(wGS&T}FUb-T}B!DeYfh1fKP1X2I2g2&8dlg)DL)i0_lk*^0S3LTdo9 z;aeaK$>OgPD5jdTn%PEFL4GsGENal=le0xwu{;VeeiIv%i5VM-iC_2{r66|pT6xnl z0zd|_<*5SjT0HG`q}07XqvFyYiGe0FZL3nJ9M#NVggp2!ho@yKphwI(TruWdbIYb8 zW7jySZ9gwIfB5XkYEVzAu3z~euD!sQmcMMUz`Jk>%qFNHYnou$bu|sj+j}PP6z-2x$RpM z`8ssK>Gw)m5ar{ITxTXKDc%M7LDL)h6Bb#{yD=8O_Mv!N}gEYc{LA5cP*y2g>7PYuk0j~IbCfY*G7pdNhH#bsV z2c&Xubfr?;oLQ?zr=z~MVhUv{u_R-S*`Sef^JTFe7e|IBG>F502U*Y(A``?>8a2t; zStGr99j4`Jc0No|QlGK z0FEeQEe8mzqhz}VxX@mnVfa?{-O`U@gEUF5D4u6rIaoG@$Zi)rmKBOY2qYch){+4S zsaX#O0qe}Ybs&Ft>8oMKXU0{}LDeGwJN+GYC#v*#a7{d0hU{F}T{iD#5S$?gF04-# zfW80vFcTvS6DR!kgQMm0-%PlEn&JMF?%|W=f5UXIWt5u{kkY>y#iCB1tyk|J$@|fS zfaf+Eg1TBpwfotp#WbTLl${2e4Z%(G&f)T$HN*IhtN6GY)xF%FwNM@;6u0BpUsyxT ziC#EmTcHyLye!T~1p+mhBm$|#l8vHpn92#Ni3XB3rBChe!>F<8>t`Jnzs;_5`eHAy zgLspamtG_@Ko;*+xm0sL;C1bS{h!2u-RFO68iQvyNbCD?*(5AQA_Y)$l0Huj=E#0Y zL=Do8zzucLsuHtjsA7j{Iap74lVr|tvNB32U6v{h zB1Vio{OOGqEAgeZKTLEmy#D8giE!Vnga_>7wSXlA%O>F#Y%Bg8n%Jsl)~t&o!s{$X zJqv?izhpwbgdfp~;CPfJ52af|H2ow?3C$Bpm%nQ&;GxYz7kP0OTi&z$u@W+{2m=A0 z%0e|t5+P1yuSxs3-CxJGfI&j|IbK5Uqu)_@j@Rg5@~YFPE_1u;QM*BVaOU}jf1gh- z1w|w;%thXYaHv?(0ayi>{LD0D@S;{mIZQdb@3+(eo${x;n<&Pt>HKqU z%ZpYAS8_xrjOEh^?Hr8X$DzQRG2=oexuz2ezdwgCSYn&jjMRpA8u&;h0m)hb#Q4u8 zE;p(q`n!*=<0)YiPW3O~da2gdCYOgrxx!C$L~vHJy<9YvnjTKbpYb=IUgK2fTg?QP z94IW2yUrjecagM8+-}^Qd!~P-*<9I4B(m31y}M7vT;x#=v28_(teb509MEPxkjB~x zt3u`$U-D;yJ~_*ARG1_+-uWbRE~gduhI^mv6g&y}u(?AKIBWT{P2)5;F%||3Q8E!I zNd8zFGg05mzKHG_-Yv# zqMLurrF7}FtyS~4v+W07;ZE7v^=1k>R8Q(QW|dXlt$)?6nmG5k+DVae@@jpVk1?SN zJUFiAc6wCKvJv8O%GPmpxYf>@tFrdf&3N88d2C}kGZ)R8V@ze7_Iqqs$TKr~!M95z zX&ynNplt2<29#t?=&L8LS$TT!LH_D%O))zpS48LFV2~O(ugg!xO6fS z{gls6!}2PZ$30qlU=f!l;aQy3P0~8Ml(CdyGsWlC3KNTR{+E(*YV(+DB zUfwANCQV?TeEKqsPxMg--IF^fq}ux>IVaaXVhU%u#C^igHBl_gthG~TE0^$nF0RQw zn?^P58-L_+P2b7QQrFN0Q&W8JHziMB^`$hLs15bmA&4lG8|DrgnR2}>>6r@ku5U}~ zQ->0b^U@@eJYOj|JT(98Sf3?K`&ueKRM}rayUcP>sK->3ylh=U(fZK8pELqG5_Jz zRbw(VSN5HpF0m5~MjFRBNa%TvzG{ zsti~PQ6WP*2dgT|`>bBO3r-BaHM0K<-G_4_4gj=IPP}Q~pE^RsdkplajttCNy+J#7 z$IpRMD-?STlUo{ic)^4`0&vsrU}$HME_XijlRMri!c4#psj@yf5$yPh+i$$CP*1$8 zJb3N^c05!eN#^WO*Ai_CNVkKt1AWFd9=Bvcc%Fr!jt%IHT!N?mwbdJS1sDT}SIS}1 zFWVFdFd#_8Mso~}0i;O4g1;Wnn;uF8)sWqbOxM0-ZNKg&!cH+Gn`gF*+N(b>N10PV z;5`LPMIigGrj|H$kPAgH{KJ4h73Gmt_0LtmSI5LcizRrWRCw~yQq(fWM$Jb!$r zkN9p7Q^ixK8E_K(MUg(?i$Tf=V9x&_V@BAN&L#x^Y=Rq% z#)Z6@5TC!-))B1?LqD-HQ^lrt3QbA1gzg@wP{0m#-TCQ}MhJ*Fsv?0GP(p2^t@CYV z<-nV;DaU@b!idP$XQ`afT*<8YU^5~A4Vvs-r<>3*LNEWc*gEJN+vD=RhU@l>ahcOQ z=0erza_WNO!=iGSQiaTiVs)=AmwHdw-J1vflcQ$Lndus#+*i#a#d8UE-I$9DXFfc+ zyl3oOF}qar7>#fNW9}?b7#RXA#M5$?#dbmfqL|I}dur#GC+)9X?5RUc>AD|(Y~EGO zsl?`Lvc;HrSt|W&SkNXuR)iLpD)LhJB=up2(ULIFVnIB_iF~2vRyZpfvp6u&indVa z@LeR5&SD>@&+N-qWRr{2gz%r0>PWLRY_20}uV@28=|@nZP{rwGUun@!Wn7|C7UnLUEX?5p+!=_ZRUU5D{DWG-0&fq7K+RXJwl`v4DKpJNP-cwIa156AF+A@6p zgO#OAN)e%z>9aL8;JqI{SmbVI=+u^qGL5kDm z(<3_h%Pzunzg&f~M7(tWP)Tu|OOMYrDc>(5DR0k2hghOD^sgW2`CPB_&1QhmP3wi- z&;18bjo1I1~C@lV3R83pASit4iUplzr~zUA`pE=z6G*xhLO}s#ODr#!Yw| zw)#2X2K}QrhgI_J(O7++gx=4%MCTCgE3oSAthH|Cgf#S{+5URSWuKoAnPGe~dT+KI zyxQSl`C@?}(z*QeT{qzGItg~(rQ_OPB21z|@7f=N{nQ=GxR_~h^0@l2YBUQCdIZRe z(T1GmpQ+{%k8AyL=*+zD*I}l^^-$*0{nE+X&DuXlOYi>fEfx%B1e8@jTl`>-zy}yz zHsw4%J7Uow6_+zTD-$OhhvJJvG(Q@|tb4_mLp|i)EU%LS{M)}m4&{IKgs;=Ur`zAteU%08-5uSh)&wSd`pcvg@hDiY2Z?gX{dC7F3^E->)Jlp@kopf%0o z26$VS`B$@}!$LaV@R(>R!dHiRjw1}cJ=ZKZ_TtHQp!n0Rk;BgLQRltX9 zxX%pd+xJSVOaTUou_8#`OwQJHvk(foW8Vk^d&|Y6?&Z5lbszI#t7foK*O*$qS)w+B zV>kqhe~WVblA2X5iPVI_d6V(8-v_X6|F8wHu6l2XS(NcL@j01}W>4B)cBd=<5-{3W zw0y=PU6w$MP|e#|NeY?@;3mYfTHysjVAWBURD@^;>QAxlHzZ}4BjM?JB3$LXPo;5&&48N znK+Gddcqv+T1U>i>)XrAzKADlayL~8{*c+7NIe{WD1?I8|F|Rv^ln9HQagpyMB3Lg z=veLhLDmUB>UucqGH=6CWmRj*bDX~(dnL0xulZeYsY#rdxn}QrmM5K=e5bQ|c;3KO zODae(gpXFPr2#3)$ja3#T@#ru6rIFslgH?2NOL6rP&TNd0#i$%>`xwntJUlW3v$|{ zTj~h70x_S4c0MoXPlq;KX0EUSx4Y4LTvKY;5W;d~3BnS<2oUa4A@kIQNOn3Kgnv#H z2nxSoMCy30jPa+qn?MrA-aV=81U^LHlsNf6;VsZV;jM^&!e0PHz~2BEDKhx4-W4ML z-%wY=-)muVk$?IdL;g?K2;PUNJtT_32yfeREU!@=ZY`~MG1o+KdDrFZ+@A^~hpA}O zDfAo-JstiZZEY#CIoyKw96~Gd?Phn3IJ&Vc_q zF7g8%?cwWFi!1G^OH+pLrSSOdq{-5sHaWW%da2clhz7#n5(Hz^Bx?#AXC&heXn`IY zpK5*5?TmuL9`F2ge4LE`*mn^qx1Mh9cTK4k0L8;X=vbJ5u zt(lSVo#1;h#wl@f5ly@9o(kNpIKAgP?q3Nk6hQ~}OwpYSoh2{V#M|F!Q)0ioT9s*n z&->)uwb+VfZ~fi6{+h>W^Dt+jG%>%E}ZrIkPmmD1o4b z5!`{RiU@`7!uTUsXO&23sH8GdV4%WAwC85!Wpeoqx>s>br6@u*9TW{*p%D8aL1Z5tK(HNZ zmJrR+y7t^t<;NJ^DCIV@iP*n~3tzO2E)mz!#vug!XPl^7sB=n0H>d2+m0A#3xJ}6v zzEcK(L|+8k{0Rj|+0~&3vSS%nonK^9?QYe9peg_!6#c-5AEEwa!AA}?tEM8+lo0T8 zp)+7SX^7_*O8ArsgxNqUO{Tk`IemkhS`{nQ* zGUj<}WBOFD`}}$e&F$R{;!-LtA$_X*kdx;=H0g}S!e+q}np3`@=acb9^Msr>*fM6% ztg;lAvKaY|HDo8BI&E5uD%0HL!zVmmP8Jw@JJ6QqMYga#NoE=Jmx^w308t!e168U) z%)R&1*A6Gforav5wmPpThncI|InmEpw;K-@Qn%J#*?K^wQt1rV9qTa*asUlTbPLG~ z5HTcAf$sOxfB{G@TG6f4%hw|h_&lxmHPg|E*kq{Hm5m{X^@{piUaeJ{rn{oeD5vxJZ4*Qly*yAv7i_p)Piad0pE39J5ce$vf+@u;2> zP(a>n>C+2C+jBVq+l5N3k^^(xZ)M_{+G@DX<81KFh>_(+JwGK$YA;>Wq+2`;a;U^f z(_(5QGOrB;mC5w6kpfp>{CiMG@`$U%dgEzOTvV>xoM;+Vc#>ny6Y@gci8Y^6)?Xu< zjLnQ;$pU}>DkUwxWvw6>iwEs6v|ra!uRn^M7?FzvvweNqtu4k3&{B|wltKc|BTzzw zP#PBYucSP@Ya#4P$k?&sKRWCJq)Fmn$2$>sfYC^$#ZH^1K&7e%URH8I_)?K6+26E1 zpKurz*ogkmaJU(*mIKp0p~AP9@{vC2kO}*fkNIP)U96K|h?gaS#pQD)#}r46&RO%? zQv%5PbIZ^knOFf=)5a=V0xKP?q{+Q-jyaf`bQY(>?%vU#42j7fbV)I zWQz==6k<l~7_lUL4!-T(QUd~q>`_3lY>-_DK_J`_9Jswi z4h68MGwJ0Qv5!#3F0}e@-&VD@8Z3DM{KnFwsSb#6lGm2i4LM-{R!%YOF>QHMDp(2R zLM2*4rgzH)jYhBl7;@#Bj@q7sA@`V4Z|UY45rXxYds6h@nsy5>^o5Hs8Hk z1*Ys0X#XxRN7Eg4%m~!5nvla11JHJITm&fg8H&8Oo^lT10bcT04wwxuW3J@Riqsw) z00$EN*xv68Ta3mLDO0QbB{VckSVA#$pOg4#*~`sZ20ZwksK0-@FrBN_BM*Lw; zU9kWHw~O*b4oe>vr9If>M={i)D2s;lSnT#ZKB5$SV z0c|S*OS(GNvWXFS0M5uL&!8Q;uenkd2KD3H!xQwN1LF8E`=C?pVUXcqXPgkD=UR*Q zeln_Ya`Vp5zb0f#{MUq>JBZYPaZ)$`WTUZ=_}@}`i-rCbOd_zkPP6^D%RwYoB%$eV7l3v0doAC%y`M6ip^dNu6Co!J#f*M4Q@sN@f zRiRgmy)&+~WX1FHwGnYojd}+~roGvRQ%I*VGf)7-)3lM>f8ghTzNz>0WaQgomt_G* z^qs0|y32agiGy##ze}-+R*p4ck^X0wLF}7z_^oyMfI8ZXID^Ov=M_-hzFPV$!3c%# z?DNB9qYQq523E5o86d?baVf^Er{HqY_PiNXRLPc#^QRW&$$ItIv9_zUz`lLgeg+yL zfuf5?>DQ3oPj7SvGM-Or0t_o#(^uIee_6jXrv85F|Em3+BL7Xz z`#Ncb&5)C4*}xb|h%GF0EYS>Kr86$-iwb zon=sEIqaqj-+HI!Z&H@jfz`VwuBgGG=yL)EJ zvr$w<~=LZjADZ+V9TCztrvBtRyGDU4+ejYYQOG z>&Q{h^dR>3wiGq*pXTK&_Q-|_QZ`O;!ir*|iR;0?>u@Gm+Ct=%^cdpg2|pU(r&yF) zLv-L!8xtLXur;y4Ii7i-ASg$L?fyWw4dsrr>({YZY+$g9of8SLl8M*YEb8-2?W;>X zx87ARMouby+N3(i9x{Y{`Xwhgyyv7H6K!;Ou9NN(j`ZxYVkkQ~vj!|Uc`J07dNMlG zU-VM^Ob5U2$Tdd%IfKfpv@CRo3qLNsq`NMCecUYO0x9feTP1ZbM(=o8QV#o_A8?Lt z7!4+c0b#>(1?vkBeI&DH6}HEK4M;b@Wy!SJI$~>+563R&9tS#5>tkBvR|!0cO%^WqAG@E9p42b4!lp8q>*37KiQjL~;E7 z*~w;4KH;Gi!`JzR7+J!k#R;8bkJi?F`1eioukECt(5yiPB^~|4J^SB(bL{QYA^%?X zFU~$XI(WV?$%4dFH=%0W96aIx_@4QGl8q~cq_i?Y$U}(VKusU+u-r_)waF}_ceS+$ z8%+I{%?R8^9G5X{N+>VMs}m|r5KhY+Fr@rA;u zsktnvc&z>Np~OIa88fFo6&+3+m(-|T$YdjEFy+$U+cs@brFL?;>M|kDdz*s9gY06u zRk!CSYAIp3<-J;}S;1f*Z|-bGKQho|L z!{$#0)EDjxx#-okx*d>IQ7s|_LgqpP^adu5%$hSihO!YTTA=4dMbs!!vr(N)0{H)9I-$)#L~B~48{s4#Urnx zr6(* z#*%(r63-ufO={f9Y+QEZ!Z9!e{w(&vKq(%`pU?S#FqkHe(nmzuDTmE`^~vs>Stu<; zod^d{nVXM$?q`JDuXD^^uo{``KB;4DBDC`ci=X`p4#31~RFMEbtE9&;Q5nmPtVomL z^Xl^p6x`|97x7%RD&}c}=JopR)hd-8HzETK<(=PeR}P8qL%D^{cQz}@ojNDJ2ME2{)MBFGZk17l6Po>ixE_OKI zoCcKu03gWQk+C*;#2x!X{2$okoRgEIlsF8q1ve`(PX$S@JJabfLWD=r-c~)5JdSJb zQNXcEv@!pzrotYR&|AJSH@+nxWq>-y3jST%@vG21Pqp*~UT$)(JZ%!zS($N}?TxiqR%9VfBvQni3izCL6O!rc!*9JT^wa07 zyF9<=+oUuWJBI-{3ipQ7xy=Ot z?Xt78^H6+b8&qb8JuHv$-giCm>a@bYyl1i2^l%3w-sy^K3U$H3F5OD%o^*UXrnvDr zbYv_f8al0CYpb^=PD-U+2TNalLU2QZPpVloH`%sIiU4YyCZR)YuRNWk^Htjpou=H- z1{R1PC)$e7K6*9bCz5%;9WHM{NV?tK9m%3&0A{EEHP#)$Xo`!QY2O72lvM+*vkv(F z?T_KlVCP2BcOw=rU0uc!598`)Ayl#0~QTP`SsO#Vm;SBsrAqj@LlM z=$^Am!Vd;7PIOQJuT}HnvWPW58?#y5XY1-<0z_r@3yp{wT01@C-LwOr92|e*3t;B3 z3Lrvj#r*z>tmu{ygP=3n&)pQj%sg$Go+QCCo)T|Ac5aNOejRnh?bJn^Bq8*=FMg^N z9oqWoS)Qx_PA`H^iA={+2do~xgv32aW?FfP6)sY^OGsuQNL_4Mc*A)_yd{Jb;Zx*A z#v*a~!)4+0*bV{`!c0jUF+zPcd&T9f2?rpqfcJ_Ozn|Z`+2n55eU?}VqEUGXP~^6KAo$pQiuH#V2rRpe7S*n&9&|F z`&(i1h+{OnHfV=vdy%1${85}c%-uO~Z{>q##-DONduF;AY(~E3XeFTnz0=Qv35;UK zv+N#CxBybpp?uIWxFO$z|K_kOt!gTqdNf>`z%+v>qC>7p!#tS|#hSy?sGk+n%F4KQ z;OQOj5V~1(*nifNIFR92U^v_h0F)|3Rg2r0uiv~cNu87i3k(&BD*fg|u1J-QcP;|3 z6T?x0;b5~fK3^f}{Wz*pO*7*ID)r|QOzMWDcV0jPcCRHv5<|sk0(V_kMeFI~pGO+t zZOm!N&@lM+h!~kV2$g?!|7;Aq$;9B}+^*i__Vj*JSt5zc&Qg{De#hV0W$Qj30`hL= z`C_8Ix^=SEl>_Y*9)N)ha^1wABKbzO@j+?%ys{|1PrML*I3ce&?A?h_I+Ly9g;o8j zvX+U?Rfu5q{{e)?Lz(;t&)!iQuI*GS*Dkw=mw(Iy+8>`5nk3`*C*UPHSV-8U zqpio-B-Dfp&%BH%5bZj{SSk;^icnE7nKz`-8Hgm!-SshR)Wwv&7Umr+$_}c8AVYhf zZJOTCFn!#^BR43si6!;I?MsW8A&FUrr!PB$g2;%F*0V_C9lfz^ZCR{%#@n{d4u->1 zA!?!nJW?a{Xeu*ONwR&FN)cOe1BPeG3jP<<9wSEQ2zNsuM2xXXkzJ&TT*~tcZfXtI z_1!*3rNg`Q%0&s^xGEQFo@zcsftM97f~eJIU(Q6jcZ-@aA8?9qObugC`c=n1U;=p5 zNF~gv>V&+q>XP19>t@FW11><#pDx(3M?)XIV4RQ!!_)Jlq4?CR6cM)D3O{AJk&7OQ z>g@f!WIYDdER(B*-1j&KP7*U#0|qq-8{g8`*%!Ad4j_ZC{Zzt_W>w zz!twiQ6N*`_lS;n4+@WOpM0RT#li%%Ew%l1E#>hvxv-!t;O!QwsA~Yq>vDG9CMOxb znCGy~ZO!}pG@pQf{q+BI{qOQ0uk=qV^}7rIpZEWluA6$Ulf%>%G$>ndp_5Ss8kuaP zJD*Dm%asb7LCzWv0h!5S*rSZRf*^1&EDOb~v|5c{lUa_7lQk<`3YTZ=`pU-2U3d4M zPu%ho5n~9<-{z4|uBO_n{)^+?uD8vAO(%x>60kkFj=x7E46}V};Y! zA4e4vs#CkJ5+yTt@`iAE3YF~%t|J0+5&V$B55Mp`MBU7uWPc^C!@U(4nJNXKHC#Mo zTwK=j{qt2WxPph$;wmc2cKP92JE-;IX6GfP#9mTFi3YgwZ}WQ?$SoZguECADO76F8{x!eMsW=2%31G!`jzsy2|fA(G(35f@&|`*hXY+Up^kJt3%+T( z%tyUjsv!P8#BVTw?*^)8ZecEeu%z7hjopsnXl3gwBr%)CXI0AAMPW&q6ncA9jzNa{p$F3a=aW zS!DgwsmQm*BVA3u7I)q0ljdpSo+!h3N$b|<@4_*v16-Y{d0R}K@D3s9ghL&9NO;$6 zEJ<%&_D9E#6BL~E0b1Sj!1B z_iB!TZG7)g4LKoTvL5xVl9qe0po`@|HU;n;J9QI>3f5z8{^tt!qrJSvhsnX9F-;)R zRVpUd@Z!PoHXa;LAK4=~UF57^3rK_97Edz(p(R${NqCd8zSP#1T3$$l_J@RGU*tQ; zg1v!m6`?;gahR%!Ac>Zc3Y9~OD&>1VLZP7WHHOckZ@oU=6pt2B9yVunn9uz2c<0C6 zw>*uflG}FM3w}a>FY%1*gg+js5;dAvx zg#pJ%E8|FHP&#S-58@805=is|4MW@z%YnWhoos|Hjaf!tzg!n#WEiXA)7dnWXVx-j zrE;eg?y%wxJI$;4mt_db8#kI6!INq_y;1RF=%vVH)) zB#al9XF@g^^r+Jw$pqPQldN?4k+?8;k;bSr)j8h0lGE42bj--Ei_aXm&_{nSEzKT9 zff#VLxNDv!rcLE>cS}jh^d1}v74&IoR6lUf17m|eiEJHH$BD|Ev5Xc+4T(AYng2+c2;;LT6W(<2rJfVapW!b0+WFYt zeo)p_k@~i%x|?YLQF|%8kt^f@j*!sQT6<4$fD?cCs-Kh0@jdR=>*AeM)}C@8w6ehprc==g0fkY9r@FzraH)!a*6_NlYd3y} z0gn6Kp6ZIf13ID3gYc?J3B#!v0~@FFP07m%5h5e;@XQP0kSq-icefrl0;FaHRIX97 zqCaS-W}Y5CcDRJ`?ER`)vzU#Ic*i^>9AV#50?suUbJqoCMSlr?Z-?;}hGeumqDQk_ zZ}&YcB)JEtkHtd@BjWYD{&CFj=>E8bX)mF1z15e=i$@1Ivb&pXzAJ$_tLu5U(4n(nSW2UP)|fg=vo74iens4fI4tx{H-LI~PY0}M>2 z8HrPY*)f6h`L(S|_jbZSZl(TmR;x}94$t*58x-N60)JEB805X6P>9sO*>Kz-7WXsqM-MDjH)(#0QT_6}YM9tbgqdIN0jdm&YwS77d6uCop3iW=-?|lUp|o z+C18GI~oMeDS&5O$@`~6p5dq_i%x#A!-E3pd(v4?JnF_cVScj_UOIV#3I*4mj`FIg z)jstNH(;-$&HwI;E@O-(Tmb_?va5}dLu6XCVp^9ATsfF&XbIh-*e9_AB9eULE+5ODT^Tm-$9 zF`;h+?p=4m(nAW+;F$P?iNb2H5 zzs5!LK$jlhya(=%5|AqGBV{o%@p+>;0j$h@gECKjLJj0+MDx<)eN`@(XGwRFKAh6R zuvD1np#*IG4fymQO$qHT_8O?*$yVk;E2M z(J6-wk1mpUqs*#Ee&XR_^LeezRt_Fo=gMFZ1wLB_I>{V*G^vR1*}ErNpQK6zl|MmnjzuDs4}O*#U9 z;do{iJF_YioR&*6lW=Q<)yIV|b&c?{nG7W`9mJ<{J}Ioh2Q<=KLQ zD=Vfx)kXHjQ!aTTAcUV~uktoC7udU&Mp|xC-_@sy29D6GB;Z?rtUi;fv02#7L|`RU zft-o}YgyE{z0iFsxs|nP3!KdNH(>P^Y&8KWZk4p~uzq|u;K!zI`l2EvxU=UO14YmD zQVa$uGR%-X-_5JIJDQ80=yeb$fj{a?J1*0D=rPt{?zzH-`)cdNKBQ_gD-J$LTBLKEs{c;W-6 z2oZhm_&1Hr32r+%aX7oAZM8>Rir}F?%0yR`*2{TE*8zv0-KN+R_dWc3`E0zajCd43 zyFe@XW5)8PUU)D$cEqfG11&TqNVCTsw=U#d2^GJi;Y3Aii7A1M2?!B9ClpQqG8VJ- ze=u>5fOuo?gS!6R(x()6fsP62QM_Wai6yT82RUc#i4FJ*h$sJlh;;wc{`SA3=-!mU z-99hJFI{QpyK}Qm%UnD^_n!M#^>FoUwZFO!3ECf=tqc0R+#BSd#zjrb5wGUW+ReLP zJYSsU`7QEc73}7d9ciNmbrA@lac0JKq|z9Kloi(&T14CJ zm1<-_&@P3?W;UXG(z4$%wR%OxShk7Mv1X||_gSGCO-}1Q#PC)V&|`_BSj0n7n*UEh zLfpE)@j2mcnA$rVM6GYbr|V9r8`L{+9Wb37{ZfRAS5J@yG+|-JHL4V_bvn&Ta^G=C z+IW$g`r`JL@c?MHKEEpg-*q$Fl?R`9k+btdaRMK*{ww~`5$bbVIlb$qfk9dHVu zvXwwxukD?|R(!w+{E1$1A^Jgciet}{mgc<}L$Mad$LB}^$!}Vi@b}akOACFnoq`T7 z5cEvQW;X6bw)Z?NY=ei<_gy%c+9%^3U3=*{-K+4mTeQ-IqWcm3ew-D_^DISdI|>HI z3QvSj#wrUi39ZCL_0m@J{*FT4km+4OkX8kw1oNS_3bK-Fc${;YV~f%hW(CTf^5(mL z(pf&bI*%WZTuZsz(6_cthD^^Rj%JbxXJL#(52IgF3nkYX_&l`MrA3qL+R6oNRU`cw z<+7J5R)xGJwpufQ5^+1vTbloo*iXsEL>#eJ1isCVH~pIuaX}iAOli|5EnHLss84U2 zj4o`b(fWsCvnj%l>p;HxkJb!dkF1ALa$06>{hL^WUga?uxVbq#%zprodlEgoEk3}Y zKRi|_g#5%Y*H76X(OCg4ejO|i4Zzo@rg?psb=o(lV3`w}^zK6k-=s*HRz;cHgzAoXScSPP6#i9E0Y`Y1+!;=Jwk6SK=*|_QK#5 z0q%A>ofPtW$TUCp2V?9X&IBzJJY=MuQOiS-s)L}?U40xZUaAj-ystW5tq9YiX(_3E z-A@|f{A*7rLoe9lBq{EFY{nh)hoTU}|r;p=NXB=a$(4cz`1lK-R`Nea98ErGN7mEUV7E9Gv->1{E zGZK}}ye?~zvXgzH8kfuaOd*n$E*^FORS3#0-6sI@)nJ_|$-EjKc8 zzddK{oHUnivq7ubJPPdfaj;&D$JwE$>neAb1w=KkxZXqDKE*)H zr_S)iNkZ{)351-?X1hE*rd8TGL4CRQA4tBcnbHVp0&OnNh|#;PsJ?5Vd@m{@hTlyGZG8oQy$>Oy&TYdnB{}A^47;lY5J-`fTLubL zDvcL5^^J}KD;we{{`z`Nhekt);oY-9o#4uyrsY3L7D(;bSPmmc?gu4=2Wf+v~QreF?-P0lmWfv_VR!{ zJkD!|=^?TuWDvJzl)T|q%wr^!S)U0cEBVG`dY{Jrj~Q#h?zA{na)6YnunQcib;^}3 zR>VKU(29ro>1R6#$c%d8h@dQRS^1Rr(@2SbYoMU}H@68YG;8|eQ;OTVEloBcLO#Kp zLKL9AE1k#PwSRvzGV;TI80I(NiJ?l}iVW3Fgn$UgpkV0XI=n%fJ6$bIUN?@@RuY;# z8I?C#x*uGo$HHD}{hc@r1m$ISEMDEJmY3^g^er)AsV+}2gXw);?Bp-Au<^B}ak~7o zDrAAVpA!5lDKT;8FPLTGYpDwxlu!wfq=oZuI?N!PSfr68N4dIcvr?8 zc5^~lfYnV}J8^-CAL21Fjh<0?$;x-@`U@PT4%}(wi?w)g^POS?Z`b3`4yGFQ#E3=r z6rP>sUsTX1>HjHlj%AnLq4^^vGB-jErTGZX-x7 z`1S>8&WU>|Pm)(ifj7<}zSZE8VnxE^&75xuBi0at9R~VEi;0TW&k+x12-AoUBlsjo zVF{$x|DvkKvMHyJAVGv*Z$z%31~VZmv=+hvJGIH?ugU25N=M#*)nQkG-7AzfWy=Yn z`f`B{m~J4ks= zw*k1fnW>PqXffr(*?IVPp5!b?fc}$k`T1Eh;J^K@qW&f6)pDGst`PIOEn|S(hdiHF zqo$DRl@-x2s8H}!E;OUJ%dWzBa@XAg%54W6{rqIpVYon-7uq)8}K)u z^onvIgJ{Hf0t=3%VUt?|IiIuAMkGt&yniU|u6DS5OjhdvWx?;fE0XpIsw=;&sV!?_ z$Ig$9FRY}-V~TAV;|Rh|B`2Imtke|JW5f?A=n^#;qy%QaKNKMm-*V7=i?a7p;dc37 zN4QuIA%nkfN8;D2{M&e|H4fB40Z>r#EOn>yjAvy0$?3j5SE3|AeJRWPsH4P=UHb*t z=W|y14Vv)YDGNj{sQd=q;)P}~ZW|^+_w)THZG6Ze2wW^ZuD^WuWr#3(=)AdjlFuT` zMZJg%aMIHpF?8I)rAj|Kl%C@x!cQ>6!N0$hzO=it`T#xquNP}Ob|P2U!6FVCFmTL< zz0^O-TXTvx!{pFoEgR@7cF+fYb^p=IzUC8o!Nx*I zo|P_pj+;P^P-8fO*Fs>VgW!`cMWE!nZp)u83#XEsxP+wWRO_!$ph-A7s>mpW;!B>R3%#`F4`p-M zWu=pyxTJ*>48(*dZ2O3CGz)h%BC0o;rfYjA9T8Hx$8)Bh@Eo??TI+^G&{;Z7WV!14 z+7C{oS_V=BJXl~lE}fJeEb93i&X1`sh~iR*?Q*BK&-hziyEhw@eYX>?q90XXqi}aW zef3FL&*;=}SpbcawDUO}yC`x+bsE|-84igqoA3!Yg=F9%0uJFq8-yARb#>H;evdii zed_ApCGabcjRmD=S(GLVqlu$BMT5*Rwb zs*~Q?q}z z3UEtDVCB}r45uQ|N8S~`&nWcI( zbY8xmj3;;=54CmuWi9-FRh@NM8%@~maR^W(xD^P&DekT%NNI73TcO1rTHFaz2wvRX z-CawO;$Ga{t#IXoCzP`Z1RyR&=cnSJixEq_tc`EWYU{`|5c^TgmoZUhbL zB&@r(3mo#8cBNi6FkM;rQS5dTJ9h8wHX6#f&l$E^nHxnJ8~$T<(W+i?>f^Hl#gBqK zQu0e&hI~msW+vv?u;S6__>UFW;*U0sVzy6yTsjZx8OI{G4$IE&<153?x1!?`cLghD zsspP4ZIh&$qKt|L>GHXG8c|7AByn8o=-b$*6-D+RQvjsMtFMF4q4p!PCNG;aae?v9 zS#w+7P^qmL(qYF$re4zjYpFK^l+ihSI zIzsMu&E*G@nqWG5IHU)UKnmz*58`M?=`wP_SO!^Z8V9L>MB7NRC)oKxiYR_}W6A01 z<mZ3e(A6zzqx#CKn5>Wz3awWe}~ zfzU6D@HLxxCgyA_>+@$pXU?<)Mk$RS-&pzYAyo(8dhs4u*%WR1J*AW@<`vf)7qZ3W zP#YY0HRiV8Ki*h&$WsJdZ{NV!cIvVO_cHc^kA3fgGL}<>VB5mO*jJIe)d(#Wo8l+@zyf9g3Ye?l^hA z6Yu@vCXR0+0eIjtcFEMx)+P&*dt>DvDvWfGu@Dme)+d6fMsC2**r8?KSQq)9ZZp56on`NK!uaCDwN?ZKgTyh35o;66L!k6rW@?Tfy9nBV48}N z^l&@v&WCbR?-j;GS-FZZT}{#3uhU8n&VxxJYmTx)5C) z%EUL{Ec2|e3RC}3BR02Wd-lOWrf|?Wy6|&WT`U$l>1yQDr`;37dX^kczZY7dMamX> zfvFXPnvWmVv^Z~0S4=pPl0q*M4~XNz?_i+x!TNC>Ibw9x^JW2^L(~b4nA@|Hr`BS& zZ1Yjny8NpIigm`*U)Bl!X;Yd=(;c|j?MtWoc5H(PE~kHp|6@jg@g9o77kohS^+mq( zz$CjMRmSFD314xfF~9xy;w|JICg+HHYgiVsYUmu^ycF zMK&RgZ!cpcDwqpZ7J?LN9I1ZK1v65Q*L3J@jXs6W$3NxLn@{2)I?YL)qHRhBXdvKJ z`!ANL!G^kV;o%hLUCzXxG#buC#Ah!GaAtQ3TqLvof+;G&y1tvlo;IrE`a7=W=TIVg47O;30z;fqjn>PvKwEfwO)3G?@>%3et$Q z2a)Cs;#)+VEHXb_eVitVyG281q+nPn1xYwW!;8eBIwU{@5q(Qgp*FE zOXG;S6SQX}J4$I3;oP-2z2PL)t4FAi^cniRZyR^(+ZU+;pO9MWG^lB?7b9{w>!K@- z&YPHFMTD}lv4}oaZy5TZT$dQeZwR3K6?(FuIRhKobkt@tE%d0G&Un=bh-BK=pa!uN zw^1P+TM@qE+l|9lyEe-6Otu>_!__EWj}fGt6TDG|}&=SdbW;zhSYw?-vbr@vYFfn1kOm9(7rN-;T zbs`~Ow#8#5fK}_OT6s|IOfyI1asL0u$n7eFJMJGW{IL;-WNVz?P%xoq^ZHGHX%_k| zloN$q7lwjAUJ&M%pdpyPo$ocyg*YN6{CIArqPlz$pDP@?LfB~o!yg{ zyoAf*5t3IzwoidLQT{Qr^|U!Px2T}si`Q&0UlaPug$9pPdgEO7?;1Ks>v5Pbn z@V~UZk((qED5Q+dR`)&F&Z}x&MDIK>X6xD8QyN_7{oV|9A9*=b$Xg#-=SKlIaahn0 zNC!D9nSe6@+0T=9HFyNhI^dgOo9Cw8QIB>QC{Gmt#;B|rv3+dg@24%vD=Fk;NjuKR zw4s=u`jyKkiUrFe6s3B;spwPkV|&!gIL11+Qv~;2EZ`xHrFi@F0ndZe3CP$O2>oZ34JY5wG_|FVX!58%>&1=1M*Gqzw7@@ai&~IOkyY`>~@3n z3Q-W)!5~%UKNC%TD_UbAO&zfsl1etRij0icd!ZuV>ruBq&M18fN^nU50{FG%c_TR@ zp|X1W+%P7_g@fkYb4^l(Cxkk>WY>OT*jZ}ZHRA;N{|LH-j2duYOl=tfpwBV`G_s(q zS7+CyQk?Yh%s~CQVj?}3BY<9%CXePZGnHxZT zz@p=}GtFi88;lia|GVSi0P2?>lITYgZ(x|zdQZ& z_@vOvH5}>LD0;RPv^48^1B4s22@IeI&sl;=MX`gTtGBMS#G)#!AA41W_mYXjWOb^a z8^&>BZKgU?s<0X}-v|om49Mn`yZnH#2@YR051K@7heLw-0~Ve76+h~bhGZt_G}Ddf zcsXs)AK&Nyvl7T|;P(AU5R~17u~Dq+P(+W4jTwW)bSa)3OY=I*{O`{XGg8bJq|8WJ z@HHhqB=HaaUS^1Ws362JdFHaX)OdD7PfA>&LZ{%b(s@ml2&-FV>8EOCPTDl;x2s{;)9;`zh2G$0g-?UH>ba_0 z$iu1oV_(ZotF-<&i@t!K1>Gos)#dkuCj&XopYAHzs4}fg-SvEAK>6ptlJPC9fxuDVmaK5c?teu| zM?(Me)BmG@|HlDmK>VNha1>zgXSOt(43SOp`k#V|#hN3>QH<8*35bSE3tqy$SCBLw zx{>{#`u2x2>pzP9Dfl#5(ke4`JDk1GP1`-rB-2KDwK)W|lkEFG3S1g*&nEO^c|-F} zv#<9mLiYQr7X(Yw^GXb@W|2rJGgJRo{a)OEN;`5yhm*=p+kAZNUlvdHN*7MrspOei zHvWE78c9Mle0-F@wW+7v{RL@hba{t0`+`+}UrGJvb2h(DF3vlWPMdbE!fRoU>+7Y$ z>wPuaF|Co!nWYu?artDEcHI*@aSD@=%%y*V!KdDe`#Ug^oa57^CA zCdp7`g&}H`u()7J7LXzUN|yM+T8q`0bxvZ@a`k3l6?uT0cxPp7+50x#(`SY5)bm8F zWEkQRG9U63ebr(;N;dBVLf?2F$VWOLe+tuZRaC#Y{Bt4#V;uKvc~$svDeaLS9@N73 z^d#wx;UjYy%{}1?Bu7E6Pfye&z)B~D@chn=X|+Dwf%+@BJRwqGo~|T*=c)xixbMhJ z;6$J$X6O@e8H>*n^3oI|sP|}hFlllr={x>qmVejhzm^qt8)Hb@CGIw@x!Qq<s z-}0syT7L*WYndJ|t-LxwNpvtD!!E~pdNVlXruSz&*WK&FguPAq&7gGfvFo`NW2>%L zI{s#P)ZLD59r=zSbd=iLrx#&uQb~uGn7bD5s;8H($Z=?OT-ny3oJ21cifUs9Fdb!R z_n^S>XihYVHDt@pmMJ|{vsQ9(R7pl;zTm>``P-4oFu1Zb7%I1YbvyMaTG`>dP)H#Q z&)6p(10<=>+|@P4vHSLc(Za24sR^t5MVF;e`9Czc9xRbD`K(`DUgukhe_I~wjYgHD zv20%*4rL_gz+>-|wh@jFKn(P6@aUfDwzGSO#Z)a+-^N6_d**3AZ^)(ameS46U`;Az zPaN~tS59TwNeQuTY~r3@V1J~x8pm*wqox>+#ke}8QX-(|eO--_yAAOSv5|dir20k; z&J5MDc04a+pjWvjJfo}JMY&Iu@(fUIkly)@z73qm|AXK+jrDJ(x_w)9Q)!r=11~7< zk*WKMjlAbm9@od}7@8>@#U%(G4(WhoY6Ve3_)}T7K`Pkw#Iv6|G`!ej#7fJD?DIFm zL}Shu1}1vn54yXHJ5M(MupHsLWo3-V8s$?}W-#gWM~JzaYFB6$V(AZsM(gBg-Zyhy z+<_ei*U7%sKefHQeo2)Ai=+`goS}<$;U`PAY^U#4onvD3(1N^QvQQAXVl>wJ8wl0{ zy2vtHb!ia`$9Z;0Yktp1VRnW8%-d%siN1VhMyT_f&Wc%Vfjc}`%uITJc<5jKTfd_T zN6Ed=$d%5}l*^S7bFnZHOSR(#`kTJ%H#O6%%0hjSBq|FF_c%_Vo~0J$q`Wz5rJPLd zW5+fb@9=c9uq+(Dgwv6|3E}ci14fJMlch)D_1by?4RsGMhnEos2}~)ZvdT`k?Wd{T zn4m1-iY)S3f0xbf>G z$;7-|77@RI)d^4I+#T!=liaY+iwps2n{UP_GQFNUo~d6ib9I)|4gBeFrTWr`d1d5r zQ+yI+09sE&udegb4f2lIGlml6aGJsw|LCroH3Ygj7Q?otUAnM0WFKtTF50c_{iUh`^gYh1b1Z+j4poBp zm8qo?$z@F^H&Y)g`56>aq&>d$Iup}Vh|RigKFE{bK~0oS5pA!4mdt4S?M}_V<&>pI zvg(Tc7TcFH>96(vPUsM$;tl%M4@yc>FJK*EvCT5@-e@$6AZg)1WqqSs!-C%DFY%D_ z7D5WuVY(W|-{47F5Dxdgb7=x_l(WbC*j8rSfa!KMvhzwi@1G4v9%yi^!r7V(u^Z#E z=3>ywxr{&$R2rzAw$E9Gx!3k0)%l zDR8VpJhLU_c~>@v*_MTE(p#e~`<0#seW3iGYZP>p`e-N$Y|RB-az9w3qOE|C0Xfz? zh@z_x_;J#Bq2y^e_sQCf)(d%uoz=PPs+xUIi*Y%wk7X@(7&-m7Q$}|LYe%b|&C{e3shkc0AiY|#h z`u9ZXYoaeufMC4$7l)m{{2ZJx^!5G*0vZXR4`H`zymGES{APW={;qi%ORBHcDF##W z7MMLtI%R)oZN_DGblL+BW86MZ-~p69_Q}kR6~F_Etm3VU?x@4Ye+D}#8wdbtokQ93 zb0_eWy8q6F!59!mn#3@TrhR_Do(h#0|$`pcvQCW-??aH9(O9N)S&#)esy zsAZ{!W0;sDi_Tdt&rk8gO`zA7zn&@K=POuPCFO@aqjD^iSpzdo%$eI?1#98XErrT> zw#%B!^-5slj!(%8Fb0T2o5rH71|}>7LVA?o1mpNv)2!OU_I+@$D&i!Fkp{@n{pU!d zs{U+V8jKPLwS48DeaTDheko;ku8 zUIo02_pXM(CJOO&b`Bzp6D`)k5V5!yR+A8=P`oe!aLY3fvo$ktkB+8%$28ALF@$xu zGtkgoJUv$saUR8<{~=3RMG2JfGJq-Q0GM7=dNmzvQj%O}Fj+1FRKfLl|HD(gJP&gS zVN3pj`gvB1Py9oa1;ls>e=&U^2oi)#L+doj-6~w~UXK44!vtB`TSntxvv%%7eOo2h zm~*nO9A5>JUc5=52++=kb;@0)WrUwBt%WZxbolP$AkIN+{M9@aF?a8Yrz!;^MlR%? z%5l@S3!^9^3~$x@anI6oBM;-7(?$U3L4Z7fhb+RT%4u4 zej(WC<4@3@20(Yd_v!2>Lyjry8qg6f!a=ua{CqfRr%Bq;s`!+iQl zAO%kRg!@`uTV2Qe@80{SHsb--kN#&a0d1`vo0{2Qtp<}4co06&Um&8cSxADP?DeY& z*t1P-fv~l4Qb9BX^22b=`+-ztJsr$D9)^k!Ws`d)8bzjm2UM`~ShtD&d-f$n?-0P4 znddXY^-N~GUDkg~WK(3&#Omofep-}~FOJ;O;X}7TK+Htt*O>@Ykf92`*p^5{#U5uB zaLt^K;sY1vZRDXj&8o^phZ4AGq-Qil)-@1iijyIc2EnxuiK-;Q$p()zwcEu*tk>%? zu93TQqp>?^_WKSWvGnc-dx0L4^M@TKGd!pTw#4*0@xc0Mgh@`z1}No{2D3!4S;de7aLGFh7@LQ`X5E<6Bp9w| zKD~yUkh+U8i=~q>q{sRn^8Os%&@I%nhVp(LS@Gqd=ywKXS6F81lhGowSCt0e9T%}+I*Z!Tz$hr(Xcl@Kbh$oQ7Py62j_S!{#z*n_VYu_@2{KHE>7f(-=?miA42F3 zH-Lcl#lEQi*ST&!gYR0tb+;&&8OS(-Xi3PwkfnO9^INFNGXg@ms^12C*6yOuC}>Ba zVqO zsp6+V?u6vdVi`k$GZssL2E^0LkR8L53&+--ygZsbu zw|`)9U1s2aBclI}hx@Mv4${BuIE4RQ+g#Ct8f~t%GsBLvh zD23wa#3mv5%sDwnIC5A=Hzn4`ZTzEwm-o4T?TOW|5pD&YpLkD44OXo&&sSXbNyFpH ztE5AF6}DbmDkg5irY5a&5|-HTpAGe8TJpWVz4_)1a%rut%osZPkr=;}PD=4`Zv-Al_C~t8vMU`u!Ha#P}l_r3l+EvGE(r<(tW;5%gxgd_}`A5*)Qu14;$x z!*Q>cQk7~@;JJBuTXPAG^?d%BxryHua5);&-SmF8lnA{7Q58vkx6~vxMCrMY5+>(uX*t)Aeo+%nyAc z#mqn3y*;qTDEyrl_pwhU*1!ZSlk)iJ^A=u!iV1dlSxg$aJ0q|+P<0IXH4P=+sljB9 zj|!EurJL7SieM;1CA(l^MzGqf5GPpG^;4Pwjf@0w2oCz53w7XIXb`t06^naVv6u{r zCP%>#O4VvRNuyf2FcnZPh#8}6k0tw=_Dh-S7Uk=@ zp=wmUic;SeE2NdRUr#<2gCht3`t?ot6udCQns^CO3uHKbLH3p>$hN^gcR`o5yS+yu zE`C9XA%DO{W9WFxq)~yZMS<)(yOsbS z4`b#8Z&nFMZZcYJcThJTt1+@4(2#6wd5X}BU4$y)Ccv8Y@+02(3@dVAwfI?7VsQ3b zr7D049^mPC7~e!5kJh8Kl{Kh4IqE9{i6lhj}5d+%wt0V4VoX?@NxQ!9GzV6YTxSg5*IBY9gY&IR~ET#!Vm+0BPjc>JKW)- zTRi09GFo2WQj%Y1Px&i$AQ>zy)YtPaH^LDb6#|$bJ!Y~)3UQPCbH^TL3o;px#B)MN zbJ<~9U90<`sCIvybn5LW-u_IRib|VjI78Zsfxin-A($hmG>*p0u?7QgPrb5#Ckf!T zpdAi2!`*hKJR*^MBR=Sh89er=1eljpgN*x-E#zEhSCZ_A4N`+M`hMMyi~p6^PyAPt z=FB?y55e1fUtd2@0z0pEl2Br}EA)?<#Og$dh9eiABZCex4|Ep@=zOSf*7H)pMvb>2 z?DjI2cb=&uCD?-JeNPTlY3qceuw)VY4@iTr7`dU9f`NUQYD7Wg6Z| z>(Wr*TBv1_*ts-51pR*DeLmwBG#*QU#&_zaBYqY2Vk_z^6ARq0a#iR>`oJnH* z5{y4zD0>gwts=xpT!D_R%6*QtYlqtnLxT{@Eq02uGG6gC@jFA>R+v)o0k}7xIgV|U z=-jDxC-kr}VZN8wqgv%hwt2zW*>^%8& zurt*6E{+^y{D9cv0YxXgmGE=ds*~Ei1L^l>7SBg4wz_b|Uo5>m?Kn3KVPq|ET&3dP z`ceujEt|Z1+}X(=0g=Y83$qA+XX2LQx`9tgAYWFU*j>$mi73d|sb6BUaBkUD<{ZN03?&SDuXw&zD zgOs0Xkk6D@=Q3c(c&mMoo_Ve};aMNWN0_P_mPTgWVCPGl40wZeQV5s|Mqgi)3Jt>~ ziLn#z&Ef(5*5gVXt!S7tlRQ}1C}pTZU02EcmBW7&+TAQ`s&8s6OyvE8?=3mVB(Rjm zu;^9}6*?o#uXdcEA>*p!D^Q|Zro4V~Jbw8F6b4)pJ`-w#Q;D1X7haYAGQ0IrMmg_p_@DiAjT1(Q1#fN53)$ zXJLkUZ9#cSO$}U%1=sI0MOmA(=DtO%pmM>ev^w&X$i63oo%zgUP*(NP>1=6o&FXn~ zwBKrSZK!0HH316nH)BUKgS$ULgWMzbIk?L;s;*I?LAIEiO{DolONrmlFW=!O@_@QC z7s?I`rXb*=OY%T{#%XjAmuAa*FgciHS_glUFV#ae^*TjPo8%j##egLV+~?=tnFNO4 zsn4O3r9%D_zSQf9MS4T;1*`HqkQme*pW)_3E|v0rv9q^H!@tHLOh<|I^?~b)j=%8V zkYyG-pv54%5$Y6v`-!+M8Fvtje;tZ7%DdOMHknx!aaH(@beq0Pq=)AKNfX(Y^<5(J zxqaKSE>yNkL$R}t;>{gMQxt#Z|k6JiYX$ zm=3h}fCIzJ6edmFC5n!+1h<%rZkBeV%^|~*SymWqDBu*TiCP=b9$s zl7(It3SW-f|C?&f36^OPu=SFza{wpr3uEe15phg4H5-cSo93JUfZQz30?cig+K)cM)%@G~#$3;D8b z2f4)GrPcNmUb`Sf(hh&SSWQ-)urDf_?TEjno36+I(CeX&xk<&PF{V}vbzOLg`cn~$! zr~TQ+*-Fmg;y4y>FEWX1g@Qoay&ETEN*WwPx_if|vV)CKXqgp*AsrxNt8IPcO>Wq} z(kBGiq@*4gPD5O~s*^T#nuiy|f!->kOSD`B@K~*Df))mVGra}n3~0|a|KWN6Q+yXs z%Y_dwbs#qk?b{-y#fL9mBc#O(NSnGn#ds{vY0vZv%EJ#aMf7{I?MG7pv#Ef$t%uhK zR%wCougCYBcB~qbsKf|rLCUmeMlHC;q~PJbxuMOYXm} z6M(-1fyaqO&thyqy|UR-*3(V+h&H{V3v?<5bHvg!CW^vxgJi$Dt2gLsU7tLBF&a$$ z4If-C5bOF|<42Ct8%1&LK1XQ~p>)pYly^UU;AP<*@ybTOz72=cp#h-JZ(#2TQQ`K* z71TXfn?#OPF{BOjF7N~KKTWd2No^rPUwYv^-XTtDTbSWO=ibmVA|R~Q()X2ylhS}O z?f*=VYpBA(=tffo3-Wv{rhYL@_6`S`1aLk)0%k#WG9LQs^t1%{2DjD7C!;@j)6Riq zH>d^Pgs=4jd#z2(yLYtQCNEXT*w7U>=dX^$Bq8=t8%zWQ!VQzNaE!}{+Le_S9`Njl zm7^;e$v0aEmd2nixE(QKV786g{Imw%k1G9NA~GORiP`g0K|OeDCv5_c{`#MLRPl%zxpi&pCEj_9vUa#WK` z%POab4{++h03U{cj|4-;N4?jPr3I&GjaYfME=bsHzA=m2-Qp^y$cXhGa$7LFSxRky z_abt%TIG9wrI?;y|8PlhBBQ&z`|0I*o&AQXJn>SR?FpI1Ga?hb99ILMe+UY4DzX*Q H#sU8a7YQQi literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/logs__ready.png b/lp-app/lp-studio-web/story-images/logs__ready.png new file mode 100644 index 0000000000000000000000000000000000000000..182425606ecaaf2d3c0544d122732a4e4727ab36 GIT binary patch literal 12281 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 akCo4l^erapF$sYvKvqf-QX%m<;Qs^tbKWBW literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/project__long-content.png b/lp-app/lp-studio-web/story-images/project__long-content.png new file mode 100644 index 0000000000000000000000000000000000000000..394c0d3b914d32adb46ad1cee10d2c09cab42ab8 GIT binary patch literal 18103 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%0D)b0YhD}{T7mXRu0{kgy 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/project__ready.png b/lp-app/lp-studio-web/story-images/project__ready.png new file mode 100644 index 0000000000000000000000000000000000000000..1cc8fd6d3a36ac99936a70670773f27d418a9246 GIT binary patch literal 11540 zcmZX41z20%(r##RiUlnm2vVfDQ)nQR;7%z;in|mkUK|oA5Tv*icZ#&Q77Dajk>c*| zt~Y$=Kj**a-u2{J*;)F|tl6{QnH_|hiX1*J6)pe(z?X+XH2?r~O4RiV_9Il?oCZ(@ z063@Rp%5*PshycOirVUwU571@pni2@^E@3L_|Quifk0WtEHZq7KN6a{P#5(H2J<1S zxX<|s2~#iM=!Cv8q2=JRi`r5=X~T=~brEp`*@^mH80?%I*t!U9Z%OPl>s0G-r9MXO z*5+ruHrV~J?}8;d4(bb_ECh4i`t!WOJ>J!7zAKXh1J%Z0g-jCU7hV1%`L!%{Jeo!_ zw=y%Q?qDi2lO8IyKKS`zK9K;7lwx&)-fQ{h$}lk{cGP1~-D_J6^3Z3+E{%orHU8-c z4iNPV5QvE&NygH)>HabGSFzO>S64GhHCkbb#{hBgrEP{>glz~EG8DV=}w>7g@ zQ(b~Na`@HxBtylr&Kf3H0S3qGxm#1zU;)p=AN5PkC4Ty)f(0xpN9!67n=LXykvs}` zV|ImKYX)+0O#OU3ZIzED)h4;BA|lUrh%a~E7oDb8U57X@egEO#^4VGD)UiZhD=@e! z%mB-VeTILgr_roGoBH)leLQNpmlqcW3^B=-^LZGbWiVN9ZmtHz8ZK^g=E`l2q{bH& zQ`B^kWy2FnE_Nq|_Qs1)sl*4_vFRZ}otG}j zvrLU$T_;nOT>ZEEtw=Y0mp$_JH_3@E5c6$#!e8y^(rS~At2l*Da89E$ha*o%}jdH7uGATJu!FT#{sBIWZ(Q$wf0*5M> zcVxiQ9Lg_JSFyf_@owejnM3HTAK%S%9-5N(W!mQ$Q9rVo>{{!rts9=Ki#@2-nAsKS^tA1RR;}Z>o;i=8_G(HF1@YkTeGhktM-bFt z7mvfTUrEP0BmOh$r|Ul3K9@tc>{9&Ue-PmVr`CDww^m9=x@Rn5&0ZgBM-p7^`;V$j zawZNgmu(Gbq!P-<@f_%Ds-R!;rH#ZqHQQ6S3Ur>okxd*1K{^jAQrxt_J+7Kp7Q@H@d%Jzm85U`kq4313lk~|RI85lV3Y_sYUf86hj!N*B zj)ZQIzctx)V|gMheL z6l)lY42=#qy^uf?!t`guMA%(3W;pzQ?YtfYUAvvmn^?RaAG9uK002n;6hoHwe%Orx zRiFM05BXD-XRXN=JRc$Byba$uJ{E2_FE`4vs z&6~c|)c)7rYBDJ!xWr4-zYCHW4JG_i)+EpXUy^|Mz^{qqrs3G1fy$}WBWbKCl$zNP zMlHGEu;wufOBH+?RhLlIUShHgO4Es0|5T5G~f?=6MB-) z2doCP_KO9T5ufi`oYRBSceZKkdE(ck(bqAt%2guoDjU;xGn329^DKAQw2I2|GAhq@ zXML})R;a`sbzAoa$~MQ=?5*$c0Dv13sw1P&fiw?SzvmuD?<9L9jT-srKI#=3loPz( zqS+blAv_!S_N^FRH(eCmR;w`Mdg43243^zk)87Luh);g$)WOl7`=-FKVW;(4E`cbJq0^ zLSZzY61q~PYK3oFJ|l^wAI97(o`Jxy>q-6lD9V$L!LP^Mnf%_HktG&0GfCVWEuqZ8 zQ(IL=b1m7Ar_YY7msMaAR*n;MdMxNheu8nHEcl8HqKoH*^U(y73nbU?9N*e%?$Xnb zTMxdVkBAnR&J4cD;U9dp(Wd@u`|{Vf(dEXG;4V4(yKi@~;w|3Qx;oDNlLakwH+A)P z5Gi;4w_YmOwdb}ysdc7uJkH&D6sor~L7z_2)kO|=O3>1*@3W&6_d+lnn=?-QJm*!w zNjJZoqPc=s*Uj>-v2o`A;-9;lvpUD-r<>sj>tjuzt`smo1!4<|O;!ka` z8wmvdBlyw?4o>#qTIh6VesHxF&Zm0OApg=jmt}ycCY$XqW&x% zO0}2cAg-oxr|h}haV!y`I3c{4kK`%UX+vHg_uyqmrW4G>3q6INK-7tF=ZiU?HLJf= zm1y6<=-!x_9`R~saD4CZC~?3dO-S5+>7xW9TD+n8d*B3HLxx|$8p|1?Qu68m!T)iO z7uOo%YN+qAI+w2SD$7$t6$5@`ZPb8_^Ws}L4Sq~RM-@4(o!1wdyJ*cry~S9HJa|j{ zIJvdxjyxKJH8nToUra*z<$A@$Xk;D@JBQjPFQ*=#9^t)?STS#O@8SP|al5 z8+UtLYCQL?;tK*v2R8dqkzXy-VPROXHVKKRUOib!Yu65~nBNZ$&h-9tG`FX47ZRKO zsCAU(E0PB^>QozU8t_6R^~{hKiw{mNu2ivQLzN!`kgqW?PMz8yQlv zAf|=yRO&J!@t+U_bAk-^Vs}`^Og`8Y_4n+x2%m6sbC}VwV=2Kd>epX|5UInySoRcG zdFo0Ag8xxuU|10L6hVf>Qm41(fVL7W*q5pA<>OuBbYk$oZH6bLGpB{1_x+J+;a3v; zclOhp!l3p7ae5~xDR73^)Id4px>8S^1DhC#mtkaR;@y(-T@ng&!ozI94IUWt8vZzg zKq=<~Hj+;1doO?cXxh#vtKi^(;}w>er$6<6;NrCYRTqSilO$4lGMuf%pw*zeADK!s z?oclZ1Udd1cgBnPx0arPIj6(egpxi4YJCJE*~;cnXevX-7kcjrDjXY1?31GG4+<2&k)_jJ`p4i42HaY8e*)2r zShHkB2@cLtJ*Ulu_sK}?49Q>Lgu+8a@>bfSs1D!K;s&d)v1k84f9%u}Fe{OkLCM!R zPYoef*LQf;`8e}Q_kiM}d!fm(s^&jFwUdtpe8;siL8Q7r3(9`sB|`JkZKtL&E{x6c z6^_U3TAlxkJ2>h_#*+R-BL}_N$7g*ck7Y=!;9rbG)ws*nz&4Mak!mJ(M&a#Vol`@G z`Oc&K3$rr&#~ss(LpD=t0n;a`#kGHw^9~AY>s<@v=a1NxS zo`)bb0N_am67`@Aet2S{o{s-C=|BFbgB?8gsRhOVTP6gs_oE4bP?+ z9O>tC-gSxv0Mt2juispw!z`9Pg{BicptHW~DUIY(;u-nkB(kuC zFFA5~1|{DY?oB&=srbQA=(TREw|KbA2pYh@Wrsh@w=@$Uv1>uL{sAS&?Ns*^Q( zwahEAi9?_;7K7;-?sZu@X)$FB(}zA#I=N?^(LimgHjL#q3`(A_Y|tgjiHfSUG!x&5 z?ec@A`K;N=P?#TA7w?al4a>l2#$;XXN!ImIIF4&lwWUDnoWstd(JpK#;XTiLJzHSj z*ob`g2#HQnQq($4jD&95XGFkV&E2QU0055~D7{A2jSy71J@#G&r5_K3K`l8d!tnp! zBYzO*|1|*+O6~=!COw$zgU}9LvY;Ux6t8;uz^EQth)_$sOf%#LPrX7lwMEu({_6_h z8{7$dXldJRIDNPPsE2j31tMW_@@ZHoC5J8%0dpKlP>y)t8xP@`aC6$de}8kk?$~+< zTt7s?f=sw=`%Tt1Cg%STAz1KhpKu7l*H7LImvsgGg|cY9a*gp|#t0#Wlg4!W*3EixEg=-+%C^b|3b5MJ*+ zmXPFbJxIp^&V`^Omc~_E*KqTbi)P~FYNfNbF7Kufep9t9I=NF!h45;0dwa+BPuJAB z$jU-dB@GW~=#GWhETVbbwjTY}@@|asiO$vfb(pX{d~a&fX5DpUkUs9>^msE^{I%A@ z3^R2MoP2C?h{?sKLCr6ZCtM6bKsE-A*u)!7v-frg(4yffpmAJ=|>oqGfMp?Suzc@@7?2T^6&eh9o$NZv+)lMi2HAr( z!k%<=I&+OgPo+n&Gos3p9n${@1Lx~31KMEbEcd7As;+1G#d)8f043Hsf3b;F)fdGu z%}02Cb9b;%2tqG-|CZ@#G}^Q;ZDWk8E>qY6evxM7NixZh71SO4fEUUMc|?N^q!Wh( zdLXU6ZSm-mA&;i96K=!(TLYOccc>lI;xQ3gOKi3ga~Z*xmw@7)bnzv*qu4NT%O+gT-Dm* zx9LjmytOi1X(NoND^nlbMy0q$Xn|!zAYQcDy*pUo4UO$rZtl!p0KDL{GtV|7pXtV! zvnUyNIIS3&k?vpwYWy@1SeA@~7(V^HS_-erR2z&ydCd_MM$k~HEG$jZjq$8DYIb#r zMdk%JCQ9ar03^&57xC&T@btzV<(BTrfbtLS>A{&LH6X1XyjzlNj&wHjgU8>+L*%0z zr2iX!FnCxe3@Y>B()xOkZ2zAVY&#(a9z+{7`dYI;Sr)l-BuyaE(;($_sSk6twe5qP ziCC6Tq)n`KDkmI+BAAU&U=UJF40WOa?(8#Z+}Aj=#_{G*J%`V)sQyX~VPL)5x7pmV z`?%P#C^o+5@?pTYwcgJOh4STtxBgH4Ph1Q5>Cb?Y81UaUU|6wiRi%~Bu6?^W z8!K`-yQhegmvSmqjnY`(8tX5SYBd+q-V(@T*0@PhRdiABnWf8%-Ady{VIeg`ZtUBW zgoy_!ES~T6fMBC~@rRcaD<&bJC%&?U^4d>#4Unn}jrhq{X zCVfHMAL)=tn)R~T(%oKS>9x@9Rpd4!F)MwMg5xO#b!|=-^a6&xA%9UR@dBT{kxyrd7_r@L!6WBCi*5y<@GtksHRtF z;?fXsMg>y&Um;-P@)c=mXjBhvOden;|{^wwrLW-?l0pZYo@XPQ*h-%=wY43*|5u>QTjfPmM=19G80nb z(@3MgK6hUbr9i_pvq;j_R5#hSdT(6%hCHQ@H}%A|jgZ(65>Z}5rI-?65Hxd* zw%K5N!3K%od)}T3Ur8Zks7eEHXRuQ5 zt`)o38QM1^DDJsdcUO5u(Au1g-e;TIxgsSaUc6>i!#BJEd#pJ*$~@tH8utcA9eycy zO${KVD}3i{CHd@sN>PQThd(-?P3Ljy6B>_qwE=KKA#7wJJ69V+LBH6a5gM|mAO)eP zTl%s_knHB7s`?rwk^<>`Qd<5~vB;q>KdnecIbWWP^oQOb+K_yLda=lGP~$aDmncUH z&KucZVSp|2S?j)4HNoZ1ZkoO@#Mt*|Uv4zEF=;F1GN7@A@D$DXx_rlFMuEVaO`jHnkl=VKE8Ub7 zPTVJc_gCf8(q7-*ef$ubRl_guN9w|+K%@p9I#uU1GyH2iH}kB6=LzaFzeotmDpL-B z3nTiIn;Xr7+uimI{5wvR%ZTj+lEpUFf%f!6@bnopkc$c2RrPcSm*z_z#)jwa=7*5Z zL3*Q0nMW9Jh8e)40lWHWo6;6;d0t{t3JbYDcXrmg{SGX5GaQH8oy|z)NkCn+IrUCsp8`jMWX<@50y+Lb}V3L z)@TyEv?*Db&(%SK93)JA<+w|{7FjrB$RO%li3+X;jX)i&m8mkng5wngWL)BLah@!7 z#`6IWlceRdJV8U58=%5g-I3GxdU5UCi~TSDjq#2gc)_d%12|aQ0kHvVIczFjYzS?RhS?|igp$>&pKLHd{7D%Gh%IrRH? z)*~+)w=eectlZpl?Q@~&_7O? zR)KKub;Yw1;oAn%b zD{~{r$Rp`(T!+q4S~W$Jh|g`t{y>zsLx%ABrR4hO{gzv?u0zV>WBFC0M3tY?JAtw}g>BiVHSDX9L2z2Wj_q|wNgRp`a@h~!$+aQD+=RjE!TFQMmw z3aLMhTAW2gpm*Pa){5tSNVp&`!MLQ4u&g?J_(q-y9SgwUYjgoLWK`Dq3T|?@xu23z z9yP(w7j_NWOUrkBu2z7*`XBI zS{53h&o#>RO_N<{uw5bBS72u1&3|L?#UJ6$7tZcBd4tCG*<3Zj^J zIyQPAESyV+)uRqKNwB=crXxIq$%{0s?+>pX@9vJrH@9C$ToRymmpdf}a_c8~EwteC zThd7hO4c}nG4aOq_1>=zoBIC!&KuRVVL?;I@pPZ->i*BeDS*VUz@aY2t~2VaQRTXw7U zb-!CbSd*ZOqMah)$n>+YxgJw?k;%4RtvbAAI<%vtpC!KMTr)0_^)niG6n3H)%X}JfKRKOG`$SF46M0 zpN#B`xW>!x1j&tjmO5vUaF4p1lV6l!x}Hw1xk+JV=cyNLPEH|3i`TbU= zG~>MJ?3kXIo8A2-zuXbBr%K~)xkDyZhqIN{(9Ej$T?);{ZW=X^7p}^#_g2ApyoRf? z1+~}*6s`7WrUf4BPm#V*L#VPc0+w>6UaWy)xT7%CF?LtAc_*i5V_2B@EkZiC-r@~M z1Pd7cZOTrPQ~5`0#79^~_!gKj$%BSp$aaHh=WrSzp z!>LOzN#P+T787A%j}V-w_R?FjS`vbAPkLnA@iB{t4p zXh2H_HiBESQKzpK3-I#ALp=BYBwqv`J=pbw=Kp2d(kLCgWO}gc{~mZi*m!m|tVP+4 zcGHSXOB71ijMf|%O10J@urRF16RP%nmYkZ(bsVdgtN^vZj%EG;YM5%o4Kak7gJ2VQ zWdh%O*{cPL{j#}fMBqHu$3G~IE}BHv&m~&dJr_J(TAbi)lsqt^hq#zH2;JUalgllA zec)o&P+iYw*(MtHlsfGn;NX8Aex}81jp&~)@wYd;7TO{R}A@K z;`E)6gL%GLLmqka{-YucW29P7$04GXLzz|El?sxl1<&+nmYpo1-ci#?3K}PaFv^yE zm?q4c-L2UAj$^|6s{VU%Q(;-rm05GY$$)gCo|*}Ul8i~E-jP~6l7&}g<`Ks-napQ% z4D};y(%Nlcsx3z)akxj1pgH>5Zj0Zwjh(BvLP6r!&Y){k9S5zwxnA6 zCFtpt;L)E$ZS;jsOt2#px!uaJfrNVo6!(v$o14GO7YmP#`&?dNp5}$r55=$semNos z@6By>=P!-bI-YS60-Il?Ph1TSTiU(2uzG1zR9@V6sea#lVQu5g$u!G=e|l<2dj7MB zrsilBVv_pm#gQwvV%B?;o4h<8qtZ+(r~99&Nw3aMz5@JJ*5EP6ZCSS^aHar#yUyxR# zSi*J)-swbBCwfn=PJ&>fOj}1Ig_7!D2;Q{EC=yBe_k`Rp&A99}?>Cp;wYu;18W?g) z8YwftzFb%`kb&pk8dZnuV8V#*h#6`u^Z-G{N5HxttKZOA!7?E&-x3GkJ$|_qmz;jN zM&<%vwHn!XA{I~0;H)+N%6Z)p!y0QC9f#u-vgmRCB1w+(-SI^5b@$78{Y>Vin+*f+wp9Ap>O|L8S|={JE9^R{&*5;BH~^Jv z_PKZszt~#B=7Bx$M%0c|(4!;vqS!ia)5$CX89R~wk8sL=uDMi`{K4)RI4i8WFss`b zxB^x-WUh7Tc_N{9O4!f<^k^p4=I=ECm6r@Rr1_iqqoZnybZEc`#4k1VmqEb^|0-i7 z=!j1z>lbcwx2BUJ_~5|`hC#(d07mPYh5`n{@s36eGajmSYa}UV=cwuHmoU2}WOp7V z^bEP6MONZdPnWEuJ%$U@<+P4nXTOx(Uq;7C4L&KSz-bxRd;EAQ;O@D?)@Q$OW#(32)*(oL-Gh_jSpl)p=_!qZ zm(p1SSc!3qi-xgvCq`yo&J_&vtjwiSd4r-Xyu$wap)v}9phq}V1f4gCxOfG|pHECR zJ8;2m;M>Rt0mrz!*GGWbq@jmj7qjD=;y%eR%P4D3@T>?h3dWe8g%uhLJS)ZghPWFl zmnDLZmS=~2QHP|ce`6~wTbK*sXhleQ9;`0tTQ2^7YUDw_-?TpCZ9kPZpSCVKzUo{* z^%RV(kNutZ15=9hs_0oTIZUv}nBo0#7yOm|kDg2ZOU^!E|VLf4`s)qu#CT*=qhUCC5HxFk|5c;F1l3=1kn zKbAFc{5-pk*4#`#>_x0Wmg zzmFKgjUtSsDS40=$KC-c1rtUbeu)9-hMZV9iM=TB`sFnk2rWJ|iJkdzW7;P9q?B(= z?I|A{E8MKvq-HgPlh z%ojz~Y8g#0gmju;K`uaPpPpei5{J~sDwS~bK0>Hg(^J}ORU$227pF_pWcd_gy5Sf& zD*j|R3~O;qUr4-dNV>oo90MSV_Ck4v_8Np}4p`p?6Ug)Ul)Un(2}Aq&0>dH-?DNFl zzcmb56@qUthVkq~(gw^glf*r>ya`@nOk*SVR9KKD1Q6-x`7S}CQ6HG~53OvPq*5_q zaP67msMtgRdyf=RGtxW!m%bu#VCTgtUqW$5nAZY+HWi_bc1jrmu*omdR5TdH98el=!ZQ2N4wfkHVjiX8>^0V{=PnKdHZjWX zJPU$Th%o5D0qBmw6^SG5geg2;S%oDUzu*(!Qodjd51D0CxQhIFhK}=!m7PNHsU(_| zRc!OzBcNqO89LCJW05MHz%`_tRU{zA7iTN09Sy&7s^$~Aqi;=8fUBQPY%SzC(wM{q zXCT!7>i65~4M%juYwpa{0@%!t{?s7!Zr(Sg($5G2&;@S~==p??2VMrcqMvqnMNY+1 z|B3hyYE&Wx5BAOlSA5OFcsv`GH7a|vDTGY!D%=~2b?meL{ot?{DE9dos>o|Bx^6%r2>nRpd^(DA@g+K={{V!rob>DsPvP{1SW%Psq^&&9&6yGf=D)jW4v42<>J?ZxakDvTOa zo%bUduk^g);|Es8h&@`UHTpQ9n5rSVVg$xzEq8%+=E~iWS9-AvxpmIwpK86QEJvkB z>kL4UIMi))?wVe!&~33@r?SqOcdnQl;30V0am{lVwTBkNTif?i zy^>@orYId4r2r!Snu%A$+rSe0Y>&%O8-$7y8pwbU*o>ah$3q?+HfC#Dt?^FplOx(L z@z%ymN;E(NEwH(O8Z`u!hE=W>0b@Q;VqG_w*~7O(wmIniYgE@iN%Fb`(9i>=b>i25 zf-s~85kJu188$p=lRqC}X?>Qdf4zh=u~DgH@}Nt~;aX~779B8bWcJXiI+YOh?wKv3 z{l>29?F7S+4(yZM-?;%DVR}>+U#+ILQE-Z?{5jZJab|bkbMxfmzuHl&9Fq;1z(!e_ zix>4cown9oP#ifyzT;xb6^aL{+_49y{yh$>Zg!BQMZMwOyR+lYN5^$US}}vSxYTc@ zxPXeKCXJykX{gRrOM#zGFEDqly`{?-^#~AGxlp00a*L7KxJyog927V)X3VYX%>j?b z;^-9@*>5DkEFz%nZRzW?Cn$uj1-bV<3pPit&}-086SK6cI!ak1+hKnk4T;Nwq1Rkl z9H>3C2sn!21EJ6jseDv!S|Z>MBd9#A^JfB*1f{ZB;F6E*H*(cD zIb>!HQ(_gT-acP4IpvBuMKKI%iP9#JrNpK{4yhCPyaQA(0bW#MIxuqf9~n?dx(l%pW_T zCjcHOK%tI{Xc+n+I60q8ue{VVu_7Re{mll$7+YjbScvWgVbrEoKuz}V85Bx@ysQeeT>4$${{fHY3ts>L literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/status__error.png b/lp-app/lp-studio-web/story-images/status__error.png new file mode 100644 index 0000000000000000000000000000000000000000..e82a11fe2ee272cd821959a4ba75e480825fce0f GIT binary patch literal 15743 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 AhyVZp literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/status__idle.png b/lp-app/lp-studio-web/story-images/status__idle.png new file mode 100644 index 0000000000000000000000000000000000000000..0fb117a6a277204271ab32b0c385a313f458398e GIT binary patch literal 10581 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/status__starting.png b/lp-app/lp-studio-web/story-images/status__starting.png new file mode 100644 index 0000000000000000000000000000000000000000..5a99aaa3d1d87c2c6a34151859b1cd316dbb060a GIT binary patch literal 12224 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 Date: Wed, 17 Jun 2026 21:21:41 -0700 Subject: [PATCH 16/62] feat: add browser serial studio hardware path Adds the browser-serial-esp32 link/provider shape, Studio hardware actions and access state, shared demo project upload helpers, and the browser Web Serial runtime shim path. Includes story image baselines for the new hardware access states. ADR: docs/adr/2026-06-18-browser-serial-shim.md Plan: /Users/yona/Dropbox/Documents/PersonalNotes/Planning/lightplayer/2026-06-17-lp-studio-foundation/02-m2-hardware-reality/plan.md --- docs/adr/2026-06-18-browser-serial-shim.md | 49 +++ lp-app/lp-studio-core/README.md | 11 + .../lp-studio-core/src/action_descriptor.rs | 32 ++ lp-app/lp-studio-core/src/device_access.rs | 28 ++ .../lp-studio-core/src/device_capability.rs | 25 ++ lp-app/lp-studio-core/src/in_flight_action.rs | 8 + lp-app/lp-studio-core/src/lib.rs | 3 + lp-app/lp-studio-core/src/studio_action.rs | 16 + lp-app/lp-studio-core/src/studio_app.rs | 144 +++++++- lp-app/lp-studio-core/src/studio_effect.rs | 13 + lp-app/lp-studio-core/src/studio_event.rs | 19 +- lp-app/lp-studio-core/src/studio_state.rs | 5 +- lp-app/lp-studio-runtime/Cargo.toml | 7 + lp-app/lp-studio-runtime/README.md | 18 + .../src/browser_protocol_client.rs | 11 +- .../src/browser_serial_protocol_client.rs | 240 +++++++++++++ .../src/browser_serial_runtime.rs | 331 ++++++++++++++++++ .../src/browser_serial_shim.rs | 106 ++++++ .../src/browser_worker_runtime.rs | 1 + lp-app/lp-studio-runtime/src/demo_project.rs | 53 ++- .../src/host_process_runtime.rs | 35 +- lp-app/lp-studio-runtime/src/lib.rs | 10 + .../src/project_session_runtime.rs | 12 +- lp-app/lp-studio-web/Cargo.toml | 2 +- lp-app/lp-studio-web/README.md | 21 +- lp-app/lp-studio-web/public/browser-serial.js | 167 +++++++++ lp-app/lp-studio-web/public/index.html | 2 + lp-app/lp-studio-web/src/app.rs | 23 +- .../src/components/device_panel.rs | 35 +- .../src/components/device_panel_stories.rs | 30 +- .../src/components/status_bar.rs | 2 +- .../src/stories/story_fixtures.rs | 50 ++- lp-app/lp-studio-web/src/style.css | 7 + .../story-images/device__connected.png | Bin 16113 -> 20871 bytes .../story-images/device__hardware-denied.png | Bin 0 -> 20766 bytes .../story-images/device__hardware-granted.png | Bin 0 -> 19680 bytes .../device__hardware-unsupported.png | Bin 0 -> 22716 bytes .../story-images/device__idle.png | Bin 13416 -> 18215 bytes .../story-images/device__long-session.png | Bin 18608 -> 23608 bytes .../story-images/device__starting.png | Bin 13106 -> 16137 bytes .../story-images/status__error.png | Bin 15743 -> 16524 bytes .../story-images/status__idle.png | Bin 10581 -> 11345 bytes .../story-images/status__ready.png | Bin 12422 -> 13197 bytes .../story-images/status__starting.png | Bin 12224 -> 13056 bytes lp-app/lpa-link/Cargo.toml | 1 + lp-app/lpa-link/README.md | 16 +- lp-app/lpa-link/src/lib.rs | 2 +- lp-app/lpa-link/src/link_connection.rs | 16 + lp-app/lpa-link/src/link_management.rs | 53 +++ .../src/providers/browser_serial_esp32.rs | 200 +++++++++++ .../src/providers/host_serial_esp32.rs | 7 +- lp-app/lpa-link/src/providers/mod.rs | 2 + 52 files changed, 1750 insertions(+), 63 deletions(-) create mode 100644 docs/adr/2026-06-18-browser-serial-shim.md create mode 100644 lp-app/lp-studio-core/src/device_access.rs create mode 100644 lp-app/lp-studio-runtime/src/browser_serial_protocol_client.rs create mode 100644 lp-app/lp-studio-runtime/src/browser_serial_runtime.rs create mode 100644 lp-app/lp-studio-runtime/src/browser_serial_shim.rs create mode 100644 lp-app/lp-studio-web/public/browser-serial.js create mode 100644 lp-app/lp-studio-web/story-images/device__hardware-denied.png create mode 100644 lp-app/lp-studio-web/story-images/device__hardware-granted.png create mode 100644 lp-app/lp-studio-web/story-images/device__hardware-unsupported.png create mode 100644 lp-app/lpa-link/src/providers/browser_serial_esp32.rs 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..f979e27c6 --- /dev/null +++ b/docs/adr/2026-06-18-browser-serial-shim.md @@ -0,0 +1,49 @@ +# ADR 2026-06-18: Browser Serial Shim + +## Status + +Accepted. + +## 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. diff --git a/lp-app/lp-studio-core/README.md b/lp-app/lp-studio-core/README.md index b15a090b8..a6e177d24 100644 --- a/lp-app/lp-studio-core/README.md +++ b/lp-app/lp-studio-core/README.md @@ -20,6 +20,17 @@ Actions are documented program objects. Their descriptors provide labels, summaries, categories, and history policy so generic UI help and future agents can inspect the available action surface. +The hardware action surface separates: + +- device access and browser permission requests; +- link/session operations such as connect, disconnect, reset, and flash; +- project operations such as uploading the built-in demo through `lp-server`; +- local navigation such as selecting a project node. + +Operational hardware actions are not undoable. Future undo should attach to +successful project edit transactions, not to permission prompts, flashing, +resets, or connection lifecycle events. + M1 does not implement undo/redo. It only classifies action history behavior so future undo can attach to successful project edit transactions instead of every operational action. diff --git a/lp-app/lp-studio-core/src/action_descriptor.rs b/lp-app/lp-studio-core/src/action_descriptor.rs index 4a770d73d..f15ad69c5 100644 --- a/lp-app/lp-studio-core/src/action_descriptor.rs +++ b/lp-app/lp-studio-core/src/action_descriptor.rs @@ -29,6 +29,13 @@ impl ActionDescriptor { ActionCategory::Device, ActionHistoryPolicy::Ephemeral, ), + StudioActionType::RequestDeviceAccess => Self::new( + action_type, + "Request device access", + "Ask the selected provider for user permission or device access.", + ActionCategory::Device, + ActionHistoryPolicy::Never, + ), StudioActionType::DiscoverDevices => Self::new( action_type, "Discover devices", @@ -50,6 +57,27 @@ impl ActionDescriptor { ActionCategory::Device, ActionHistoryPolicy::Never, ), + StudioActionType::ResetDevice => Self::new( + action_type, + "Reset device", + "Ask the current link to reset or reboot the connected device.", + ActionCategory::Device, + ActionHistoryPolicy::Never, + ), + StudioActionType::FlashDeviceFirmware => Self::new( + action_type, + "Flash device firmware", + "Write a selected firmware image to the connected device.", + ActionCategory::Device, + ActionHistoryPolicy::Never, + ), + StudioActionType::UploadDemoProject => Self::new( + action_type, + "Upload demo project", + "Write the built-in Studio demo project through the server protocol.", + ActionCategory::Project, + ActionHistoryPolicy::Never, + ), StudioActionType::LoadDemoProject => Self::new( action_type, "Load demo project", @@ -115,6 +143,10 @@ mod tests { StudioActionType::DiscoverDevices, StudioActionType::ConnectDevice, StudioActionType::DisconnectDevice, + StudioActionType::RequestDeviceAccess, + StudioActionType::ResetDevice, + StudioActionType::FlashDeviceFirmware, + StudioActionType::UploadDemoProject, StudioActionType::LoadDemoProject, StudioActionType::RefreshStatus, StudioActionType::ReadProjectInventory, diff --git a/lp-app/lp-studio-core/src/device_access.rs b/lp-app/lp-studio-core/src/device_access.rs new file mode 100644 index 000000000..b311f9fb1 --- /dev/null +++ b/lp-app/lp-studio-core/src/device_access.rs @@ -0,0 +1,28 @@ +use lpa_link::LinkProviderId; +use serde::{Deserialize, Serialize}; + +/// Browser or host access state for a low-level device provider. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct DeviceAccess { + pub provider_id: LinkProviderId, + pub status: DeviceAccessStatus, +} + +impl DeviceAccess { + pub fn new(provider_id: impl Into, status: DeviceAccessStatus) -> Self { + Self { + provider_id: provider_id.into(), + status, + } + } +} + +/// User/device permission state before a link endpoint can be connected. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub enum DeviceAccessStatus { + Unknown, + Unsupported { reason: String }, + PermissionRequired, + PermissionDenied { reason: String }, + Granted, +} diff --git a/lp-app/lp-studio-core/src/device_capability.rs b/lp-app/lp-studio-core/src/device_capability.rs index fc2f196e4..2f2090a94 100644 --- a/lp-app/lp-studio-core/src/device_capability.rs +++ b/lp-app/lp-studio-core/src/device_capability.rs @@ -2,13 +2,38 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] pub enum DeviceCapability { + /// The provider can request or report user/device access permission. + RequestDeviceAccess, + /// The endpoint can open a client connection to a running `lp-server`. Connect, + /// The endpoint is an ESP32 reached through browser Web Serial. + UseBrowserSerialEsp32, + /// The endpoint is an ESP32 reached through host OS serial. + UseHostSerialEsp32, + /// The endpoint is a browser worker running `fw-browser`. UseBrowserWorker, + /// The endpoint is an in-process host runtime running `fw-host`. UseHostProcess, + /// The endpoint can reset or reboot the underlying device/runtime. + ResetDevice, + /// The endpoint can flash firmware onto the underlying device. + FlashFirmware, + /// The server connection can write project files. + WriteProjectFiles, + /// The link can read a raw filesystem image below the running server. + ReadRawFilesystem, + /// The link can write a raw filesystem image below the running server. + WriteRawFilesystem, + /// The server connection can report heartbeat/status messages. ReadHeartbeat, + /// The server connection can list loaded or available projects. ListProjects, + /// The server connection can load a project. LoadProject, + /// The server connection can read project inventory. ReadProjectInventory, + /// The link or server can surface logs. ReadLogs, + /// The link or server can surface diagnostics. ReadDiagnostics, } diff --git a/lp-app/lp-studio-core/src/in_flight_action.rs b/lp-app/lp-studio-core/src/in_flight_action.rs index 14d7f0bb0..cfd467e3b 100644 --- a/lp-app/lp-studio-core/src/in_flight_action.rs +++ b/lp-app/lp-studio-core/src/in_flight_action.rs @@ -26,9 +26,13 @@ impl InFlightAction { #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum StudioActionTypeName { SelectLinkProvider, + RequestDeviceAccess, DiscoverDevices, ConnectDevice, DisconnectDevice, + ResetDevice, + FlashDeviceFirmware, + UploadDemoProject, LoadDemoProject, RefreshStatus, ReadProjectInventory, @@ -39,9 +43,13 @@ impl From for StudioActionTypeName { fn from(value: StudioActionType) -> Self { match value { StudioActionType::SelectLinkProvider => Self::SelectLinkProvider, + StudioActionType::RequestDeviceAccess => Self::RequestDeviceAccess, StudioActionType::DiscoverDevices => Self::DiscoverDevices, StudioActionType::ConnectDevice => Self::ConnectDevice, StudioActionType::DisconnectDevice => Self::DisconnectDevice, + StudioActionType::ResetDevice => Self::ResetDevice, + StudioActionType::FlashDeviceFirmware => Self::FlashDeviceFirmware, + StudioActionType::UploadDemoProject => Self::UploadDemoProject, StudioActionType::LoadDemoProject => Self::LoadDemoProject, StudioActionType::RefreshStatus => Self::RefreshStatus, StudioActionType::ReadProjectInventory => Self::ReadProjectInventory, diff --git a/lp-app/lp-studio-core/src/lib.rs b/lp-app/lp-studio-core/src/lib.rs index 091f119b4..80ffb8bc4 100644 --- a/lp-app/lp-studio-core/src/lib.rs +++ b/lp-app/lp-studio-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod action_meta; pub mod action_origin; pub mod client_session; pub mod connection_session; +pub mod device_access; pub mod device_capability; pub mod device_id; pub mod device_session; @@ -29,6 +30,7 @@ pub use action_meta::ActionMeta; pub use action_origin::ActionOrigin; pub use client_session::ClientSession; pub use connection_session::ConnectionSession; +pub use device_access::{DeviceAccess, DeviceAccessStatus}; pub use device_capability::DeviceCapability; pub use device_id::DeviceId; pub use device_session::DeviceSession; @@ -45,6 +47,7 @@ pub use studio_log_entry::{StudioLogEntry, StudioLogLevel}; pub use studio_state::StudioState; pub const BROWSER_WORKER_PROVIDER_ID: &str = "browser-worker"; +pub const BROWSER_SERIAL_ESP32_PROVIDER_ID: &str = "browser-serial-esp32"; pub const HOST_PROCESS_PROVIDER_ID: &str = "host-process"; pub const HOST_SERIAL_ESP32_PROVIDER_ID: &str = "host-serial-esp32"; pub const STUDIO_DEMO_PROJECT_ID: &str = "studio-demo"; diff --git a/lp-app/lp-studio-core/src/studio_action.rs b/lp-app/lp-studio-core/src/studio_action.rs index f49e8db8e..cea6fdffe 100644 --- a/lp-app/lp-studio-core/src/studio_action.rs +++ b/lp-app/lp-studio-core/src/studio_action.rs @@ -7,9 +7,13 @@ use crate::{ActionDescriptor, ActionMeta}; #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum StudioActionType { SelectLinkProvider, + RequestDeviceAccess, DiscoverDevices, ConnectDevice, DisconnectDevice, + ResetDevice, + FlashDeviceFirmware, + UploadDemoProject, LoadDemoProject, RefreshStatus, ReadProjectInventory, @@ -20,9 +24,13 @@ impl StudioActionType { pub fn all() -> Vec { vec![ Self::SelectLinkProvider, + Self::RequestDeviceAccess, Self::DiscoverDevices, Self::ConnectDevice, Self::DisconnectDevice, + Self::ResetDevice, + Self::FlashDeviceFirmware, + Self::UploadDemoProject, Self::LoadDemoProject, Self::RefreshStatus, Self::ReadProjectInventory, @@ -35,9 +43,13 @@ impl StudioActionType { #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum StudioActionKind { SelectLinkProvider { provider_id: LinkProviderId }, + RequestDeviceAccess, DiscoverDevices, ConnectDevice { endpoint_id: LinkEndpointId }, DisconnectDevice, + ResetDevice, + FlashDeviceFirmware { firmware_id: Option }, + UploadDemoProject, LoadDemoProject, RefreshStatus, ReadProjectInventory, @@ -48,9 +60,13 @@ impl StudioActionKind { pub fn action_type(&self) -> StudioActionType { match self { Self::SelectLinkProvider { .. } => StudioActionType::SelectLinkProvider, + Self::RequestDeviceAccess => StudioActionType::RequestDeviceAccess, Self::DiscoverDevices => StudioActionType::DiscoverDevices, Self::ConnectDevice { .. } => StudioActionType::ConnectDevice, Self::DisconnectDevice => StudioActionType::DisconnectDevice, + Self::ResetDevice => StudioActionType::ResetDevice, + Self::FlashDeviceFirmware { .. } => StudioActionType::FlashDeviceFirmware, + Self::UploadDemoProject => StudioActionType::UploadDemoProject, Self::LoadDemoProject => StudioActionType::LoadDemoProject, Self::RefreshStatus => StudioActionType::RefreshStatus, Self::ReadProjectInventory => StudioActionType::ReadProjectInventory, diff --git a/lp-app/lp-studio-core/src/studio_app.rs b/lp-app/lp-studio-core/src/studio_app.rs index 2680b1d85..c2bab29db 100644 --- a/lp-app/lp-studio-core/src/studio_app.rs +++ b/lp-app/lp-studio-core/src/studio_app.rs @@ -2,8 +2,9 @@ use lpa_link::{LinkEndpointId, LinkProviderId}; use crate::{ ActionDescriptor, ActionId, ActionMeta, ActionOrigin, ClientSession, ConnectionSession, - DeviceId, DeviceSession, InFlightAction, ProjectSession, STUDIO_DEMO_PROJECT_ID, StudioAction, - StudioActionKind, StudioDiagnostic, StudioEffect, StudioEvent, StudioState, + DeviceAccess, DeviceId, DeviceSession, InFlightAction, ProjectSession, STUDIO_DEMO_PROJECT_ID, + StudioAction, StudioActionKind, StudioDiagnostic, StudioEffect, StudioEvent, StudioLogEntry, + StudioLogLevel, StudioState, }; pub struct StudioApp { @@ -45,6 +46,14 @@ impl StudioApp { StudioActionKind::SelectLinkProvider { provider_id } => { self.state.link_selection.selected_provider_id = provider_id; self.state.link_selection.endpoints.clear(); + self.state.device_access = None; + } + StudioActionKind::RequestDeviceAccess => { + self.mark_in_flight(action.meta.action_id, descriptor); + effects.push(StudioEffect::RequestDeviceAccess { + action_id: action.meta.action_id, + provider_id: self.state.link_selection.selected_provider_id.clone(), + }); } StudioActionKind::DiscoverDevices => { self.mark_in_flight(action.meta.action_id, descriptor); @@ -74,7 +83,38 @@ impl StudioApp { .push(StudioDiagnostic::info("No device session is connected.")); } } - StudioActionKind::LoadDemoProject => { + StudioActionKind::ResetDevice => { + self.mark_in_flight(action.meta.action_id, descriptor); + if let Some(session) = &self.state.device_session { + effects.push(StudioEffect::ResetDevice { + action_id: action.meta.action_id, + endpoint_id: session.endpoint_id.clone(), + }); + } else { + self.finish_action(action.meta.action_id); + self.state.diagnostics.push(StudioDiagnostic::error( + Some(action.meta.action_id), + "No device session is connected.", + )); + } + } + StudioActionKind::FlashDeviceFirmware { firmware_id } => { + self.mark_in_flight(action.meta.action_id, descriptor); + if let Some(session) = &self.state.device_session { + effects.push(StudioEffect::FlashDeviceFirmware { + action_id: action.meta.action_id, + endpoint_id: session.endpoint_id.clone(), + firmware_id, + }); + } else { + self.finish_action(action.meta.action_id); + self.state.diagnostics.push(StudioDiagnostic::error( + Some(action.meta.action_id), + "No device session is connected.", + )); + } + } + StudioActionKind::UploadDemoProject | StudioActionKind::LoadDemoProject => { self.mark_in_flight(action.meta.action_id, descriptor); effects.push(StudioEffect::SeedDemoProject { action_id: action.meta.action_id, @@ -114,6 +154,16 @@ impl StudioApp { pub fn apply_event(&mut self, event: StudioEvent) -> Vec { let mut effects = Vec::new(); match event { + StudioEvent::DeviceAccessUpdated { + action_id, + provider_id, + status, + } => { + if let Some(action_id) = action_id { + self.finish_action(action_id); + } + self.state.device_access = Some(DeviceAccess::new(provider_id, status)); + } StudioEvent::EndpointsDiscovered { action_id, provider_id, @@ -157,6 +207,33 @@ impl StudioApp { self.state.client_session = None; self.state.project_session = None; } + StudioEvent::DeviceReset { + action_id, + endpoint_id, + } => { + self.finish_action(action_id); + self.state.logs.push(StudioLogEntry::new( + StudioLogLevel::Info, + "lp-studio-core", + format!("device reset requested for {}", endpoint_id.as_str()), + )); + } + StudioEvent::FirmwareFlashCompleted { + action_id, + endpoint_id, + firmware_id, + } => { + self.finish_action(action_id); + let firmware_label = firmware_id.unwrap_or_else(|| "selected firmware".to_string()); + self.state.logs.push(StudioLogEntry::new( + StudioLogLevel::Info, + "lp-studio-core", + format!( + "firmware flash completed for {} using {firmware_label}", + endpoint_id.as_str() + ), + )); + } StudioEvent::DemoProjectSeeded { action_id, project_id, @@ -261,6 +338,67 @@ mod tests { assert_eq!(app.state().in_flight.len(), 1); } + #[test] + fn request_device_access_produces_provider_scoped_effect() { + let mut app = StudioApp::new(); + app.dispatch_kind( + StudioActionKind::SelectLinkProvider { + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + }, + ActionOrigin::User, + ); + + let effects = app.dispatch_kind(StudioActionKind::RequestDeviceAccess, ActionOrigin::User); + + assert!(matches!( + &effects[0], + StudioEffect::RequestDeviceAccess { provider_id, .. } + if provider_id.as_str() == BROWSER_WORKER_PROVIDER_ID + )); + assert_eq!(app.state().in_flight.len(), 1); + } + + #[test] + fn device_access_event_updates_state_and_finishes_action() { + let mut app = StudioApp::new(); + let action_id = ActionId::new(11); + app.mark_in_flight( + action_id, + ActionDescriptor::for_type(crate::StudioActionType::RequestDeviceAccess), + ); + + app.apply_event(StudioEvent::DeviceAccessUpdated { + action_id: Some(action_id), + provider_id: LinkProviderId::new(BROWSER_WORKER_PROVIDER_ID), + status: crate::DeviceAccessStatus::Granted, + }); + + assert!(app.state().in_flight.is_empty()); + assert_eq!( + app.state() + .device_access + .as_ref() + .map(|access| access.provider_id.as_str()), + Some(BROWSER_WORKER_PROVIDER_ID) + ); + } + + #[test] + fn hardware_management_requires_connected_device() { + let mut app = StudioApp::new(); + + let reset_effects = app.dispatch_kind(StudioActionKind::ResetDevice, ActionOrigin::User); + let flash_effects = app.dispatch_kind( + StudioActionKind::FlashDeviceFirmware { firmware_id: None }, + ActionOrigin::User, + ); + + assert!(reset_effects.is_empty()); + assert!(flash_effects.is_empty()); + assert_eq!(app.state().diagnostics.len(), 2); + assert!(app.state().in_flight.is_empty()); + } + #[test] fn discovered_endpoints_update_state_and_finish_action() { let mut app = StudioApp::new(); diff --git a/lp-app/lp-studio-core/src/studio_effect.rs b/lp-app/lp-studio-core/src/studio_effect.rs index ebb5c3437..213bf126a 100644 --- a/lp-app/lp-studio-core/src/studio_effect.rs +++ b/lp-app/lp-studio-core/src/studio_effect.rs @@ -6,6 +6,10 @@ use crate::ActionId; #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] pub enum StudioEffect { + RequestDeviceAccess { + action_id: ActionId, + provider_id: LinkProviderId, + }, DiscoverEndpoints { action_id: ActionId, provider_id: LinkProviderId, @@ -18,6 +22,15 @@ pub enum StudioEffect { action_id: ActionId, session_id: LinkSessionId, }, + ResetDevice { + action_id: ActionId, + endpoint_id: LinkEndpointId, + }, + FlashDeviceFirmware { + action_id: ActionId, + endpoint_id: LinkEndpointId, + firmware_id: Option, + }, SeedDemoProject { action_id: ActionId, project_id: String, diff --git a/lp-app/lp-studio-core/src/studio_event.rs b/lp-app/lp-studio-core/src/studio_event.rs index a9485c63b..55d0ec0c6 100644 --- a/lp-app/lp-studio-core/src/studio_event.rs +++ b/lp-app/lp-studio-core/src/studio_event.rs @@ -2,10 +2,18 @@ use lpa_link::{LinkConnectionKind, LinkEndpoint, LinkEndpointId, LinkProviderId, use lpc_wire::{LoadedProject, WireProjectHandle, WireProjectInventoryReadResponse}; use serde::{Deserialize, Serialize}; -use crate::{ActionId, DeviceCapability, StudioDiagnostic, StudioHeartbeat, StudioLogEntry}; +use crate::{ + ActionId, DeviceAccessStatus, DeviceCapability, StudioDiagnostic, StudioHeartbeat, + StudioLogEntry, +}; #[derive(Clone, Debug, Deserialize, Serialize)] pub enum StudioEvent { + DeviceAccessUpdated { + action_id: Option, + provider_id: LinkProviderId, + status: DeviceAccessStatus, + }, EndpointsDiscovered { action_id: ActionId, provider_id: LinkProviderId, @@ -23,6 +31,15 @@ pub enum StudioEvent { action_id: ActionId, session_id: LinkSessionId, }, + DeviceReset { + action_id: ActionId, + endpoint_id: LinkEndpointId, + }, + FirmwareFlashCompleted { + action_id: ActionId, + endpoint_id: LinkEndpointId, + firmware_id: Option, + }, DemoProjectSeeded { action_id: ActionId, project_id: String, diff --git a/lp-app/lp-studio-core/src/studio_state.rs b/lp-app/lp-studio-core/src/studio_state.rs index 2a9cbc9ba..b5a8abc45 100644 --- a/lp-app/lp-studio-core/src/studio_state.rs +++ b/lp-app/lp-studio-core/src/studio_state.rs @@ -1,13 +1,14 @@ use serde::{Deserialize, Serialize}; use crate::{ - ClientSession, ConnectionSession, DeviceSession, InFlightAction, LinkSelection, ProjectSession, - StudioDiagnostic, StudioHeartbeat, StudioLogEntry, + ClientSession, ConnectionSession, DeviceAccess, DeviceSession, InFlightAction, LinkSelection, + ProjectSession, StudioDiagnostic, StudioHeartbeat, StudioLogEntry, }; #[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] pub struct StudioState { pub link_selection: LinkSelection, + pub device_access: Option, pub device_session: Option, pub connection_session: Option, pub client_session: Option, diff --git a/lp-app/lp-studio-runtime/Cargo.toml b/lp-app/lp-studio-runtime/Cargo.toml index f5885089a..cc0eb8d95 100644 --- a/lp-app/lp-studio-runtime/Cargo.toml +++ b/lp-app/lp-studio-runtime/Cargo.toml @@ -44,6 +44,13 @@ browser-worker = [ "dep:wasm-bindgen-futures", "dep:web-sys", ] +browser-serial-esp32 = [ + "lpa-link/browser-serial-esp32", + "dep:js-sys", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:web-sys", +] [lints] workspace = true diff --git a/lp-app/lp-studio-runtime/README.md b/lp-app/lp-studio-runtime/README.md index 6a743b1ed..4e25460c6 100644 --- a/lp-app/lp-studio-runtime/README.md +++ b/lp-app/lp-studio-runtime/README.md @@ -22,14 +22,32 @@ The browser-worker path is: StudioEffect -> lpa-link browser-worker model -> JavaScript Worker -> fw-browser ``` +The browser serial ESP32 path is: + +```text +StudioEffect -> lpa-link browser-serial-esp32 model -> Web Serial shim -> ESP32 lp-server +``` + Demo project loading uses the same server protocol on both paths: write files under `/projects/studio-demo/...`, then call `LoadProject` with `studio-demo`. +The demo upload request list lives in `demo_project`, so future hardware paths +such as `browser-serial-esp32` can reuse the same `lp-server` filesystem writes +instead of forking project sync behavior. Direct/raw filesystem image access is +not part of this server protocol path; it belongs below the client connection in +`lpa-link` management. + +`browser-serial-esp32` targets an already-flashed ESP32 running LightPlayer. It +uses a small JavaScript shim because `web-sys` currently gates Web Serial behind +unstable API cfg flags; Rust still owns Studio state, request/response handling, +and project upload semantics. + ## Validation ```bash cargo check -p lp-studio-runtime --features host-process cargo test -p lp-studio-runtime --features host-process cargo check -p lp-studio-runtime --target wasm32-unknown-unknown --features browser-worker +cargo check -p lp-studio-runtime --target wasm32-unknown-unknown --features browser-worker,browser-serial-esp32 ``` diff --git a/lp-app/lp-studio-runtime/src/browser_protocol_client.rs b/lp-app/lp-studio-runtime/src/browser_protocol_client.rs index 8fba08680..61718f255 100644 --- a/lp-app/lp-studio-runtime/src/browser_protocol_client.rs +++ b/lp-app/lp-studio-runtime/src/browser_protocol_client.rs @@ -1,5 +1,4 @@ use js_sys::Promise; -use lpc_model::AsLpPathBuf; use lpc_wire::{ ClientRequest, WireProjectCommandResponse, WireServerMessage, WireServerMsgBody, json, messages::ClientMessage, @@ -33,14 +32,8 @@ impl BrowserProtocolClient { project_id: &str, ) -> Result, StudioRuntimeError> { let mut events = Vec::new(); - for file in demo_project::demo_project_files() { - let path = format!("/projects/{project_id}/{}", file.relative_path); - let response = self - .send_request(ClientRequest::Filesystem(lpc_wire::FsRequest::Write { - path: path.as_str().as_path_buf(), - data: file.bytes.to_vec(), - })) - .await?; + for request in demo_project::demo_write_requests(project_id) { + let response = self.send_request(request).await?; events.extend(response.events); demo_project::ensure_write_response(&response.response.msg) .map_err(StudioRuntimeError::Protocol)?; diff --git a/lp-app/lp-studio-runtime/src/browser_serial_protocol_client.rs b/lp-app/lp-studio-runtime/src/browser_serial_protocol_client.rs new file mode 100644 index 000000000..9e575da51 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/browser_serial_protocol_client.rs @@ -0,0 +1,240 @@ +use lpc_wire::{ + ClientRequest, WireProjectCommandResponse, WireServerMessage, WireServerMsgBody, json, + messages::ClientMessage, +}; +use wasm_bindgen::JsValue; + +use lp_studio_core::{StudioEffect, StudioEvent, StudioLogEntry, StudioLogLevel}; + +use crate::browser_serial_shim; +use crate::protocol_event::{inventory_request, server_event}; +use crate::{StudioRuntimeError, demo_project}; + +pub struct BrowserSerialProtocolClient { + port_id: u32, + next_request_id: u64, +} + +impl BrowserSerialProtocolClient { + pub fn new(port_id: u32) -> Self { + Self { + port_id, + next_request_id: 1, + } + } + + pub fn port_id(&self) -> u32 { + self.port_id + } + + pub async fn seed_demo_project( + &mut self, + action_id: lp_studio_core::ActionId, + project_id: &str, + ) -> Result, StudioRuntimeError> { + let mut events = Vec::new(); + for request in demo_project::demo_write_requests(project_id) { + let response = self.send_request(request).await?; + events.extend(response.events); + demo_project::ensure_write_response(&response.response.msg) + .map_err(StudioRuntimeError::Protocol)?; + } + events.push(StudioEvent::DemoProjectSeeded { + action_id, + project_id: project_id.to_string(), + }); + Ok(events) + } + + pub async fn execute_project_effect( + &mut self, + effect: StudioEffect, + ) -> Result, StudioRuntimeError> { + match effect { + StudioEffect::LoadProject { + action_id, + project_id, + } => self.load_project(action_id, &project_id).await, + StudioEffect::ReadProjectInventory { action_id, handle } => { + self.read_inventory(action_id, handle).await + } + StudioEffect::RefreshStatus { action_id } => { + self.refresh_loaded_projects(action_id).await + } + _ => Ok(Vec::new()), + } + } + + async fn load_project( + &mut self, + action_id: lp_studio_core::ActionId, + project_id: &str, + ) -> Result, StudioRuntimeError> { + let exchange = self + .send_request(ClientRequest::LoadProject { + path: project_id.to_string(), + }) + .await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::LoadProject { handle } => { + events.push(StudioEvent::ProjectLoaded { + action_id, + project_id: project_id.to_string(), + handle, + }); + Ok(events) + } + other => Err(StudioRuntimeError::Protocol(format!( + "unexpected load project response: {other:?}" + ))), + } + } + + async fn read_inventory( + &mut self, + action_id: lp_studio_core::ActionId, + handle: lpc_wire::WireProjectHandle, + ) -> Result, StudioRuntimeError> { + let exchange = self.send_request(inventory_request(handle)).await?; + let mut events = exchange.events; + match exchange.response.msg { + WireServerMsgBody::ProjectCommand { + response: + WireProjectCommandResponse::ReadInventory { + response: inventory, + }, + } => { + events.push(StudioEvent::ProjectInventoryRead { + action_id, + inventory, + }); + Ok(events) + } + other => Err(StudioRuntimeError::Protocol(format!( + "unexpected inventory response: {other:?}" + ))), + } + } + + async fn refresh_loaded_projects( + &mut self, + action_id: lp_studio_core::ActionId, + ) -> Result, StudioRuntimeError> { + let exchange = self.send_request(ClientRequest::ListLoadedProjects).await?; + let mut events = exchange.events; + if let WireServerMsgBody::ListLoadedProjects { projects } = exchange.response.msg { + events.push(StudioEvent::LoadedProjectsRefreshed { + action_id, + projects, + }); + } + Ok(events) + } + + async fn send_request( + &mut self, + request: ClientRequest, + ) -> Result { + let request_id = self.next_request_id(); + let frame = json::to_string(&ClientMessage { + id: request_id, + msg: request, + }) + .map_err(|error| StudioRuntimeError::Protocol(error.to_string()))?; + browser_serial_shim::write_line(self.port_id, &format!("M!{frame}\n")).await?; + + let mut events = Vec::new(); + for _ in 0..600 { + events.extend(self.take_link_errors()); + for line in browser_serial_shim::take_lines(self.port_id) { + if let Some(response) = self.handle_line(line, request_id, &mut events)? { + if let WireServerMsgBody::Error { error } = &response.msg { + return Err(StudioRuntimeError::Protocol(error.clone())); + } + return Ok(BrowserSerialExchange { response, events }); + } + } + sleep_ms(10).await?; + } + Err(StudioRuntimeError::Transport( + "timed out waiting for browser serial protocol response".to_string(), + )) + } + + fn handle_line( + &self, + line: String, + request_id: u64, + events: &mut Vec, + ) -> Result, StudioRuntimeError> { + let Some(json_frame) = line.strip_prefix("M!") else { + events.push(StudioEvent::LogReceived { + entry: StudioLogEntry::new(StudioLogLevel::Info, "fw-esp32", line), + }); + return Ok(None); + }; + + let response = json::from_str::(json_frame) + .map_err(|error| StudioRuntimeError::Protocol(error.to_string()))?; + if response.id == request_id { + return Ok(Some(response)); + } + if response.id == 0 { + if let Some(event) = server_event(response) { + events.push(event); + } + } else { + events.push(StudioEvent::LogReceived { + entry: StudioLogEntry::new( + StudioLogLevel::Warn, + "lp-studio-runtime", + format!( + "Ignoring uncorrelated serial response id={} while waiting for id={request_id}", + response.id + ), + ), + }); + } + Ok(None) + } + + fn take_link_errors(&self) -> Vec { + browser_serial_shim::take_errors(self.port_id) + .into_iter() + .map(|message| StudioEvent::LogReceived { + entry: StudioLogEntry::new(StudioLogLevel::Error, "browser-serial", message), + }) + .collect() + } + + fn next_request_id(&mut self) -> u64 { + let id = self.next_request_id; + self.next_request_id += 1; + id + } +} + +pub struct BrowserSerialExchange { + pub response: WireServerMessage, + pub events: Vec, +} + +pub async fn sleep_ms(ms: i32) -> Result<(), StudioRuntimeError> { + 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); + } + }); + wasm_bindgen_futures::JsFuture::from(promise) + .await + .map(|_| ()) + .map_err(|error| StudioRuntimeError::Browser(format!("{error:?}"))) +} diff --git a/lp-app/lp-studio-runtime/src/browser_serial_runtime.rs b/lp-app/lp-studio-runtime/src/browser_serial_runtime.rs new file mode 100644 index 000000000..1403a4832 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/browser_serial_runtime.rs @@ -0,0 +1,331 @@ +use lp_studio_core::{ + ActionOrigin, BROWSER_SERIAL_ESP32_PROVIDER_ID, DeviceAccessStatus, DeviceCapability, + StudioActionKind, StudioApp, StudioEffect, StudioEvent, StudioLogEntry, StudioLogLevel, +}; +use lpa_link::providers::browser_serial_esp32::{ + BrowserSerialEsp32Provider, BrowserSerialEsp32Session, +}; +use lpa_link::{LinkConnectionKind, LinkEndpointId, LinkProvider, LinkProviderId, LinkSession}; +use lpc_model::DEFAULT_SERIAL_BAUD_RATE; + +use crate::StudioRuntimeError; +use crate::browser_serial_protocol_client::BrowserSerialProtocolClient; +use crate::browser_serial_shim; +use crate::effect_executor::EffectExecutor; + +pub struct BrowserSerialStudioRuntime { + provider: BrowserSerialEsp32Provider, + endpoint_ports: Vec<(LinkEndpointId, u32)>, + session: Option, + client: Option, +} + +impl BrowserSerialStudioRuntime { + pub fn new() -> Self { + Self { + provider: BrowserSerialEsp32Provider::new(BROWSER_SERIAL_ESP32_PROVIDER_ID), + endpoint_ports: Vec::new(), + session: None, + client: None, + } + } + + pub async fn close(&mut self) -> Result<(), StudioRuntimeError> { + if let Some(client) = &self.client { + browser_serial_shim::close(client.port_id()).await?; + } + if let Some(session) = &mut self.session { + session + .close() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + } + self.client = None; + self.session = None; + Ok(()) + } + + async fn request_device_access( + &mut self, + action_id: lp_studio_core::ActionId, + provider_id: LinkProviderId, + ) -> Result, StudioRuntimeError> { + if provider_id.as_str() != BROWSER_SERIAL_ESP32_PROVIDER_ID { + return Err(StudioRuntimeError::UnsupportedProvider( + provider_id.as_str().to_string(), + )); + } + if !browser_serial_shim::is_supported() { + return Ok(vec![StudioEvent::DeviceAccessUpdated { + action_id: Some(action_id), + provider_id, + status: DeviceAccessStatus::Unsupported { + reason: "Web Serial is not supported in this browser.".to_string(), + }, + }]); + } + + let port = match browser_serial_shim::request_port().await { + Ok(port) => port, + Err(error) => { + return Ok(vec![StudioEvent::DeviceAccessUpdated { + action_id: Some(action_id), + provider_id, + status: DeviceAccessStatus::PermissionDenied { + reason: error.to_string(), + }, + }]); + } + }; + + let endpoint_id = self.provider.create_granted_endpoint(port.label); + self.endpoint_ports.push((endpoint_id.clone(), port.id)); + let endpoints = self + .provider + .discover() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + + Ok(vec![ + StudioEvent::DeviceAccessUpdated { + action_id: Some(action_id), + provider_id: provider_id.clone(), + status: DeviceAccessStatus::Granted, + }, + StudioEvent::EndpointsDiscovered { + action_id, + provider_id, + endpoints, + }, + ]) + } + + async fn discover( + &mut self, + action_id: lp_studio_core::ActionId, + provider_id: LinkProviderId, + ) -> Result, StudioRuntimeError> { + if provider_id.as_str() != BROWSER_SERIAL_ESP32_PROVIDER_ID { + return Err(StudioRuntimeError::UnsupportedProvider( + provider_id.as_str().to_string(), + )); + } + let endpoints = self + .provider + .discover() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + Ok(vec![StudioEvent::EndpointsDiscovered { + action_id, + provider_id, + endpoints, + }]) + } + + async fn connect( + &mut self, + action_id: lp_studio_core::ActionId, + endpoint_id: LinkEndpointId, + ) -> Result, StudioRuntimeError> { + let port_id = self.port_id_for_endpoint(&endpoint_id)?; + browser_serial_shim::open(port_id, DEFAULT_SERIAL_BAUD_RATE).await?; + + let mut session = self + .provider + .connect(&endpoint_id) + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let connection = session + .connection() + .await + .map_err(|error| StudioRuntimeError::Link(error.to_string()))?; + let session_id = session.id().clone(); + let logs = session.logs(); + let diagnostics = session.diagnostics(); + let connection_kind = match connection.kind { + LinkConnectionKind::BrowserSerialEsp32 { protocol } => { + LinkConnectionKind::BrowserSerialEsp32 { protocol } + } + other => other, + }; + self.client = Some(BrowserSerialProtocolClient::new(port_id)); + self.session = Some(session); + + let mut events = Vec::new(); + for log in logs { + events.push(StudioEvent::LogReceived { + entry: StudioLogEntry::new(map_log_level(log.level), "lpa-link", log.message), + }); + } + for diagnostic in diagnostics { + events.push(StudioEvent::DiagnosticRaised { + diagnostic: lp_studio_core::StudioDiagnostic::info(diagnostic.message), + }); + } + events.push(StudioEvent::DeviceConnected { + action_id, + provider_id: LinkProviderId::new(BROWSER_SERIAL_ESP32_PROVIDER_ID), + endpoint_id, + session_id, + connection_kind, + capabilities: browser_serial_capabilities(), + }); + Ok(events) + } + + fn project_client(&mut self) -> Result<&mut BrowserSerialProtocolClient, StudioRuntimeError> { + self.client + .as_mut() + .ok_or(StudioRuntimeError::MissingClient) + } + + fn port_id_for_endpoint( + &self, + endpoint_id: &LinkEndpointId, + ) -> Result { + self.endpoint_ports + .iter() + .find(|(entry_endpoint_id, _)| entry_endpoint_id == endpoint_id) + .map(|(_, port_id)| *port_id) + .ok_or_else(|| { + StudioRuntimeError::Link(format!( + "no browser serial port handle for endpoint {}", + endpoint_id.as_str() + )) + }) + } +} + +impl Default for BrowserSerialStudioRuntime { + fn default() -> Self { + Self::new() + } +} + +impl EffectExecutor for BrowserSerialStudioRuntime { + async fn execute_effect( + &mut self, + effect: StudioEffect, + ) -> Result, StudioRuntimeError> { + match effect { + StudioEffect::RequestDeviceAccess { + action_id, + provider_id, + } => self.request_device_access(action_id, provider_id).await, + StudioEffect::DiscoverEndpoints { + action_id, + provider_id, + } => self.discover(action_id, provider_id).await, + StudioEffect::ConnectEndpoint { + action_id, + endpoint_id, + } => self.connect(action_id, endpoint_id).await, + StudioEffect::DisconnectSession { + action_id, + session_id, + } => { + self.close().await?; + Ok(vec![StudioEvent::DeviceDisconnected { + action_id, + session_id, + }]) + } + StudioEffect::ResetDevice { + action_id, + endpoint_id: _, + } => Ok(vec![StudioEvent::ActionFailed { + action_id, + message: "browser serial reset is not implemented yet".to_string(), + }]), + StudioEffect::FlashDeviceFirmware { + action_id, + endpoint_id: _, + firmware_id: _, + } => Ok(vec![StudioEvent::ActionFailed { + action_id, + message: "browser firmware flashing is planned for the next phase".to_string(), + }]), + StudioEffect::SeedDemoProject { + action_id, + project_id, + } => { + self.project_client()? + .seed_demo_project(action_id, &project_id) + .await + } + effect => self.project_client()?.execute_project_effect(effect).await, + } + } +} + +pub async fn run_browser_serial_demo() -> Result { + let mut app = StudioApp::new(); + app.dispatch_kind( + StudioActionKind::SelectLinkProvider { + provider_id: LinkProviderId::new(BROWSER_SERIAL_ESP32_PROVIDER_ID), + }, + ActionOrigin::System, + ); + let mut runtime = BrowserSerialStudioRuntime::new(); + let effects = app.dispatch_kind(StudioActionKind::RequestDeviceAccess, ActionOrigin::User); + drain_effects(&mut app, &mut runtime, effects).await?; + let endpoint_id = app + .state() + .link_selection + .endpoints + .first() + .ok_or_else(|| { + StudioRuntimeError::Link( + "browser serial permission did not yield an endpoint".to_string(), + ) + })? + .id + .clone(); + let effects = app.dispatch_kind( + StudioActionKind::ConnectDevice { endpoint_id }, + ActionOrigin::User, + ); + drain_effects(&mut app, &mut runtime, effects).await?; + let effects = app.dispatch_kind(StudioActionKind::UploadDemoProject, ActionOrigin::User); + drain_effects(&mut app, &mut runtime, effects).await?; + Ok(app) +} + +async fn drain_effects( + app: &mut StudioApp, + runtime: &mut BrowserSerialStudioRuntime, + mut effects: Vec, +) -> Result<(), StudioRuntimeError> { + while let Some(effect) = effects.pop() { + let events = runtime.execute_effect(effect).await?; + for event in events { + effects.extend(app.apply_event(event)); + } + } + Ok(()) +} + +fn browser_serial_capabilities() -> Vec { + vec![ + DeviceCapability::RequestDeviceAccess, + DeviceCapability::Connect, + DeviceCapability::UseBrowserSerialEsp32, + DeviceCapability::WriteProjectFiles, + DeviceCapability::ReadHeartbeat, + DeviceCapability::ListProjects, + DeviceCapability::LoadProject, + DeviceCapability::ReadProjectInventory, + DeviceCapability::ReadLogs, + DeviceCapability::ReadDiagnostics, + ] +} + +fn map_log_level(level: lpa_link::LinkLogLevel) -> StudioLogLevel { + match level { + lpa_link::LinkLogLevel::Trace => StudioLogLevel::Trace, + lpa_link::LinkLogLevel::Debug => StudioLogLevel::Debug, + lpa_link::LinkLogLevel::Info => StudioLogLevel::Info, + lpa_link::LinkLogLevel::Warn => StudioLogLevel::Warn, + lpa_link::LinkLogLevel::Error => StudioLogLevel::Error, + } +} diff --git a/lp-app/lp-studio-runtime/src/browser_serial_shim.rs b/lp-app/lp-studio-runtime/src/browser_serial_shim.rs new file mode 100644 index 000000000..344e128d1 --- /dev/null +++ b/lp-app/lp-studio-runtime/src/browser_serial_shim.rs @@ -0,0 +1,106 @@ +use js_sys::{Array, Promise, Reflect}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +use crate::StudioRuntimeError; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserSerialPortHandle { + pub id: u32, + pub label: String, +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialIsSupported)] + fn js_is_supported() -> bool; + + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialRequestPort)] + fn js_request_port() -> Promise; + + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialOpen)] + fn js_open(id: u32, baud_rate: u32) -> Promise; + + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialWriteLine)] + fn js_write_line(id: u32, line: &str) -> Promise; + + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialTakeLines)] + fn js_take_lines(id: u32) -> Array; + + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialTakeErrors)] + fn js_take_errors(id: u32) -> Array; + + #[wasm_bindgen(js_namespace = globalThis, js_name = lpBrowserSerialClose)] + 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_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<(), StudioRuntimeError> { + JsFuture::from(js_open(id, baud_rate)) + .await + .map(|_| ()) + .map_err(js_error) +} + +pub async fn write_line(id: u32, line: &str) -> Result<(), StudioRuntimeError> { + 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 close(id: u32) -> Result<(), StudioRuntimeError> { + 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_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(StudioRuntimeError::Browser(format!( + "browser serial response missing numeric `{key}`" + ))); + }; + Ok(value as u32) +} + +fn reflect_string(value: &JsValue, key: &str) -> Result { + let value = Reflect::get(value, &JsValue::from_str(key)).map_err(js_error)?; + value.as_string().ok_or_else(|| { + StudioRuntimeError::Browser(format!("browser serial response missing string `{key}`")) + }) +} + +fn js_error(value: JsValue) -> StudioRuntimeError { + if let Some(error) = value.dyn_ref::() { + StudioRuntimeError::Browser(error.message().into()) + } else if let Some(message) = value.as_string() { + StudioRuntimeError::Browser(message) + } else { + StudioRuntimeError::Browser(format!("{value:?}")) + } +} diff --git a/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs b/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs index e4d3f3aee..4cca696eb 100644 --- a/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs +++ b/lp-app/lp-studio-runtime/src/browser_worker_runtime.rs @@ -190,6 +190,7 @@ fn browser_worker_capabilities() -> Vec { vec![ DeviceCapability::Connect, DeviceCapability::UseBrowserWorker, + DeviceCapability::WriteProjectFiles, DeviceCapability::ReadHeartbeat, DeviceCapability::ListProjects, DeviceCapability::LoadProject, diff --git a/lp-app/lp-studio-runtime/src/demo_project.rs b/lp-app/lp-studio-runtime/src/demo_project.rs index 450d12d52..3a5aa6086 100644 --- a/lp-app/lp-studio-runtime/src/demo_project.rs +++ b/lp-app/lp-studio-runtime/src/demo_project.rs @@ -38,22 +38,57 @@ pub fn demo_project_files() -> &'static [DemoProjectFile] { } pub fn demo_write_messages(first_id: u64, project_id: &str) -> Vec { - demo_project_files() + demo_write_requests(project_id) .iter() .enumerate() - .map(|(index, file)| { + .map(|(index, request)| ClientMessage { + id: first_id + index as u64, + msg: request.clone(), + }) + .collect() +} + +pub fn demo_write_requests(project_id: &str) -> Vec { + demo_project_files() + .iter() + .map(|file| { let path = format!("/projects/{project_id}/{}", file.relative_path).as_path_buf(); - ClientMessage { - id: first_id + index as u64, - msg: ClientRequest::Filesystem(FsRequest::Write { - path, - data: file.bytes.to_vec(), - }), - } + ClientRequest::Filesystem(FsRequest::Write { + path, + data: file.bytes.to_vec(), + }) }) .collect() } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn demo_write_messages_allocate_contiguous_ids() { + let messages = demo_write_messages(40, DEMO_PROJECT_ID); + + assert_eq!(messages.len(), demo_project_files().len()); + assert_eq!(messages[0].id, 40); + assert_eq!( + messages[messages.len() - 1].id, + 40 + demo_project_files().len() as u64 - 1 + ); + } + + #[test] + fn demo_write_requests_target_project_directory() { + let requests = demo_write_requests("hardware-demo"); + + assert!(matches!( + &requests[0], + ClientRequest::Filesystem(FsRequest::Write { path, .. }) + if path.as_str() == "/projects/hardware-demo/clock.toml" + )); + } +} + pub fn ensure_write_response(body: &WireServerMsgBody) -> Result<(), String> { match body { WireServerMsgBody::Filesystem(lpc_wire::FsResponse::Write { error, .. }) => { diff --git a/lp-app/lp-studio-runtime/src/host_process_runtime.rs b/lp-app/lp-studio-runtime/src/host_process_runtime.rs index 2463b1979..5bc556f3d 100644 --- a/lp-app/lp-studio-runtime/src/host_process_runtime.rs +++ b/lp-app/lp-studio-runtime/src/host_process_runtime.rs @@ -1,6 +1,6 @@ use lp_studio_core::{ - DeviceCapability, HOST_PROCESS_PROVIDER_ID, StudioEffect, StudioEvent, StudioLogEntry, - StudioLogLevel, + DeviceAccessStatus, DeviceCapability, HOST_PROCESS_PROVIDER_ID, StudioEffect, StudioEvent, + StudioLogEntry, StudioLogLevel, }; use lpa_link::providers::host_process::{HostProcessProvider, HostProcessSession}; use lpa_link::{LinkEndpointId, LinkProvider, LinkProviderId, LinkSession}; @@ -128,6 +128,21 @@ impl EffectExecutor for HostProcessStudioRuntime { effect: StudioEffect, ) -> Result, StudioRuntimeError> { match effect { + StudioEffect::RequestDeviceAccess { + action_id, + provider_id, + } => { + if provider_id.as_str() != HOST_PROCESS_PROVIDER_ID { + return Err(StudioRuntimeError::UnsupportedProvider( + provider_id.as_str().to_string(), + )); + } + Ok(vec![StudioEvent::DeviceAccessUpdated { + action_id: Some(action_id), + provider_id, + status: DeviceAccessStatus::Granted, + }]) + } StudioEffect::DiscoverEndpoints { action_id, provider_id, @@ -146,6 +161,21 @@ impl EffectExecutor for HostProcessStudioRuntime { session_id, }]) } + StudioEffect::ResetDevice { + action_id, + endpoint_id: _, + } => Ok(vec![StudioEvent::ActionFailed { + action_id, + message: "host-process reset is not implemented".to_string(), + }]), + StudioEffect::FlashDeviceFirmware { + action_id, + endpoint_id: _, + firmware_id: _, + } => Ok(vec![StudioEvent::ActionFailed { + action_id, + message: "host-process firmware flashing is not supported".to_string(), + }]), StudioEffect::SeedDemoProject { action_id, project_id, @@ -180,6 +210,7 @@ fn host_process_capabilities() -> Vec { vec![ DeviceCapability::Connect, DeviceCapability::UseHostProcess, + DeviceCapability::WriteProjectFiles, DeviceCapability::ReadHeartbeat, DeviceCapability::ListProjects, DeviceCapability::LoadProject, diff --git a/lp-app/lp-studio-runtime/src/lib.rs b/lp-app/lp-studio-runtime/src/lib.rs index c4bd3e4e3..d47ffbb34 100644 --- a/lp-app/lp-studio-runtime/src/lib.rs +++ b/lp-app/lp-studio-runtime/src/lib.rs @@ -20,6 +20,13 @@ pub mod browser_protocol_client; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] pub mod browser_worker_runtime; +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +pub mod browser_serial_protocol_client; +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +pub mod browser_serial_runtime; +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +pub mod browser_serial_shim; + pub use effect_executor::EffectExecutor; pub use error::StudioRuntimeError; @@ -30,3 +37,6 @@ pub use host_process_runtime::HostProcessStudioRuntime; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] pub use browser_worker_runtime::{BrowserWorkerStudioRuntime, run_browser_worker_demo}; + +#[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] +pub use browser_serial_runtime::{BrowserSerialStudioRuntime, run_browser_serial_demo}; diff --git a/lp-app/lp-studio-runtime/src/project_session_runtime.rs b/lp-app/lp-studio-runtime/src/project_session_runtime.rs index 771a086f6..416725817 100644 --- a/lp-app/lp-studio-runtime/src/project_session_runtime.rs +++ b/lp-app/lp-studio-runtime/src/project_session_runtime.rs @@ -1,4 +1,3 @@ -use lpc_model::AsLpPathBuf; use lpc_wire::{ClientRequest, WireProjectCommandResponse, WireServerMsgBody}; use lp_studio_core::{StudioEvent, StudioLogEntry, StudioLogLevel}; @@ -22,15 +21,8 @@ impl<'a> ProjectSessionRuntime<'a> { project_id: &str, ) -> Result, StudioRuntimeError> { let mut events = Vec::new(); - for file in demo_project::demo_project_files() { - let path = format!("/projects/{project_id}/{}", file.relative_path); - let exchange = self - .client - .send_request(ClientRequest::Filesystem(lpc_wire::FsRequest::Write { - path: path.as_str().as_path_buf(), - data: file.bytes.to_vec(), - })) - .await?; + for request in demo_project::demo_write_requests(project_id) { + let exchange = self.client.send_request(request).await?; events.extend(exchange.events); demo_project::ensure_write_response(&exchange.response.msg) .map_err(StudioRuntimeError::Protocol)?; diff --git a/lp-app/lp-studio-web/Cargo.toml b/lp-app/lp-studio-web/Cargo.toml index a60f442c0..800cd879d 100644 --- a/lp-app/lp-studio-web/Cargo.toml +++ b/lp-app/lp-studio-web/Cargo.toml @@ -17,7 +17,7 @@ lpa-link = { path = "../lpa-link", optional = true } lpc-model = { path = "../../lp-core/lpc-model", optional = true } lpc-wire = { path = "../../lp-core/lpc-wire", optional = true } lp-studio-core = { path = "../lp-studio-core" } -lp-studio-runtime = { path = "../lp-studio-runtime", features = ["browser-worker"] } +lp-studio-runtime = { path = "../lp-studio-runtime", features = ["browser-worker", "browser-serial-esp32"] } web-sys = { version = "0.3", optional = true, features = ["Location", "Window"] } [lints] diff --git a/lp-app/lp-studio-web/README.md b/lp-app/lp-studio-web/README.md index d8266ebdb..eb00d7e9e 100644 --- a/lp-app/lp-studio-web/README.md +++ b/lp-app/lp-studio-web/README.md @@ -2,9 +2,10 @@ `lp-studio-web` is the first static browser shell for LightPlayer Studio. -It renders `lp-studio-core` state and drives the browser-local `browser-worker` -runtime path from `lp-studio-runtime`. It does not own Studio domain behavior and -does not use Dioxus server functions. +It renders `lp-studio-core` state and drives browser runtimes from +`lp-studio-runtime`: the browser-local `browser-worker` proof path and the +`browser-serial-esp32` path for already-flashed ESP32 hardware. It does not own +Studio domain behavior and does not use Dioxus server functions. ## Run @@ -19,6 +20,17 @@ packages them with wasm-bindgen, prepares the worker assets, and serves Use `just studio-web-build` or `just studio-web` when you want the release/static build path. +## Hardware + +The hardware button uses Web Serial, so it requires a supported Chromium-class +browser and a secure/local context. The current hardware path assumes the ESP32 +already has LightPlayer firmware running; browser-side flashing is planned as +the next hardware phase. + +The app loads `public/browser-serial.js` before the Rust wasm module. That shim +owns the direct Web Serial stream objects, while Rust owns Studio actions, +status, protocol parsing, and demo project upload. + ## Stories `lp-studio-web` has a native Dioxus storybook for isolated component states. @@ -87,5 +99,6 @@ non-generated files under `lp-app/lp-studio-web/` have changed. ## Boundary - `lp-studio-core` owns actions, state, effects, diagnostics, and sessions. -- `lp-studio-runtime` owns browser worker protocol flow and demo project loading. +- `lp-studio-runtime` owns browser worker/serial protocol flow and demo project + loading. - `lp-studio-web` owns Dioxus components and static presentation. diff --git a/lp-app/lp-studio-web/public/browser-serial.js b/lp-app/lp-studio-web/public/browser-serial.js new file mode 100644 index 000000000..b7dc8d135 --- /dev/null +++ b/lp-app/lp-studio-web/public/browser-serial.js @@ -0,0 +1,167 @@ +const sessions = new Map(); +let nextSessionId = 1; + +export function installLightPlayerBrowserSerial() { + globalThis.lpBrowserSerialIsSupported = isSupported; + globalThis.lpBrowserSerialRequestPort = requestPort; + globalThis.lpBrowserSerialOpen = openPort; + globalThis.lpBrowserSerialWriteLine = writeLine; + globalThis.lpBrowserSerialTakeLines = takeLines; + globalThis.lpBrowserSerialTakeErrors = takeErrors; + globalThis.lpBrowserSerialClose = closePort; +} + +function isSupported() { + return Boolean(globalThis.navigator?.serial); +} + +async function requestPort() { + if (!isSupported()) { + throw new Error("Web Serial is not supported in this browser."); + } + const port = await navigator.serial.requestPort(); + const id = nextSessionId++; + sessions.set(id, { + port, + reader: null, + writer: null, + decoder: new TextDecoder(), + encoder: new TextEncoder(), + buffer: "", + lines: [], + errors: [], + closed: false, + }); + return { id, label: labelForPort(port) }; +} + +async function openPort(id, baudRate) { + const session = requireSession(id); + await session.port.open({ baudRate }); + session.reader = session.port.readable.getReader(); + session.writer = session.port.writable.getWriter(); + session.closed = false; + readPump(id, session); +} + +async function writeLine(id, line) { + const session = requireSession(id); + if (!session.writer) { + throw new Error("Serial port is not open."); + } + await session.writer.write(session.encoder.encode(line)); +} + +function takeLines(id) { + const session = requireSession(id); + return session.lines.splice(0, session.lines.length); +} + +function takeErrors(id) { + const session = requireSession(id); + return session.errors.splice(0, session.errors.length); +} + +async function closePort(id) { + const session = sessions.get(id); + if (!session) { + return; + } + session.closed = true; + try { + await session.reader?.cancel(); + } catch (error) { + session.errors.push(errorMessage(error)); + } + try { + session.reader?.releaseLock(); + } catch (error) { + session.errors.push(errorMessage(error)); + } + try { + await session.writer?.close(); + } catch (error) { + session.errors.push(errorMessage(error)); + } + try { + session.writer?.releaseLock(); + } catch (error) { + session.errors.push(errorMessage(error)); + } + try { + await session.port.close(); + } catch (error) { + session.errors.push(errorMessage(error)); + } + sessions.delete(id); +} + +async function readPump(id, session) { + try { + while (!session.closed) { + const { value, done } = await session.reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + session.buffer += session.decoder.decode(value, { stream: true }); + drainCompleteLines(session); + } + } catch (error) { + if (!session.closed) { + session.errors.push(errorMessage(error)); + } + } finally { + const wasClosed = session.closed; + session.closed = true; + if (!wasClosed && sessions.get(id) === session) { + session.errors.push("Serial port disconnected."); + } + } +} + +function drainCompleteLines(session) { + for (;;) { + const newline = session.buffer.indexOf("\n"); + if (newline < 0) { + return; + } + const line = session.buffer.slice(0, newline).replace(/\r$/, ""); + session.buffer = session.buffer.slice(newline + 1); + session.lines.push(line); + } +} + +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"; +} + +function numberToHex(value) { + if (typeof value !== "number") { + return null; + } + return value.toString(16).padStart(4, "0"); +} + +function requireSession(id) { + const session = sessions.get(id); + if (!session) { + throw new Error(`Unknown browser serial session: ${id}`); + } + return session; +} + +function errorMessage(error) { + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/lp-app/lp-studio-web/public/index.html b/lp-app/lp-studio-web/public/index.html index b92bdcd32..bd80fba14 100644 --- a/lp-app/lp-studio-web/public/index.html +++ b/lp-app/lp-studio-web/public/index.html @@ -8,7 +8,9 @@

diff --git a/lp-app/lp-studio-web/src/app.rs b/lp-app/lp-studio-web/src/app.rs index 30d74247b..8c7a5597d 100644 --- a/lp-app/lp-studio-web/src/app.rs +++ b/lp-app/lp-studio-web/src/app.rs @@ -1,6 +1,6 @@ use dioxus::prelude::*; use lp_studio_core::StudioApp; -use lp_studio_runtime::run_browser_worker_demo; +use lp_studio_runtime::{run_browser_serial_demo, run_browser_worker_demo}; use crate::components::device_panel::DevicePanel; use crate::components::inventory_view::InventoryView; @@ -42,13 +42,32 @@ pub fn App() -> Element { running.set(false); }); }; + let connect_hardware = move |_| { + if *running.read() { + return; + } + running.set(true); + error.set(None); + spawn(async move { + match run_browser_serial_demo().await { + Ok(app) => studio.set(app), + Err(runtime_error) => error.set(Some(runtime_error.to_string())), + } + running.set(false); + }); + }; rsx! { style { "{STYLE}" } main { class: "studio-shell", StatusBar { state: state.clone(), running: is_running, error: error_text.clone() } section { class: "studio-grid", - DevicePanel { state: state.clone(), running: is_running, on_start_demo: start_demo } + DevicePanel { + state: state.clone(), + running: is_running, + on_start_demo: start_demo, + on_connect_hardware: connect_hardware, + } ProjectPanel { state: state.clone() } InventoryView { state: state.clone() } LogPanel { state } diff --git a/lp-app/lp-studio-web/src/components/device_panel.rs b/lp-app/lp-studio-web/src/components/device_panel.rs index 31519552d..b993d68e8 100644 --- a/lp-app/lp-studio-web/src/components/device_panel.rs +++ b/lp-app/lp-studio-web/src/components/device_panel.rs @@ -1,11 +1,12 @@ use dioxus::prelude::*; -use lp_studio_core::StudioState; +use lp_studio_core::{DeviceAccessStatus, StudioState}; #[component] pub fn DevicePanel( state: StudioState, running: bool, on_start_demo: EventHandler, + on_connect_hardware: EventHandler, ) -> Element { let provider = state .link_selection @@ -13,6 +14,11 @@ pub fn DevicePanel( .as_str() .to_string(); let endpoint_count = state.link_selection.endpoints.len(); + let access = state + .device_access + .as_ref() + .map(|access| access_status_label(&access.status)) + .unwrap_or_else(|| "not requested".to_string()); let session = state .device_session .as_ref() @@ -22,15 +28,24 @@ pub fn DevicePanel( section { class: "panel device-panel", div { class: "panel-heading", h2 { "Device" } - button { - disabled: running, - onclick: move |event| on_start_demo.call(event), - if running { "Running" } else { "Start demo" } + div { class: "button-row", + button { + disabled: running, + onclick: move |event| on_start_demo.call(event), + if running { "Running" } else { "Start local" } + } + button { + disabled: running, + onclick: move |event| on_connect_hardware.call(event), + if running { "Running" } else { "Connect hardware" } + } } } dl { dt { "Provider" } dd { "{provider}" } + dt { "Access" } + dd { "{access}" } dt { "Endpoints" } dd { "{endpoint_count}" } dt { "Session" } @@ -39,3 +54,13 @@ pub fn DevicePanel( } } } + +fn access_status_label(status: &DeviceAccessStatus) -> String { + match status { + DeviceAccessStatus::Unknown => "unknown".to_string(), + DeviceAccessStatus::Unsupported { reason } => format!("unsupported: {reason}"), + DeviceAccessStatus::PermissionRequired => "permission required".to_string(), + DeviceAccessStatus::PermissionDenied { reason } => format!("denied: {reason}"), + DeviceAccessStatus::Granted => "granted".to_string(), + } +} diff --git a/lp-app/lp-studio-web/src/components/device_panel_stories.rs b/lp-app/lp-studio-web/src/components/device_panel_stories.rs index fd74b48e4..17279744b 100644 --- a/lp-app/lp-studio-web/src/components/device_panel_stories.rs +++ b/lp-app/lp-studio-web/src/components/device_panel_stories.rs @@ -3,7 +3,9 @@ use dioxus::prelude::*; use crate::components::device_panel::DevicePanel; use crate::stories::story::StoryDescriptor; use crate::stories::story_fixtures::{ - studio_state_connected, studio_state_connecting, studio_state_idle, studio_state_long_content, + studio_state_connected, studio_state_connecting, studio_state_hardware_denied, + studio_state_hardware_granted, studio_state_hardware_unsupported, studio_state_idle, + studio_state_long_content, }; pub const STORIES: &[StoryDescriptor] = &[ @@ -25,6 +27,24 @@ pub const STORIES: &[StoryDescriptor] = &[ "Connected", "A browser-worker session is connected.", ), + StoryDescriptor::new( + "device/hardware-unsupported", + "DevicePanel", + "Hardware Unsupported", + "Web Serial is unavailable.", + ), + StoryDescriptor::new( + "device/hardware-denied", + "DevicePanel", + "Hardware Denied", + "Serial permission was denied.", + ), + StoryDescriptor::new( + "device/hardware-granted", + "DevicePanel", + "Hardware Granted", + "A browser serial endpoint was granted.", + ), StoryDescriptor::new( "device/long-session", "DevicePanel", @@ -38,6 +58,11 @@ pub fn render_story(id: &str) -> Option { "device/idle" => Some(device_story(studio_state_idle(), false)), "device/starting" => Some(device_story(studio_state_connecting(), true)), "device/connected" => Some(device_story(studio_state_connected(), false)), + "device/hardware-unsupported" => { + Some(device_story(studio_state_hardware_unsupported(), false)) + } + "device/hardware-denied" => Some(device_story(studio_state_hardware_denied(), false)), + "device/hardware-granted" => Some(device_story(studio_state_hardware_granted(), false)), "device/long-session" => Some(device_story(studio_state_long_content(), false)), _ => None, } @@ -48,7 +73,8 @@ fn device_story(state: lp_studio_core::StudioState, running: bool) -> Element { DevicePanel { state, running, - on_start_demo: move |_| {} + on_start_demo: move |_| {}, + on_connect_hardware: move |_| {}, } } } diff --git a/lp-app/lp-studio-web/src/components/status_bar.rs b/lp-app/lp-studio-web/src/components/status_bar.rs index cc6b99fc1..f0155e35d 100644 --- a/lp-app/lp-studio-web/src/components/status_bar.rs +++ b/lp-app/lp-studio-web/src/components/status_bar.rs @@ -26,7 +26,7 @@ pub fn StatusBar(state: StudioState, running: bool, error: Option) -> El header { class: "status-bar", div { h1 { "LightPlayer Studio" } - p { "Browser firmware runtime" } + p { "Firmware runtime and hardware control" } } div { class: "status-pill", "{status}" } div { class: "status-metric", "{heartbeat}" } diff --git a/lp-app/lp-studio-web/src/stories/story_fixtures.rs b/lp-app/lp-studio-web/src/stories/story_fixtures.rs index 278c42a3e..f9b7a0850 100644 --- a/lp-app/lp-studio-web/src/stories/story_fixtures.rs +++ b/lp-app/lp-studio-web/src/stories/story_fixtures.rs @@ -1,7 +1,8 @@ use lp_studio_core::{ - ActionId, BROWSER_WORKER_PROVIDER_ID, ClientSession, ConnectionSession, DeviceCapability, - DeviceId, DeviceSession, ProjectSession, STUDIO_DEMO_PROJECT_ID, StudioDiagnostic, - StudioHeartbeat, StudioLogEntry, StudioLogLevel, StudioState, + ActionId, BROWSER_SERIAL_ESP32_PROVIDER_ID, BROWSER_WORKER_PROVIDER_ID, ClientSession, + ConnectionSession, DeviceAccess, DeviceAccessStatus, DeviceCapability, DeviceId, DeviceSession, + ProjectSession, STUDIO_DEMO_PROJECT_ID, StudioDiagnostic, StudioHeartbeat, StudioLogEntry, + StudioLogLevel, StudioState, }; use lpa_link::{ LinkConnectionKind, LinkEndpoint, LinkEndpointId, LinkEndpointStatus, LinkProviderId, @@ -40,6 +41,48 @@ pub fn studio_state_connected() -> StudioState { state } +pub fn studio_state_hardware_unsupported() -> StudioState { + let mut state = StudioState::default(); + state.link_selection.selected_provider_id = + LinkProviderId::new(BROWSER_SERIAL_ESP32_PROVIDER_ID); + state.device_access = Some(DeviceAccess::new( + BROWSER_SERIAL_ESP32_PROVIDER_ID, + DeviceAccessStatus::Unsupported { + reason: "Web Serial is not supported in this browser.".to_string(), + }, + )); + state +} + +pub fn studio_state_hardware_denied() -> StudioState { + let mut state = StudioState::default(); + state.link_selection.selected_provider_id = + LinkProviderId::new(BROWSER_SERIAL_ESP32_PROVIDER_ID); + state.device_access = Some(DeviceAccess::new( + BROWSER_SERIAL_ESP32_PROVIDER_ID, + DeviceAccessStatus::PermissionDenied { + reason: "No port selected.".to_string(), + }, + )); + state +} + +pub fn studio_state_hardware_granted() -> StudioState { + let mut state = StudioState::default(); + state.link_selection.selected_provider_id = + LinkProviderId::new(BROWSER_SERIAL_ESP32_PROVIDER_ID); + state.device_access = Some(DeviceAccess::new( + BROWSER_SERIAL_ESP32_PROVIDER_ID, + DeviceAccessStatus::Granted, + )); + state.link_selection.endpoints = vec![LinkEndpoint::new( + "browser-serial-esp32-port-1", + BROWSER_SERIAL_ESP32_PROVIDER_ID, + "ESP32 Serial (303a:1001)", + )]; + state +} + pub fn studio_state_ready() -> StudioState { let mut state = studio_state_connected(); state.project_session = Some(project_session( @@ -117,6 +160,7 @@ fn attach_device_session(state: &mut StudioState) { capabilities: vec![ DeviceCapability::Connect, DeviceCapability::UseBrowserWorker, + DeviceCapability::WriteProjectFiles, DeviceCapability::ReadHeartbeat, DeviceCapability::LoadProject, DeviceCapability::ReadProjectInventory, diff --git a/lp-app/lp-studio-web/src/style.css b/lp-app/lp-studio-web/src/style.css index 1c502d3d1..3d6f7e2a7 100644 --- a/lp-app/lp-studio-web/src/style.css +++ b/lp-app/lp-studio-web/src/style.css @@ -146,6 +146,13 @@ button:disabled { margin-bottom: 10px; } +.button-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + .panel h2 { font-size: 15px; color: var(--text-primary); diff --git a/lp-app/lp-studio-web/story-images/device__connected.png b/lp-app/lp-studio-web/story-images/device__connected.png index 7ac3f78765edc4455cdd8721594fc087d503f108..b33e6b05b2809872e46d2bc9c73acc9ed1e4637e 100644 GIT binary patch delta 16679 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 diff --git a/lp-app/lp-studio-web/story-images/device__hardware-denied.png b/lp-app/lp-studio-web/story-images/device__hardware-denied.png new file mode 100644 index 0000000000000000000000000000000000000000..64906123f815251fefcbe6b14e88bf11f4f976a5 GIT binary patch literal 20766 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 literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/device__hardware-unsupported.png b/lp-app/lp-studio-web/story-images/device__hardware-unsupported.png new file mode 100644 index 0000000000000000000000000000000000000000..0903d2be3024f4fcdbe6411c5f2dfe901e82acef GIT binary patch literal 22716 zcmZU(1yCGa6E=!0vN(%NV1dP5gX;oIu;A_l3r=u%C%8KVNN^7lAh^2|EChFVa>@Js zf7QKpyQa?0?40iNw4ABwrzY~fk}M_~DH+(q|#9dB*gQu+p3CWf#e(5srQ`bEuL_<_-w1l}KqDqYx|l?A zBN)(_m%u831WvCIF48GJzBClt>)(xn-9C8UUu!^qj-D5NEPN_(TEBbr+jmh%$?!zr zbxAmwd|ZY#{Obt^Ni5*?Ctzj?rte0SX29y2_IX-XY2oI%6f6E`=?_otOrBFF6Q;;%Q3j7~ho;^xOI-*{(P-)t9e(=@l2fqHX!pK` z-0lkslwRxK-sENEa&Sw%u2j4VQ&4=-!uB*PVq|ToZ!DC&WwN)t-9=3z12X^kk;BW{ zP}^E8Sy@1DaboW#kP{c$lF@gsbDwBlr({%|CG(1AqoF7vKY{=2;eznI(iH5~Xtq-Z zABb0jUF{j`u{_ghDPGS$7~ON(3_oX9uyva-?&9={Y2)a{`d`7XRK8wrzK4%@~*Vgtmkz7N9*RTt~J1hc`n5-Z{e_o z+FgMTxx(sR(MXu^jS{A4S>K5T(@%&Z^m{A3K=lA{3B0I9RlN#s8m@X35h8T2^Gmm%%)!%@6+wm z6-LV#gXJ<5FvqkkGO@YojChBQHorFK+H2JtYZf*8Mc)eVjtboVI{ED>=+eYDZ&6bE z;MJy?nr7Y^dduTQPcL(icVy~gOT3P8qo=1Pm-DHi*|w^hjcrs@OT#MuJ-)OGzVtoH zlL)NK1m&~}O7%j9%|?*lT+pGE-0VY5+M4n(xF|u?pHFvoz5~Yn3@8R-vmSQ;j7BK8 z(d$;JtquJiCbbz<24N$8jvrXW=(-hrt75h$?(Nl7*y$Luwt$E5GuX z0YSMg;Y(_DO{exV*C1rzAG~@=VuE zOBm+j0KZH8wHMJJ>Enoj@kL^A2!ZImxIcEvA2XcOq-*vdDDI#1Ac-IxmhfP3Oh#T- ziPaDYRTu+=5FBzk{p=!%(T^LVW8%BRV~$>1lSe%TF8B|d>Ips&ByAFZXK`w)`z@-Q z;R!F3NPvYp2wC+CofKbvixgjgkt04)5)D`w2{ADS32;PrK$)0XLRnJYeJ{r6VW^Q1 zE2cN}Sj=9z-ddZAB_ebl_nug_Tz8@ScsdKs+`>!$(Z@-xMPA+_@#&3!&Jdc zO)^NdzqTm+`^drP?Xua_9emLArx;6--SI`X@4g@NXMst|1J;!1d~<tucM|#=&dsvLbz)fXb6C+h^wF^5z8@Z@R zNJyy#H>xDm$=)q$Jke$V-5x_f`uEjHq$=4}?x~@WZLIo+Iu&ls%8ctl$Tx?Uo1aeU zGHCw;4T^M=m7;>bGy`@5ggfv-Jk+9W?J4_Fs<1K?$h;dXi;YHNE?Y-l9NKHi40^B? z*rc8XqmcEbwnTYR1%6%^>PM9f(X=*zb>%X518pcupZd#C-PFFg*E&&cIuLabHDpL4 z!1v2q%-CV(hRqE@-1;i{>9E67G@ok<>eg1=Zw6|CipZ`^r z9pqE|?T!0@Gr3`uWZ@CqB+1!rVeh-Kd*=r6EFEM>#tx7SEq{OjS*>EtohBgHI1}j;%Q&HGhI-rAEn(tT({vOltf?-p&6g5 zM-hkU_bq&>uZs8Z_-l_}u=-&)kcF#FKnHiHWu(#!_?8)Q(83l9JIhBPm%SBVlB(`^ z^;+RQ+QCop`k16wT~}bUpKGdMNibz|cF;%jY=DYw^mF`;|6-dr0b>wHO!r#QS~qf3 zv=!vVqy|i!PG)TnNhghrOlL5)Fa{3>5u@50Lx_pVJR~TH`7wA$$FGbhg~&aE8rw=s z+g3xp+78MEjoC&0AB;n<_#}cbtYglR)50^(=kif|Rc-5%@K#@*FWbNg5IN`hV`7NM zklkV4#Ro5B7(Q7rVDaNj(z21EI<{FM93o-|xxYF3_Od;ED2c7PpDGF}Cp1bBbNT!0 z#7egT)N`)fotL(^)tVn6e`aepENvQdHV8iTIgav4bSsgOo&bOpxGTQJSmFp8&sp+{F#%NfC6rQdAJ#>#S;eUl`tn_4AP8Ld^`c}#tJYg7jwPy zO_I;uhy3auQXn_gUWu`;VZXkHZ4N}P4#f=vG|FWzuUm=&)Y2x4qqML?D0}!2eXQcm zy&-T+7_Wp4n%9&6Oeu0jeD1v3Ut0lJz-%!BHVFtka;DPd*L+7B##bpSl{hy^vuRjb zR;X-T8$!UgMQ89SuyiaeVAqiov|$uQaripq5MgQ=vx&Nk6b$lrHjYSyOqXQ%5Od@v88 z8YgxcDbMGP`Z%JXie%%-PFCZ|%+ktHE6UG0&uk^-x)kmJ`+0UG`U0k&?SpjJsD>Rj zi&=wa@EhfUk1DGt&lJU-xRyReyrEKFQXs{^d&Zh0_vJXr|rC{u0*>uQX1+ zz0`#rBXwCK{ub-@Ls!jJXd~m!z1C~a6M+(@Oe@3f=tlTJh3;bIJEP{Gymt>iAW>uL(F_PJ>UwiuvziNdsJxkLyftoUE) zB?dv=elJpeWAA#vuqW4V^BRQDPdCYR|99*8eLO4?BB%QQ8|$#qK(8j`{@>#c1*D4H zu-*OBioapUr?ZWhsnE;6i*FzEAVmyum-JQ}WSNO2Sk4_vGn|oBWF+{s2zY(79`bln zI8#U+K|#DXx1wUBsXHdJ;5Cu z_!nqXKAs&4uP@jC-mm8*G;Oq5`yF;eA6iK*`pqQLMGQzS9`e3-_;}F1yP|6*!SeF! z(BZAns3wmh9C1_)OfvGdfE1wY|bie9#TMw3&4Wpv4d2u4ZfZ?^8$8JVOnx9_=d z|F?h^iq6-m#rNT&bLO+0mrwlYGeTSD7Hn^n?V&H)uq(HZ@yEGx6QiEhWER17JM7=m z&>}B*i{f- z3L&rJ5P??Cak7^(5Q#9z^`pC0>%RqNG|r(FNsK6o>FTL3%?e&@6$cr~bkEm1ZZxF# z3oG!CDlIKtQyhh==PA1|Q&FPfU2h8)S3eTIlO8NhO3NLilauEm<${&0M|F@j#B+7z$>&tC0r>@6#+adFP5~WG7-BAR9fq1Q+kh zLM7eQgl2c0g~c~~FHv`t*G_2ei)V(jRR}U2npEshk>~~}Y#*KS{Z;i;_`oGZ~@9tO>|Q*B|(acx}CI z2i)1n#EyD*8CDH|3Gt0sNP>hMJt{j73>#pI11l}-?Uo{@)m4d?8;FjI$qzc{2DhTN zUp7Bfvxk&FNb~ zLj0Emjma{={&-RvdzF~pCTUc0F*hg(Ih{HeOY1$cr48iWn_&)?(OcJ>eaA?OZ!*-6 zgvKF^O1K5#=%SR z%+WIb%V)nT9i{aPYjj-vs$4ifAqjB0?XW5UNlX{DIJ&vnFxLt)xlTUvqka8X*Dena z=<=)A%mut;#3c0jN_SXak*TH~tol^iaJ{H>lH)bT|TAZGr znZ-iJ}S4I1clq(LE9uOX8AUx@0(`(d^fm@4I?o&%kq^HDRcWLMu&7o`Ao zYHT7xMG|NN55F&GLDZ-}IXb$Zr-CBnpeF_`Ar~cs@IYb!S_DwR;4nc`0)caB;uYTR zGAs(SWL}e!HxdTkRyp|8clTJ&%`+8%=v9fpYcA=3n0}{iie{dpJ2>Rhegept)Umy9KT&*Su7*q ztq{Lg28hag3gq6_;~81*{}Np`qA&dl`O#ofz3;Ci^;*~%Qz8x=T$i7c?LxE%UR)S7 zl`h(9c?|t^659t7#rn2CRKQkk+xxzXv~VM!w7j7r%5i=`O7U31V3>b^l=qF1s)#LUJ|s zd4JJnpO+FO6V0Odi<}S+Zc+bDZ8)gD@2>|(9S`R_9uknQmrh>PE98o%6Z?4k7j&K% zbnaZ}+UF-7{mWv6?kuvp#_RVxnI zsuVG_)>0yW3ydFpki)lm>%j2pwPZuonVY=*3m<NRwb5Okml z75$mAKtT#I<_xl=Dc{g)`?pshz$s*+t-x&YAKB@O&i|tP9+y>h4uLh0^j`sP@VwP3 zJAUu_dcFK#K}t4^^ufUNHFW=*TKQfBo;8pR`Y-xSrXw06;_@37iUXI*n-m%2&780y{z^g~4?rOPEL6C&m8Dh!inYY)Sltj70v_YkR}9jVsl8-HL3F znu&8o(bA6@doHjVL~9e;yP6{u@>+h2HBs>$9G;7MqU2SC_0`o_ot-~5useQKDRj28 zDCN2DhA$((*?Qi74nA+d^cpGJv>CT;*7NM>srPhnGQW8LP$&aSp2v&?{)1y*PaU<` z;cq&j?3I@`6>j-+iIeSIIqwBj71u^dbYDuev?A+;;~H~Y8pS|uu>#t@Pc0SZ7ppy` z$f8c|D8mIUSic%N5B|~SX-67^$p1oLfwEBYQ8hE>M@_@W=)%!*RKsMUq|La_E=8Tdsx&IZfA74=7#vlYsC>9?;Uf6Z8xN^cBxHR;s$5>MA(e(^rzDr zd=a+q6H6Ii{noW%&a6YyvuHiZ$;?gSK9-b>d7kGMIf^51x)3l+B2aWDG9!~Z89jgc zLbDWDqy~g`LiN4v~PbX)J!?b9vFovZYXj&I`FZL&q->UvFb+ZJ% zmPbBsV-!y6#}Rg@PFuAq?r+^r5z(5*HO6}S+wgctml!+SA*Zw=hn6N?80uU+L)*U` z-gyuE!e{PRJ882tYh}#N0?Gu=|?3nPJaM?KX zxe*N)X|)*DZJB#Pz%HNnl0CF>Z!fwg`rW!UBLThnP}HvwO$Y%Cz><$}SyVjY7t0Yq z1zp-)Cq!rpaaT7!-+lHG^lb#)8iFKO?XCHC$Wr@EYqcw=Q%R*6f&l?kKeF+7tKw;P z2*n7JzWna^{g)n$Uot>cf(ZwjK1MBj=pA|RP1(+3C``t}7f0BC?&K&^bg*I6yH1xR zk=G_pU7&R4gzW`{WYYp*$;4!kZi%;1ezxE{5vkfU0H?U=@WQW8h=#m5Vjru@D>qN* z2%}%uk9uu4{n};cHgC^3NCPMR@N$$lKDqIJrq-r}aO69J3zpy#S{vjOg8?cbY_Mde zfRLs;B^Ha5mxkGPyPqcj9or6xAz!&47nW2L#R#yy@X#M;V5&~vX9Yq67;4meWT5w7 zOF`Z&<-Q_AZLpdr35k%oaki&7L-Km_2d7)@3NBk5CB6IF!e9Z$4_)7mC8!1?=GR zoQ7^!NBFZ7Ln!jjB93M-=eTpD$L^@5hZYx0x3<<&#&Y}boUrE>QcR?1rtaI0{``|q zS`0o>@sfXUKPWv9b0^hgdHWkD=zV#ar1LSF570r_Rc9NzPVrWcO0r?2VLBBI#fyw4 zQMJM|N>d(k7IM;j^On6lnfFc_Nsom(BDdWr+kE__y*>6SeA}|!(j%;fTp$`w#_e0`HB)8SuxU=Hy-uHq-1g-0#Eva4i{lRQ!P%6`nH(JqKumZ znS&a;h)>DQuzQgQWUfos+cF*mmOk2jcbnZ+HQ7%%es@t8jtt^Ncr95BI1qy=*y+?U zsP}s~B?M*j)@4?LZ%R=vkCvJ?ja`rDbZo&KZ>K@j3Pt7ol*LE45{*3 zLVD{*GI;eSwE_$IxE)5II&@dLKIxyRU44uIbJBf^7q^At5dG1ypu0l)Jc$h=`~bBY z&b(--9H`fL?SfoLA{~IIF(FOtEKiZfWGO+IDC6WwE?*4`?h+vnH(uj{wC8lzfL-l0 zDS&7w08|)(@`4ok7>)sQahWH9T>10mN23`ud$Y*uYdx9$2n2W&F?IhXv_c9F>0|i` zgqR@P9+c^{+x6OhC+TV>*tg+x0p~C+q)h{wP080*FjFB4{U~UT`Ggqi1;2&;|<7#|zulLp*3O zn?pRPd2(2(Vyppecr%LLdwhS;Iqz?P(}5TLmy%8R$VVAZaZ}p}@XyqUswzkS&QT{Z zr^+#xAOv?Kut}{##P>H-$$=r#gj8V$9p7c9*)`?~4y$rW9`v$4RR#ATFLvePz_{Yi z#0XlW=*N${9kehZ7*XGiVvz?}?*Q(b%otcvr~x9?tTzDw{R2q zc<}_p$OK$CpbC7Z;0M*G3tGZ@fEG9odMTF;;uR23#8r{Afov9G3xj_)n=5CKn>nc+ zDdEt%ULBJ65JVEwi_!}ViVFH?=4AhqLj8g_@n>pKZE#@tkFPd)FK@fzg!I0*P(mU= zwPju#fp+j>u#N0ccuO?mKl%c9)*@qnz50 z_$oNze-UcYHKStttnpOz(B<$XaC;@Cainn$U%B8}Nr{=S43fhEL3Hmn3hBBf-t+KL zBjcj~1%7-U4ez-mq+D7BGB-R`8Cb>!TSv58XKR0s8jS~ed9_MV7=IWO2@ z;zX`gvC3mhDiXzwcRu2|UoQb~rYFt5EzW?+IGxPmCzHbN_zBzi-5;l;8TraS6fTE< zW&fBD^6O86Fp~yD&FSIbuFi435`kgrr)a~q)SiWmE2#0P#*^L@l%*;yy{Ni2^Xa;Q zhLiFN9qp&PXz|4F^gaH1lv;NI{Tb72;6QW5y@KM@Js>-A(?jF{z&$)uav3xc)29GX z?=5><%pSUT?e^njqOeS~GP-g zI5~6d3vhwkyjlP?Njz+YU+I#(Nn;CjeB@l5*@Qg>pC%{2@$mL z`6%xN9VDk}T8sQkg%9cT2&M_Z3-6t!N+g= z7|a6Trl!k88e-OTeX-fb{jIjQP6}_;)r)#=X5CU;ZLTjL?O%9^{)>$1YBMH6q}TS_ z2&0)g94{vsDTwKN$|oVyY|C=;BA)F^&-jRq#SPb+Logx;@42mZnuBxLF90XlOpYcM zH%ISShKlp}Y+mA3{;)=EXvG|Y*v}mU~!Ly~WVZtM&6(iZdP11hDpKS@R^{WRGS=tMeF#@GH6hk0M+3LYgB50O`*SV> z4*G4v01!h>ioL%_8!9XRotj_F9-BCm4G*e z_VK@!oM#8=#>$8bL*J@WPQw;22TtVvA_9Y~R|fkpx0qmOgTHVotX)!#2|#ogF{Jw6 zbc7pOBg^W!WQu3hLpq0DIB^)_{9pFR6a=XeK`N`gP2eeUAIb4ve!VRXWD7)|hboYd zta>c=P#6DpxNi;D?nw^-vZrx$d3 zmeMw7n%Un_pHD?WEoBEAujursrfgzfI*1Z*P}o4vL(~a^UnV<+ovbT3r$-hv6!`q! z-Gh>}=osc-J9$T#bT`=LJxiJA$sIC8sPvLbdlWHyf@<;k-mRdMm1zBj2nbVwp!q|Cn#Q<*)Fw;* z34>IxoB&*WD(nw#r^=C@ApA4 zIO>ZNfg+dL1Y;?393dK(NeLU$#E?NNS`M0TugC z!mSsB zE2t#Cax&>$|G{;c@5)%f=!b+dht+Rl*%&g+NT(k95Hv$k;x`ewlT8^kHte#WtF6tg zeXhYEYTtxCsaGxqF8?*L9hQSc%pIzdWFOyV9)CC13WXhK4;>H{9Me*E&OC$ChpR? zd#cEX(D_uhxK91|VP)UO%(LgiAN@TYwC{)7QZ5}%b5=WqZcN>*s$Rdb@zcKB#<+hY zE$LxGMP4~e$o^!!Zm*jaQOFVJAHLI}Q1VsImSI`hE}r2E?wLYbils zKX~_`=peG6p?XBCXsbXbHvQs!W~66OuE$ow)eaWD%I{x7*iV}7bmm^&$@kpc>2?=; zw_l7}T(*RB6hA4oMEysqi}$_~*z3zGy}BusW0#>VOMMn*Evwr0wK~q!#ToI0BWanh z&!6pq&y&%!*KVthd{%uEyqrqtx}`aX=$H@rV$HvF4&>dNR6fS?@k?lDjlU5L-V?6V z{Gc?IHUEz(g^MVljmon(O(oVe>!0egpjJnpvXS}7W#ivT65~9!!Rlp(5fk0P(6S^` zso8tAL^Zq_0uI{Egi=XR1^gqb0un=eA*?C{K~cuTI21%=At4R4!vZLGt=~bi9 zxO-oI(?V;gBgnL@etf6pr=QNYn|~Nu%TQkr3D|oC z@PBpJOC`ag7MKkaWtye(lf)MzIo0$i|C56^9y$AJRN5RUaU~x1pWDtOhZbn85*21l zJxpmhiBZPwa(6F+<@2^h7LRp9%n1LeTR5k1z*NU5s07wnXnIG=nT-SCA-BU!z^Ihb zeyESt<)BEnxE7cGZ)tEw>LkqvLnYXv)rU|B`@#d&W;Ib~ckY30QL3EvsZ7X*TL=qe zZ4w9Cbk67F@cd(&obmo|@POXa*@Cnry7J$gn) zcB2+Abh_p z{=K|ZDNm$1k)tc%;#@HE`#RFKx!Pb&TcCk|VerJQnw{b>`LgpE;FY)LCvf+E+$<|o zsM*cWN9gX(zSHCFvgPS@xi!V&{7(Pwn)id@#q&ppzhi4YZ9{r_r!TYW`C#bH{TA6d z=VmashwKyssdP~U)*E2m11Q}Ep9YQ!lRIfDx(k&JSJ3bMGq}Ft%`q%KG~iikDDwHZtKeB;E&)yn z+V10pZnd9DfG)Uvz?LlnFh5=SflG~!5AW=tFM^q^mMKz1IXyEuGew+DB^)L54-w*O z8XUDsVmyG3^pBk>FdGJXOMRG6F?te+N=K?`B2 zi~fQ1y^?+Zw-wO#WJQMru?4>ncP^o;)dN||#u#T3{?9k6_5Q$Pn0hJ^oE^Z8c;m#= z=kLKx_yYJ>^ez@+8%bu(P_3s+2^@h|@;3IkIp6<5Gnvpel|w8&81bDAAg~(wbAMe0 zAc5~r73Ls|5Hx%Nq6rcSh-nCkpA^6So&9}z`V)Ae7bZl{3tA*$7>seP<3))iw$Z|; zV`dtb4vI7a8leG?t!MQ2ba7XNuRp-NHYjDr%-1%^* zSy!M77=bH5KOYQWHQ>S9!^p2)V9Tp0XA4C=IJAZz%Mi0fbL5~X`!MtHL0m!sV5>m6 z)_J{DbSQq@6)KM7E{QYA5b-fEjNL#OoeitudGO~Oal}A7r~K2|x#R%Cv0P4gY9Tx4 z+am8Y_@kU>)o-dU*(>+aPK3! zKBD1q?2<iH<=G(<)k+=uivgs zm?2huM6djHwS|rHe9u{5S68PiD6gIEAsz*wq8`CFe8UdUbf$CcJ-5y_yq`Ad$^Cczhb?r3| z0&_)>A?YXxLnD4@ocWyEa+XrZgvfyM5$>5p?J%* zR5G$nn=R!9+sY75tKW~~`lK2qnD!6L=>x5TE3aicm7V!#^5b7V|Mqj%9*)VDdP;-L z?_G*c&qIu+a9Fj(77xD}e1cY2mt_2vGzFW;A3kc87>X^*mg>0^R&nlJToH30UWjW5 zJpT|mMxZF6MMCxd^Q*KKn%zZezjlZGUhdtyVu6=!5<@}J$*Jad)7*$L5X(C$^m8$x!2)JNOmWs6NjWu@Esa3(Z%-CWBxoX&0hSkvfD# z=#O7P9@k_#=fC5aN(Tc@gbI*@FalZopk#2!H4@-JmugOpZRzS8(TUU=0++=(?O2Jy z!um?wU}A(zNbdk?yfKHGh+1~JeBE>sCd?QoKO))}A&?dW59TQA?}r|a2qd4x4qMZ5 zd2C|J?0gY+5D(&ZwgrY$VDod+^zFZOIT_CoS*yuD3VXx*={L{F-~Q54fwMK-@r4}O zot$Iu^Vv&ht|+w25Y7WjXL||bp8)_RF(>TL07$<$uZ?rIa(1A%38FAsjMd`m$^wbp zU3WkTW7I48HRX;~&%QPM_j#B7NDvf%wg~(FgDgJ3OqU%uDBto-TdWB%%L!BiTC3Z> zDQq?7O?o)IF?b(U3QaSOlY)jBS{iMuux5+xEmnddjPa>2$*rl&e&|-Awq9 z;OkIiF!J&jJswB;jufI`laxRP;lTQI>L$8DkYPgbMH#P}VJUqJ0Hk`SB9SUcg=Ps2 zk{vVUCx`_HdNb)2Ucl$2fCI!OOCNW%-*9yC{RU@`QpYGO8=jOgNUBsk|7Yy{IQK9^aAb|J43ncK7_-Z*ee=oTx>k{eDdzZSAP;?7CMV=dw&HieWuz;;D0CCt98O^Wb529O zrWXwv<1;3kkbFhcE#J56&Sx7bprjrG4`KbD4o0_A-JjxqB(KO>B z8sn(G--He}{9*A728_3AQj{_ExTAzv$s{iSsZDV7UO;AiiB>g93st;sAy z4a40gA1B5o`7mp?XLq|>Ix;*w%=Asdw9$GWygA{k!%Bw^N(cm8gAjm`mfavXIAr4L zLYqiI6v3=R#FB7)Msdk~h1JJQ4}v*lKC*al0>=Yb=HF7?ym?_%%qYIAQidU|NN->WjG2d#f<2ol(_*u@LbIYZkJ6kNc zi&Y)Ud3}wVGd=PnM|hAyGTvA+2%=!Gns{Y~uFIN$rthNm^7EAS{m=$HXqcsNZ^`2z z;C%>q^MB6jlrjqKi#bP7%ttB);WZJmC@RKHs_9);)Py9{XF7CVhlz>l{;hF;Td4?D zAs*-qGwz#jDwLN<2%ts{GWdQ*P^qE&O#=stlK}I@#8>GJ#lcZwP{)-xBm3@Hgf7jv zCs{iw-L{vuhlqk@GW|=gM6SjCA)JyrDZ|`?YuJfs)-amA=g_;>0qFK{l!W1p$n_aIWD2k^$VbDaV z9Lp)?hJ}4MQ^dDS6g}jJ9mQ>}`W&rJwBQ;S#{BA+%oyd7lt-Pv^&elJU)T}HwgL)md=m9s%{LCKu~I|dx-sp ztRZLfpM0AC9HjFzK(@ywtzeH|SOHfR<@vTjW&{esujY&8{Ue zY`Nw^e!bIgUIa65)NaBbby5LX{vz#AIJ&{U|J}zyW1ze!x!GT%YY;gSyI!;7khg`ZGQ0@2#K*zdS#3ILiJIMayUZ4X)98`g;Yh(X{j2dY@YzoLKzX ziiLfPm1nu~?D%JLkqU1kCqn@^@l*y{Rls}_H7ZEl;g!jAxrGA*od{L8#iPV)@g?Hn zyp)>3JVVeo)?2?s;9#(90u2Eis!5d)@GCPCaMi10kb;Pd(1IHTe(jZ}2`#^*2#&KN z5Ftyvw(806vO~3W`I}LVhykx{>U(D=O*5z8zo>7!|5F}g9@k|}If!@B^BOZ^{-*#Z zjurH;--G*a4ETieub+eazhgjE*;)suTGm?ngqM!Lr~v2u%R`$xW{!x9&)*J_P_gr+ zq~)y)?@ia&&&==lBpT+bY_$i}p=%-k$Drcd15x zcog(1=T`>9f&CbfA~$~?)5v1k9!yvjFeYb!SDYY+fB>!P7SvahcsqV92rlFo@ItGO>T^Ydmy&A zxTbgAoqT7G!2Y;wv}4mAdk?vzl86`WmgSgZ>I0kG7hjnb?+fQFiUOdg){aJe@DA z`n&`7m=b$K_2-F*E>1_r{*UbDy6-?wS&_SLavv=B15@6kjVg9Zig8Li8Y$gTtccUCZ4x~jy0QzCTx)**r9|6h2AltbaBQ?2n{!ZEN zpY{1N6!2h_y2DAaXD8%el}(%{T^i@gGV*lP|7atn(pRhpuf;#(6lX4{`{XcaI8npNYI#iyqX%7}e zR1Caw(Q%8Al>BI1BcT_{`cKv5EmwQN1TlwJ98ud@aIJJig8V{3klFMS!9ejz);{_00wTUp5rijG5KQf%E z`}?UZiI2>q)z`U>`=xB&r#h8KgjXQ4I7Fv-nO7ri1KvP9ogh8tT^7!5&YRdZLkh{# zhGJtkn~;Jp*xaAJmXp(-C6oM!J!IM_czg}~1~9dtR%qRH{0zqV)j2e0Z2q?9gBJ!b zk)!J45h6u%DMYO3ksbyZk2=R}sZ9zmq-6HzgKIcinRY_Cw+2}E_BE7Tow2p3+{un#` z5H4LLA;s}Rl~~4rEb)FVD1<0DKM0ECi3??&vI^p)qq|Ui`a)0f*8Mc6DzH~?=?^d? z=T$N+cW<&YE-|zY4{dt(xF3#O$imNapqgpy*^!8Q!%X9(7wR{4IlK{P7z=ZT=+=-DAG za<}?hWwo&AYbtJq-1GQYT?DxTa(bDR8n;Uq4S45~A5FZ2SR~`eVs5P1)8j#pA?rp7 zn70WN1_Z_k%PD0?JiJ!)NekfsW>w``8{3xXFcOH5j6^1zqX0Oau5vkC z-fhc^B>@Iq@G@Z!;d$D!7G?&#y#ZVrDcqEY$067XjOjcsGPsei-Mv7)e47?44W<-B zrk5D)N-AIc4q-@d%yyvXcD;oXCFv2#-tnOF1T81r{N6Qs2UP86o=q=yu>{AHos4d9 zu57E#VUvHWQ>gsKPd!N{J7(SPM)-6XI%Q1+^LuL4!Oqs}UCK^406d$1(8~{?aR@*k z$C1d!JtyA;JTj7%*LnU->wI@RJA_?;gReH?e|IBHFfH6lL*Ta(WwA*>jEkdYjR%Zs zQFA(QJv=q-TqUQ)>@VafQ~wp&&`qpFA;z$Q)HB5(BEn-uO-1g1{#4qa_y*wAuffpz zWq1oU(M=U-Pz3rM<5Q|74hLyN2m$W18O=jT#ml?eiW$l2Kz@V~3ZB29IqUjQ**qt4 z&xWJ5Z#_eG5eTuplOonI)<3}7p~!b@Rt;3;)iMhXXw@!Z8sCI1}8Wv7HYX zbp*t}t#ph!L3^lKWBG#9$Dq6%A4Ds+5V;|gCLGqN$|gW2oA&wU+7T#-|C{IGEj&jt zp*z!1(?Uo^L?TOxvsPoEmz>Y1qkvRhxnNEBM7X(2?NKJ0l_}+L=f|U0sYZrec z0KDF(F@6vlekA}rLVJ#JljC|rhZwc}d~~H!eI4|dVM)6a3{2p6_Nb=j`DNS9R|f)4 z=E{Cf{ip>wQha1f>pct>5vn*XM9(%!34vKjVF>v+z7-H^a=`SD#R%oE_owWYehsg= zZ3fagaimjDp_Ye6ZZkaul%2V)mfLWa2VMRXVbh6Dv4@NzlhQDzalCvlll*J#>AuyH zO*s40Vy7*~Z*)UDL8huc+D`_)qyOc{>oq^d|K&&ZUwS^AOLjen1=_<99+8We*wRI1 z5Eo4b)muLcuQfG@b`(gM+y%#69o$%46yPPNH2^Qb+fobPG*cv&)j7){Vj}L3&>-pU z7`<%GuX^2r*>X14&LCT80r=-du|r@FK4e1>NqY<0U6cC^ zKUuFp%If+v0i(Z-8f0R|nEdYj9ghD|ib??2GRCGr6C~m6I+SCWuci z0M;8TfqbIZ@&6QY)p1R|?HfU2fW!!C#~2~q43HWFMt6gBcYKuw>Cq!4MMMw~kouC+ zDWQNg(jln=((&7P-}m!-{@8Zz>xpak?sM;a&bgi&Td~?yi{)GY`#p!?8JF!t^E~47 z1no}V#2KBNOQn`HGJt>FSa&NibsZ4!+DP$492q8t1MdzOSXn>J_Yf5mfy$^s+N`XH zQU>MEU2Lb!ZWEnr2D^TgPaY&wAj5P&7}GxvNx?Y#lK~g13H~cR5)u1&=sU3}*Wya& z2YXgv&KC8Ufbq4I-^cFT&8OcwXc+lebqTrjqS9B>#^wut>(we99nXj~+^0o=JVcbF z8h^S`lqVhad~zHn?HM{AF~u1X;&l3P2U0-y6LtiLwNlp&tInDlHGf2@cu!fF=nS&x znVRXXjEGachwGBbq23g<27|3Wxo~$2GVe50EXbujf9hZ?!Hi1L>I(2W01zY@o!TTs zb<4%4%O(wy5A3W?AtHfsZufs~T?J4=Akde&LufQeH-zaNwS#euz7vfE)pzEn6 zdnC)CX^C5Nq80vG>!$G@zVgCLFF}xN43PU0;SGMpT!8wfP)?P8<^^V67LVb;ZGO5k z%8w$mr&1iC;aUj+f>)vc_$WlPUW-h4`w&xzdlQ&=&Ulk!Tq*vptWAm;^_zzVH;eoD zZo$%}v+;E^Jp+wC_cOi}RVN=EA;VVG`sK^zrI$^repI|o0Xum)y&-J$bl;yiYFcv6 zv0Pm2<(N2^z0aKQ1p$Rr*0E3{YnGL zQ(%C#3^F%@W+&nT_w@>`!MCr>2_kiTxP}oU)m4^qRoSKeDpih7vJwhvxLSpw=8`8Y zGE!M+@$}lhsn=f+>RYTJ*O9t$mYAqQr_ zCvTwXcOv$a^F9;VCjtTBv3I*l-UOlqp^4X}k&tMX`WKT#UsHNI z)`9oGK*a3t)4LSJLq!pngvbdd+f7?C!?#G^{Ol#h`bNj z>qdYqM8Z&1j(*lO)*Xa(3HW4R@v0{WVFS9Yov?n=T3l!CuZ5x_?9Pk>m@bSL5sIHE zi>G@ye_)Pz_htq1#a1$0{>{;(k1{bGXoYp%>>b(_-%6v79D$KB-3{6xIy_luBq4ng zTvLnHJ(Z#I1&;nx<%^%Z__JUx6jlk;xjV-@zr)s8CeCdjURCy2pB@EZL&sNlr>O=< z-U={~7!ny66h!gGW58yxfRT2MjF|$Ua@hp274iym{QRhiGj7CBi!h{EtGu@xh!&}X ztiW>J(zS;i%qZDj?B!!v`&uRKhe$YhWwhy6QU8FVa);x>G*4V&X6Dww_^`b(fsGoZ z*N3|cC~q0tZNZ{_z8LOqM@+u#Z!oKJ&X=9K7MTyb8E-#6l~jfN4D<$*(@a58-ynjf zg)9UYYjE(sMFO>RnpUabD%2coM_hk@owWKV6&aN9{63QJzOrGVXlBBsf93=^X;Ul^ z{pp6An3B03``3zceEA-Jbu{S!7){9^bh>rr0{fVOUrn3gcgwQJUOg074q^N-nt}+! zYLXo-7M~dRgtMQ9v2=OHZS@jKKy}D*pZ#;mcREI+;0}r{+V-(#kPy^hpbf%}pr~81 zf&OK@S?O`e4$qm(epEk?pRC|0tW#0paA9FSb$KQxPWp4_X8-Uyj>x7-bxkra9CkJHZ_03^92K6o+11Zu-!T0V|Dsd&As-RQ{fZw@m z@`v9FmRXhvSylO~H28Ak@8~>3^*y#5qWd0{+r_l2D}W#vC~v6{0>We5*?Tq=%G8Bf zkP>>Nx(y6=L01XAz){-Pi&}?rX}?r;ncssQcx;TYM*zOF!NLG{NbNHL_Tnl~1=gR+ zU}0E4AR?R4lDft&x8rykdq)f@dDDy^Wi=hR-QwDjiEWuR8F0;x&?3Z9<@a@iB#>NyhOKddF<$Wtq0(`VQ3TR( z=miq)k|P*YRq zwxn12bqKMLuoUw)l51yShy6mWu!VwR`H-Ny=`7I`*To|RX9{*(2Sx>VAKJvxA7HOH z`Q`x`^&k9Qq@dCd`C9Nj@n1{AjGE@X8td`s4qB&A78 zVX7i??@nlaoJC(i@pxtdDYUW8&f6q2?<;;4EUza`IK7;7JgmuGjtl43PYM3pn@6+C z<@t~BSf`zeb14q-X<_}D1f>OIi~Ezt!0JQ(Xr~LDKfxcQt~AR$uiDp4;Bc0=C@6h~ zyB!_U=3lcVun2J2@tOTFy5Q4Tq#}%|2taDA5CSU`Kz`q_XQ zHo;)91B`_k{RRfp0mw0J91(lp6#r}Y#uEnK<3%X4`woeb_qZeEzjlWenZTD<^9ED{ z53V~>8q_D)RBNA=`a2e67OT$-FIZ>2*qf)zsxr=MFkYCe%%@N|8;dav=cB|g5`@zV zQc~6w7V=Yxw(c@i{yVADAb-PtUAxYkRhy|O3U#>Yj`1Gjlkw);Xop&iHK)sk->*#r z`430AaHd!z;n(x@8OKW)3qry9A<`&UhL*^YU%^WMthE9(rL;A5; zg{s@f+FIeeulZC)<`=7rBk6f(%5ZdZ)@DIG6Vna8U8K1stfLsvS|mxU`O|%2()p{m zi8t!d_r2aC=R#y~EQj~)haAh!~9-#%GR_%%|(^5H~k2|4^08WQB0Jem+kOx=D*w?KXxcsA+kY+_+* z_PmXEyAJU`y2K}2H6@SRcD<%#mjL^NylM?jh1% zmj&;JdCMv(tw_k5&*|@=~yx|&Y3ZEUup{2K4TSsqkxGpomfFR*gijj54dEepyM~m4D@h3|6OGF9ES@(>yxgo379oS-semKFgDNR3< zXv49-;Uhged3F`$aY|}FRZGp4Y(9vMZu`JO8VjnK)2|1;<(W%9*%VMH61{nc)5_D^SF73i(gUvW0gOiu$s8`|rd0GFL`g_6AJHpEokPX(q~)kpc+-vD753&7u;qh812Ugr*c6NtvXpuPI=- z{qI?#4juw(UXqivsP{+XG@tlZ?l0POHD<&FjOEcOMi7UXoa1@GEqma1)4d08JO(eg zf#?$)1xp72jT&6|6$F_6co?nO$nhl!sq4kS9X#IZK7rr`+)0K-+BZ%{+jn9KU?~LY zJ*6VYJH$isK1A|Vg->04Hv&9aZHW6p&5{b}4?7@u2oxS%IlATcz1wS!9P9M}c& zulD2ezJf9768ERQi;8Siyp7;zN{f_Zht#cay_e;^XAKMJe77j*pxFwUTQ zKaeDx=O}Jwku@?rdU2Na_%T&N^2v+=``U#SdY&;+B}8#93GZ0qs0Q(KCPspY)w&;d ztPnOO%~I$4HTqHphexwJBo-&<7;ZjRYD8v2wXzbni4rTX^CfTH^`9lxSEU)NQL@Fv<6X?@Cv{jw0fqQr+qeKeWI-VVnIr zQH+mCCZH+EWoK(9*+r(Ky1FBH>7QDjD_<|G7Y9?5{C5{UyAhkMfn2BXUW$@3JX}^a$Q(G{M|<=vg`ht=pod3h619|7 zp3n^ft}*w@2Ac~K@Qg1^0oej2a!dL0ulm}XKf~yvA6zfbPLAw<%odT{-zPcHknC|6 zny#%mD3@}~ZD@sg$3+(}HfuqY7g6*u^34?8I1ZIa@O$`R=xS-{62vT2MDvEXpy%b! zy;lEFsb9G#-J|>A?)FebP07cVP+`9D1L9JAKT399GC&(PJM+rr(OOWIW?&YWZ!L3W zVaBY}Jmk%`i$)|(V9l0A8&Oag)!oI=?a24Rr9%mWPj$`U%FB5bROAkFwK9>_y13ga zRE3e0N<{}RP70{|ettE>BNEfl)?KxYnn|wE7lFQ{{>Z z6=c?9c4X+yIa_6W0q;bM(R&dY3k(}d1>{QKsQ;Ra)opHI{qxScYO6%h%ppjyJC9E0*@CA*;dBa`H`(g{r{RM7 z9bh&XaWI6VTgG!YT*8)(__WyZm$&u6iep8IJ|wim^%u$W#Shrfq&pnZw4(hSTecy! z)X@>!y1F6fJtmX4AQ+F4l##jVkIGo;XFn_bv>qyuc9-MV>?(YZFZ82s^V1=FNW_GD zM-lqN@X4I|X^lWWe=hnm=19ZYj`boQb$cR+E1uj))!@(O?w8@o|B%=!EML zH4Egn%Qr)GCpOK?xMT;Jl@|1d~)e8ci+2ca&caM){rdUlW99W@{#bn+0|Vz&3ad| zOt^Pxa9p@6r$1R#;CJ1&Tht+jQckpz2mj{c#J?$XXV=qn{Kz^V&j?J;V|4!A^U`9j zIzZV_ss4F4BQOB`R*Avk2~)y^N4)fm`MI){y)TO} z3v!i%!E7qCZ?}IfRDKeW*xNxrRq&39oU2w=tr|6)Gr8<@XytqUS!ZMtUH73=nH10} z3r<{EAHeCbKBc6i5_SGtD0mB$I7>am4Ri}-S7#ONxx0+eSMfeU=k zT>g!}Ihr15ebVl78Z!ki`;RjT0N2YY=f{Rue@1gX&~DvYZ?p(?r`F6Z@;g~95x+gw zyn@00v7$CzkSUr|-!hy(+sSYApgvwir+HZfNXcY_EUK%%!^AZ4Z;y2WIodn-YFjLJ z35W_N8)JRq?}runddS1>?Uv(eBuK$~U8?00vlXW91y(0mf*Ybif%*8~!2^Q-8%%Q= z1Am&FLWcQwz%t^XfEDG1B8Y2+e{=PfiBmY$>3bVXaU{ z$eMmjnn;7{g z1{mVjoK+w1Z;r7u^e^_eQX@u~fQOCB4{swr*Xxc{psRXdSuQ%y|8W^k5<_eNf$Kc0 zzoe*l>glyusgY1xAa-c?mzIQ7r8Vs9!`uJDXk>37#Y@WRJznLk(`!A(7N9w*rOodWH zY%BwP(S=X2RybqC_2E|Xur?eO^dXl0%7HF#0Ak+*;Eiz@)EyvTLf#x58F*E)<4=hh z!O!Cn9qH*hoi2|Uk?0y4r6|I5^!&}nWh$iMoFfIP4LRz(v3qoszp^m~*u{YCnJ*!B zT-(a(nS#uF12NHXU}Jd(>!Yn@U?w^su{y~~eX0q{^fOc4eH{^S^$v19k=M@QX|1dT z#AtZs=a&mkaW)Zg4vL(_`N)({(R*dLJoc(+2ncN^^9PGnOO_d!2vWR?ne+T80Y<@UY8MDH!`d_5-0JYChz$yKnT z!n|d*EejNyVAf`{C+iso)5&6ZQZ|ks>31IBu+V21?M=_a1n^Hx)|y2hAw~ju@dHrC~;pC4PROWluZ|(U}!2UXuzvrmf`;gwGg2w literal 0 HcmV?d00001 diff --git a/lp-app/lp-studio-web/story-images/device__idle.png b/lp-app/lp-studio-web/story-images/device__idle.png index 59fd00efcee5f9feb37a64a737b3177e4a2b5ea3..cacd0497fd497db52f3b97c4c3422a16f380012b 100644 GIT binary patch delta 15880 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