diff --git a/apps/fieldcraft-web/Cargo.toml b/apps/fieldcraft-web/Cargo.toml index 3261f05..a303b33 100644 --- a/apps/fieldcraft-web/Cargo.toml +++ b/apps/fieldcraft-web/Cargo.toml @@ -29,6 +29,6 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage", "Crypto", "Headers", "Request", "RequestInit", "Response"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "DomTokenList", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "Storage", "Crypto", "Headers", "Request", "RequestInit", "RequestCache", "Response"] } # Autosaves kept in the browser's local storage, which holds text. base64 = "0.22" diff --git a/apps/fieldcraft-web/src/deploy.rs b/apps/fieldcraft-web/src/deploy.rs new file mode 100644 index 0000000..814d806 --- /dev/null +++ b/apps/fieldcraft-web/src/deploy.rs @@ -0,0 +1,75 @@ +//! A new deployment of the web app: every half hour the page's own address is fetched past the browser's cache, and when it loads another build +//! of the app than the one running (Trunk names each build's files by their content hash), the +//! status bar offers "Reload to update" (docs/updates.md). Reloading asks first when the model +//! has unsaved changes, as closing the page does. + +use std::cell::Cell; +use std::rc::Rc; + +use wasm_bindgen::JsCast as _; + +/// How often the page looks for a new deployment (seconds). +pub const INTERVAL: f64 = 30.0 * 60.0; + +/// Watches for a new deployment. +pub struct Watch { + /// The build this page runs. + running: Option, + /// A fetch found another build. + found: Rc>, + /// App time of the last look. + pub looked_at: f64, +} + +impl Watch { + pub fn start() -> Watch { + let html = web_sys::window().and_then(|w| w.document()).and_then(|d| d.document_element()).map(|e| e.outer_html()).unwrap_or_default(); + let running = crate::build_in_page(&html).map(str::to_string); + if running.is_none() { + log::info!("deploy: no hashed build in the page (a development server?); not watching"); + } + Watch { running, found: Rc::default(), looked_at: 0.0 } + } + + /// Whether a newer deployment was found. + pub fn found(&self) -> bool { + self.found.get() + } + + /// Fetch the page again and compare its build with the running one. + pub fn look(&self) { + let (Some(running), Some(window)) = (self.running.clone(), web_sys::window()) else { return }; + if self.found.get() { + return; + } + let url = window.location().pathname().unwrap_or_else(|_| "./".into()); + let init = web_sys::RequestInit::new(); + init.set_cache(web_sys::RequestCache::NoStore); + let promise = window.fetch_with_str_and_init(&url, &init); + let found = self.found.clone(); + wasm_bindgen_futures::spawn_local(async move { + let text = async { + let response: web_sys::Response = wasm_bindgen_futures::JsFuture::from(promise).await.ok()?.dyn_into().ok()?; + if !response.ok() { + return None; + } + wasm_bindgen_futures::JsFuture::from(response.text().ok()?).await.ok()?.as_string() + }; + // Offline or refused: look again next time. + if let Some(html) = text.await + && let Some(deployed) = crate::build_in_page(&html) + && deployed != running + { + log::info!("deploy: {deployed} is deployed (running {running})"); + found.set(true); + } + }); + } +} + +/// Reload the page (the new deployment loads). +pub fn reload() { + if let Some(w) = web_sys::window() { + let _ = w.location().reload(); + } +} diff --git a/apps/fieldcraft-web/src/main.rs b/apps/fieldcraft-web/src/main.rs index 06637a9..85f5420 100644 --- a/apps/fieldcraft-web/src/main.rs +++ b/apps/fieldcraft-web/src/main.rs @@ -18,6 +18,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_arch = "wasm32")] +mod deploy; #[cfg(target_arch = "wasm32")] mod storage; #[cfg(target_arch = "wasm32")] @@ -45,6 +47,25 @@ fn sample_in_query(query: &str) -> Option { }) } +/// The build of the app a page loads: the content-hashed name Trunk gives its WebAssembly +/// (`fieldcraft-web-_bg.wasm`). A new deployment loads another one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn build_in_page(html: &str) -> Option<&str> { + const HEAD: &str = "fieldcraft-web-"; + const TAIL: &str = "_bg.wasm"; + let mut rest = html; + while let Some(at) = rest.find(HEAD) { + let candidate = rest.get(at..)?; + let hash = candidate.get(HEAD.len()..)?; + let len = hash.bytes().take_while(u8::is_ascii_alphanumeric).count(); + if len > 0 && hash.get(len..)?.starts_with(TAIL) { + return candidate.get(..HEAD.len() + len + TAIL.len()); + } + rest = candidate.get(HEAD.len()..)?; + } + None +} + /// The name a file from the computer is kept under in the page: its last path part, without /// characters a path would read as folders. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] @@ -67,6 +88,18 @@ mod tests { assert_eq!(sample_in_query("?samples=x"), None); } + #[test] + fn the_running_build_is_found_in_the_page() { + let page = r#""#; + assert_eq!(build_in_page(page), Some("fieldcraft-web-1a2b3c_bg.wasm")); + let next = page.replace("1a2b3c", "9f8e7d"); + assert_ne!(build_in_page(page), build_in_page(&next)); + // A development page (no hash) and other text: nothing to compare. + assert_eq!(build_in_page(r#""#), None); + assert_eq!(build_in_page("fieldcraft-web-"), None); + assert_eq!(build_in_page(""), None); + } + #[test] fn file_names_stay_in_their_folder() { assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft"); diff --git a/apps/fieldcraft-web/src/web.rs b/apps/fieldcraft-web/src/web.rs index cbb65e4..f60c545 100644 --- a/apps/fieldcraft-web/src/web.rs +++ b/apps/fieldcraft-web/src/web.rs @@ -107,7 +107,8 @@ pub fn start() { if recoverable > 0 { app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false); } - Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false, sent_at: 0.0 })) + let deploy = crate::deploy::Watch::start(); + Ok(Box::new(Shell { app, inbox, store, autosaver, autosaved_at: 0.0, storage_error: false, sent_at: 0.0, deploy })) }), ) .await; @@ -213,6 +214,8 @@ struct Shell { storage_error: bool, /// App time usage events were last sent. sent_at: f64, + /// Looks for a new deployment of the app. + deploy: crate::deploy::Watch, } impl Shell { @@ -283,6 +286,14 @@ impl eframe::App for Shell { crate::usage::send(); } UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty())); + if self.app.now - self.deploy.looked_at >= crate::deploy::INTERVAL || self.app.now < self.deploy.looked_at { + self.deploy.looked_at = self.app.now; + self.deploy.look(); + } + if self.deploy.found() && !self.app.reload_offered() { + self.app.offer_reload(Box::new(crate::deploy::reload)); + } + ctx.request_repaint_after(std::time::Duration::from_secs_f64(crate::deploy::INTERVAL)); } /// Dropped files are read here (browsers read them asynchronously) and arrive through the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1484650..abe6056 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -391,6 +391,8 @@ pub struct ModelApp { consent_asked: bool, /// Checking for and installing updates, when this run may ([`updates`]). updates: Option, + /// The web app: a new deployment is out, and this reloads the page into it. + reload: Option>, /// The setup progress the browser marks, and what it was worked out for. setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc>), /// The file opening in the background, and files waiting to open after it. @@ -481,6 +483,7 @@ impl ModelApp { usage: None, consent_asked: false, updates: None, + reload: None, setup: (None, std::sync::Arc::default()), loading: None, queued: std::collections::VecDeque::new(), diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs index e60b8be..f55cdac 100644 --- a/crates/ui-egui/src/updates.rs +++ b/crates/ui-egui/src/updates.rs @@ -276,6 +276,16 @@ impl ModelApp { } } + /// The web app: a new deployment is out; the status bar offers to reload into it. + pub fn offer_reload(&mut self, reload: Box) { + self.reload = Some(reload); + } + + /// Whether the status bar offers to reload into a new deployment. + pub fn reload_offered(&self) -> bool { + self.reload.is_some() + } + /// For the control channel's `ui.inspect`. pub fn update_json(&self) -> serde_json::Value { let Some(u) = &self.updates else { return serde_json::Value::Null }; @@ -291,9 +301,19 @@ impl ModelApp { } } -/// The status bar's notice: an update offered, or one downloading. +/// The status bar's notice: an update offered, or one downloading; in the browser, a new +/// deployment to reload into. pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(app.ui.dark); + if let Some(reload) = &app.reload { + let tip = if app.has_unsaved_work() { "Reloads the page; you are asked about the unsaved changes first" } else { "Reloads the page" }; + if ui.add(egui::Link::new(RichText::new("Reload to update").size(11.5))).on_hover_text(tip).clicked() { + reload(); + } + ui.label(RichText::new("A new version of FieldCraft is available —").size(11.5).color(t.text)); + ui.separator(); + return; + } let Some(u) = &app.updates else { return }; if let Some(busy) = &u.busy { ui.label(RichText::new(busy).size(11.5).color(t.text_dim));