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.
This commit is contained in:
G.J.R. Timmer
2026-10-29 09:25:48 -04:00
parent c334c36645
commit df9207d1be
7 changed files with 68 additions and 13 deletions
+4
View File
@@ -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",
+8 -4
View File
@@ -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<String>) {
/// 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?;
+35 -6
View File
@@ -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<Option<Option<Vec<u8>>>> = 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<Vec<u8>> {
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<Vec<u8>> {
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.
+6
View File
@@ -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
@@ -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());
}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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)