From 1cce9f725367502f129a96c850504b4560cd18c4 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 3 Oct 2026 02:08:14 -0700 Subject: [PATCH] feat: the browser build updates itself, reloading into a new build when idle - Options names the browser build by when it was built, and Update automatically fetches a newer build into the browser's cache, then reloads into it while you're idle or when you come back to the tab, on the same page and scroll - Update Now in Options, and Check for Updates, reload into a newer build at once Assisted-by: claude-opus-5.5 --- arc/platforms.md | 6 + crates/snowbound/src/options.rs | 27 ++++- crates/snowbound/src/update.rs | 13 ++- crates/snowbound/src/web.rs | 197 +++++++++++++++++++++++++++++++- crates/snowbound/web/glue.js | 68 ++++++++++- crates/snowbound/web/index.html | 4 +- crates/ui/src/lib.rs | 5 + tools/release_web.py | 7 +- 8 files changed, 312 insertions(+), 15 deletions(-) diff --git a/arc/platforms.md b/arc/platforms.md index 375cc856e1db66079f754f248ee8f62cd7096598..a6b1921c6d994ed5b9b0d1b9b091c5cad62b7f39 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -203,6 +203,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. kind of context it gives, so each renderer starts on a canvas of its own, which then takes the page's place and its input. Only where even the 2D canvas fails does `start` reject, with a `NoGraphicsError`. +- Updating is a reload. With Update automatically on, the page checks hourly whether + `index.html` names another build's folder than the one its module came from; finding + one, it fetches that build's module, JavaScript and fonts into the HTTP cache, then + reloads at a quiet moment (idle 90 seconds, or when the user comes back to the tab, with + no popup or dialog open and its files written), back on the same page and scroll. A + reload that didn't bring the build isn't tried again. - AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden button, then on every visit) the trees AccessKit would get are mirrored as hidden elements with ARIA roles; acting on one sends AccessKit's action back. diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index 64226ea118d7c2cc95d0854c68e3487a0091b730..f2f05cea7f275ebbc9f547a2b5b2b5e887d2f3fe 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -256,6 +256,13 @@ const SECTIONS: &[Section] = &[ "update download", Control::Check(|options| &mut options.automatic_updates), ), + #[cfg(target_arch = "wasm32")] + Row::new( + "Update automatically", + "update reload newer version", + Control::Check(|options| &mut options.automatic_updates), + ) + .hint("Reloads into new versions while you're idle, your work saved"), #[cfg(target_os = "linux")] Row::new( "Installed:", @@ -304,6 +311,9 @@ pub struct Options { /// Live Share's relay; empty uses Snowbound's. relay: String, notebook_theme: Option, + /// Update Now was chosen: OK, then reload into the newer build. + #[cfg(target_arch = "wasm32")] + update_now: bool, renderer: Backend, pub(crate) keyboard: crate::keys::Keyboard, } @@ -393,6 +403,8 @@ impl State { relay: self.live_options.relay.clone().unwrap_or_default(), notebook_theme: self.notebook_theme.clone(), renderer: self.renderer_choice, + #[cfg(target_arch = "wasm32")] + update_now: false, keyboard: crate::keys::Keyboard::new(), }); self.ui.open_popup(id()); @@ -668,6 +680,8 @@ impl State { ); let [cancel, ok] = ui::dialog_buttons(ui, "OK", true); let ok = ok || entered; + #[cfg(target_arch = "wasm32")] + let ok = ok || options.update_now; ui.close(); ui.close(); if ok { @@ -706,6 +720,11 @@ impl State { if switch { let _ = self.proxy.send_event(UserEvent::Renderer(options.renderer)); } + #[cfg(target_arch = "wasm32")] + if options.update_now { + self.ui.close_popup(id()); + return crate::platform::update_now(self); + } } else if !cancel { self.options = Some(options); return; @@ -1099,7 +1118,9 @@ fn cache(state: &mut State, _: &mut Options, _: &str) { } } -fn version(state: &mut State, _: &mut Options, _: &str) { +/// The build running, and in the browser Update Now once a newer one is fetched. +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] +fn version(state: &mut State, options: &mut Options, _: &str) { let ui = &mut state.ui; let spec = Spec { size: [fill(), px(row_height(&ui.theme))], @@ -1107,6 +1128,10 @@ fn version(state: &mut State, _: &mut Options, _: &str) { ..Spec::default() }; ui.leaf("version", spec); + #[cfg(target_arch = "wasm32")] + if crate::platform::update_ready() && ui::button(ui, "update", "Update Now").clicked { + options.update_now = true; + } } /// Where Install put Snowbound and Uninstall, or Install where it isn't in the app menu. diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index 568629556a4a8d82e7ec5b63a5079c85c222d003..b8dd22af8c72a94af5bd989660da880c2f56d5a8 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -3,8 +3,8 @@ //! `history.json` every build. A thread checks on launch and daily, downloads a newer build //! for this platform, verifies it, and unpacks it beside the install. Restart to Update //! swaps it in once the app quits. -//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload, -//! so nothing is fetched or installed there. +//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload: +//! `web.rs` reloads into the build the site's `index.html` names where it is another. #![cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg(target_os = "linux")] @@ -124,6 +124,9 @@ impl std::fmt::Display for Version { /// The running build as Options and About name it. pub fn describe_running() -> String { + #[cfg(target_arch = "wasm32")] + return platform::describe_build(); + #[cfg(not(target_arch = "wasm32"))] running().map_or_else( || "Snowbound development build".to_owned(), |version| format!("Snowbound {version}"), @@ -240,9 +243,9 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { Err(BROWSER.to_owned()) } -/// Why the browser installs no builds: it loads the newest each time the page opens. +/// Why the browser installs no builds: it reloads into them. #[cfg(target_arch = "wasm32")] -const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; +const BROWSER: &str = "The browser updates Snowbound by reloading it."; /// `KEY`'s ed25519 public key, after minisign's algorithm and key id. #[cfg(not(target_arch = "wasm32"))] @@ -711,7 +714,7 @@ impl Updates { /// Checks now, and shows what the check finds when it finishes. pub fn check_now(&self) { #[cfg(target_arch = "wasm32")] - platform::inform("Snowbound is up to date", BROWSER); + platform::check_for_update(true); #[cfg(not(target_arch = "wasm32"))] { self.shared.lock().unwrap().asked = true; diff --git a/crates/snowbound/src/web.rs b/crates/snowbound/src/web.rs index 0bb650cef4e005dd0f612594040d56545a3666ec..8ecf705998a7e34653462960c8e26a2c04805f59 100644 --- a/crates/snowbound/src/web.rs +++ b/crates/snowbound/src/web.rs @@ -92,6 +92,20 @@ extern "C" { /// follows. #[wasm_bindgen(js_name = adoptCanvas)] pub fn adopt_canvas(canvas: &web_sys::HtmlCanvasElement); + /// The build running, as the folder its module came from names it; none where it came + /// from none. + #[wasm_bindgen(js_name = runningBuild)] + fn running_build() -> Option; + /// Resolves to the build the site's `index.html` names where it is another, its files + /// fetched for the reload into it; null where it is the same. + #[wasm_bindgen(js_name = fetchUpdate)] + fn fetch_update() -> js_sys::Promise; + /// Reloads once the files handed over are written, giving the next load `resume`; + /// resolves to false, staying, where a dialog of the page's is open. + #[wasm_bindgen(js_name = reloadInto)] + fn reload_into(resume: &str) -> js_sys::Promise; + /// What the load before this one gave it, once. + fn resumed() -> Option; } /// Where the browser keeps its notebooks, which a first visit makes one in. @@ -154,6 +168,13 @@ const FALLBACKS: [(&str, &[RangeInclusive]); 7] = [ const EMOJI: &str = "Noto-COLRv1.ttf.gz"; /// How long changed files wait to be written out, so a burst of edits writes once. const STORE_AFTER: Duration = Duration::from_millis(500); +/// When `tools/release_web.py` built this module, in seconds since 1970; development builds +/// have none. +const BUILT: Option<&str> = option_env!("SNOWBOUND_WEB_BUILD"); +/// How often the site is checked for a newer build, and how long the page goes without input +/// before it reloads into one. +const CHECK_EVERY: Duration = Duration::from_secs(60 * 60); +const IDLE: Duration = Duration::from_secs(90); /// What the page's input brought, waiting for the next frame. enum Input { @@ -210,6 +231,16 @@ thread_local! { static LANGUAGE: RefCell = const { RefCell::new(String::new()) }; /// The look last handed to `keepLook`. static LOOK: RefCell = const { RefCell::new(String::new()) }; + /// When the site is next checked for a newer build, whether one waits in the HTTP cache, + /// when the page last took input, whether the user came back to it, and whether it is + /// reloading. + static CHECK_DUE: Cell> = const { Cell::new(None) }; + static UPDATE: RefCell> = const { RefCell::new(None) }; + /// A build the page reloaded to and didn't get, which it doesn't reload to again. + static MISSED: RefCell> = const { RefCell::new(None) }; + static LAST_INPUT: Cell> = const { Cell::new(None) }; + static RETURNED: Cell = const { Cell::new(false) }; + static RELOADING: Cell = const { Cell::new(false) }; } /// Whether the browser runs on a Mac, where Command takes the editing chords. @@ -949,6 +980,20 @@ pub async fn start( // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases // the rest in. state.full_page = true; + // Reloaded into an update, the page goes back to where it was scrolled. + if let Some(resume) = resumed().and_then(|resume| serde_json::from_str::(&resume).ok()) + { + for (axis, offset) in resume.offset.into_iter().enumerate() { + match state.view.scroll_to(axis, offset) { + Ok(response) => state.respond(response), + Err(error) => report(error), + } + } + if Some(&resume.build) != running_build().as_ref() { + MISSED.set(Some(resume.build)); + } + } + CHECK_DUE.set(Some(Instant::now() + Duration::from_secs(10))); // Text no run tags is in the browser's language, as typed in it. if let Some(spelling) = &state.spelling { spelling.untagged(canvas::language::lcid(&input_language())); @@ -1138,6 +1183,7 @@ async fn open( } fn queue(input: Input) { + LAST_INPUT.set(Some(Instant::now())); INPUT.with_borrow_mut(|queue| queue.push(input)); request_frame(); } @@ -1290,6 +1336,13 @@ pub fn focus(focused: bool) { queue(Input::Focus(focused)); } +/// The user came back to the page's window or tab. +#[wasm_bindgen] +pub fn returned() { + RETURNED.set(true); + request_frame(); +} + #[wasm_bindgen] pub fn appearance_changed(dark: bool) { host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light }); @@ -1496,10 +1549,16 @@ fn turn(state: &mut State) -> Result<(), Box> { if STORE_DUE.get().is_some_and(|due| due <= now) { store(); } - let next = [blink, wake.filter(|wake| *wake > now), STORE_DUE.get()] - .into_iter() - .flatten() - .min(); + let updating = follow_updates(state, now); + let next = [ + blink, + wake.filter(|wake| *wake > now), + STORE_DUE.get(), + updating, + ] + .into_iter() + .flatten() + .min(); if let Some(next) = next { wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3); } @@ -1520,6 +1579,136 @@ fn remember_look(state: &State) { } } +/// This build as Options and About name it. +pub fn describe_build() -> String { + match BUILT.and_then(|seconds| seconds.parse::().ok()) { + Some(seconds) => { + let [date, time] = date_text((seconds + 11_644_473_600) * 10_000_000); + format!("Snowbound built {date}, {time}") + } + None => "Snowbound development build".to_owned(), + } +} + +/// What a reload into an update hands the page it loads. +#[derive(serde::Serialize, serde::Deserialize)] +struct Resume { + /// How far the page was scrolled, in device pixels. + offset: [f32; 2], + /// The build reloaded for. + build: String, +} + +/// Whether a newer build waits in the HTTP cache for the page to reload into. +pub fn update_ready() -> bool { + UPDATE.with_borrow(Option::is_some) +} + +/// Looks for a newer build and fetches it into the HTTP cache; `asked` says what it finds. +pub fn check_for_update(asked: bool) { + if running_build().is_none() { + if asked { + inform( + "Snowbound is up to date", + "Development builds don't update.", + ); + } + return; + } + wasm_bindgen_futures::spawn_local(async move { + let found = wasm_bindgen_futures::JsFuture::from(fetch_update()).await; + let found = found.map(|found| { + found + .as_string() + .filter(|found| !MISSED.with_borrow(|missed| missed.as_ref() == Some(found))) + }); + match found { + Ok(Some(newer)) => { + UPDATE.set(Some(newer)); + send(UserEvent::Then(Box::new(move |state| { + if asked { + let reload = state.reply(|state, ()| { + update_now(state); + Ok(()) + }); + confirm( + "Update ready", + "Reload to use the newest Snowbound.", + "Later", + "Reload", + reload, + ); + } + state.window.request_redraw(); + Ok(()) + }))); + } + Ok(_) if asked => tell( + "Snowbound is up to date", + &format!("You have {}.", describe_build()), + ), + Err(_) if asked => tell( + "Couldn't check for updates", + "Snowbound couldn’t reach its site. Check your connection, then try again.", + ), + _ => {} + } + }); +} + +/// Reloads into the newer build once everything is written, back on the page shown and +/// scrolled as it is. +pub fn update_now(state: &mut State) { + if RELOADING.replace(true) { + return; + } + if let Err(error) = state.persist() { + report(error); + } + store(); + let Some(build) = UPDATE.with_borrow(Clone::clone) else { + return RELOADING.set(false); + }; + let resume = Resume { + offset: state.view.viewport.origin.map(|origin| -origin), + build, + }; + let reloading = reload_into(&serde_json::to_string(&resume).unwrap_or_default()); + wasm_bindgen_futures::spawn_local(async move { + let reloaded = wasm_bindgen_futures::JsFuture::from(reloading).await; + if !reloaded.is_ok_and(|reloaded| reloaded.is_truthy()) { + RELOADING.set(false); + } + }); +} + +/// With updates automatic: checks the site when due, and reloads into a newer build at a +/// quiet moment, idle or just come back to, with no popup open and nothing loading or recording. +/// Returns when to come back. +fn follow_updates(state: &mut State, now: Instant) -> Option { + let returned = RETURNED.take(); + if !state.updates.automatic() { + return None; + } + if CHECK_DUE.get().is_some_and(|due| due <= now) { + CHECK_DUE.set(Some(now + CHECK_EVERY)); + if !update_ready() { + check_for_update(false); + } + } + let idle = LAST_INPUT.get().map_or(now, |last| last + IDLE); + let quiet = !state.ui.popups_open() + && state.opening.is_none() + && state.loading == 0 + && matches!(state.media, crate::recording::Media::Idle); + if update_ready() && (returned || idle <= now) && quiet { + update_now(state); + } + // A moment that wasn't quiet is looked at again shortly. + let retry = update_ready().then(|| idle.max(now + Duration::from_secs(5))); + [CHECK_DUE.get(), retry].into_iter().flatten().min() +} + /// Fetches the fallback faces `text` calls for that are not yet asked for. fn fetch_fallbacks(text: &str) { for character in text.chars().map(u32::from) { diff --git a/crates/snowbound/web/glue.js b/crates/snowbound/web/glue.js index f86fd9096128ba927b1e3808af0631456f620680..184a7f3e908fb4807666d597d532699d99cdbd7e 100644 --- a/crates/snowbound/web/glue.js +++ b/crates/snowbound/web/glue.js @@ -114,7 +114,8 @@ function storageWorker() { files = await list(root, "", []); } postMessage(files, files.flatMap(([, bytes]) => (bytes ? [bytes.buffer] : []))); - } else + } else if (data.kind === "settle") postMessage(null); + else for (const [path, length, ranges] of data.changes) if (length === undefined) await remove(path); else if (length === null) await folder(names(path)); @@ -130,11 +131,17 @@ export function loadFiles() { storage = new Worker(source); storage.postMessage({ kind: "load" }); return new Promise((resolve, reject) => { - storage.onmessage = ({ data }) => resolve(data); + storage.onmessage = ({ data }) => { + storage.onmessage = () => settling.shift()?.(); + resolve(data); + }; storage.onerror = (error) => reject(new Error(`The storage worker failed: ${error.message}`)); }); } +// Callers waiting for the storage worker to finish what it was given. +const settling = []; + /** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries; * those under a folder of the user's go there, a committed section only where nothing else * wrote it since it was read. */ @@ -313,6 +320,62 @@ export function fetchDictionary(name) { .catch((error) => console.warn("Dictionary", name, error)); } +/** Resolves once every file handed over so far is written. */ +function settled() { + const kept = new Promise((resolve) => { + settling.push(resolve); + storage.postMessage({ kind: "settle" }); + }); + return Promise.all([kept, writing]); +} + +// A deployed build's module and JavaScript lie in b/HASH/, which names the build. +const BUILD_FOLDER = /\bb\/([0-9a-f]+)\//; +const BUILD = import.meta.url.match(BUILD_FOLDER)?.[1]; + +/** The build running, as its folder names it; undefined where it came from none. */ +export function runningBuild() { + return BUILD; +} + +/** Resolves to the build the site's index.html names where it is another, once what the + * page fetches before the app starts (its SIZES), the JavaScript and what that imports are in + * the HTTP cache, where the reload finds them; null where it is the same. */ +export async function fetchUpdate() { + const response = await fetch(location.pathname, { cache: "no-cache" }); + if (!response.ok) throw new Error(`${location.pathname}: ${response.status}`); + const page = await response.text(); + const build = page.match(BUILD_FOLDER)?.[1]; + if (!build || build === BUILD) return null; + const folder = `b/${build}/`; + const script = await fetch(`${folder}snowbound_web.js`).then((each) => each.text()); + const imported = [...script.matchAll(/from\s*["']\.\/([^"']+)["']/g)].map((found) => folder + found[1]); + const sizes = JSON.parse(page.match(/const SIZES = (\{.*?\});/)?.[1] ?? "{}"); + const starting = [`${folder}snowbound_web_bg.wasm`, ...Object.keys(sizes)]; + await Promise.all([...new Set([...imported, ...starting])].map((url) => + fetch(url).then((each) => each.arrayBuffer()))); + return build; +} + +const RESUME = "snowbound.resume"; + +/** Reloads once the files handed over are written, giving the next load `resume`; resolves + * to false, staying, where a dialog of the page's is open. */ +export async function reloadInto(resume) { + if (document.querySelector("dialog[open]")) return false; + await settled(); + sessionStorage.setItem(RESUME, resume); + location.reload(); + return true; +} + +/** What the load before this one gave it, once. */ +export function resumed() { + const resume = sessionStorage.getItem(RESUME); + sessionStorage.removeItem(RESUME); + return resume ?? undefined; +} + export function requestFrame() { if (!framePending) { framePending = true; @@ -674,6 +737,7 @@ export function attach(module) { if (others.length) wasm.files("place", await read(others)); }); addEventListener("pagehide", () => wasm.flush()); + addEventListener("focus", () => wasm.returned()); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") wasm.flush(); }); diff --git a/crates/snowbound/web/index.html b/crates/snowbound/web/index.html index e35d9f4c3de160806cc94b4fc26d9a991c5d1633..daaa0d44dc106d123d335e67e91fe92241666c1b 100644 --- a/crates/snowbound/web/index.html +++ b/crates/snowbound/web/index.html @@ -180,9 +180,11 @@ }; // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab. + // Reloading into an update, it waits for the page it replaces to let go. const owned = await new Promise((resolve) => { if (!navigator.locks) return resolve(true); - navigator.locks.request("snowbound", { ifAvailable: true }, (lock) => { + const updating = sessionStorage.getItem("snowbound.resume") !== null; + navigator.locks.request("snowbound", { ifAvailable: !updating }, (lock) => { resolve(Boolean(lock)); return lock && new Promise(() => {}); }); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 0d122699df93402eae364eee956f0a966c60fa39..96a1eff4549271769b04de877627848b405f8239 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1159,6 +1159,11 @@ impl Ui { self.popups.iter().any(|popup| popup.id == id) } + /// Whether any menu, dialog or other popup is open. + pub fn popups_open(&self) -> bool { + !self.popups.is_empty() + } + /// Closes popup `id` and those opened from it, returning the focus it took; a submenu /// closes the menus it opened from with it, as choosing from it ends them. pub fn close_popup(&mut self, id: Id) { diff --git a/tools/release_web.py b/tools/release_web.py index 76d6ec7551ce9013ac54c10c7ef5c77f5eac57db..4768ed6db3013720bae5a548b7342e863c14fc4c 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -24,6 +24,7 @@ import shutil import subprocess import sys import tempfile +import time import urllib.request ROOT = Path(__file__).resolve().parents[1] @@ -96,9 +97,11 @@ def environment(): def build(out): - """Writes index.html, the module, its JavaScript and the fonts to `out`.""" + """Writes index.html, the module, its JavaScript and the fonts to `out`, the module knowing + when it was built.""" + built = str(int(time.time())) run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu', - *PROFILE], env=environment()) + *PROFILE], env={**environment(), 'SNOWBOUND_WEB_BUILD': built}) if out.exists(): shutil.rmtree(out) out.mkdir(parents=True) -- 2.54.0