authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 02:08:14-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:14:47-07:00
log1cce9f725367502f129a96c850504b4560cd18c4
treebb9340d6ce28c678b8b2eb67c06bbc1eede1a4c2
parent514ad509ad5650d2b51457a47964e4f5e02846c8
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

8 files changed, 312 insertions(+), 15 deletions(-)

arc/platforms.md+6
......@@ -203,6 +203,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
203203 kind of context it gives, so each renderer starts on a canvas of its own, which then takes
204204 the page's place and its input. Only where even the 2D canvas fails does `start` reject,
205205 with a `NoGraphicsError`.
206- Updating is a reload. With Update automatically on, the page checks hourly whether
207 `index.html` names another build's folder than the one its module came from; finding
208 one, it fetches that build's module, JavaScript and fonts into the HTTP cache, then
209 reloads at a quiet moment (idle 90 seconds, or when the user comes back to the tab, with
210 no popup or dialog open and its files written), back on the same page and scroll. A
211 reload that didn't bring the build isn't tried again.
206212- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden
207213 button, then on every visit) the trees AccessKit would get are mirrored as hidden
208214 elements with ARIA roles; acting on one sends AccessKit's action back.
crates/snowbound/src/options.rs+26-1
......@@ -256,6 +256,13 @@ const SECTIONS: &[Section] = &[
256256 "update download",
257257 Control::Check(|options| &mut options.automatic_updates),
258258 ),
259 #[cfg(target_arch = "wasm32")]
260 Row::new(
261 "Update automatically",
262 "update reload newer version",
263 Control::Check(|options| &mut options.automatic_updates),
264 )
265 .hint("Reloads into new versions while you're idle, your work saved"),
259266 #[cfg(target_os = "linux")]
260267 Row::new(
261268 "Installed:",
......@@ -304,6 +311,9 @@ pub struct Options {
304311 /// Live Share's relay; empty uses Snowbound's.
305312 relay: String,
306313 notebook_theme: Option<String>,
314 /// Update Now was chosen: OK, then reload into the newer build.
315 #[cfg(target_arch = "wasm32")]
316 update_now: bool,
307317 renderer: Backend,
308318 pub(crate) keyboard: crate::keys::Keyboard,
309319}
......@@ -393,6 +403,8 @@ impl State {
393403 relay: self.live_options.relay.clone().unwrap_or_default(),
394404 notebook_theme: self.notebook_theme.clone(),
395405 renderer: self.renderer_choice,
406 #[cfg(target_arch = "wasm32")]
407 update_now: false,
396408 keyboard: crate::keys::Keyboard::new(),
397409 });
398410 self.ui.open_popup(id());
......@@ -668,6 +680,8 @@ impl State {
668680 );
669681 let [cancel, ok] = ui::dialog_buttons(ui, "OK", true);
670682 let ok = ok || entered;
683 #[cfg(target_arch = "wasm32")]
684 let ok = ok || options.update_now;
671685 ui.close();
672686 ui.close();
673687 if ok {
......@@ -706,6 +720,11 @@ impl State {
706720 if switch {
707721 let _ = self.proxy.send_event(UserEvent::Renderer(options.renderer));
708722 }
723 #[cfg(target_arch = "wasm32")]
724 if options.update_now {
725 self.ui.close_popup(id());
726 return crate::platform::update_now(self);
727 }
709728 } else if !cancel {
710729 self.options = Some(options);
711730 return;
......@@ -1099,7 +1118,9 @@ fn cache(state: &mut State, _: &mut Options, _: &str) {
10991118 }
11001119}
11011120
1102fn version(state: &mut State, _: &mut Options, _: &str) {
1121/// The build running, and in the browser Update Now once a newer one is fetched.
1122#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
1123fn version(state: &mut State, options: &mut Options, _: &str) {
11031124 let ui = &mut state.ui;
11041125 let spec = Spec {
11051126 size: [fill(), px(row_height(&ui.theme))],
......@@ -1107,6 +1128,10 @@ fn version(state: &mut State, _: &mut Options, _: &str) {
11071128 ..Spec::default()
11081129 };
11091130 ui.leaf("version", spec);
1131 #[cfg(target_arch = "wasm32")]
1132 if crate::platform::update_ready() && ui::button(ui, "update", "Update Now").clicked {
1133 options.update_now = true;
1134 }
11101135}
11111136
11121137/// Where Install put Snowbound and Uninstall, or Install where it isn't in the app menu.
crates/snowbound/src/update.rs+8-5
......@@ -3,8 +3,8 @@
33//! `history.json` every build. A thread checks on launch and daily, downloads a newer build
44//! for this platform, verifies it, and unpacks it beside the install. Restart to Update
55//! swaps it in once the app quits.
6//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload,
7//! so nothing is fetched or installed there.
6//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload:
7//! `web.rs` reloads into the build the site's `index.html` names where it is another.
88#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
99
1010#[cfg(target_os = "linux")]
......@@ -124,6 +124,9 @@ impl std::fmt::Display for Version {
124124
125125/// The running build as Options and About name it.
126126pub fn describe_running() -> String {
127 #[cfg(target_arch = "wasm32")]
128 return platform::describe_build();
129 #[cfg(not(target_arch = "wasm32"))]
127130 running().map_or_else(
128131 || "Snowbound development build".to_owned(),
129132 |version| format!("Snowbound {version}"),
......@@ -240,9 +243,9 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> {
240243 Err(BROWSER.to_owned())
241244}
242245
243/// Why the browser installs no builds: it loads the newest each time the page opens.
246/// Why the browser installs no builds: it reloads into them.
244247#[cfg(target_arch = "wasm32")]
245const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens.";
248const BROWSER: &str = "The browser updates Snowbound by reloading it.";
246249
247250/// `KEY`'s ed25519 public key, after minisign's algorithm and key id.
248251#[cfg(not(target_arch = "wasm32"))]
......@@ -711,7 +714,7 @@ impl Updates {
711714 /// Checks now, and shows what the check finds when it finishes.
712715 pub fn check_now(&self) {
713716 #[cfg(target_arch = "wasm32")]
714 platform::inform("Snowbound is up to date", BROWSER);
717 platform::check_for_update(true);
715718 #[cfg(not(target_arch = "wasm32"))]
716719 {
717720 self.shared.lock().unwrap().asked = true;
crates/snowbound/src/web.rs+193-4
......@@ -92,6 +92,20 @@ extern "C" {
9292 /// follows.
9393 #[wasm_bindgen(js_name = adoptCanvas)]
9494 pub fn adopt_canvas(canvas: &web_sys::HtmlCanvasElement);
95 /// The build running, as the folder its module came from names it; none where it came
96 /// from none.
97 #[wasm_bindgen(js_name = runningBuild)]
98 fn running_build() -> Option<String>;
99 /// Resolves to the build the site's `index.html` names where it is another, its files
100 /// fetched for the reload into it; null where it is the same.
101 #[wasm_bindgen(js_name = fetchUpdate)]
102 fn fetch_update() -> js_sys::Promise;
103 /// Reloads once the files handed over are written, giving the next load `resume`;
104 /// resolves to false, staying, where a dialog of the page's is open.
105 #[wasm_bindgen(js_name = reloadInto)]
106 fn reload_into(resume: &str) -> js_sys::Promise;
107 /// What the load before this one gave it, once.
108 fn resumed() -> Option<String>;
95109}
96110
97111/// Where the browser keeps its notebooks, which a first visit makes one in.
......@@ -154,6 +168,13 @@ const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 7] = [
154168const EMOJI: &str = "Noto-COLRv1.ttf.gz";
155169/// How long changed files wait to be written out, so a burst of edits writes once.
156170const STORE_AFTER: Duration = Duration::from_millis(500);
171/// When `tools/release_web.py` built this module, in seconds since 1970; development builds
172/// have none.
173const BUILT: Option<&str> = option_env!("SNOWBOUND_WEB_BUILD");
174/// How often the site is checked for a newer build, and how long the page goes without input
175/// before it reloads into one.
176const CHECK_EVERY: Duration = Duration::from_secs(60 * 60);
177const IDLE: Duration = Duration::from_secs(90);
157178
158179/// What the page's input brought, waiting for the next frame.
159180enum Input {
......@@ -210,6 +231,16 @@ thread_local! {
210231 static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) };
211232 /// The look last handed to `keepLook`.
212233 static LOOK: RefCell<String> = const { RefCell::new(String::new()) };
234 /// When the site is next checked for a newer build, whether one waits in the HTTP cache,
235 /// when the page last took input, whether the user came back to it, and whether it is
236 /// reloading.
237 static CHECK_DUE: Cell<Option<Instant>> = const { Cell::new(None) };
238 static UPDATE: RefCell<Option<String>> = const { RefCell::new(None) };
239 /// A build the page reloaded to and didn't get, which it doesn't reload to again.
240 static MISSED: RefCell<Option<String>> = const { RefCell::new(None) };
241 static LAST_INPUT: Cell<Option<Instant>> = const { Cell::new(None) };
242 static RETURNED: Cell<bool> = const { Cell::new(false) };
243 static RELOADING: Cell<bool> = const { Cell::new(false) };
213244}
214245
215246/// Whether the browser runs on a Mac, where Command takes the editing chords.
......@@ -949,6 +980,20 @@ pub async fn start(
949980 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases
950981 // the rest in.
951982 state.full_page = true;
983 // Reloaded into an update, the page goes back to where it was scrolled.
984 if let Some(resume) = resumed().and_then(|resume| serde_json::from_str::<Resume>(&resume).ok())
985 {
986 for (axis, offset) in resume.offset.into_iter().enumerate() {
987 match state.view.scroll_to(axis, offset) {
988 Ok(response) => state.respond(response),
989 Err(error) => report(error),
990 }
991 }
992 if Some(&resume.build) != running_build().as_ref() {
993 MISSED.set(Some(resume.build));
994 }
995 }
996 CHECK_DUE.set(Some(Instant::now() + Duration::from_secs(10)));
952997 // Text no run tags is in the browser's language, as typed in it.
953998 if let Some(spelling) = &state.spelling {
954999 spelling.untagged(canvas::language::lcid(&input_language()));
......@@ -1138,6 +1183,7 @@ async fn open(
11381183}
11391184
11401185fn queue(input: Input) {
1186 LAST_INPUT.set(Some(Instant::now()));
11411187 INPUT.with_borrow_mut(|queue| queue.push(input));
11421188 request_frame();
11431189}
......@@ -1290,6 +1336,13 @@ pub fn focus(focused: bool) {
12901336 queue(Input::Focus(focused));
12911337}
12921338
1339/// The user came back to the page's window or tab.
1340#[wasm_bindgen]
1341pub fn returned() {
1342 RETURNED.set(true);
1343 request_frame();
1344}
1345
12931346#[wasm_bindgen]
12941347pub fn appearance_changed(dark: bool) {
12951348 host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light });
......@@ -1496,10 +1549,16 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
14961549 if STORE_DUE.get().is_some_and(|due| due <= now) {
14971550 store();
14981551 }
1499 let next = [blink, wake.filter(|wake| *wake > now), STORE_DUE.get()]
1500 .into_iter()
1501 .flatten()
1502 .min();
1552 let updating = follow_updates(state, now);
1553 let next = [
1554 blink,
1555 wake.filter(|wake| *wake > now),
1556 STORE_DUE.get(),
1557 updating,
1558 ]
1559 .into_iter()
1560 .flatten()
1561 .min();
15031562 if let Some(next) = next {
15041563 wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3);
15051564 }
......@@ -1520,6 +1579,136 @@ fn remember_look(state: &State) {
15201579 }
15211580}
15221581
1582/// This build as Options and About name it.
1583pub fn describe_build() -> String {
1584 match BUILT.and_then(|seconds| seconds.parse::<u64>().ok()) {
1585 Some(seconds) => {
1586 let [date, time] = date_text((seconds + 11_644_473_600) * 10_000_000);
1587 format!("Snowbound built {date}, {time}")
1588 }
1589 None => "Snowbound development build".to_owned(),
1590 }
1591}
1592
1593/// What a reload into an update hands the page it loads.
1594#[derive(serde::Serialize, serde::Deserialize)]
1595struct Resume {
1596 /// How far the page was scrolled, in device pixels.
1597 offset: [f32; 2],
1598 /// The build reloaded for.
1599 build: String,
1600}
1601
1602/// Whether a newer build waits in the HTTP cache for the page to reload into.
1603pub fn update_ready() -> bool {
1604 UPDATE.with_borrow(Option::is_some)
1605}
1606
1607/// Looks for a newer build and fetches it into the HTTP cache; `asked` says what it finds.
1608pub fn check_for_update(asked: bool) {
1609 if running_build().is_none() {
1610 if asked {
1611 inform(
1612 "Snowbound is up to date",
1613 "Development builds don't update.",
1614 );
1615 }
1616 return;
1617 }
1618 wasm_bindgen_futures::spawn_local(async move {
1619 let found = wasm_bindgen_futures::JsFuture::from(fetch_update()).await;
1620 let found = found.map(|found| {
1621 found
1622 .as_string()
1623 .filter(|found| !MISSED.with_borrow(|missed| missed.as_ref() == Some(found)))
1624 });
1625 match found {
1626 Ok(Some(newer)) => {
1627 UPDATE.set(Some(newer));
1628 send(UserEvent::Then(Box::new(move |state| {
1629 if asked {
1630 let reload = state.reply(|state, ()| {
1631 update_now(state);
1632 Ok(())
1633 });
1634 confirm(
1635 "Update ready",
1636 "Reload to use the newest Snowbound.",
1637 "Later",
1638 "Reload",
1639 reload,
1640 );
1641 }
1642 state.window.request_redraw();
1643 Ok(())
1644 })));
1645 }
1646 Ok(_) if asked => tell(
1647 "Snowbound is up to date",
1648 &format!("You have {}.", describe_build()),
1649 ),
1650 Err(_) if asked => tell(
1651 "Couldn't check for updates",
1652 "Snowbound couldn’t reach its site. Check your connection, then try again.",
1653 ),
1654 _ => {}
1655 }
1656 });
1657}
1658
1659/// Reloads into the newer build once everything is written, back on the page shown and
1660/// scrolled as it is.
1661pub fn update_now(state: &mut State) {
1662 if RELOADING.replace(true) {
1663 return;
1664 }
1665 if let Err(error) = state.persist() {
1666 report(error);
1667 }
1668 store();
1669 let Some(build) = UPDATE.with_borrow(Clone::clone) else {
1670 return RELOADING.set(false);
1671 };
1672 let resume = Resume {
1673 offset: state.view.viewport.origin.map(|origin| -origin),
1674 build,
1675 };
1676 let reloading = reload_into(&serde_json::to_string(&resume).unwrap_or_default());
1677 wasm_bindgen_futures::spawn_local(async move {
1678 let reloaded = wasm_bindgen_futures::JsFuture::from(reloading).await;
1679 if !reloaded.is_ok_and(|reloaded| reloaded.is_truthy()) {
1680 RELOADING.set(false);
1681 }
1682 });
1683}
1684
1685/// With updates automatic: checks the site when due, and reloads into a newer build at a
1686/// quiet moment, idle or just come back to, with no popup open and nothing loading or recording.
1687/// Returns when to come back.
1688fn follow_updates(state: &mut State, now: Instant) -> Option<Instant> {
1689 let returned = RETURNED.take();
1690 if !state.updates.automatic() {
1691 return None;
1692 }
1693 if CHECK_DUE.get().is_some_and(|due| due <= now) {
1694 CHECK_DUE.set(Some(now + CHECK_EVERY));
1695 if !update_ready() {
1696 check_for_update(false);
1697 }
1698 }
1699 let idle = LAST_INPUT.get().map_or(now, |last| last + IDLE);
1700 let quiet = !state.ui.popups_open()
1701 && state.opening.is_none()
1702 && state.loading == 0
1703 && matches!(state.media, crate::recording::Media::Idle);
1704 if update_ready() && (returned || idle <= now) && quiet {
1705 update_now(state);
1706 }
1707 // A moment that wasn't quiet is looked at again shortly.
1708 let retry = update_ready().then(|| idle.max(now + Duration::from_secs(5)));
1709 [CHECK_DUE.get(), retry].into_iter().flatten().min()
1710}
1711
15231712/// Fetches the fallback faces `text` calls for that are not yet asked for.
15241713fn fetch_fallbacks(text: &str) {
15251714 for character in text.chars().map(u32::from) {
crates/snowbound/web/glue.js+66-2
......@@ -114,7 +114,8 @@ function storageWorker() {
114114 files = await list(root, "", []);
115115 }
116116 postMessage(files, files.flatMap(([, bytes]) => (bytes ? [bytes.buffer] : [])));
117 } else
117 } else if (data.kind === "settle") postMessage(null);
118 else
118119 for (const [path, length, ranges] of data.changes)
119120 if (length === undefined) await remove(path);
120121 else if (length === null) await folder(names(path));
......@@ -130,11 +131,17 @@ export function loadFiles() {
130131 storage = new Worker(source);
131132 storage.postMessage({ kind: "load" });
132133 return new Promise((resolve, reject) => {
133 storage.onmessage = ({ data }) => resolve(data);
134 storage.onmessage = ({ data }) => {
135 storage.onmessage = () => settling.shift()?.();
136 resolve(data);
137 };
134138 storage.onerror = (error) => reject(new Error(`The storage worker failed: ${error.message}`));
135139 });
136140}
137141
142// Callers waiting for the storage worker to finish what it was given.
143const settling = [];
144
138145/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries;
139146 * those under a folder of the user's go there, a committed section only where nothing else
140147 * wrote it since it was read. */
......@@ -313,6 +320,62 @@ export function fetchDictionary(name) {
313320 .catch((error) => console.warn("Dictionary", name, error));
314321}
315322
323/** Resolves once every file handed over so far is written. */
324function settled() {
325 const kept = new Promise((resolve) => {
326 settling.push(resolve);
327 storage.postMessage({ kind: "settle" });
328 });
329 return Promise.all([kept, writing]);
330}
331
332// A deployed build's module and JavaScript lie in b/HASH/, which names the build.
333const BUILD_FOLDER = /\bb\/([0-9a-f]+)\//;
334const BUILD = import.meta.url.match(BUILD_FOLDER)?.[1];
335
336/** The build running, as its folder names it; undefined where it came from none. */
337export function runningBuild() {
338 return BUILD;
339}
340
341/** Resolves to the build the site's index.html names where it is another, once what the
342 * page fetches before the app starts (its SIZES), the JavaScript and what that imports are in
343 * the HTTP cache, where the reload finds them; null where it is the same. */
344export async function fetchUpdate() {
345 const response = await fetch(location.pathname, { cache: "no-cache" });
346 if (!response.ok) throw new Error(`${location.pathname}: ${response.status}`);
347 const page = await response.text();
348 const build = page.match(BUILD_FOLDER)?.[1];
349 if (!build || build === BUILD) return null;
350 const folder = `b/${build}/`;
351 const script = await fetch(`${folder}snowbound_web.js`).then((each) => each.text());
352 const imported = [...script.matchAll(/from\s*["']\.\/([^"']+)["']/g)].map((found) => folder + found[1]);
353 const sizes = JSON.parse(page.match(/const SIZES = (\{.*?\});/)?.[1] ?? "{}");
354 const starting = [`${folder}snowbound_web_bg.wasm`, ...Object.keys(sizes)];
355 await Promise.all([...new Set([...imported, ...starting])].map((url) =>
356 fetch(url).then((each) => each.arrayBuffer())));
357 return build;
358}
359
360const RESUME = "snowbound.resume";
361
362/** Reloads once the files handed over are written, giving the next load `resume`; resolves
363 * to false, staying, where a dialog of the page's is open. */
364export async function reloadInto(resume) {
365 if (document.querySelector("dialog[open]")) return false;
366 await settled();
367 sessionStorage.setItem(RESUME, resume);
368 location.reload();
369 return true;
370}
371
372/** What the load before this one gave it, once. */
373export function resumed() {
374 const resume = sessionStorage.getItem(RESUME);
375 sessionStorage.removeItem(RESUME);
376 return resume ?? undefined;
377}
378
316379export function requestFrame() {
317380 if (!framePending) {
318381 framePending = true;
......@@ -674,6 +737,7 @@ export function attach(module) {
674737 if (others.length) wasm.files("place", await read(others));
675738 });
676739 addEventListener("pagehide", () => wasm.flush());
740 addEventListener("focus", () => wasm.returned());
677741 document.addEventListener("visibilitychange", () => {
678742 if (document.visibilityState === "hidden") wasm.flush();
679743 });
crates/snowbound/web/index.html+3-1
......@@ -180,9 +180,11 @@
180180 };
181181
182182 // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab.
183 // Reloading into an update, it waits for the page it replaces to let go.
183184 const owned = await new Promise((resolve) => {
184185 if (!navigator.locks) return resolve(true);
185 navigator.locks.request("snowbound", { ifAvailable: true }, (lock) => {
186 const updating = sessionStorage.getItem("snowbound.resume") !== null;
187 navigator.locks.request("snowbound", { ifAvailable: !updating }, (lock) => {
186188 resolve(Boolean(lock));
187189 return lock && new Promise(() => {});
188190 });
crates/ui/src/lib.rs+5
......@@ -1159,6 +1159,11 @@ impl Ui {
11591159 self.popups.iter().any(|popup| popup.id == id)
11601160 }
11611161
1162 /// Whether any menu, dialog or other popup is open.
1163 pub fn popups_open(&self) -> bool {
1164 !self.popups.is_empty()
1165 }
1166
11621167 /// Closes popup `id` and those opened from it, returning the focus it took; a submenu
11631168 /// closes the menus it opened from with it, as choosing from it ends them.
11641169 pub fn close_popup(&mut self, id: Id) {
tools/release_web.py+5-2
......@@ -24,6 +24,7 @@ import shutil
2424import subprocess
2525import sys
2626import tempfile
27import time
2728import urllib.request
2829
2930ROOT = Path(__file__).resolve().parents[1]
......@@ -96,9 +97,11 @@ def environment():
9697
9798
9899def build(out):
99 """Writes index.html, the module, its JavaScript and the fonts to `out`."""
100 """Writes index.html, the module, its JavaScript and the fonts to `out`, the module knowing
101 when it was built."""
102 built = str(int(time.time()))
100103 run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu',
101 *PROFILE], env=environment())
104 *PROFILE], env={**environment(), 'SNOWBOUND_WEB_BUILD': built})
102105 if out.exists():
103106 shutil.rmtree(out)
104107 out.mkdir(parents=True)