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 <[email protected]> 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
|
||||
|
||||
Reference in new issue
Block a user