From c334c36645b7242aa43a2624c88e0ffc17651e59 Mon Sep 17 00:00:00 2001 From: "G.J.R. Timmer" Date: Thu, 29 Oct 2026 07:02:46 -0400 Subject: [PATCH 1/3] feat(web): startup option in effectcraft-config.json A startup key (last, empty or demo) chooses the project the page opens on load. empty restores the last visit's project and otherwise starts blank, never with the demo; demo always opens the demo project; last, or no key, is the current behaviour. ?empty and ?demo still win. The config file is fetched once per page load and the service worker never caches it. --- apps/effectcraft-web/Cargo.toml | 4 ++ apps/effectcraft-web/src/app.rs | 13 +++-- apps/effectcraft-web/src/config_file.rs | 44 +++++++++++++++++ apps/effectcraft-web/src/lib.rs | 5 +- apps/effectcraft-web/src/startup.rs | 49 +++++++++++++++++++ apps/effectcraft-web/tests/it/main.rs | 1 + .../effectcraft-web/tests/it/startup_tests.rs | 40 +++++++++++++++ apps/effectcraft-web/web/sw.js | 4 +- docs/web.md | 19 +++++++ packaging/web/README.md | 13 +++++ packaging/web/_headers | 3 ++ 11 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 apps/effectcraft-web/src/config_file.rs create mode 100644 apps/effectcraft-web/src/startup.rs create mode 100644 apps/effectcraft-web/tests/it/main.rs create mode 100644 apps/effectcraft-web/tests/it/startup_tests.rs diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 28ea2f369..e7bb3c347 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -51,6 +51,10 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "Request", + "RequestCache", + "RequestInit", + "Response", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 169529e5d..be7063f9f 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; +use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -220,6 +220,8 @@ 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; browse::restore(); diskcache::init(); storage::refresh(); @@ -255,10 +257,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { let mut session = session(); session.load_settings(); let recovery = session.begin_recovery(); - // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's - // project (the demo on a first visit). - let restored = !empty && !query_flag("demo") && persist::restore(&mut session); - if !restored && !empty { + // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's + // `startup` says (default: the last visit's project, the demo on a first visit). + let demo = query_flag("demo"); + let restored = !empty && !demo && startup.restores() && persist::restore(&mut session); + if !restored && !empty && (demo || startup.demo_when_nothing_restored()) { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..baeca3269 --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,44 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +async fn fetch() -> Option> { + 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()?; + 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()) +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 97d2e887f..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -18,10 +18,11 @@ //! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..9d30cb609 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,49 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | 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 | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(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/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..f5a491368 --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,40 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +#[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); +} + +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +#[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()); +} diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/docs/web.md b/docs/web.md index c18f7e869..3f9374caa 100644 --- a/docs/web.md +++ b/docs/web.md @@ -242,6 +242,25 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): `mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. +### Startup project + +By default the page opens the last visit's project, and the demo project on a first visit. A +`startup` key in `effectcraft-config.json` (next to `index.html`) changes that: + +```json +{"startup":"empty"} +``` + +| Value | On load | +|---|---| +| `"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 | + +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 +`Cache-Control: no-cache` (the service worker never caches it). + ### Audio Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a diff --git a/packaging/web/README.md b/packaging/web/README.md index 0a1f56fe8..6642b2b30 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -58,6 +58,19 @@ location /effectcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Startup project (optional) + +To choose what the page opens on load, add an `effectcraft-config.json` next to `index.html`: + +```json +{"startup":"empty"} +``` + +`"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 +the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: +[docs/web.md](../../docs/web.md#startup-project). + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index 013e1cf8a..e59592fb1 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -12,3 +12,6 @@ /index.html Cache-Control: no-cache + +/effectcraft-config.json + Cache-Control: no-cache 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 2/3] 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) From 80d01f09d52b1f4eb29645380261709ab0699659 Mon Sep 17 00:00:00 2001 From: "G.J.R. Timmer" Date: Thu, 29 Oct 2026 10:57:00 -0400 Subject: [PATCH 3/3] fix(web): a demo startup keeps the session snapshot once the user opens or edits a project --- apps/effectcraft-web/src/app.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index bea794e82..5326c5ea5 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -82,8 +82,12 @@ 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` and an explicit `"demo"` startup). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, } impl eframe::App for WebApp { @@ -116,6 +120,11 @@ impl eframe::App for WebApp { api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); + // The user's own work after a demo start is worth keeping too: turn the snapshot on. + if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) { + self.demo_start = false; + self.persist = true; + } // What a reload comes back to. if self.persist { // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. @@ -224,7 +233,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // `./effectcraft-config.json`: what to open on load. // `?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. + // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something. let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); @@ -291,7 +300,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: keep_session })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) }), ) .await?;