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 = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
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.
|
# Autosaves kept in the browser's local storage, which holds text.
|
||||||
base64 = "0.22"
|
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)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod deploy;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod storage;
|
mod storage;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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
|
/// 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.
|
/// characters a path would read as folders.
|
||||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||||
@@ -67,6 +88,18 @@ mod tests {
|
|||||||
assert_eq!(sample_in_query("?samples=x"), None);
|
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]
|
#[test]
|
||||||
fn file_names_stay_in_their_folder() {
|
fn file_names_stay_in_their_folder() {
|
||||||
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
|
assert_eq!(safe_name("beam.fieldcraft"), "beam.fieldcraft");
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ pub fn start() {
|
|||||||
if recoverable > 0 {
|
if recoverable > 0 {
|
||||||
app.set_status(format!("{recoverable} autosaved model(s) can be recovered: File › Recover…"), false);
|
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;
|
.await;
|
||||||
@@ -213,6 +214,8 @@ struct Shell {
|
|||||||
storage_error: bool,
|
storage_error: bool,
|
||||||
/// App time usage events were last sent.
|
/// App time usage events were last sent.
|
||||||
sent_at: f64,
|
sent_at: f64,
|
||||||
|
/// Looks for a new deployment of the app.
|
||||||
|
deploy: crate::deploy::Watch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Shell {
|
impl Shell {
|
||||||
@@ -283,6 +286,14 @@ impl eframe::App for Shell {
|
|||||||
crate::usage::send();
|
crate::usage::send();
|
||||||
}
|
}
|
||||||
UNSAVED.with(|u| u.set(self.app.session.is_dirty() && !self.app.session.journal.is_empty()));
|
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
|
/// Dropped files are read here (browsers read them asynchronously) and arrive through the
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ pub struct ModelApp {
|
|||||||
consent_asked: bool,
|
consent_asked: bool,
|
||||||
/// Checking for and installing updates, when this run may ([`updates`]).
|
/// Checking for and installing updates, when this run may ([`updates`]).
|
||||||
updates: Option<updates::State>,
|
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.
|
/// 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>>),
|
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.
|
/// The file opening in the background, and files waiting to open after it.
|
||||||
@@ -481,6 +483,7 @@ impl ModelApp {
|
|||||||
usage: None,
|
usage: None,
|
||||||
consent_asked: false,
|
consent_asked: false,
|
||||||
updates: None,
|
updates: None,
|
||||||
|
reload: None,
|
||||||
setup: (None, std::sync::Arc::default()),
|
setup: (None, std::sync::Arc::default()),
|
||||||
loading: None,
|
loading: None,
|
||||||
queued: std::collections::VecDeque::new(),
|
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`.
|
/// For the control channel's `ui.inspect`.
|
||||||
pub fn update_json(&self) -> serde_json::Value {
|
pub fn update_json(&self) -> serde_json::Value {
|
||||||
let Some(u) = &self.updates else { return serde_json::Value::Null };
|
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) {
|
pub fn notice(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||||
let t = crate::theme::Tokens::get(app.ui.dark);
|
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 };
|
let Some(u) = &app.updates else { return };
|
||||||
if let Some(busy) = &u.busy {
|
if let Some(busy) = &u.busy {
|
||||||
ui.label(RichText::new(busy).size(11.5).color(t.text_dim));
|
ui.label(RichText::new(busy).size(11.5).color(t.text_dim));
|
||||||
|
|||||||
Reference in new issue
Block a user