mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user