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.
This commit is contained in:
G.J.R. Timmer
2026-10-29 07:11:48 -04:00
parent 990fa668cd
commit c334c36645
11 changed files with 188 additions and 7 deletions
+4
View File
@@ -51,6 +51,10 @@ web-sys = { version = "0.3", features = [
"HtmlInputElement",
"Location",
"Navigator",
"Request",
"RequestCache",
"RequestInit",
"Response",
"Url",
"UrlSearchParams",
"Window",
+8 -5
View File
@@ -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));
+44
View File
@@ -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<Option<Option<Vec<u8>>>> = const { RefCell::new(None) };
}
async fn fetch() -> Option<Vec<u8>> {
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<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;
+49
View File
@@ -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::<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 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,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());
}
+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 `?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; `"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
+3
View File
@@ -12,3 +12,6 @@
/index.html
Cache-Control: no-cache
/effectcraft-config.json
Cache-Control: no-cache