Merge pull request #2580 from gjrtimmer/feat/web-startup-option

feat(web): choose the project the page opens with a `startup` config key
This commit is contained in:
BFlat
2026-10-30 04:09:49 +07:00
committed by GitHub
11 changed files with 255 additions and 10 deletions
+8
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,12 @@ web-sys = { version = "0.3", features = [
"HtmlInputElement",
"Location",
"Navigator",
"ReadableStream",
"ReadableStreamDefaultReader",
"Request",
"RequestCache",
"RequestInit",
"Response",
"Url",
"UrlSearchParams",
"Window",
+24 -8
View File
@@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use effectcraft_engine::Session;
use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker};
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;
use wasm_bindgen::prelude::*;
@@ -83,8 +84,12 @@ 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`; 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 {
@@ -117,6 +122,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.
@@ -223,6 +233,11 @@ 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.
// `?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 until the user opens or edits something.
let keep_session = !empty && startup.keeps_session();
browse::restore();
diskcache::init();
storage::refresh();
@@ -248,7 +263,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);
@@ -258,10 +273,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));
@@ -287,7 +303,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, demo_start: !empty && !keep_session }))
}),
)
.await?;
+73
View File
@@ -0,0 +1,73 @@
//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key.
use std::cell::RefCell;
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);
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 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.
pub async fn bytes() -> Option<Vec<u8>> {
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
}
+4 -1
View File
@@ -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;
+55
View File
@@ -0,0 +1,55 @@
//! 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::<Value>(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 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
}
}
+1
View File
@@ -0,0 +1 @@
mod startup_tests;
@@ -0,0 +1,52 @@
//! The `startup` key of the config file.
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);
assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo);
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
[&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));
}
}
/// 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();
big.extend(std::iter::repeat_n(b'x', CONFIG_MAX));
big.extend_from_slice(br#""}"#);
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());
}
+3 -1
View File
@@ -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);
+19
View File
@@ -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 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
`Cache-Control: no-cache` (the service worker never caches it).
### Audio
Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a
+13
View File
@@ -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 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](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project).
## Embedding in a page (iframe)
```html
+3
View File
@@ -12,3 +12,6 @@
/index.html
Cache-Control: no-cache
/effectcraft-config.json
Cache-Control: no-cache