mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 20:47:18 -06:00
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:
@@ -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",
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,3 +12,6 @@
|
||||
|
||||
/index.html
|
||||
Cache-Control: no-cache
|
||||
|
||||
/effectcraft-config.json
|
||||
Cache-Control: no-cache
|
||||
|
||||
Reference in New Issue
Block a user