//! The browser's Web Locks (`navigator.locks`), which tell Data Recovery whether another tab still //! runs ([`vectorcraft_engine::cmd::recovery::RecoveryStore::announce`]). A tab holds a lock named //! after its recovery area for as long as it lives; the browser releases it only when the tab is //! gone (closed, crashed or reloaded), not while its timers are paused in the background, which a //! heartbeat can't tell apart. //! //! web-sys has Web Locks only as an unstable API, so they are reached through `js_sys::Reflect`. //! Pages that aren't secure (plain `http://` other than localhost) and older browsers have none: //! heartbeats alone decide there. use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; use js_sys::{Function, Promise, Reflect}; use vectorcraft_engine::cmd::recovery::Hold; use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure; /// How often the snapshot of the locks held is refreshed (ms). const REFRESH_MS: i32 = 10_000; /// How old a snapshot may be and still be trusted (ms). A lock asked for after the snapshot was /// taken comes with a heartbeat, which stays fresh for at least three minutes, so anything under /// that is safe; an older snapshot counts every area as held. const FRESH_MS: f64 = 60_000.0; thread_local! { /// What releases each lock this tab holds, by name: it resolves the promise the lock is held /// by (wasm runs on one thread). static RELEASE: RefCell> = const { RefCell::new(BTreeMap::new()) }; } /// The locks of the page, named ``. #[derive(Clone)] pub struct WebLocks { prefix: &'static str, /// The areas whose locks were held at the last snapshot, and when it was asked for (ms since /// the epoch). `navigator.locks.query()` is asynchronous, so the snapshot is refreshed every /// [`REFRESH_MS`] and read from here. seen: Arc)>>, } /// `navigator.locks`, if the browser has it here. fn manager() -> Option { let locks = Reflect::get(&web_sys::window()?.navigator(), &"locks".into()).ok()?; locks.is_object().then_some(locks) } /// Method `name` of `obj`. fn method(obj: &JsValue, name: &str) -> Option { Reflect::get(obj, &name.into()).ok()?.dyn_into().ok() } impl WebLocks { /// The page's Web Locks with a first snapshot of them, kept fresh from then on; `None` when /// the browser has none or won't say which are held (heartbeats alone decide then). pub async fn start(prefix: &'static str) -> Option { let locks = Self { prefix, seen: Arc::new(Mutex::new((f64::NEG_INFINITY, BTreeSet::new()))) }; locks.refresh().await?; let again = locks.clone(); let tick = Closure::::new(move || { let locks = again.clone(); wasm_bindgen_futures::spawn_local(async move { // A failed refresh leaves the snapshot to age, and every area counts as held. let _ = locks.refresh().await; }); }); web_sys::window()?.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), REFRESH_MS).ok()?; // The timer lives as long as the page. tick.forget(); Some(locks) } /// Take a new snapshot of the locks held. async fn refresh(&self) -> Option<()> { let m = manager()?; let asked = js_sys::Date::now(); let query: Promise = method(&m, "query")?.call0(&m).ok()?.dyn_into().ok()?; let snapshot = wasm_bindgen_futures::JsFuture::from(query).await.ok()?; let held = js_sys::Array::from(&Reflect::get(&snapshot, &"held".into()).ok()?); let areas = held .iter() .filter_map(|l| Reflect::get(&l, &"name".into()).ok()?.as_string()) .filter_map(|n| n.strip_prefix(self.prefix).map(str::to_string)) .collect(); *self.seen.lock().unwrap_or_else(|e| e.into_inner()) = (asked, areas); Some(()) } /// Does a tab hold `area`'s lock? Yes when the last snapshot is too old to tell. pub fn held(&self, area: &str) -> bool { let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner()); js_sys::Date::now() - seen.0 > FRESH_MS || seen.1.contains(area) } /// Hold `area`'s lock until the returned hold is dropped (or the page goes). pub fn hold(&self, area: &str) -> Option { let m = manager()?; let name = format!("{}{area}", self.prefix); // The lock is held until this promise resolves: when the hold is dropped. let mut release = None; let until = Promise::new(&mut |resolve, _| release = Some(resolve)); let release = release?; let granted = Closure::once_into_js(move |_lock: JsValue| until); method(&m, "request")?.call2(&m, &name.clone().into(), &granted).ok()?; RELEASE.with(|r| r.borrow_mut().insert(name.clone(), release)); Some(Box::new(LockHold(name))) } } /// A lock this tab holds (by name), released when dropped. struct LockHold(String); impl Drop for LockHold { fn drop(&mut self) { if let Some(resolve) = RELEASE.with(|r| r.borrow_mut().remove(&self.0)) { // Resolving can't fail in a way worth reporting: the lock goes with the page anyway. let _ = resolve.call0(&JsValue::NULL); } } }