forked from 1b-agent/fieldcraft-upstream
Web: offer Reload to update when a new deployment is out (#231)
Every half hour the page fetches its own address past the browser's cache; when it loads another content-hashed build of the app than the running one, the status bar offers "Reload to update" (the page's unsaved-changes question still guards the reload). Rust only, no service worker: the browser build has no other update path and none of the desktop's download code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
7dd50a3ca4
commit
93b2ae343c
6 files changed
+145
-3
No files matched your search
@@ -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"
|
||||
@@ -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<String>,
|
||||
/// A fetch found another build.
|
||||
found: Rc<Cell<bool>>,
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The build of the app a page loads: the content-hashed name Trunk gives its WebAssembly
|
||||
/// (`fieldcraft-web-<hash>_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#"<link rel="modulepreload" href="/fieldcraft/fieldcraft-web-1a2b3c.js"><link rel="preload" href="/fieldcraft/fieldcraft-web-1a2b3c_bg.wasm" as="fetch">"#;
|
||||
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#"<script src="fieldcraft-web.js"></script>"#), 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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -391,6 +391,8 @@ pub struct ModelApp {
|
||||
consent_asked: bool,
|
||||
/// Checking for and installing updates, when this run may ([`updates`]).
|
||||
updates: Option<updates::State>,
|
||||
/// The web app: a new deployment is out, and this reloads the page into it.
|
||||
reload: Option<Box<dyn Fn()>>,
|
||||
/// The setup progress the browser marks, and what it was worked out for.
|
||||
setup: (Option<(u64, u64, bool, bool)>, std::sync::Arc<Vec<fieldcraft_engine::progress::StageState>>),
|
||||
/// 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(),
|
||||
|
||||
@@ -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<dyn Fn()>) {
|
||||
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));
|
||||
|
||||
Reference in new issue
Block a user