From df9207d1bebd7c382156e9133f4a8213e47939da Mon Sep 17 00:00:00 2001 From: "G.J.R. Timmer" Date: Thu, 29 Oct 2026 09:25:48 -0400 Subject: [PATCH] feat(web): keep the saved session on a demo start, bound the config request An explicit demo startup no longer saves the session, so the demo cannot replace the last visit's project. The config request is aborted after 5 s, stops reading once the body passes the size limit, and is skipped for ?empty and ?demo. The packaged hosting notes link to the published docs. --- apps/effectcraft-web/Cargo.toml | 4 ++ apps/effectcraft-web/src/app.rs | 12 ++++-- apps/effectcraft-web/src/config_file.rs | 41 ++++++++++++++++--- apps/effectcraft-web/src/startup.rs | 6 +++ .../effectcraft-web/tests/it/startup_tests.rs | 12 ++++++ docs/web.md | 2 +- packaging/web/README.md | 4 +- 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index e7bb3c347..7743347af 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -37,6 +37,8 @@ js-sys = "0.3" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", + "AbortController", + "AbortSignal", "CssStyleDeclaration", "Document", "Element", @@ -51,6 +53,8 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "ReadableStream", + "ReadableStreamDefaultReader", "Request", "RequestCache", "RequestInit", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index be7063f9f..bea794e82 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; +use crate::startup::Startup; use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; @@ -81,7 +82,7 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty` and an explicit `"demo"` startup). pub persist: bool, } @@ -221,7 +222,10 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); // `./effectcraft-config.json`: what to open on load. - let startup = config_file::startup().await; + // `?empty` and `?demo` decide by themselves, so they skip the request. + let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; + // An explicit `"demo"` start leaves the saved session alone: the demo must not overwrite it. + let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); storage::refresh(); @@ -247,7 +251,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); - let kept = !empty && persist::backend() != "memory"; + let kept = keep_session && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); @@ -287,7 +291,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: !empty })) + Ok(Box::new(WebApp { app, persist: keep_session })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs index baeca3269..614d7d4cb 100644 --- a/apps/effectcraft-web/src/config_file.rs +++ b/apps/effectcraft-web/src/config_file.rs @@ -2,28 +2,57 @@ use std::cell::RefCell; -use wasm_bindgen::JsCast; +use wasm_bindgen::{JsCast, closure::Closure}; use wasm_bindgen_futures::JsFuture; use crate::startup::{self, Startup}; +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + thread_local! { /// `None`: not fetched yet; `Some(None)`: no usable file. static FILE: RefCell>>> = const { RefCell::new(None) }; } +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; let init = web_sys::RequestInit::new(); // Always ask the server: the file is the operator's switch, never a cached copy. init.set_cache(web_sys::RequestCache::NoCache); - let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", &init).ok()?; - let resp: web_sys::Response = JsFuture::from(web_sys::window()?.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; if resp.status() != 200 { return None; } - let buf = JsFuture::from(resp.array_buffer().ok()?).await.ok()?; - let a = js_sys::Uint8Array::new(&buf); - (a.length() as usize <= startup::CONFIG_MAX).then(|| a.to_vec()) + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } } /// The config file's bytes (`None` when there is none), fetched on the first call. diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs index 9d30cb609..4759096f3 100644 --- a/apps/effectcraft-web/src/startup.rs +++ b/apps/effectcraft-web/src/startup.rs @@ -42,6 +42,12 @@ impl Startup { self != Startup::Demo } + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + /// Whether the demo project opens when nothing was restored (off for a blank start). pub fn demo_when_nothing_restored(self) -> bool { self != Startup::Empty diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs index f5a491368..3ce90569c 100644 --- a/apps/effectcraft-web/tests/it/startup_tests.rs +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -2,6 +2,7 @@ use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; +/// The three accepted values parse. #[test] fn the_three_values() { assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); @@ -9,6 +10,7 @@ fn the_three_values() { assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); } +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. #[test] fn absent_or_invalid_is_the_default() { for bytes in @@ -18,12 +20,14 @@ fn absent_or_invalid_is_the_default() { } } +/// Other keys do not hide `startup`. #[test] fn a_config_with_other_keys_still_has_its_startup() { assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); } +/// A file over `CONFIG_MAX` is ignored. #[test] fn an_oversized_file_is_the_default() { let mut big = br#"{"startup":"empty","pad":""#.to_vec(); @@ -32,9 +36,17 @@ fn an_oversized_file_is_the_default() { assert_eq!(parse(&big), Startup::Last); } +/// Which project each value restores or opens. #[test] fn what_each_value_does_on_load() { assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); } + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/docs/web.md b/docs/web.md index 3f9374caa..1cdecab00 100644 --- a/docs/web.md +++ b/docs/web.md @@ -255,7 +255,7 @@ By default the page opens the last visit's project, and the demo project on a fi |---|---| | `"last"` (or no key) | the last visit's project; the demo project on a first visit | | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | -| `"demo"` | the demo project, whatever the last visit left | +| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was | The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown value, is the default. The file is fetched once per page load; serve it with diff --git a/packaging/web/README.md b/packaging/web/README.md index 6642b2b30..672d9ad67 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -67,9 +67,9 @@ To choose what the page opens on load, add an `effectcraft-config.json` next to ``` `"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` -always opens the demo project; `"last"` (the default) restores the last visit's project and shows +always opens the demo project without saving the session (the last visit's project stays); `"last"` (the default) restores the last visit's project and shows the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: -[docs/web.md](../../docs/web.md#startup-project). +[docs/web.md](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project). ## Embedding in a page (iframe)