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....@@ -203,6 +203,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
203 kind of context it gives, so each renderer starts on a canvas of its own, which then takes203 kind of context it gives, so each renderer starts on a canvas of its own, which then takes
204 the page's place and its input. Only where even the 2D canvas fails does `start` reject,204 the page's place and its input. Only where even the 2D canvas fails does `start` reject,
205 with a `NoGraphicsError`.205 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.
206- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden212- AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden
207 button, then on every visit) the trees AccessKit would get are mirrored as hidden213 button, then on every visit) the trees AccessKit would get are mirrored as hidden
208 elements with ARIA roles; acting on one sends AccessKit's action back.214 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] = &[...@@ -256,6 +256,13 @@ const SECTIONS: &[Section] = &[
256 "update download",256 "update download",
257 Control::Check(|options| &mut options.automatic_updates),257 Control::Check(|options| &mut options.automatic_updates),
258 ),258 ),
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"),
259 #[cfg(target_os = "linux")]266 #[cfg(target_os = "linux")]
260 Row::new(267 Row::new(
261 "Installed:",268 "Installed:",
...@@ -304,6 +311,9 @@ pub struct Options {...@@ -304,6 +311,9 @@ pub struct Options {
304 /// Live Share's relay; empty uses Snowbound's.311 /// Live Share's relay; empty uses Snowbound's.
305 relay: String,312 relay: String,
306 notebook_theme: Option<String>,313 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,
307 renderer: Backend,317 renderer: Backend,
308 pub(crate) keyboard: crate::keys::Keyboard,318 pub(crate) keyboard: crate::keys::Keyboard,
309}319}
...@@ -393,6 +403,8 @@ impl State {...@@ -393,6 +403,8 @@ impl State {
393 relay: self.live_options.relay.clone().unwrap_or_default(),403 relay: self.live_options.relay.clone().unwrap_or_default(),
394 notebook_theme: self.notebook_theme.clone(),404 notebook_theme: self.notebook_theme.clone(),
395 renderer: self.renderer_choice,405 renderer: self.renderer_choice,
406 #[cfg(target_arch = "wasm32")]
407 update_now: false,
396 keyboard: crate::keys::Keyboard::new(),408 keyboard: crate::keys::Keyboard::new(),
397 });409 });
398 self.ui.open_popup(id());410 self.ui.open_popup(id());
...@@ -668,6 +680,8 @@ impl State {...@@ -668,6 +680,8 @@ impl State {
668 );680 );
669 let [cancel, ok] = ui::dialog_buttons(ui, "OK", true);681 let [cancel, ok] = ui::dialog_buttons(ui, "OK", true);
670 let ok = ok || entered;682 let ok = ok || entered;
683 #[cfg(target_arch = "wasm32")]
684 let ok = ok || options.update_now;
671 ui.close();685 ui.close();
672 ui.close();686 ui.close();
673 if ok {687 if ok {
...@@ -706,6 +720,11 @@ impl State {...@@ -706,6 +720,11 @@ impl State {
706 if switch {720 if switch {
707 let _ = self.proxy.send_event(UserEvent::Renderer(options.renderer));721 let _ = self.proxy.send_event(UserEvent::Renderer(options.renderer));
708 }722 }
723 #[cfg(target_arch = "wasm32")]
724 if options.update_now {
725 self.ui.close_popup(id());
726 return crate::platform::update_now(self);
727 }
709 } else if !cancel {728 } else if !cancel {
710 self.options = Some(options);729 self.options = Some(options);
711 return;730 return;
...@@ -1099,7 +1118,9 @@ fn cache(state: &mut State, _: &mut Options, _: &str) {...@@ -1099,7 +1118,9 @@ fn cache(state: &mut State, _: &mut Options, _: &str) {
1099 }1118 }
1100}1119}
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) {
1103 let ui = &mut state.ui;1124 let ui = &mut state.ui;
1104 let spec = Spec {1125 let spec = Spec {
1105 size: [fill(), px(row_height(&ui.theme))],1126 size: [fill(), px(row_height(&ui.theme))],
...@@ -1107,6 +1128,10 @@ fn version(state: &mut State, _: &mut Options, _: &str) {...@@ -1107,6 +1128,10 @@ fn version(state: &mut State, _: &mut Options, _: &str) {
1107 ..Spec::default()1128 ..Spec::default()
1108 };1129 };
1109 ui.leaf("version", spec);1130 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 }
1110}1135}
11111136
1112/// Where Install put Snowbound and Uninstall, or Install where it isn't in the app menu.1137/// 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 @@...@@ -3,8 +3,8 @@
3//! `history.json` every build. A thread checks on launch and daily, downloads a newer build3//! `history.json` every build. A thread checks on launch and daily, downloads a newer build
4//! for this platform, verifies it, and unpacks it beside the install. Restart to Update4//! for this platform, verifies it, and unpacks it beside the install. Restart to Update
5//! swaps it in once the app quits.5//! swaps it in once the app quits.
6//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload,6//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload:
7//! so nothing is fetched or installed there.7//! `web.rs` reloads into the build the site's `index.html` names where it is another.
8#![cfg_attr(target_arch = "wasm32", allow(dead_code))]8#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
99
10#[cfg(target_os = "linux")]10#[cfg(target_os = "linux")]
...@@ -124,6 +124,9 @@ impl std::fmt::Display for Version {...@@ -124,6 +124,9 @@ impl std::fmt::Display for Version {
124124
125/// The running build as Options and About name it.125/// The running build as Options and About name it.
126pub fn describe_running() -> String {126pub fn describe_running() -> String {
127 #[cfg(target_arch = "wasm32")]
128 return platform::describe_build();
129 #[cfg(not(target_arch = "wasm32"))]
127 running().map_or_else(130 running().map_or_else(
128 || "Snowbound development build".to_owned(),131 || "Snowbound development build".to_owned(),
129 |version| format!("Snowbound {version}"),132 |version| format!("Snowbound {version}"),
...@@ -240,9 +243,9 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> {...@@ -240,9 +243,9 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> {
240 Err(BROWSER.to_owned())243 Err(BROWSER.to_owned())
241}244}
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.
244#[cfg(target_arch = "wasm32")]247#[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
247/// `KEY`'s ed25519 public key, after minisign's algorithm and key id.250/// `KEY`'s ed25519 public key, after minisign's algorithm and key id.
248#[cfg(not(target_arch = "wasm32"))]251#[cfg(not(target_arch = "wasm32"))]
...@@ -711,7 +714,7 @@ impl Updates {...@@ -711,7 +714,7 @@ impl Updates {
711 /// Checks now, and shows what the check finds when it finishes.714 /// Checks now, and shows what the check finds when it finishes.
712 pub fn check_now(&self) {715 pub fn check_now(&self) {
713 #[cfg(target_arch = "wasm32")]716 #[cfg(target_arch = "wasm32")]
714 platform::inform("Snowbound is up to date", BROWSER);717 platform::check_for_update(true);
715 #[cfg(not(target_arch = "wasm32"))]718 #[cfg(not(target_arch = "wasm32"))]
716 {719 {
717 self.shared.lock().unwrap().asked = true;720 self.shared.lock().unwrap().asked = true;
crates/snowbound/src/web.rs+193-4
...@@ -92,6 +92,20 @@ extern "C" {...@@ -92,6 +92,20 @@ extern "C" {
92 /// follows.92 /// follows.
93 #[wasm_bindgen(js_name = adoptCanvas)]93 #[wasm_bindgen(js_name = adoptCanvas)]
94 pub fn adopt_canvas(canvas: &web_sys::HtmlCanvasElement);94 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>;
95}109}
96110
97/// Where the browser keeps its notebooks, which a first visit makes one in.111/// Where the browser keeps its notebooks, which a first visit makes one in.
...@@ -154,6 +168,13 @@ const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 7] = [...@@ -154,6 +168,13 @@ const FALLBACKS: [(&str, &[RangeInclusive<u32>]); 7] = [
154const EMOJI: &str = "Noto-COLRv1.ttf.gz";168const EMOJI: &str = "Noto-COLRv1.ttf.gz";
155/// How long changed files wait to be written out, so a burst of edits writes once.169/// How long changed files wait to be written out, so a burst of edits writes once.
156const STORE_AFTER: Duration = Duration::from_millis(500);170const 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
158/// What the page's input brought, waiting for the next frame.179/// What the page's input brought, waiting for the next frame.
159enum Input {180enum Input {
...@@ -210,6 +231,16 @@ thread_local! {...@@ -210,6 +231,16 @@ thread_local! {
210 static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) };231 static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) };
211 /// The look last handed to `keepLook`.232 /// The look last handed to `keepLook`.
212 static LOOK: RefCell<String> = const { RefCell::new(String::new()) };233 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) };
213}244}
214245
215/// Whether the browser runs on a Mac, where Command takes the editing chords.246/// Whether the browser runs on a Mac, where Command takes the editing chords.
...@@ -949,6 +980,20 @@ pub async fn start(...@@ -949,6 +980,20 @@ pub async fn start(
949 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases980 // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases
950 // the rest in.981 // the rest in.
951 state.full_page = true;982 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)));
952 // Text no run tags is in the browser's language, as typed in it.997 // Text no run tags is in the browser's language, as typed in it.
953 if let Some(spelling) = &state.spelling {998 if let Some(spelling) = &state.spelling {
954 spelling.untagged(canvas::language::lcid(&input_language()));999 spelling.untagged(canvas::language::lcid(&input_language()));
...@@ -1138,6 +1183,7 @@ async fn open(...@@ -1138,6 +1183,7 @@ async fn open(
1138}1183}
11391184
1140fn queue(input: Input) {1185fn queue(input: Input) {
1186 LAST_INPUT.set(Some(Instant::now()));
1141 INPUT.with_borrow_mut(|queue| queue.push(input));1187 INPUT.with_borrow_mut(|queue| queue.push(input));
1142 request_frame();1188 request_frame();
1143}1189}
...@@ -1290,6 +1336,13 @@ pub fn focus(focused: bool) {...@@ -1290,6 +1336,13 @@ pub fn focus(focused: bool) {
1290 queue(Input::Focus(focused));1336 queue(Input::Focus(focused));
1291}1337}
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
1293#[wasm_bindgen]1346#[wasm_bindgen]
1294pub fn appearance_changed(dark: bool) {1347pub fn appearance_changed(dark: bool) {
1295 host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light });1348 host(|host| host.theme = if dark { Theme::Dark } else { Theme::Light });
...@@ -1496,10 +1549,16 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {...@@ -1496,10 +1549,16 @@ fn turn(state: &mut State) -> Result<(), Box<dyn Error>> {
1496 if STORE_DUE.get().is_some_and(|due| due <= now) {1549 if STORE_DUE.get().is_some_and(|due| due <= now) {
1497 store();1550 store();
1498 }1551 }
1499 let next = [blink, wake.filter(|wake| *wake > now), STORE_DUE.get()]1552 let updating = follow_updates(state, now);
1500 .into_iter()1553 let next = [
1501 .flatten()1554 blink,
1502 .min();1555 wake.filter(|wake| *wake > now),
1556 STORE_DUE.get(),
1557 updating,
1558 ]
1559 .into_iter()
1560 .flatten()
1561 .min();
1503 if let Some(next) = next {1562 if let Some(next) = next {
1504 wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3);1563 wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3);
1505 }1564 }
...@@ -1520,6 +1579,136 @@ fn remember_look(state: &State) {...@@ -1520,6 +1579,136 @@ fn remember_look(state: &State) {
1520 }1579 }
1521}1580}
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
1523/// Fetches the fallback faces `text` calls for that are not yet asked for.1712/// Fetches the fallback faces `text` calls for that are not yet asked for.
1524fn fetch_fallbacks(text: &str) {1713fn fetch_fallbacks(text: &str) {
1525 for character in text.chars().map(u32::from) {1714 for character in text.chars().map(u32::from) {
crates/snowbound/web/glue.js+66-2
...@@ -114,7 +114,8 @@ function storageWorker() {...@@ -114,7 +114,8 @@ function storageWorker() {
114 files = await list(root, "", []);114 files = await list(root, "", []);
115 }115 }
116 postMessage(files, files.flatMap(([, bytes]) => (bytes ? [bytes.buffer] : [])));116 postMessage(files, files.flatMap(([, bytes]) => (bytes ? [bytes.buffer] : [])));
117 } else117 } else if (data.kind === "settle") postMessage(null);
118 else
118 for (const [path, length, ranges] of data.changes)119 for (const [path, length, ranges] of data.changes)
119 if (length === undefined) await remove(path);120 if (length === undefined) await remove(path);
120 else if (length === null) await folder(names(path));121 else if (length === null) await folder(names(path));
...@@ -130,11 +131,17 @@ export function loadFiles() {...@@ -130,11 +131,17 @@ export function loadFiles() {
130 storage = new Worker(source);131 storage = new Worker(source);
131 storage.postMessage({ kind: "load" });132 storage.postMessage({ kind: "load" });
132 return new Promise((resolve, reject) => {133 return new Promise((resolve, reject) => {
133 storage.onmessage = ({ data }) => resolve(data);134 storage.onmessage = ({ data }) => {
135 storage.onmessage = () => settling.shift()?.();
136 resolve(data);
137 };
134 storage.onerror = (error) => reject(new Error(`The storage worker failed: ${error.message}`));138 storage.onerror = (error) => reject(new Error(`The storage worker failed: ${error.message}`));
135 });139 });
136}140}
137141
142// Callers waiting for the storage worker to finish what it was given.
143const settling = [];
144
138/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries;145/** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries;
139 * those under a folder of the user's go there, a committed section only where nothing else146 * those under a folder of the user's go there, a committed section only where nothing else
140 * wrote it since it was read. */147 * wrote it since it was read. */
...@@ -313,6 +320,62 @@ export function fetchDictionary(name) {...@@ -313,6 +320,62 @@ export function fetchDictionary(name) {
313 .catch((error) => console.warn("Dictionary", name, error));320 .catch((error) => console.warn("Dictionary", name, error));
314}321}
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
316export function requestFrame() {379export function requestFrame() {
317 if (!framePending) {380 if (!framePending) {
318 framePending = true;381 framePending = true;
...@@ -674,6 +737,7 @@ export function attach(module) {...@@ -674,6 +737,7 @@ export function attach(module) {
674 if (others.length) wasm.files("place", await read(others));737 if (others.length) wasm.files("place", await read(others));
675 });738 });
676 addEventListener("pagehide", () => wasm.flush());739 addEventListener("pagehide", () => wasm.flush());
740 addEventListener("focus", () => wasm.returned());
677 document.addEventListener("visibilitychange", () => {741 document.addEventListener("visibilitychange", () => {
678 if (document.visibilityState === "hidden") wasm.flush();742 if (document.visibilityState === "hidden") wasm.flush();
679 });743 });
crates/snowbound/web/index.html+3-1
...@@ -180,9 +180,11 @@...@@ -180,9 +180,11 @@
180 };180 };
181181
182 // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab.182 // 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.
183 const owned = await new Promise((resolve) => {184 const owned = await new Promise((resolve) => {
184 if (!navigator.locks) return resolve(true);185 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) => {
186 resolve(Boolean(lock));188 resolve(Boolean(lock));
187 return lock && new Promise(() => {});189 return lock && new Promise(() => {});
188 });190 });
crates/ui/src/lib.rs+5
...@@ -1159,6 +1159,11 @@ impl Ui {...@@ -1159,6 +1159,11 @@ impl Ui {
1159 self.popups.iter().any(|popup| popup.id == id)1159 self.popups.iter().any(|popup| popup.id == id)
1160 }1160 }
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
1162 /// Closes popup `id` and those opened from it, returning the focus it took; a submenu1167 /// Closes popup `id` and those opened from it, returning the focus it took; a submenu
1163 /// closes the menus it opened from with it, as choosing from it ends them.1168 /// closes the menus it opened from with it, as choosing from it ends them.
1164 pub fn close_popup(&mut self, id: Id) {1169 pub fn close_popup(&mut self, id: Id) {
tools/release_web.py+5-2
...@@ -24,6 +24,7 @@ import shutil...@@ -24,6 +24,7 @@ import shutil
24import subprocess24import subprocess
25import sys25import sys
26import tempfile26import tempfile
27import time
27import urllib.request28import urllib.request
2829
29ROOT = Path(__file__).resolve().parents[1]30ROOT = Path(__file__).resolve().parents[1]
...@@ -96,9 +97,11 @@ def environment():...@@ -96,9 +97,11 @@ def environment():
9697
9798
98def build(out):99def 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()))
100 run(['cargo', 'build', '--locked', '-p', 'snowbound', '--release', '--target', 'wasm32-unknown-unknown', '--no-default-features', '--features', 'wgpu',103 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})
102 if out.exists():105 if out.exists():
103 shutil.rmtree(out)106 shutil.rmtree(out)
104 out.mkdir(parents=True)107 out.mkdir(parents=True)