247 lines
9.8 KiB
Rust
247 lines
9.8 KiB
Rust
//! Persistence: the [`crate::store::Store`] mirrored to the browser's storage (the Origin
|
|
//! Private File System, IndexedDB where OPFS can't write, memory as a last resort; `js/host.js`).
|
|
//!
|
|
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
|
|
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
|
|
//! one write per changed key, in order, with nothing written twice concurrently. A write that
|
|
//! fails (quota exceeded…) is kept and tried again, after 2 s, then longer, up to a minute.
|
|
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
|
|
//! or so while it changes, so a reload (or a crash) comes back to where the user was
|
|
//! ([`restore`]).
|
|
|
|
use std::cell::Cell;
|
|
|
|
use effectcraft_engine::Session;
|
|
use effectcraft_engine::config::ConfigStore;
|
|
use effectcraft_engine::project::Project;
|
|
use wasm_bindgen::prelude::*;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
|
|
use crate::files::STORE;
|
|
use crate::store::{SNAPSHOT, SNAPSHOT_META, SnapshotMeta, WebConfig};
|
|
|
|
#[wasm_bindgen(module = "/js/host.js")]
|
|
extern "C" {
|
|
#[wasm_bindgen(js_name = storeOpen)]
|
|
fn store_open(prefer: &str) -> js_sys::Promise;
|
|
#[wasm_bindgen(js_name = storeLoadAll)]
|
|
fn store_load_all() -> js_sys::Promise;
|
|
#[wasm_bindgen(js_name = storePut)]
|
|
fn store_put(key: &str, data: js_sys::Uint8Array, modified: f64) -> js_sys::Promise;
|
|
#[wasm_bindgen(js_name = storeDelete)]
|
|
fn store_delete(key: &str) -> js_sys::Promise;
|
|
#[wasm_bindgen(js_name = storeEstimate)]
|
|
pub(crate) fn store_estimate() -> js_sys::Promise;
|
|
}
|
|
|
|
thread_local! {
|
|
static FLUSHING: Cell<bool> = const { Cell::new(false) };
|
|
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
|
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
|
/// [`flushed`] promises waiting for the running flush: (resolve, reject).
|
|
static FLUSH_WAITERS: std::cell::RefCell<Vec<(js_sys::Function, js_sys::Function)>> = const { std::cell::RefCell::new(Vec::new()) };
|
|
/// Delay before retrying failed writes (ms; doubles while they keep failing).
|
|
static RETRY_MS: Cell<i32> = const { Cell::new(RETRY_FIRST_MS) };
|
|
static RETRY_PENDING: Cell<bool> = const { Cell::new(false) };
|
|
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
|
|
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
|
}
|
|
|
|
const RETRY_FIRST_MS: i32 = 2_000;
|
|
const RETRY_MAX_MS: i32 = 60_000;
|
|
|
|
/// The storage in use: `opfs`, `indexeddb` or `memory`.
|
|
pub fn backend() -> String {
|
|
BACKEND.with(|b| b.borrow().clone())
|
|
}
|
|
|
|
/// The last storage error (quota exceeded…), if any.
|
|
pub fn last_error() -> Option<String> {
|
|
LAST_ERROR.with(|e| e.borrow().clone())
|
|
}
|
|
|
|
/// A JavaScript error (a rejected promise's reason) as text.
|
|
pub(crate) fn js_err(e: JsValue) -> String {
|
|
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
|
}
|
|
|
|
/// Open the browser storage and read everything into the store. `prefer` forces a backend
|
|
/// (`?storage=indexeddb` / `memory`; empty = OPFS, then IndexedDB). Returns the entry count.
|
|
pub async fn load(prefer: &str) -> Result<usize, String> {
|
|
let name = JsFuture::from(store_open(prefer)).await.map_err(js_err)?;
|
|
BACKEND.with(|b| *b.borrow_mut() = name.as_string().unwrap_or_default());
|
|
let all = JsFuture::from(store_load_all()).await.map_err(js_err)?;
|
|
let all: js_sys::Array = all.dyn_into().map_err(js_err)?;
|
|
let n = all.length() as usize;
|
|
{
|
|
let mut m = STORE.lock();
|
|
for e in all.iter() {
|
|
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).unwrap_or(JsValue::UNDEFINED);
|
|
let Some(key) = get("key").as_string() else { continue };
|
|
let data: js_sys::Uint8Array = match get("data").dyn_into() {
|
|
Ok(d) => d,
|
|
Err(_) => continue,
|
|
};
|
|
m.load(&key, data.to_vec().into(), get("modified").as_f64().unwrap_or(0.0));
|
|
}
|
|
}
|
|
let _ = STORE.on_change.set(schedule_flush);
|
|
Ok(n)
|
|
}
|
|
|
|
/// Write pending changes to the browser storage (in the background; one flush at a time).
|
|
pub fn schedule_flush() {
|
|
if FLUSHING.with(|f| f.replace(true)) {
|
|
return; // the running flush picks the new changes up
|
|
}
|
|
wasm_bindgen_futures::spawn_local(async {
|
|
// Keys whose write failed in this flush, and the last failure.
|
|
let mut failed: Vec<String> = vec![];
|
|
let mut error = None;
|
|
loop {
|
|
let pending: Vec<_> = STORE.lock().take_pending().into_iter().filter(|p| !failed.contains(&p.key)).collect();
|
|
if pending.is_empty() {
|
|
break;
|
|
}
|
|
for p in pending {
|
|
let r = match &p.data {
|
|
Some(d) => JsFuture::from(store_put(&p.key, js_sys::Uint8Array::from(&d[..]), p.modified)).await,
|
|
None => JsFuture::from(store_delete(&p.key)).await,
|
|
};
|
|
if let Err(e) = r {
|
|
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
|
|
log::warn!("{msg}");
|
|
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg.clone()));
|
|
error = Some(msg);
|
|
failed.push(p.key);
|
|
}
|
|
}
|
|
}
|
|
// Failed writes stay pending (#215): flushed later, and the waiters hear of the failure
|
|
// instead of being told the changes are stored.
|
|
{
|
|
let mut m = STORE.lock();
|
|
for k in &failed {
|
|
m.retry(k);
|
|
}
|
|
}
|
|
FLUSHING.with(|f| f.set(false));
|
|
let waiters = FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut()));
|
|
match &error {
|
|
None => {
|
|
RETRY_MS.with(|r| r.set(RETRY_FIRST_MS));
|
|
for (resolve, _) in waiters {
|
|
let _ = resolve.call0(&JsValue::NULL);
|
|
}
|
|
}
|
|
Some(msg) => {
|
|
let e: JsValue = js_sys::Error::new(msg).into();
|
|
for (_, reject) in waiters {
|
|
let _ = reject.call1(&JsValue::NULL, &e);
|
|
}
|
|
retry_later();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Flush again after the retry delay (once; the delay doubles up to a minute while writes keep
|
|
/// failing).
|
|
fn retry_later() {
|
|
if RETRY_PENDING.with(|r| r.replace(true)) {
|
|
return;
|
|
}
|
|
let ms = RETRY_MS.with(|r| {
|
|
let ms = r.get();
|
|
r.set(ms.saturating_mul(2).min(RETRY_MAX_MS));
|
|
ms
|
|
});
|
|
let again = Closure::once_into_js(|| {
|
|
RETRY_PENDING.with(|r| r.set(false));
|
|
schedule_flush();
|
|
});
|
|
if let Some(w) = web_sys::window() {
|
|
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(again.unchecked_ref(), ms);
|
|
}
|
|
}
|
|
|
|
/// A promise that resolves once every change so far is in the browser storage, and rejects
|
|
/// when a write failed (the change stays pending and is tried again).
|
|
pub fn flushed() -> js_sys::Promise {
|
|
js_sys::Promise::new(&mut |resolve, reject| {
|
|
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
|
|
let _ = resolve.call0(&JsValue::NULL);
|
|
} else {
|
|
FLUSH_WAITERS.with(|w| w.borrow_mut().push((resolve, reject)));
|
|
schedule_flush();
|
|
}
|
|
})
|
|
}
|
|
|
|
/// The config store for the session.
|
|
pub fn config() -> WebConfig {
|
|
WebConfig::new(STORE.clone())
|
|
}
|
|
|
|
/// Record the open project and editor state when they changed (checked at most every
|
|
/// `min_secs`).
|
|
pub fn snapshot(s: &Session, now: f64, min_secs: f64) {
|
|
let (rev, at) = SNAP.with(|c| c.get());
|
|
if now - at < min_secs {
|
|
return;
|
|
}
|
|
SNAP.with(|c| c.set((rev, now)));
|
|
let meta = SnapshotMeta { path: s.path.clone(), dirty: s.is_dirty(), state: serde_json::to_value(&s.state).ok(), saved: 0.0 };
|
|
let key = serde_json::to_string(&meta).unwrap_or_default();
|
|
if rev == s.revision && LAST_META.with(|m| *m.borrow() == key) {
|
|
return;
|
|
}
|
|
let c = config();
|
|
if rev != s.revision {
|
|
let _ = c.write(SNAPSHOT, &s.project.to_json());
|
|
}
|
|
let _ = c.write(SNAPSHOT_META, &serde_json::to_string(&SnapshotMeta { saved: js_sys::Date::now(), ..meta }).unwrap_or_default());
|
|
SNAP.with(|c| c.set((s.revision, now)));
|
|
LAST_META.with(|m| *m.borrow_mut() = key);
|
|
}
|
|
|
|
/// Reopen the last visit's project and editor state. Returns whether there was one.
|
|
pub fn restore(s: &mut Session) -> bool {
|
|
let c = config();
|
|
let (Some(json), Some(meta)) = (c.read(SNAPSHOT), c.read(SNAPSHOT_META)) else { return false };
|
|
let meta: SnapshotMeta = serde_json::from_str(&meta).unwrap_or_default();
|
|
let p = match Project::from_json(&json) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
log::warn!("restoring the last session: {e}");
|
|
return false;
|
|
}
|
|
};
|
|
s.replace_project(p, meta.path.clone());
|
|
if let Some(st) = meta.state.and_then(|v| serde_json::from_value(v).ok()) {
|
|
s.state = st;
|
|
}
|
|
if meta.dirty {
|
|
// Unsaved changes stay unsaved.
|
|
s.mark_unsaved();
|
|
}
|
|
SNAP.with(|c| c.set((s.revision, 0.0)));
|
|
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
|
|
true
|
|
}
|
|
|
|
/// Register persisted media with the media pool (so restored projects find their footage).
|
|
pub fn register_media(pool: &effectcraft_media::MediaPool) -> usize {
|
|
let mut n = 0;
|
|
for (path, _, _, persisted) in STORE.files() {
|
|
if persisted
|
|
&& !path.to_ascii_lowercase().ends_with(".ecproj")
|
|
&& let Some(d) = STORE.file(&path)
|
|
{
|
|
pool.add_bytes(&path, d);
|
|
n += 1;
|
|
}
|
|
}
|
|
n
|
|
}
|