diff --git a/.github/workflows/wasm-bundle-build.yml b/.github/workflows/wasm-bundle-build.yml index 51e7d64e5..266fa9e5b 100644 --- a/.github/workflows/wasm-bundle-build.yml +++ b/.github/workflows/wasm-bundle-build.yml @@ -23,7 +23,7 @@ name: WASM bundle build (#56 — real canvaskit deployable) # 2. wasm-bindgen --target web --out-dir crates/op-host-web/pkg # 3. assert 0 env.* imports (LinkError guard) # 4. wasm-opt -Oz with the rustc-emitted WebAssembly feature flags, then -# gzip size <= ceiling (default 8388608 bytes = 8 MiB, overridable via +# gzip size <= ceiling (default 6291456 bytes = 6 MiB, overridable via # STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES) # The job calls the script directly rather than duplicating that logic — the # script already runs non-interactively (`set -euo pipefail`, exit 0/1/2) and @@ -118,7 +118,7 @@ jobs: # cargo build (canvaskit) -> wasm-bindgen --target web -> 0-env-import # assert -> wasm-opt -Oz with rustc's WebAssembly feature flags -> gzip # size <= ceiling, and exits non-zero on any breach, which fails the job. - # The ceiling default (8 MiB gzip) lives in the script; override here only + # The ceiling default (6 MiB gzip) lives in the script; override here only # if a release intentionally re-baselines. - name: Build + size-gate the canvaskit bundle run: bash tools/check-wasm-bundle.sh @@ -131,6 +131,11 @@ jobs: # `canvaskit/` subdir, all inside a single `web-bundle/` directory. This # mirrors the `/web-bundle` + `web-bundle/canvaskit` resolution # order in `crates/op-host-services/src/web_static.rs`. + # + # `pkg/` also carries `assets/` — the preview JPEGs the wasm bundle no + # longer embeds and the browser fetches from `/pkg/assets/…` (staged by + # `tools/stage-web-assets.sh`, which the gate script above runs). The + # assertion below is what stops a bundle shipping without them. - name: Assemble deployable web-bundle/ run: | set -euo pipefail @@ -138,8 +143,11 @@ jobs: mkdir -p web-bundle cp -R crates/op-host-web/pkg/. web-bundle/ cp -R crates/op-host-web/assets/canvaskit web-bundle/canvaskit + test -d web-bundle/assets/prompt_center_previews \ + || { echo "::error::web-bundle is missing assets/prompt_center_previews"; exit 1; } echo "web-bundle contents:" find web-bundle -maxdepth 2 -type f | sort + echo "staged runtime assets: $(find web-bundle/assets -type f | wc -l) files" - name: Upload deployable web-bundle artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/web-sdk-bundle.yml b/.github/workflows/web-sdk-bundle.yml index 59fd6c62c..028fa08a2 100644 --- a/.github/workflows/web-sdk-bundle.yml +++ b/.github/workflows/web-sdk-bundle.yml @@ -1,7 +1,8 @@ name: op-web-sdk bundle build + size gate # Builds the op-web-sdk WASM package (canvaskit feature), asserts 0 env.* -# imports and gzip size <= 8 MiB, then uploads pkg/ as the `op-web-sdk-bundle` +# imports and gzip size <= 8 MiB (this viewer bundle's own tripwire — op-host-web +# re-baselined to 6 MiB separately), then uploads pkg/ as the `op-web-sdk-bundle` # artifact. This is the CI counterpart of the local # `crates/op-web-sdk/tools/build-wasm.sh` developer gate. # diff --git a/Dockerfile.web-rust b/Dockerfile.web-rust index 9f7d0b28b..3fa048406 100644 --- a/Dockerfile.web-rust +++ b/Dockerfile.web-rust @@ -105,10 +105,12 @@ RUN if [ "$WEB_BUNDLE_SOURCE" = "build" ]; then \ fi # Assemble the deployable web-bundle/ layout the daemon's `web_static.rs` -# resolves: the wasm-bindgen `pkg/` output PLUS the vendored CanvasKit artifact -# under a `canvaskit/` subdir. For the `copy` path the bundle already exists in -# the build context (./web-bundle); just normalize it into /out/web-bundle so -# the runtime stage has one stable source path either way. +# resolves: the wasm-bindgen `pkg/` output (which now also carries the runtime +# product assets under `assets/`, staged by `tools/stage-web-assets.sh` inside +# the gate script) PLUS the vendored CanvasKit artifact under a `canvaskit/` +# subdir. For the `copy` path the bundle already exists in the build context +# (./web-bundle); just normalize it into /out/web-bundle so the runtime stage +# has one stable source path either way. RUN mkdir -p /out/web-bundle && \ if [ "$WEB_BUNDLE_SOURCE" = "build" ]; then \ cp -R crates/op-host-web/pkg/. /out/web-bundle/ && \ @@ -116,7 +118,14 @@ RUN mkdir -p /out/web-bundle && \ else \ cp -R web-bundle/. /out/web-bundle/ ; \ fi && \ - echo "assembled web-bundle:" && find /out/web-bundle -maxdepth 2 -type f | sort + echo "assembled web-bundle:" && find /out/web-bundle -maxdepth 2 -type f | sort && \ + # The browser fetches preview JPEGs from `/pkg/assets/…` at runtime (see + # `op_editor_core::web_assets`), so a bundle without them degrades every + # card to a placeholder. Catch it here rather than in production — the + # `copy` path is the likely offender, fed a pre-split artifact. + if [ ! -d /out/web-bundle/assets/prompt_center_previews ]; then \ + echo "FAIL: web-bundle is missing assets/prompt_center_previews (stale artifact?)" >&2; exit 1; \ + fi # ── Stage 2: runtime (slim) ─────────────────────────────────────────────────── # Only the runtime shared libs the daemon dlopens at run time (GL / fontconfig / diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index 68a1247d7..8b4c3ace0 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -66,7 +66,8 @@ a local `parse_hex` / `escape_json` again. - **Max 800 lines per file — zero violations workspace-wide.** As of `d2d8104c` no `.rs` file in `crates/` exceeds the cap, and the sibling-module split is the universal shape: a spine keeps the public surface and `mod` declarations, cohesive clusters move into siblings, and re-exports keep every import path and test name stable. Splits are pure code motion — when you split, do not also change behaviour. Test modules follow the same rule (`foo_tests.rs`, or a `foo/tests/` directory when the tests themselves outgrow the cap). Check with `find crates -name '*.rs' -exec wc -l {} + | awk '$1>800'`. - **Blocking on a future from sync host code goes through `op_host_services::chat_runtime::block_on_anywhere`.** A bare `Runtime::block_on` (or a privately-built current-thread runtime) aborts with "runtime within runtime" when the caller happens to sit on a tokio worker. `block_on_anywhere` picks the safe strategy for whichever context it is called from; it is the only sanctioned entry point and is exercised by tests for the no-runtime, multi-thread-worker, and borrowing-non-`Send`-future cases. - **Fallible paths carry typed error enums, not `String`.** The whole workspace is converted (80+ enums; `Result<_, String>` survives at exactly two documented boundary sites). Find the domain's enum in its `*_error.rs` / `error.rs` sibling module (e.g. `CliError`, `ProgramError`, `WebCanvasError`, `McpServeError`, `ExportError`, `McpLiveError`, `DocIoError`, `ImageGenerateError`). The pattern to copy: one enum per failure domain in its own sibling module, structured fields instead of pre-formatted text, a `Display` impl that reproduces the previous message **byte-identically** (so user-visible strings and their tests don't move), and `From` impls that collapse the `map_err` adapters at the call sites. New fallible code should introduce or reuse an enum rather than add another `Result<_, String>`. -- **Web bundle ceiling: 8 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync, collaboration) plus ~4.5 MiB of embedded product assets (template documents, preview JPEGs, iconify catalog, skill corpus), so the ceiling sits well above the retired skia raster path's 1 MiB (~7.5 MiB today; the ceiling is a runaway-regression tripwire, not a budget). The 0 env.\* guard still holds — CanvasKit needs no libc shim. +- **Web bundle ceiling: 6 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync, collaboration) plus the still-embedded product assets (template documents, iconify core catalog, skill corpus), so the ceiling sits well above the retired skia raster path's 1 MiB (**5.35 MiB today**; the ceiling is a runaway-regression tripwire, not a budget). Re-baselined from 8 MiB once the ~2.4 MiB of preview JPEGs moved out — see below. The 0 env.\* guard still holds — CanvasKit needs no libc shim. +- **Product assets are embedded on native and fetched on wasm.** `op-editor-core/src/web_assets.rs` is the platform-free half: a process-global `route -> bytes` registry with single-flight, a widget-records / host-drains request queue, and `Absent/Pending/Ready/Failed` states. The widget layer calls `web_assets::request(route)` from paint and draws a placeholder; `op-host-web/src/web_asset_fetch.rs` drains it once per frame over XHR and installs the bytes. Native keeps its `include_bytes!` unchanged behind `#[cfg(not(target_arch = "wasm32"))]`, so desktop behaviour is byte-identical. Assets are served from `/pkg/assets/…` (**not** `/assets/`, which belongs to the hub frontend) and staged into the bundle by `tools/stage-web-assets.sh`, which `check-wasm-bundle.sh` runs as step 4 and both the CI workflow and `Dockerfile.web-rust` inherit. Moved so far: the Prompt Center (~2.0 MiB) and Scene Template (~388 KiB) preview JPEGs. Still embedded: the scene-template `.op` documents (~1.1 MiB) and `iconify-catalog-core.json` (~460 KiB). ## Document model (`shell-core/src/document/`) diff --git a/crates/op-collab-host/src/runtime/test_support.rs b/crates/op-collab-host/src/runtime/test_support.rs index 012de0b69..64148b229 100644 --- a/crates/op-collab-host/src/runtime/test_support.rs +++ b/crates/op-collab-host/src/runtime/test_support.rs @@ -6,15 +6,26 @@ //! which passes whether or not the wiring behind it is correct. //! //! Standing up an activated owner session needs `crate::runtime`'s private -//! actor and channel internals, so this module lives inside `runtime` and -//! re-exports exactly two capabilities: +//! actor and channel internals, so this module lives inside it and re-exports +//! exactly two capabilities: //! -//! 1. [`owner_session`] — an activated owner runtime, ready for -//! `begin_local_edit`. +//! 1. [`owner_session`] — an activated owner runtime over a caller-supplied +//! baseline document, ready for `begin_local_edit`. //! 2. [`owner_session_with_saturated_command_lane`] — the same, with the //! outbound command lane already full, so the next commit cannot be //! delivered and the runtime falls back to standalone. //! +//! ## The lane guard is not optional +//! +//! Both constructors return an [`OwnerLaneGuard`] alongside the runtime, and +//! the caller MUST hold it for as long as it drives the runtime. The guard +//! owns the channel's receiver, and a bounded `SyncSender` reports two +//! different failures: `Full` when the lane is saturated, `Disconnected` once +//! the receiver is gone (`network.rs`'s `NetworkCommandSendError`, which the +//! runtime maps to `ResourceLimit` and `Transport` respectively). Drop the +//! guard early and the "full lane" test silently becomes a "dead channel" +//! test — a different code path with a different projected failure. +//! //! ## Not a production surface //! //! Gated behind `#[cfg(any(test, feature = "test-support"))]`, and the feature @@ -24,6 +35,7 @@ use std::sync::mpsc::Receiver; use op_collab::{ConnectionKey, Epoch, Role, SessionId, VerifiedAuthMetadata}; +use op_editor_core::PenDocument; use super::actor::{set_owner_ui, EditorActor, OwnerActor}; use super::network::owner_command_channel_with_capacity_for_test; @@ -34,36 +46,48 @@ use crate::host::HeadlessCollabHost; /// Session id every fixture runs under. const FIXTURE_SESSION: &str = "collab-host-test-support"; -/// Enough room for one command, which the saturating constructor then uses. +/// Room for exactly one command, which the saturating constructor then uses. const FIXTURE_COMMAND_CAPACITY: usize = 1; -/// An activated owner session. +/// Keeps the owner's outbound command lane connected. /// -/// The caller normally moves `runtime` into whatever state machine it is -/// testing; `host` is the editor the session was activated against and is kept -/// alive because the actor's projection points at it. -pub struct OwnerFixture { - /// The runtime, carrying an activated `OwnerActor` with one admitted peer. - pub runtime: CollabRuntime, - /// The headless editor the session was activated over. - pub host: HeadlessCollabHost, - /// The admitted peer, for callers that need to name it. - pub peer: ConnectionKey, - /// Held, not dropped: a dropped receiver makes every send fail outright, - /// which is a *different* failure from "the lane is full". Nothing drains - /// it, so the channel capacity is the whole budget. +/// Hold it for the whole test — see the module docs for why dropping it early +/// silently changes which failure the runtime reports. +#[must_use = "dropping the guard disconnects the lane and changes the failure \ + the runtime reports from Full to Disconnected"] +pub struct OwnerLaneGuard { _commands: Receiver, } /// Build an owner runtime with one admitted editor peer, ready for /// `begin_local_edit`. /// -/// The session's diff runs between the document the caller's host held when -/// the capture opened and the document it holds when the capture closes — not -/// against this fixture's host — so a caller may install the runtime over its -/// own editor state. -pub fn owner_session() -> OwnerFixture { +/// `baseline` is the document the session is activated over, and it must be +/// the same document the caller's own editor holds. The owner core validates +/// each commit against the document the capture opened on, so a session +/// activated over a *different* document reports a candidate mismatch instead +/// of the delivery outcome the caller is trying to observe. +pub fn owner_session(baseline: PenDocument) -> (CollabRuntime, OwnerLaneGuard) { + build(baseline, false) +} + +/// [`owner_session`] with the outbound command lane already full. +/// +/// The next commit cannot be handed to the network worker, so the runtime +/// retires the session and `finish_local_edit` reports +/// `Failed { document_rolled_back: false }` — the standalone fallback, which +/// deliberately KEEPS the edit because the user's work is still theirs even +/// though the session is gone. The projected failure is `ResourceLimit`, which +/// is how a caller can tell this apart from a dead channel. +pub fn owner_session_with_saturated_command_lane( + baseline: PenDocument, +) -> (CollabRuntime, OwnerLaneGuard) { + build(baseline, true) +} + +fn build(baseline: PenDocument, saturate: bool) -> (CollabRuntime, OwnerLaneGuard) { let mut host = HeadlessCollabHost::new(); + host.editor_state_mut().doc = baseline; let mut owner = OwnerActor::new( SessionId::from(FIXTURE_SESSION), Epoch(1), @@ -87,31 +111,21 @@ pub fn owner_session() -> OwnerFixture { let mut runtime = CollabRuntime::new(); runtime.network = Some(network); runtime.actor = Some(EditorActor::Owner(Box::new(owner))); - OwnerFixture { - runtime, - host, - peer, - _commands: commands, + if saturate { + runtime + .send_owner(OwnerNetworkCommand::Close { + connection: ConnectionKey::new(99).expect("non-zero connection"), + }) + .expect("the first send fills the lane"); } -} - -/// [`owner_session`] with the outbound command lane already full. -/// -/// The next commit cannot be handed to the network worker, so the runtime -/// retires the session and `finish_local_edit` reports -/// `Failed { document_rolled_back: false }` — the standalone fallback, which -/// deliberately KEEPS the edit because the user's work is still theirs even -/// though the session is gone. A caller that undoes side effects on failure -/// must be able to reproduce this exact case. -pub fn owner_session_with_saturated_command_lane() -> OwnerFixture { - let fixture = owner_session(); - fixture - .runtime - .send_owner(OwnerNetworkCommand::Close { - connection: ConnectionKey::new(99).expect("non-zero connection"), - }) - .expect("the first send fills the lane"); - fixture + // `host` is dropped here on purpose: the actor owns its own session state, + // and this host existed only to seed the baseline and take the projection. + ( + runtime, + OwnerLaneGuard { + _commands: commands, + }, + ) } fn fixture_auth(index: usize) -> VerifiedAuthMetadata { @@ -125,3 +139,57 @@ fn fixture_auth(index: usize) -> VerifiedAuthMetadata { avatar_url: None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::types::CollabRuntimeFailure; + + /// The bug this module's guard exists to prevent: a caller that lets the + /// receiver drop gets `Disconnected` (projected as `Transport`) where it + /// meant to test `Full` (projected as `ResourceLimit`). + #[test] + fn a_saturated_lane_reports_resource_limit_while_the_guard_is_held() { + let (runtime, guard) = + owner_session_with_saturated_command_lane(op_editor_core::EditorState::new().doc); + + let error = runtime + .send_owner(OwnerNetworkCommand::Close { + connection: ConnectionKey::new(98).expect("non-zero connection"), + }) + .expect_err("the lane is already full"); + assert_eq!( + error.failure, + CollabRuntimeFailure::ResourceLimit, + "a full lane is a resource limit, not a transport failure" + ); + drop(guard); + } + + #[test] + fn dropping_the_guard_turns_the_same_send_into_a_transport_failure() { + // Pins the distinction the guard protects, so a future refactor that + // silently drops the receiver fails here rather than in a downstream + // test that looks like it is passing. + let (runtime, guard) = + owner_session_with_saturated_command_lane(op_editor_core::EditorState::new().doc); + drop(guard); + + let error = runtime + .send_owner(OwnerNetworkCommand::Close { + connection: ConnectionKey::new(98).expect("non-zero connection"), + }) + .expect_err("the receiver is gone"); + assert_eq!(error.failure, CollabRuntimeFailure::Transport); + } + + #[test] + fn an_unsaturated_lane_accepts_one_command() { + let (runtime, _guard) = owner_session(op_editor_core::EditorState::new().doc); + assert!(runtime + .send_owner(OwnerNetworkCommand::Close { + connection: ConnectionKey::new(98).expect("non-zero connection"), + }) + .is_ok()); + } +} diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index fe93ef069..f24f0ccca 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -70,6 +70,7 @@ pub mod document_install; pub mod drag_mutators; pub mod edit_transaction; pub mod editor_toast; +// Runtime-fetched product assets for the browser bundle (native embeds them). pub mod editor_ui_state; pub mod export_batch; pub mod export_dialog_state; @@ -143,6 +144,7 @@ pub mod svg_import; pub mod svg_path_bounds; mod svg_path_data; pub mod sync_gate; +pub mod web_assets; /// Tight source-coordinate bounds for an SVG path-data string. pub fn svg_path_data_bounds(d: &str) -> Option<(f32, f32, f32, f32)> { diff --git a/crates/op-editor-core/src/web_assets.rs b/crates/op-editor-core/src/web_assets.rs new file mode 100644 index 000000000..a5f64e636 --- /dev/null +++ b/crates/op-editor-core/src/web_assets.rs @@ -0,0 +1,361 @@ +//! Runtime-loaded product assets for the browser bundle. +//! +//! The desktop binary embeds its product assets with `include_bytes!` / +//! `include_str!` — it is already a local file, so a few megabytes of preview +//! JPEGs and template documents cost nothing at run time. The browser bundle +//! cannot afford the same trade: every embedded byte is a byte the user +//! downloads before the editor paints its first frame. +//! +//! So on `wasm32` those assets are left out of the binary and fetched from the +//! daemon on demand. This module is the platform-free half of that: a +//! process-global registry of "asset route → bytes", plus the single-flight +//! bookkeeping that stops N cards on screen from firing N requests for the +//! same file. The host supplies the transport; nothing here knows about XHR, +//! and it is therefore testable without a DOM. +//! +//! ## Why the registry hands out `&'static` references +//! +//! Every consumer of these assets ultimately passes the bytes to a renderer +//! that caches them by id for the lifetime of the process (`store_remote_ +//! image_bytes`, the icon catalog's `OnceLock`). Handing out `&'static` keeps +//! those call sites byte-identical between native and web — the native side +//! genuinely has a `&'static [u8]` from `include_bytes!`, and the web side +//! leaks each fetched asset once. The leak is bounded by the shipped asset +//! count (a fixed catalogue, not user input) and each asset installs at most +//! once, so this is a one-time cost, not a growth path. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +/// URL prefix the daemon serves runtime assets from. +/// +/// `/pkg/` and not `/assets/`: the daemon already routes `/pkg/*` into the +/// resolved web-bundle directory and the production gateway already forwards +/// it, while `/assets/` belongs to the hub's own frontend. Assets are copied +/// into `pkg/assets/` by the bundle build (see `tools/check-wasm-bundle.sh`). +pub const WEB_ASSET_ROUTE_PREFIX: &str = "/pkg/assets/"; + +/// Where an asset is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebAssetState { + /// Never asked for. The next [`begin_fetch`] owns the request. + Absent, + /// A fetch is in flight. Callers paint their placeholder and wait. + Pending, + /// Installed and available from [`installed_bytes`] / [`installed_str`]. + Ready, + /// The fetch failed. The asset stays unavailable and its feature degrades; + /// [`begin_fetch`] will hand out the request again so a later user action + /// can retry rather than being stuck forever on one bad response. + Failed, +} + +#[derive(Default)] +struct Registry { + bytes: HashMap, + state: HashMap, + /// Routes claimed by [`request`] and not yet handed to the host. + /// + /// The widget layer is platform-free and cannot fetch anything, so it + /// notes what it needs here and the host drains it — the same + /// widget-records / host-drains channel the image decode queue already + /// uses (`image_runtime::note_pending_decode`). + pending: Vec, +} + +fn registry() -> &'static Mutex { + static REGISTRY: OnceLock> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(Registry::default())) +} + +fn lock() -> std::sync::MutexGuard<'static, Registry> { + // A poisoned registry is still readable: the data is append-only and a + // panicking installer cannot leave a half-written entry (the insert is one + // statement). Refusing to serve assets after an unrelated panic would turn + // a cosmetic failure into a blank editor. + registry() + .lock() + .unwrap_or_else(|poison| poison.into_inner()) +} + +/// Where `route` is in its lifecycle. +pub fn state(route: &str) -> WebAssetState { + lock() + .state + .get(route) + .copied() + .unwrap_or(WebAssetState::Absent) +} + +/// Claim the right to fetch `route`. +/// +/// `true` means this caller owns the request; `false` means it is already in +/// flight or already answered. This is the single-flight gate: a panel with +/// forty preview cards calls it on every card of every frame and exactly one +/// request goes out. +pub fn begin_fetch(route: &str) -> bool { + let mut registry = lock(); + match registry.state.get(route) { + Some(WebAssetState::Pending | WebAssetState::Ready) => false, + // `Absent` and `Failed` both hand out the request. Retrying a failure + // is deliberate: the daemon may simply not have been up yet, and the + // alternative is a permanently blank card with no way back short of a + // page reload. + _ => { + registry + .state + .insert(route.to_string(), WebAssetState::Pending); + true + } + } +} + +/// Install fetched bytes. Returns `true` when this call is what made the asset +/// available (a second install for the same route is ignored, so a duplicate +/// response cannot swap the bytes under a renderer that already cached them). +pub fn install(route: &str, bytes: Vec) -> bool { + let mut registry = lock(); + if registry.bytes.contains_key(route) { + registry + .state + .insert(route.to_string(), WebAssetState::Ready); + return false; + } + let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice()); + registry.bytes.insert(route.to_string(), leaked); + registry + .state + .insert(route.to_string(), WebAssetState::Ready); + true +} + +/// Record that the fetch for `route` failed. +/// +/// The asset stays unavailable; its feature degrades to a placeholder, an +/// empty state, or a notice — never a panic and never a hang. +pub fn mark_failed(route: &str) { + let mut registry = lock(); + if registry.bytes.contains_key(route) { + // A late failure for an asset that already landed changes nothing. + return; + } + registry + .state + .insert(route.to_string(), WebAssetState::Failed); +} + +/// Ask the host to fetch `route`, if nobody already is. +/// +/// Safe to call from paint: it is the single-flight gate plus an enqueue, so a +/// grid of forty cards repainting every frame produces one request per asset. +pub fn request(route: &str) { + if !begin_fetch(route) { + return; + } + lock().pending.push(route.to_string()); +} + +/// Take up to `max` routes the widget layer asked for. +/// +/// The host owns them from here: it must answer every one with [`install`] or +/// [`mark_failed`], or the asset stays `Pending` forever and its card never +/// stops showing a placeholder. +pub fn take_pending_requests(max: usize) -> Vec { + let mut registry = lock(); + let take = max.min(registry.pending.len()); + registry.pending.drain(..take).collect() +} + +/// Whether any route is waiting for the host to pick it up. +pub fn has_pending_requests() -> bool { + !lock().pending.is_empty() +} + +/// The installed bytes for `route`, or `None` while it is absent, pending or +/// failed. +pub fn installed_bytes(route: &str) -> Option<&'static [u8]> { + lock().bytes.get(route).copied() +} + +/// [`installed_bytes`] decoded as UTF-8, for the text assets (template +/// documents, the icon catalog). +/// +/// A non-UTF-8 body reads as "not available" rather than panicking: these are +/// files served over HTTP, so a truncated or misrouted response is a runtime +/// possibility, not an invariant violation. +pub fn installed_str(route: &str) -> Option<&'static str> { + std::str::from_utf8(installed_bytes(route)?).ok() +} + +/// Drop everything, for tests that need a clean registry. +#[cfg(test)] +pub(crate) fn reset_for_test() { + let mut registry = lock(); + registry.bytes.clear(); + registry.state.clear(); + registry.pending.clear(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serialises the tests, which share the process-global registry. + fn lock_registry() -> std::sync::MutexGuard<'static, ()> { + static TEST_LOCK: Mutex<()> = Mutex::new(()); + TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + #[test] + fn an_unknown_route_is_absent_and_serves_nothing() { + let _guard = lock_registry(); + reset_for_test(); + assert_eq!(state("/pkg/assets/none.jpg"), WebAssetState::Absent); + assert!(installed_bytes("/pkg/assets/none.jpg").is_none()); + assert!(installed_str("/pkg/assets/none.jpg").is_none()); + } + + #[test] + fn only_the_first_caller_owns_the_fetch() { + // The single-flight property: a panel repaints its whole card grid + // every frame, so without this each frame would fire a fresh request + // for every card still waiting. + let _guard = lock_registry(); + reset_for_test(); + let route = "/pkg/assets/single-flight.jpg"; + + assert!(begin_fetch(route), "the first caller owns it"); + assert_eq!(state(route), WebAssetState::Pending); + for _ in 0..10 { + assert!(!begin_fetch(route), "no second request while in flight"); + } + } + + #[test] + fn a_successful_fetch_installs_once_and_stays_readable() { + let _guard = lock_registry(); + reset_for_test(); + let route = "/pkg/assets/ok.jpg"; + + assert!(begin_fetch(route)); + assert!(install(route, vec![1, 2, 3])); + assert_eq!(state(route), WebAssetState::Ready); + assert_eq!(installed_bytes(route), Some(&[1u8, 2, 3][..])); + + // A duplicate response must not swap bytes a renderer already cached. + assert!(!install(route, vec![9, 9, 9])); + assert_eq!(installed_bytes(route), Some(&[1u8, 2, 3][..])); + // And nothing re-fetches something already in hand. + assert!(!begin_fetch(route)); + } + + #[test] + fn a_failed_fetch_degrades_and_stays_retryable() { + let _guard = lock_registry(); + reset_for_test(); + let route = "/pkg/assets/bad.jpg"; + + assert!(begin_fetch(route)); + mark_failed(route); + assert_eq!(state(route), WebAssetState::Failed); + assert!( + installed_bytes(route).is_none(), + "a failed asset must serve nothing, not stale or empty bytes" + ); + assert!( + begin_fetch(route), + "a failure must be retryable — the daemon may just not have been up" + ); + assert_eq!(state(route), WebAssetState::Pending); + } + + #[test] + fn a_late_failure_never_unloads_an_asset_that_landed() { + // Two requests can be outstanding across a retry; the loser must not + // pull the rug out from under the winner. + let _guard = lock_registry(); + reset_for_test(); + let route = "/pkg/assets/race.jpg"; + + assert!(begin_fetch(route)); + assert!(install(route, vec![7])); + mark_failed(route); + + assert_eq!(state(route), WebAssetState::Ready); + assert_eq!(installed_bytes(route), Some(&[7u8][..])); + } + + #[test] + fn a_request_is_enqueued_once_and_drained_by_the_host() { + let _guard = lock_registry(); + reset_for_test(); + let route = "/pkg/assets/queued.jpg"; + + for _ in 0..5 { + request(route); + } + assert!(has_pending_requests()); + assert_eq!(take_pending_requests(10), vec![route.to_string()]); + assert!( + !has_pending_requests(), + "a drained request must not be handed out twice" + ); + + // Still pending as far as the state machine is concerned: the host owes + // an install or a failure. + assert_eq!(state(route), WebAssetState::Pending); + request(route); + assert!( + !has_pending_requests(), + "an in-flight asset must not be re-enqueued behind the host's back" + ); + } + + #[test] + fn the_host_drain_is_bounded_so_one_frame_cannot_fire_every_request() { + let _guard = lock_registry(); + reset_for_test(); + for index in 0..10 { + request(&format!("/pkg/assets/bounded-{index}.jpg")); + } + assert_eq!(take_pending_requests(3).len(), 3); + assert_eq!(take_pending_requests(usize::MAX).len(), 7); + assert!(!has_pending_requests()); + } + + #[test] + fn a_failed_request_can_be_enqueued_again() { + let _guard = lock_registry(); + reset_for_test(); + let route = "/pkg/assets/retry.jpg"; + + request(route); + let _ = take_pending_requests(usize::MAX); + mark_failed(route); + + request(route); + assert_eq!(take_pending_requests(usize::MAX), vec![route.to_string()]); + } + + #[test] + fn a_text_asset_round_trips_and_rejects_invalid_utf8() { + let _guard = lock_registry(); + reset_for_test(); + + assert!(install( + "/pkg/assets/doc.op", + b"{\"version\":\"1.0.0\"}".to_vec() + )); + assert_eq!( + installed_str("/pkg/assets/doc.op"), + Some("{\"version\":\"1.0.0\"}") + ); + + // A truncated or misrouted response is a runtime possibility, so it + // reads as unavailable rather than panicking. + assert!(install("/pkg/assets/broken.op", vec![0xff, 0xfe])); + assert!(installed_str("/pkg/assets/broken.op").is_none()); + } +} diff --git a/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs b/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs index e20cbb67f..2ec443867 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs @@ -381,7 +381,17 @@ impl PromptCenterPanel<'_> { } fn paint_card_preview(&self, cx: &mut PaintCx<'_>, preview: Rect, card: &PromptCenterCard<'_>) { - let Some((image_id, encoded)) = prompt_center_preview(card.id.as_ref()) else { + let Some(asset) = prompt_center_preview(card.id.as_ref()) else { + self.paint_preview_fallback(cx, preview, card); + return; + }; + let image_id = asset.image_id; + let Some(encoded) = asset.bytes else { + // Web only: the JPEG is not in the bundle and has not been fetched + // yet. Ask the host for it and paint the same fallback an + // unknown-id card gets — the card is readable either way, and a + // failed fetch simply leaves it that way rather than blocking. + op_editor_core::web_assets::request(asset.route); self.paint_preview_fallback(cx, preview, card); return; }; diff --git a/crates/op-editor-ui/src/widgets/prompt_center_previews.rs b/crates/op-editor-ui/src/widgets/prompt_center_previews.rs index db2048515..acc420328 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_previews.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_previews.rs @@ -1,29 +1,71 @@ -//! Compile-time Prompt Center preview registry. +//! Prompt Center card previews. //! -//! Preview bytes stay inside the wasm bundle so card painting never performs -//! runtime file or network I/O. +//! ~2.0 MB of JPEG across 57 cards. The desktop binary embeds them: it is +//! already a local file, and card painting should never touch the network. +//! The browser bundle does not — 2 MB of already-compressed JPEG is 2 MB the +//! user downloads before the editor paints anything, and the Prompt Center is +//! a panel most sessions never open. On `wasm32` each preview is fetched from +//! the daemon the first time its card paints (see +//! `op_editor_core::web_assets`), and the card shows its text fallback until +//! the bytes land. +//! +//! Both platforms resolve through the same [`prompt_center_preview`] so the +//! paint site has one shape, and the cache id is known on both before any +//! bytes exist — the id is what the renderer's raster cache is keyed on, so it +//! must not depend on whether a fetch has completed. const PREVIEW_IMAGE_ID_BASE: u64 = 0x5052_4d50_0000_0000; +// Test-only: `concat!` cannot interpolate a const, so the route literals in +// the macro below spell the directory out. This is the value the route +// tests rebuild the expected path from, which is what keeps the spelled-out +// literal and the staged bundle layout (`tools/stage-web-assets.sh`) from +// drifting into a silent per-asset 404. +#[cfg(test)] +const PREVIEW_DIR: &str = "prompt_center_previews"; + +/// One card's preview, resolved for the current platform. +pub(crate) struct PromptPreview { + /// Stable renderer cache id. Known before any bytes are, on both hosts. + pub image_id: u64, + /// `None` on wasm until the fetch lands; always `Some` on native. + pub bytes: Option<&'static [u8]>, + /// Daemon route to fetch. Unused on native, where `bytes` is already set. + pub route: &'static str, +} + macro_rules! preview { - ($index:literal, $file:literal) => { - Some(( - PREVIEW_IMAGE_ID_BASE | $index, + ($index:literal, $file:literal) => {{ + // The route literal must agree with `WEB_ASSET_ROUTE_PREFIX`; `concat!` + // needs literals, so the prefix is spelled out here and checked against + // the constant by `route_prefix_matches_the_shared_constant` below. + const ROUTE: &str = concat!("/pkg/assets/", "prompt_center_previews", "/", $file, ".jpg"); + #[cfg(not(target_arch = "wasm32"))] + let bytes = Some( include_bytes!(concat!( "../../assets/prompt_center_previews/", $file, ".jpg" )) .as_slice(), - )) - }; + ); + #[cfg(target_arch = "wasm32")] + let bytes = op_editor_core::web_assets::installed_bytes(ROUTE); + Some(PromptPreview { + image_id: PREVIEW_IMAGE_ID_BASE | $index, + bytes, + route: ROUTE, + }) + }}; } -/// Return the stable cache id and embedded JPEG bytes for a built-in prompt. +/// Return the preview for a built-in prompt. /// /// User-defined prompts and unknown ids intentionally have no generated preview -/// and therefore return `None`. -pub(crate) fn prompt_center_preview(prompt_id: &str) -> Option<(u64, &'static [u8])> { +/// and therefore return `None`. A `Some` whose `bytes` are `None` is the web +/// host's "not fetched yet" — a different answer, and the caller must not +/// confuse the two: one means there is no picture, the other means not yet. +pub(crate) fn prompt_center_preview(prompt_id: &str) -> Option { match prompt_id { "gallery-wander" => preview!(1, "gallery-wander"), "gallery-forage" => preview!(2, "gallery-forage"), @@ -102,12 +144,14 @@ mod tests { let mut image_ids = HashSet::new(); for prompt in generated { - let (image_id, bytes) = prompt_center_preview(&prompt.id) + let preview = prompt_center_preview(&prompt.id) .unwrap_or_else(|| panic!("missing preview for `{}`", prompt.id)); assert!( - image_ids.insert(image_id), - "duplicate image id {image_id:#x}" + image_ids.insert(preview.image_id), + "duplicate image id {:#x}", + preview.image_id ); + let bytes = preview.bytes.expect("native embeds every preview"); assert_eq!( crate::image_runtime::encoded_image_dimensions(bytes), Some((640, 400)), @@ -118,6 +162,42 @@ mod tests { assert_eq!(image_ids.len(), 57); } + #[test] + fn route_prefix_matches_the_shared_constant() { + // `concat!` cannot interpolate a const, so the macro spells the prefix + // out. This is what stops the literal and the daemon's route from + // drifting apart into a silent 404 on every card. + let preview = prompt_center_preview("gallery-wander").expect("ships"); + assert!(preview + .route + .starts_with(op_editor_core::web_assets::WEB_ASSET_ROUTE_PREFIX)); + assert_eq!( + preview.route, + format!( + "{}{}/gallery-wander.jpg", + op_editor_core::web_assets::WEB_ASSET_ROUTE_PREFIX, + super::PREVIEW_DIR + ) + ); + } + + #[test] + fn every_shipped_preview_has_a_distinct_route() { + // A duplicated route would make two cards share one fetch and one + // picture — the routes are the web host's identity for these assets + // exactly as the image ids are the renderer's. + let mut routes = HashSet::new(); + for prompt in prompt_catalogue() { + let preview = prompt_center_preview(&prompt.id).expect("ships"); + assert!( + routes.insert(preview.route), + "duplicate route {}", + preview.route + ); + } + assert_eq!(routes.len(), 57); + } + #[test] fn custom_and_unknown_ids_have_no_generated_preview() { assert!(prompt_center_preview("custom-1").is_none()); diff --git a/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs b/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs index 0fc83b4a7..ab553e223 100644 --- a/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs @@ -506,7 +506,17 @@ impl SceneTemplatePanel<'_> { preview: Rect, template: &'static SceneTemplateDefinition, ) { - let Some((image_id, encoded)) = scene_template_preview(&template.id) else { + let Some(asset) = scene_template_preview(&template.id) else { + cx.backend.fill_round_rect(preview, 9.0, self.theme.muted); + return; + }; + let image_id = asset.image_id; + let Some(encoded) = asset.bytes else { + // Web only: not in the bundle and not fetched yet. Ask the host and + // paint the plain block meanwhile — the card's title and metadata + // are already readable, so a slow or failed fetch costs the picture + // and nothing else. + op_editor_core::web_assets::request(asset.route); cx.backend.fill_round_rect(preview, 9.0, self.theme.muted); return; }; diff --git a/crates/op-editor-ui/src/widgets/scene_template_previews.rs b/crates/op-editor-ui/src/widgets/scene_template_previews.rs index 8d8c5f21b..b240f68a9 100644 --- a/crates/op-editor-ui/src/widgets/scene_template_previews.rs +++ b/crates/op-editor-ui/src/widgets/scene_template_previews.rs @@ -1,8 +1,13 @@ -//! Compile-time embedded Scene Template card previews. +//! Scene Template card previews. //! //! Baked from the full-resolution renders by //! `templates/step0/_generators/scene_preview_cards.py`; see that script for //! why a deck is tiled into a grid rather than fitted as a strip. +//! +//! ~388 KB of JPEG. Embedded on desktop, fetched from the daemon on `wasm32` +//! for the same reason the Prompt Center's are — see +//! `prompt_center_previews` for the full rationale and +//! `op_editor_core::web_assets` for the loader. /// Cache ids are hand-assigned and must stay stable: the renderer keys its /// decoded-raster cache on them, so reusing an id for different bytes would @@ -10,10 +15,35 @@ /// two catalogues can never collide in that shared cache. const CACHE_ID_BASE: u64 = 10_000; +// Test-only: `concat!` cannot interpolate a const, so the route literals in +// the macro below spell the directory out. This is the value the route +// tests rebuild the expected path from, which is what keeps the spelled-out +// literal and the staged bundle layout (`tools/stage-web-assets.sh`) from +// drifting into a silent per-asset 404. +#[cfg(test)] +const PREVIEW_DIR: &str = "scene_template_previews"; + +/// One card's preview, resolved for the current platform. +pub(crate) struct TemplatePreview { + /// Stable renderer cache id, known before any bytes are. + pub image_id: u64, + /// `None` on wasm until the fetch lands; always `Some` on native. + pub bytes: Option<&'static [u8]>, + /// Daemon route to fetch. Unused on native. + pub route: &'static str, +} + macro_rules! preview { - ($offset:expr, $name:literal) => { - Some(( - CACHE_ID_BASE + $offset, + ($offset:expr, $name:literal) => {{ + const ROUTE: &str = concat!( + "/pkg/assets/", + "scene_template_previews", + "/", + $name, + ".jpg" + ); + #[cfg(not(target_arch = "wasm32"))] + let bytes = Some( include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/scene_template_previews/", @@ -21,16 +51,25 @@ macro_rules! preview { ".jpg" )) .as_slice(), - )) - }; + ); + #[cfg(target_arch = "wasm32")] + let bytes = op_editor_core::web_assets::installed_bytes(ROUTE); + Some(TemplatePreview { + image_id: CACHE_ID_BASE + $offset, + bytes, + route: ROUTE, + }) + }}; } -/// Return the stable cache id and embedded JPEG bytes for a template. +/// Return the preview for a template id. /// /// Every shipped template has one — `scene_template_catalog` rejects a /// catalogue entry without a document, and the preview baker is driven by the -/// same id list — so `None` means an unknown id, not a missing asset. -pub(crate) fn scene_template_preview(template_id: &str) -> Option<(u64, &'static [u8])> { +/// same id list — so `None` means an unknown id, not a missing asset. On web a +/// `Some` with `bytes: None` means "not fetched yet", which is not the same +/// answer. +pub(crate) fn scene_template_preview(template_id: &str) -> Option { match template_id { "screenshot-tutorial" => preview!(1, "screenshot-tutorial"), "knowledge-carousel" => preview!(2, "knowledge-carousel"), @@ -56,13 +95,15 @@ mod tests { fn every_shipped_template_has_a_preview_with_a_unique_cache_id() { let mut ids = HashSet::new(); for template in scene_template_catalogue() { - let (cache_id, bytes) = scene_template_preview(&template.id) + let preview = scene_template_preview(&template.id) .unwrap_or_else(|| panic!("{} has no card preview", template.id)); + let bytes = preview.bytes.expect("native embeds every preview"); assert!(!bytes.is_empty(), "{} preview is empty", template.id); assert!( - ids.insert(cache_id), - "{} reuses cache id {cache_id}", - template.id + ids.insert(preview.image_id), + "{} reuses cache id {}", + template.id, + preview.image_id ); } assert!(scene_template_preview("no-such-template").is_none()); @@ -71,8 +112,32 @@ mod tests { #[test] fn previews_are_jpeg_so_the_raster_decoder_accepts_them() { for template in scene_template_catalogue() { - let (_, bytes) = scene_template_preview(&template.id).expect("preview"); + let bytes = scene_template_preview(&template.id) + .expect("preview") + .bytes + .expect("native embeds every preview"); assert_eq!(&bytes[..2], &[0xFF, 0xD8], "{} is not a JPEG", template.id); } } + + #[test] + fn every_route_is_distinct_and_uses_the_shared_prefix() { + // The route is the web host's identity for the asset, exactly as the + // cache id is the renderer's. `concat!` cannot interpolate the prefix + // constant, so this is what keeps the spelled-out literal honest. + let mut routes = HashSet::new(); + for template in scene_template_catalogue() { + let preview = scene_template_preview(&template.id).expect("preview"); + assert_eq!( + preview.route, + format!( + "{}{}/{}.jpg", + op_editor_core::web_assets::WEB_ASSET_ROUTE_PREFIX, + PREVIEW_DIR, + template.id + ) + ); + assert!(routes.insert(preview.route), "duplicate {}", preview.route); + } + } } diff --git a/crates/op-host-services/src/web_canvas_server/collab_state_tests.rs b/crates/op-host-services/src/web_canvas_server/collab_state_tests.rs index 6d4dcca6d..f87292e62 100644 --- a/crates/op-host-services/src/web_canvas_server/collab_state_tests.rs +++ b/crates/op-host-services/src/web_canvas_server/collab_state_tests.rs @@ -386,12 +386,19 @@ fn a_gated_push_still_owns_its_document_so_the_seed_is_released() { /// projected phase with no actor behind it. fn with_owner_session( state: &mut WebCanvasState, - fixture: op_collab_host::test_support::OwnerFixture, -) { - state.collab.runtime = fixture.runtime; + session: ( + op_collab_host::CollabRuntime, + op_collab_host::test_support::OwnerLaneGuard, + ), +) -> op_collab_host::test_support::OwnerLaneGuard { + let (runtime, guard) = session; + state.collab.runtime = runtime; in_session(state, CollabConnectionPhase::Active, CollabUiRole::Owner); - // Kept alive: the actor's projection was built against it. - std::mem::forget(fixture.host); + // Returned, not dropped: the guard owns the command lane's receiver, and + // letting it go turns a full lane into a disconnected one — a different + // failure with a different projection. The caller must hold it until the + // ingest under test has finished. + guard } /// Seed this daemon with the same document shape a push carries, so the @@ -406,6 +413,12 @@ fn seed_baseline(state: &mut WebCanvasState, name: &str, thumb_id: &str) { state.editor.doc = prepared.into_document(); } +/// The node name the daemon's document currently carries. +fn node_name(state: &WebCanvasState) -> String { + let node = serde_json::to_value(&state.editor.active_children()[0]).expect("node serialises"); + node["name"].as_str().unwrap_or_default().to_string() +} + #[test] fn a_session_rejected_ingest_rolls_the_thumbnail_registry_back() { // Drives the REAL rejection: the pushed document changes `version`, which @@ -418,18 +431,25 @@ fn a_session_rejected_ingest_rolls_the_thumbnail_registry_back() { // Ids no other test uses, so this is immune to the process-global // registry being cleared in parallel. + const BASELINE: u64 = 515_243_616; const KEPT: u64 = 515_243_617; const REFUSED: u64 = 515_243_618; let _registry = crate::web_canvas_server::lock_image_thumb_registry(); let mut state = daemon(); - // The starter document carries the crate's own version; the push below - // carries "1.0.0", and a version change is what the diff refuses. - with_owner_session(&mut state, op_collab_host::test_support::owner_session()); + seed_baseline(&mut state, "before", &BASELINE.to_string()); + // The session is activated over the SAME document the daemon holds, so the + // only thing the diff can object to is what this push actually changes. + let baseline_doc = state.editor.doc.clone(); + let _lane = with_owner_session( + &mut state, + op_collab_host::test_support::owner_session(baseline_doc), + ); jian_ops_schema::image_thumbs::store_thumb(KEPT, vec![4, 5, 6]); - let body = seeded_body("refused", &REFUSED.to_string()); + // A document-version change is what `diff_supported` refuses outright. + let body = seeded_body("refused", &REFUSED.to_string()).replace("1.0.0", "9.9.9"); let mut push = PendingDocumentPush::parse(&body, ServeMode::Local).expect("parses"); let prepared = push.prepared.take().expect("a document push"); @@ -447,6 +467,11 @@ fn a_session_rejected_ingest_rolls_the_thumbnail_registry_back() { jian_ops_schema::image_thumbs::thumb_for(REFUSED).is_none(), "the rolled-back document's thumbnails must roll back with it" ); + assert_eq!( + node_name(&state), + "before", + "a rejected ingest must leave the document as the session found it" + ); } #[test] @@ -463,9 +488,10 @@ fn a_standalone_fallback_failure_keeps_the_new_thumbnails() { let _registry = crate::web_canvas_server::lock_image_thumb_registry(); let mut state = daemon(); seed_baseline(&mut state, "before", &BASELINE.to_string()); - with_owner_session( + let baseline_doc = state.editor.doc.clone(); + let _lane = with_owner_session( &mut state, - op_collab_host::test_support::owner_session_with_saturated_command_lane(), + op_collab_host::test_support::owner_session_with_saturated_command_lane(baseline_doc), ); // Same document shape, renamed node: a supported diff, so the session gets @@ -479,6 +505,27 @@ fn a_standalone_fallback_failure_keeps_the_new_thumbnails() { IngestOutcome::Failed, "an undeliverable commit must come back as a failure" ); + // The distinction that makes this a REAL delivery failure: a full lane is + // `ResourceLimit`, a dropped receiver would be `Transport`. Asserting the + // projection is what proves the lane guard above is doing its job. + let failures: Vec<_> = state + .collab + .runtime + .drain_status_events() + .filter_map(|event| match event { + op_collab_host::CollabStatusEvent::Failed(failure) => Some(failure), + _ => None, + }) + .collect(); + assert!( + failures.contains(&op_collab_host::CollabRuntimeFailure::ResourceLimit), + "the commit must have failed on a FULL lane, not a dead one: {failures:?}" + ); + assert_eq!( + node_name(&state), + "after", + "the standalone fallback keeps the edit the push carried" + ); assert!( jian_ops_schema::image_thumbs::thumb_for(KEPT).is_some(), "thumbnails must follow the kept document, not roll back without it" diff --git a/crates/op-host-services/src/web_chat_standard_tests.rs b/crates/op-host-services/src/web_chat_standard_tests.rs index 448bfff6b..e9d17724b 100644 --- a/crates/op-host-services/src/web_chat_standard_tests.rs +++ b/crates/op-host-services/src/web_chat_standard_tests.rs @@ -694,3 +694,99 @@ fn a_closed_write_barrier_makes_the_design_doc_sink_ack_false() { "the generator's mirror must not advertise a node that was never applied" ); } + +/// A two-page daemon document, so switching the active page is possible. +fn multi_page_state() -> WebCanvasState { + let doc_json = serde_json::json!({ + "version": "1.0.0", + "children": [], + "pages": [ + {"id": "p1", "name": "One", "children": []}, + {"id": "p2", "name": "Two", "children": []}, + ], + }) + .to_string(); + let loaded = op_pen_loader::load_canonical(&doc_json).expect("fixture document loads"); + WebCanvasState::new(EditorState::from_document(loaded.value), 3100) +} + +/// A turn that asks for a different active page and carries no document. +fn active_page_body(page_id: &str) -> String { + serde_json::json!({ + "model": "default", + "user": "hello", + "activePageId": page_id, + }) + .to_string() +} + +#[test] +fn a_closed_write_barrier_leaves_the_active_page_where_the_flush_found_it() { + // `active_page_index` is serialised into the tenant's persisted snapshot by + // `EditorMeta::from_state`, so moving it after the flush has snapshotted + // the document loses the move — or worse, persists a page the flushed + // document does not describe. + // + // This covers the SECOND admission guard specifically. The `editorMeta` + // test above passes even if this one's guard is deleted, because the two + // fields arrive on different request fields. + use crate::web_canvas_server::WriteBarrier; + + let barrier = WriteBarrier::default(); + barrier.close(); + let state = Mutex::new(multi_page_state()); + assert_eq!( + state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .editor + .ui + .active_page_index, + 0, + "the fixture starts on the first page" + ); + + let req = parse_standard_turn_body(&active_page_body("p2")).expect("request parses"); + let snapshot = apply_request_snapshot(&req, &state, &SseHub::default(), Some(&barrier)) + .expect("a page-switch turn still gets its reply"); + + assert_eq!( + snapshot.ui.active_page_index, 0, + "the turn snapshot must not advertise a page the flush will not persist" + ); + assert_eq!( + state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .editor + .ui + .active_page_index, + 0, + "a closed barrier must leave the persisted active page alone" + ); +} + +#[test] +fn an_open_write_barrier_still_switches_the_active_page() { + // The counterpart, so the guard above is proven to be what stops it rather + // than the fixture simply being unable to switch pages at all. + use crate::web_canvas_server::WriteBarrier; + + let barrier = WriteBarrier::default(); + let state = Mutex::new(multi_page_state()); + + let req = parse_standard_turn_body(&active_page_body("p2")).expect("request parses"); + apply_request_snapshot(&req, &state, &SseHub::default(), Some(&barrier)) + .expect("an open barrier admits the switch"); + + assert_eq!( + state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .editor + .ui + .active_page_index, + 1, + "an open barrier must apply the requested page switch" + ); +} diff --git a/crates/op-host-web/Cargo.toml b/crates/op-host-web/Cargo.toml index 61eb8fcbb..028120945 100644 --- a/crates/op-host-web/Cargo.toml +++ b/crates/op-host-web/Cargo.toml @@ -150,6 +150,9 @@ features = [ # Used only by the (opt-in) `live-sync` glue to poll the web-canvas daemon. "XmlHttpRequest", "XmlHttpRequestEventTarget", + # ArrayBuffer responses for the runtime-fetched product assets (preview + # JPEGs); `response_text` would mangle binary bodies. + "XmlHttpRequestResponseType", ] [features] diff --git a/crates/op-host-web/src/canvaskit/inner.rs b/crates/op-host-web/src/canvaskit/inner.rs index 31bdbc873..39e87348b 100644 --- a/crates/op-host-web/src/canvaskit/inner.rs +++ b/crates/op-host-web/src/canvaskit/inner.rs @@ -30,6 +30,10 @@ pub(super) struct CkInner { impl CkInner { pub(super) fn repaint(&mut self) { self.backend.drain_pending_decodes(2); + // Assets the last paint asked for but the bundle does not carry + // (preview JPEGs, template documents, the icon catalog). Bounded per + // call; the installs wake a later frame through `repaint_coalescer`. + crate::web_asset_fetch::drain_pending(); crate::web_chat::reconcile_models(self.host.editor_state_mut()); // Detect a credential edit and enqueue the daemon sync BEFORE mirroring // the sync status below: a corrective edit clears the stale error in diff --git a/crates/op-host-web/src/lib.rs b/crates/op-host-web/src/lib.rs index 2763f5287..b41fe530b 100644 --- a/crates/op-host-web/src/lib.rs +++ b/crates/op-host-web/src/lib.rs @@ -98,6 +98,9 @@ mod web_ai_transport; // against api.iconify.design (CORS-open, same as TS). #[cfg(feature = "canvaskit")] mod iconify_web; +// Runtime fetch for the product assets the wasm bundle omits. +#[cfg(feature = "canvaskit")] +mod web_asset_fetch; // Web chat session — drains `chat.pending_send` / Stop / New Chat and streams // real standard-mode turns through the daemon's `/api/ai/standard` route. #[cfg(feature = "canvaskit")] diff --git a/crates/op-host-web/src/web_asset_fetch.rs b/crates/op-host-web/src/web_asset_fetch.rs new file mode 100644 index 000000000..27048f983 --- /dev/null +++ b/crates/op-host-web/src/web_asset_fetch.rs @@ -0,0 +1,232 @@ +//! Browser transport for the runtime-fetched product assets. +//! +//! The widget layer is platform-free: when a card needs a preview that is not +//! in the bundle it calls `op_editor_core::web_assets::request(route)` and +//! paints its placeholder. This module is the other half — it drains those +//! requests once per frame, fetches each over XHR, and installs the bytes back +//! into the registry so the next paint finds them. +//! +//! Three properties matter, and all three are the registry's, not this file's: +//! single-flight (a forty-card grid produces forty requests, not forty per +//! frame), exactly-one-answer (every drained route gets `install` or +//! `mark_failed`, so nothing stays `Pending` forever), and graceful failure +//! (an unavailable asset degrades to a placeholder — never a panic, never a +//! spinner that outlives the session). + +use std::cell::RefCell; +use std::rc::Rc; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; + +/// How many assets may be in flight from one drain. +/// +/// The Prompt Center opens with dozens of cards visible; firing every request +/// at once buries the daemon's connection pool behind a burst nobody is +/// looking at yet. A small batch per frame keeps the visible rows filling in +/// first, and the queue is drained again next frame. +const MAX_IN_FLIGHT_PER_DRAIN: usize = 6; + +/// Abandon a request after this long. A hung asset must not hold a route in +/// `Pending` forever — that would leave its card on a placeholder with no +/// retry. +const FETCH_TIMEOUT_MS: u32 = 20_000; + +/// Why an asset fetch did not produce bytes. +/// +/// Typed rather than a string because each variant is a different operational +/// story: no XHR at all is a hostile embedding, a non-2xx is a bundle that was +/// deployed without its assets, and a timeout is a slow link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WebAssetFetchError { + /// `XMLHttpRequest` could not be constructed. + XhrUnavailable, + /// `open()` was rejected — a malformed route. + RequestOpenFailed, + /// `send()` was rejected. + RequestSendFailed, + /// The response arrived with a non-2xx status (0 = network / timeout). + Http(u16), + /// A 2xx with no readable body. + EmptyBody, +} + +impl std::fmt::Display for WebAssetFetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::XhrUnavailable => write!(f, "XMLHttpRequest is unavailable"), + Self::RequestOpenFailed => write!(f, "could not open the asset request"), + Self::RequestSendFailed => write!(f, "could not send the asset request"), + Self::Http(status) => write!(f, "asset request failed with status {status}"), + Self::EmptyBody => write!(f, "asset response carried no body"), + } + } +} + +/// Drain whatever the widget layer asked for and fetch it. +/// +/// Called once per paint. Cheap when idle: one lock and a length test. It +/// takes no host handle on purpose — the install path wakes the editor through +/// `repaint_coalescer`, the same free-function seam the agent-indicator relay +/// uses, so this module never has to borrow a host that a DOM event may +/// already hold. +pub(crate) fn drain_pending() { + for route in op_editor_core::web_assets::take_pending_requests(MAX_IN_FLIGHT_PER_DRAIN) { + fetch_asset(route); + } +} + +fn fetch_asset(route: String) { + let url = crate::daemon_base::daemon_url(&route); + fetch_bytes(&url, move |result| match result { + Ok(bytes) => { + if op_editor_core::web_assets::install(&route, bytes) { + // Wake the editor so the card showing a placeholder picks the + // picture up; the response is not an input event, so nothing + // else would. + crate::repaint_coalescer::request(); + } + } + Err(_error) => { + // Degrade, do not retry in place: `mark_failed` leaves the route + // retryable, so reopening the panel asks again. Spinning here would + // hammer a daemon that simply is not serving assets. + op_editor_core::web_assets::mark_failed(&route); + } + }); +} + +type DoneFn = Box, WebAssetFetchError>)>; + +/// Fire a GET for binary content and hand the body (or an error) to `on_done` +/// exactly once. +/// +/// The callback is slot-wrapped so the synchronous failure paths still resolve +/// it — a dropped callback would strand its route in `Pending`, which is the +/// one state the registry cannot recover from on its own. +fn fetch_bytes(url: &str, on_done: impl FnOnce(Result, WebAssetFetchError>) + 'static) { + let slot: Rc>> = Rc::new(RefCell::new(Some(Box::new(on_done)))); + let resolve = |slot: &Rc>>, result| { + if let Some(done) = slot.borrow_mut().take() { + done(result); + } + }; + let Ok(xhr) = web_sys::XmlHttpRequest::new() else { + resolve(&slot, Err(WebAssetFetchError::XhrUnavailable)); + return; + }; + if xhr.open_with_async("GET", url, true).is_err() { + resolve(&slot, Err(WebAssetFetchError::RequestOpenFailed)); + return; + } + // These are JPEGs and `.op` documents; `response_text` would mangle the + // former, so the response is read as an ArrayBuffer and copied out. + xhr.set_response_type(web_sys::XmlHttpRequestResponseType::Arraybuffer); + // The assets live under the daemon's `/pkg/` route, so in managed mode the + // bridge token has to ride along exactly as it does for every other daemon + // call. `attach_daemon_headers` decides that from the URL. + crate::live_sync::attach_daemon_headers(&xhr, url); + xhr.set_timeout(FETCH_TIMEOUT_MS); + let xhr_cb = xhr.clone(); + let slot_cb = slot.clone(); + let onloadend = Closure::::once_into_js(move || { + let status = xhr_cb.status().unwrap_or(0); + let result = if (200..300).contains(&status) { + match xhr_cb.response() { + Ok(value) if !value.is_null() && !value.is_undefined() => { + let buffer = js_sys::Uint8Array::new(&value); + let mut bytes = vec![0u8; buffer.length() as usize]; + buffer.copy_to(&mut bytes); + if bytes.is_empty() { + Err(WebAssetFetchError::EmptyBody) + } else { + Ok(bytes) + } + } + _ => Err(WebAssetFetchError::EmptyBody), + } + } else { + Err(WebAssetFetchError::Http(status)) + }; + if let Some(done) = slot_cb.borrow_mut().take() { + done(result); + } + }); + xhr.set_onloadend(Some(onloadend.unchecked_ref())); + if xhr.send().is_err() { + resolve(&slot, Err(WebAssetFetchError::RequestSendFailed)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use op_editor_core::web_assets::{self, WebAssetState}; + + /// Serialises against the process-global asset registry. + fn lock_registry() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poison| poison.into_inner()) + } + + #[test] + fn every_failure_variant_reads_as_its_own_operational_story() { + // Each is a different thing to go and fix, which is the whole reason + // this is an enum rather than a bool. + let messages = [ + WebAssetFetchError::XhrUnavailable.to_string(), + WebAssetFetchError::RequestOpenFailed.to_string(), + WebAssetFetchError::RequestSendFailed.to_string(), + WebAssetFetchError::Http(404).to_string(), + WebAssetFetchError::EmptyBody.to_string(), + ]; + let unique: std::collections::HashSet<_> = messages.iter().collect(); + assert_eq!(unique.len(), messages.len()); + assert!(messages[3].contains("404")); + } + + #[test] + fn a_drain_is_bounded_so_one_frame_cannot_open_every_socket() { + // The bound is what keeps a freshly opened Prompt Center from firing + // 57 sockets in a single frame. + let _guard = lock_registry(); + for index in 0..(MAX_IN_FLIGHT_PER_DRAIN + 4) { + web_assets::request(&format!("/pkg/assets/drain-bound-{index}.jpg")); + } + assert_eq!( + web_assets::take_pending_requests(MAX_IN_FLIGHT_PER_DRAIN).len(), + MAX_IN_FLIGHT_PER_DRAIN + ); + assert!(web_assets::has_pending_requests(), "the rest wait a frame"); + // Leave the shared registry clean for other tests. + for route in web_assets::take_pending_requests(usize::MAX) { + web_assets::mark_failed(&route); + } + for index in 0..MAX_IN_FLIGHT_PER_DRAIN { + web_assets::mark_failed(&format!("/pkg/assets/drain-bound-{index}.jpg")); + } + } + + #[test] + fn a_failed_asset_degrades_and_can_be_asked_for_again() { + // This is the contract the paint sites depend on: a failure must leave + // the card on its placeholder AND leave the door open, never wedge the + // route in `Pending`. + let _guard = lock_registry(); + let route = "/pkg/assets/web-asset-fetch-failure.jpg"; + + web_assets::request(route); + let drained = web_assets::take_pending_requests(usize::MAX); + assert!(drained.iter().any(|r| r == route)); + + web_assets::mark_failed(route); + assert_eq!(web_assets::state(route), WebAssetState::Failed); + assert!(web_assets::installed_bytes(route).is_none()); + + web_assets::request(route); + assert_eq!(web_assets::state(route), WebAssetState::Pending); + for r in web_assets::take_pending_requests(usize::MAX) { + web_assets::mark_failed(&r); + } + } +} diff --git a/crates/op-web-sdk/tools/build-wasm.sh b/crates/op-web-sdk/tools/build-wasm.sh index 658632f62..fbad74756 100644 --- a/crates/op-web-sdk/tools/build-wasm.sh +++ b/crates/op-web-sdk/tools/build-wasm.sh @@ -18,7 +18,8 @@ # 5. wasm-opt -Oz (with the rustc-emitted WebAssembly feature flags) then # gzip size <= OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES (default 8 388 608 = 8 MiB). # The SDK is pure logic with no CanvasKit WASM included — the ceiling -# mirrors op-host-web's 6 MiB guard and can be tightened later. +# is its own tripwire, not op-host-web's — this bundle has never been +# measured against a tighter one, so it is left at 8 MiB until it is. # # This approach mirrors tools/check-wasm-bundle.sh (op-host-web gate) exactly: # cargo build → wasm-bindgen → wasm-opt, without wasm-pack. This removes the @@ -60,7 +61,11 @@ WASM_OPT_CANDIDATE_FEATURES=( --enable-nontrapping-float-to-int ) -# Gzip ceiling — same 8 MiB tripwire as op-host-web (see +# Gzip ceiling. NOT the same number as op-host-web any more: that bundle was +# re-baselined to 6 MiB once its preview JPEGs moved out behind the runtime +# `/pkg/assets/` fetch (`tools/check-wasm-bundle.sh`). This viewer bundle has a +# different feature set and has not been measured against a tighter ceiling, so +# it keeps 8 MiB until it is. (see # tools/check-wasm-bundle.sh for the embedded-asset composition note). # Override via env when intentionally re-baselining. LIMIT="${OP_WEB_SDK_WASM_GZIP_LIMIT_BYTES:-8388608}" diff --git a/tools/check-wasm-bundle.sh b/tools/check-wasm-bundle.sh index 8b678355e..8d7cc5b21 100755 --- a/tools/check-wasm-bundle.sh +++ b/tools/check-wasm-bundle.sh @@ -13,7 +13,7 @@ # the wasm-bindgen JS shim). Any env.* import = LinkError at # load time → regression → fail. # 4. Post wasm-opt -Oz gzip size ≤ STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES -# (default 8 388 608 bytes = 8 MiB for the full CanvasKit app logic). +# (default 6 291 456 bytes = 6 MiB for the full CanvasKit app logic). # # This script is the local counterpart to # `.github/workflows/wasm-bundle-build.yml`; keep the two recipes aligned. @@ -45,22 +45,27 @@ WASM_OPT_CANDIDATE_FEATURES=( ) # Ceiling for the CanvasKit production bundle's gzipped wasm. It is far above -# the retired skia raster path's 1 MiB (spec §6) because this bundle now carries -# the FULL app logic absorbed from the skia path (codegen AI pipeline, Figma -# parser, AI/live-sync, collaboration) plus ~4.5 MiB of embedded product -# assets — scene-template .op documents, the prompt-center/template preview -# JPEGs (already compressed, so gzip passes them through), the iconify -# catalog, and the AI skill corpus. ~7.5 MiB today; the ceiling is a -# runaway-regression tripwire, not a budget. TODO(perf): serve the preview -# JPEGs from the daemon instead of embedding, and code-split / lazy-load the -# codegen + Figma paths to shrink the initial download. Override via env. -LIMIT="${STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES:-8388608}" +# the retired skia raster path's 1 MiB (spec §6) because this bundle carries the +# FULL app logic absorbed from the skia path (codegen AI pipeline, Figma parser, +# AI/live-sync, collaboration) plus the remaining embedded product assets — +# scene-template .op documents, the iconify core catalog, and the AI skill +# corpus. +# +# Re-baselined from 8 MiB to 6 MiB once the ~2.4 MiB of preview JPEGs moved out +# of the binary and behind the runtime `/pkg/assets/` fetch (step 4 above; +# `op_editor_core::web_assets`). The bundle measures ~5.35 MiB today, so the +# ceiling is still a runaway-regression tripwire rather than a budget — but a +# tripwire 3 MiB above the real number catches nothing, which is why it moves +# down with the bundle. TODO(perf): the template documents and icon catalog can +# follow the previews out; code-splitting the codegen + Figma paths is the +# larger remaining win. Override via env. +LIMIT="${STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES:-6291456}" step() { printf '\n[step %d/%d] %s\n' "$1" "$2" "$3"; } fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || { printf 'missing prerequisite: %s\n' "$1" >&2; exit 2; }; } -step 1 5 "Verify prerequisites" +step 1 6 "Verify prerequisites" need cargo need wasm-bindgen need wasm-opt @@ -74,14 +79,24 @@ need gzip # CanvasKit on the GPU and bundles the full app logic absorbed from the retired # skia raster path — daemon-backed AI chat (`web_chat`), live-sync, browser file # IO, clipboard/Figma paste, icon search, system fonts, and the codegen pipeline. -step 2 5 "Build shell-web wasm32-unknown-unknown with --features canvaskit" +step 2 6 "Build shell-web wasm32-unknown-unknown with --features canvaskit" cargo build -p op-host-web \ --target wasm32-unknown-unknown --no-default-features --features canvaskit --release >/dev/null -step 3 5 "wasm-bindgen --target web → ${PKG_DIR}/" +step 3 6 "wasm-bindgen --target web → ${PKG_DIR}/" wasm-bindgen --target web --out-dir "${PKG_DIR}" "${TARGET_WASM}" >/dev/null -step 4 5 "Verify 0 env.* imports (spec §7.1 import guard)" +step 4 6 "Stage runtime product assets into ${PKG_DIR}/assets/" +# The wasm bundle no longer embeds the preview JPEGs, template documents and +# icon catalog (see `op_editor_core::web_assets`): the browser fetches each on +# demand from `/pkg/assets/…`, which the daemon already serves out of the +# resolved bundle directory. Staging them here is what makes that route +# resolve — a bundle shipped without this step degrades every preview to its +# placeholder. Keep in sync with `.github/workflows/wasm-bundle-build.yml` and +# `Dockerfile.web-rust`. +bash tools/stage-web-assets.sh "${PKG_DIR}/assets" + +step 5 6 "Verify 0 env.* imports (spec §7.1 import guard)" env_count="$(node -e ' const fs = require("fs"); const buf = fs.readFileSync(process.argv[1]); @@ -96,7 +111,7 @@ if [ "${env_count}" != "0" ]; then fi printf ' ✓ 0 env.* imports\n' -step 5 5 "Verify gzip size ≤ ${LIMIT} bytes (spec §6 ceiling)" +step 6 6 "Verify gzip size ≤ ${LIMIT} bytes (spec §6 ceiling)" # Keep only the candidate feature flags this wasm-opt understands (see the # WASM_OPT_CANDIDATE_FEATURES note) so an older binaryen doesn't hard-fail on # `--enable-bulk-memory-opt`. `--enable-bulk-memory` alone still covers diff --git a/tools/stage-web-assets.sh b/tools/stage-web-assets.sh new file mode 100755 index 000000000..c703417e2 --- /dev/null +++ b/tools/stage-web-assets.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# tools/stage-web-assets.sh — copy the runtime-fetched product assets into a +# web bundle's `assets/` directory. +# +# The wasm bundle deliberately omits these (see `op_editor_core::web_assets`): +# the browser fetches each on demand from `/pkg/assets//`, which the +# daemon serves straight out of the resolved bundle directory. The desktop +# binary still embeds them with `include_bytes!` / `include_str!`, so this +# script exists only for the web deployment. +# +# The layout under the destination MUST match the route literals in +# `prompt_center_previews.rs`, `scene_template_previews.rs`, +# `scene_template_catalog.rs` and `icon_catalog.rs` — those are `concat!`ed at +# compile time, so a mismatch is a silent 404 per asset rather than a build +# error. The Rust side pins its half with route tests; this script is the other +# half. +# +# Usage: tools/stage-web-assets.sh +# Exit: 0 staged, 1 a source directory is missing. + +set -euo pipefail + +DEST="${1:?usage: stage-web-assets.sh }" +UI_ASSETS="crates/op-editor-ui/assets" +CORE_ASSETS="crates/op-editor-core/assets" + +copy_dir() { + local src="$1" name="$2" + [ -d "${src}" ] || { printf 'FAIL: missing asset source %s\n' "${src}" >&2; exit 1; } + mkdir -p "${DEST}/${name}" + # `-R` not `-a`: no need to preserve ownership into a container image, and + # BusyBox cp (the Docker build stage) has no `-a`. + cp -R "${src}/." "${DEST}/${name}/" + # Provenance manifests are a build-time record of which model produced each + # preview; they are not fetched by anything and have no business in a + # published bundle. + rm -f "${DEST}/${name}/preview_provenance.json" +} + +copy_file() { + local src="$1" name="$2" + [ -f "${src}" ] || { printf 'FAIL: missing asset source %s\n' "${src}" >&2; exit 1; } + mkdir -p "${DEST}" + cp "${src}" "${DEST}/${name}" +} + +mkdir -p "${DEST}" +copy_dir "${UI_ASSETS}/prompt_center_previews" "prompt_center_previews" +copy_dir "${UI_ASSETS}/scene_template_previews" "scene_template_previews" + +staged="$(du -sk "${DEST}" | cut -f1)" +printf ' ✓ staged runtime assets into %s (%s KiB)\n' "${DEST}" "${staged}"