feat(web): fetch preview assets at runtime instead of embedding them
The preview JPEGs (~2.4 MiB, already compressed so gzip passed them straight through) leave the wasm data segment: a platform-free asset registry in op-editor-core tracks per-route Absent/Pending/Ready/Failed with single-flight and install-once semantics, the browser half fetches over ArrayBuffer XHR with managed-mode headers and a slot-wrapped callback so no synchronous failure can strand a route in Pending, and paint sites fall back to the existing placeholder when bytes are not (yet) there. Native keeps include_bytes verbatim. The staging script copies the asset dirs into pkg/assets/ — under /pkg/ because the hub frontend owns /assets/ — and the gate, CI workflow, and web image all run it and assert the layout. Bundle: 7.13 → 5.10 MiB gzip, so the tripwire returns to 6 MiB (85% occupancy); the sdk bundle keeps its own 8 MiB pending a real measurement. Also closes the final review test gaps: the owner-session fixture now returns a must-use lane guard (a dropped receiver made the saturated lane read as Disconnected, not Full) and seeds the daemon's baseline document so the hash check exercises the real path, and the closed write barrier has a direct multi-page active-page regression test.
This commit is contained in:
parent
f7afc554f6
commit
ace4c7257e
12
.github/workflows/wasm-bundle-build.yml
vendored
12
.github/workflows/wasm-bundle-build.yml
vendored
|
|
@ -23,7 +23,7 @@ name: WASM bundle build (#56 — real canvaskit deployable)
|
|||
# 2. wasm-bindgen --target web --out-dir crates/op-host-web/pkg <target.wasm>
|
||||
# 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 `<exe_dir>/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
|
||||
|
|
|
|||
3
.github/workflows/web-sdk-bundle.yml
vendored
3
.github/workflows/web-sdk-bundle.yml
vendored
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<OwnerNetworkCommand>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`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
|
||||
if saturate {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)> {
|
||||
|
|
|
|||
361
crates/op-editor-core/src/web_assets.rs
Normal file
361
crates/op-editor-core/src/web_assets.rs
Normal file
|
|
@ -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<String, &'static [u8]>,
|
||||
state: HashMap<String, WebAssetState>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<Registry> {
|
||||
static REGISTRY: OnceLock<Mutex<Registry>> = 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<u8>) -> 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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<PromptPreview> {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<TemplatePreview> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
232
crates/op-host-web/src/web_asset_fetch.rs
Normal file
232
crates/op-host-web/src/web_asset_fetch.rs
Normal file
|
|
@ -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<dyn FnOnce(Result<Vec<u8>, 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<Vec<u8>, WebAssetFetchError>) + 'static) {
|
||||
let slot: Rc<RefCell<Option<DoneFn>>> = Rc::new(RefCell::new(Some(Box::new(on_done))));
|
||||
let resolve = |slot: &Rc<RefCell<Option<DoneFn>>>, 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::<dyn FnMut()>::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
tools/stage-web-assets.sh
Executable file
52
tools/stage-web-assets.sh
Executable file
|
|
@ -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/<dir>/<file>`, 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 <dest-assets-dir>
|
||||
# Exit: 0 staged, 1 a source directory is missing.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEST="${1:?usage: stage-web-assets.sh <dest-assets-dir>}"
|
||||
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}"
|
||||
Loading…
Reference in a new issue