| author | |
| committer | |
| log | 1cce9f725367502f129a96c850504b4560cd18c4 |
| tree | bb9340d6ce28c678b8b2eb67c06bbc1eede1a4c2 |
| parent | 514ad509ad5650d2b51457a47964e4f5e02846c8 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- 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.58 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 | 203 | kind of context it gives, so each renderer starts on a canvas of its own, which then takes |
| 204 | 204 | the page's place and its input. Only where even the 2D canvas fails does `start` reject, |
| 205 | 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 | 212 | - AccessKit has no web adapter, so once a screen reader asks for it (a visually hidden |
| 207 | 213 | button, then on every visit) the trees AccessKit would get are mirrored as hidden |
| 208 | 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 | 256 | "update download", |
| 257 | 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 | 266 | #[cfg(target_os = "linux")] |
| 260 | 267 | Row::new( |
| 261 | 268 | "Installed:", |
| ... | ... | @@ -304,6 +311,9 @@ pub struct Options { |
| 304 | 311 | /// Live Share's relay; empty uses Snowbound's. |
| 305 | 312 | relay: String, |
| 306 | 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 | 317 | renderer: Backend, |
| 308 | 318 | pub(crate) keyboard: crate::keys::Keyboard, |
| 309 | 319 | } |
| ... | ... | @@ -393,6 +403,8 @@ impl State { |
| 393 | 403 | relay: self.live_options.relay.clone().unwrap_or_default(), |
| 394 | 404 | notebook_theme: self.notebook_theme.clone(), |
| 395 | 405 | renderer: self.renderer_choice, |
| 406 | #[cfg(target_arch = "wasm32")] | |
| 407 | update_now: false, | |
| 396 | 408 | keyboard: crate::keys::Keyboard::new(), |
| 397 | 409 | }); |
| 398 | 410 | self.ui.open_popup(id()); |
| ... | ... | @@ -668,6 +680,8 @@ impl State { |
| 668 | 680 | ); |
| 669 | 681 | let [cancel, ok] = ui::dialog_buttons(ui, "OK", true); |
| 670 | 682 | let ok = ok || entered; |
| 683 | #[cfg(target_arch = "wasm32")] | |
| 684 | let ok = ok || options.update_now; | |
| 671 | 685 | ui.close(); |
| 672 | 686 | ui.close(); |
| 673 | 687 | if ok { |
| ... | ... | @@ -706,6 +720,11 @@ impl State { |
| 706 | 720 | if switch { |
| 707 | 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 | 728 | } else if !cancel { |
| 710 | 729 | self.options = Some(options); |
| 711 | 730 | return; |
| ... | ... | @@ -1099,7 +1118,9 @@ fn cache(state: &mut State, _: &mut Options, _: &str) { |
| 1099 | 1118 | } |
| 1100 | 1119 | } |
| 1101 | 1120 | |
| 1102 | fn 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))] | |
| 1123 | fn version(state: &mut State, options: &mut Options, _: &str) { | |
| 1103 | 1124 | let ui = &mut state.ui; |
| 1104 | 1125 | let spec = Spec { |
| 1105 | 1126 | size: [fill(), px(row_height(&ui.theme))], |
| ... | ... | @@ -1107,6 +1128,10 @@ fn version(state: &mut State, _: &mut Options, _: &str) { |
| 1107 | 1128 | ..Spec::default() |
| 1108 | 1129 | }; |
| 1109 | 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 | } |
| 1111 | 1136 | |
| 1112 | 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 | 3 | //! `history.json` every build. A thread checks on launch and daily, downloads a newer build |
| 4 | 4 | //! for this platform, verifies it, and unpacks it beside the install. Restart to Update |
| 5 | 5 | //! 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. | |
| 8 | 8 | #![cfg_attr(target_arch = "wasm32", allow(dead_code))] |
| 9 | 9 | |
| 10 | 10 | #[cfg(target_os = "linux")] |
| ... | ... | @@ -124,6 +124,9 @@ impl std::fmt::Display for Version { |
| 124 | 124 | |
| 125 | 125 | /// The running build as Options and About name it. |
| 126 | 126 | pub fn describe_running() -> String { |
| 127 | #[cfg(target_arch = "wasm32")] | |
| 128 | return platform::describe_build(); | |
| 129 | #[cfg(not(target_arch = "wasm32"))] | |
| 127 | 130 | running().map_or_else( |
| 128 | 131 | || "Snowbound development build".to_owned(), |
| 129 | 132 | |version| format!("Snowbound {version}"), |
| ... | ... | @@ -240,9 +243,9 @@ fn verify(_: &[u8], _: &[u8], _: &str) -> Result<(), String> { |
| 240 | 243 | Err(BROWSER.to_owned()) |
| 241 | 244 | } |
| 242 | 245 | |
| 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 | 247 | #[cfg(target_arch = "wasm32")] |
| 245 | const BROWSER: &str = "The browser loads the newest Snowbound each time the page opens."; | |
| 248 | const BROWSER: &str = "The browser updates Snowbound by reloading it."; | |
| 246 | 249 | |
| 247 | 250 | /// `KEY`'s ed25519 public key, after minisign's algorithm and key id. |
| 248 | 251 | #[cfg(not(target_arch = "wasm32"))] |
| ... | ... | @@ -711,7 +714,7 @@ impl Updates { |
| 711 | 714 | /// Checks now, and shows what the check finds when it finishes. |
| 712 | 715 | pub fn check_now(&self) { |
| 713 | 716 | #[cfg(target_arch = "wasm32")] |
| 714 | platform::inform("Snowbound is up to date", BROWSER); | |
| 717 | platform::check_for_update(true); | |
| 715 | 718 | #[cfg(not(target_arch = "wasm32"))] |
| 716 | 719 | { |
| 717 | 720 | self.shared.lock().unwrap().asked = true; |
crates/snowbound/src/web.rs+193-4| ... | ... | @@ -92,6 +92,20 @@ extern "C" { |
| 92 | 92 | /// follows. |
| 93 | 93 | #[wasm_bindgen(js_name = adoptCanvas)] |
| 94 | 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 | } |
| 96 | 110 | |
| 97 | 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 | 168 | const EMOJI: &str = "Noto-COLRv1.ttf.gz"; |
| 155 | 169 | /// How long changed files wait to be written out, so a burst of edits writes once. |
| 156 | 170 | const 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. | |
| 173 | const 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. | |
| 176 | const CHECK_EVERY: Duration = Duration::from_secs(60 * 60); | |
| 177 | const IDLE: Duration = Duration::from_secs(90); | |
| 157 | 178 | |
| 158 | 179 | /// What the page's input brought, waiting for the next frame. |
| 159 | 180 | enum Input { |
| ... | ... | @@ -210,6 +231,16 @@ thread_local! { |
| 210 | 231 | static LANGUAGE: RefCell<String> = const { RefCell::new(String::new()) }; |
| 211 | 232 | /// The look last handed to `keepLook`. |
| 212 | 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 | } |
| 214 | 245 | |
| 215 | 246 | /// Whether the browser runs on a Mac, where Command takes the editing chords. |
| ... | ... | @@ -949,6 +980,20 @@ pub async fn start( |
| 949 | 980 | // The first frame is the loading shell's layout, a toolbar over the page; `loaded` eases |
| 950 | 981 | // the rest in. |
| 951 | 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 | 997 | // Text no run tags is in the browser's language, as typed in it. |
| 953 | 998 | if let Some(spelling) = &state.spelling { |
| 954 | 999 | spelling.untagged(canvas::language::lcid(&input_language())); |
| ... | ... | @@ -1138,6 +1183,7 @@ async fn open( |
| 1138 | 1183 | } |
| 1139 | 1184 | |
| 1140 | 1185 | fn queue(input: Input) { |
| 1186 | LAST_INPUT.set(Some(Instant::now())); | |
| 1141 | 1187 | INPUT.with_borrow_mut(|queue| queue.push(input)); |
| 1142 | 1188 | request_frame(); |
| 1143 | 1189 | } |
| ... | ... | @@ -1290,6 +1336,13 @@ pub fn focus(focused: bool) { |
| 1290 | 1336 | queue(Input::Focus(focused)); |
| 1291 | 1337 | } |
| 1292 | 1338 | |
| 1339 | /// The user came back to the page's window or tab. | |
| 1340 | #[wasm_bindgen] | |
| 1341 | pub fn returned() { | |
| 1342 | RETURNED.set(true); | |
| 1343 | request_frame(); | |
| 1344 | } | |
| 1345 | ||
| 1293 | 1346 | #[wasm_bindgen] |
| 1294 | 1347 | pub fn appearance_changed(dark: bool) { |
| 1295 | 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 | 1549 | if STORE_DUE.get().is_some_and(|due| due <= now) { |
| 1497 | 1550 | store(); |
| 1498 | 1551 | } |
| 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(); | |
| 1503 | 1562 | if let Some(next) = next { |
| 1504 | 1563 | wake_in(next.saturating_duration_since(now).as_secs_f64() * 1e3); |
| 1505 | 1564 | } |
| ... | ... | @@ -1520,6 +1579,136 @@ fn remember_look(state: &State) { |
| 1520 | 1579 | } |
| 1521 | 1580 | } |
| 1522 | 1581 | |
| 1582 | /// This build as Options and About name it. | |
| 1583 | pub 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)] | |
| 1595 | struct 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. | |
| 1603 | pub 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. | |
| 1608 | pub 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. | |
| 1661 | pub 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. | |
| 1688 | fn 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 | 1712 | /// Fetches the fallback faces `text` calls for that are not yet asked for. |
| 1524 | 1713 | fn fetch_fallbacks(text: &str) { |
| 1525 | 1714 | for character in text.chars().map(u32::from) { |
crates/snowbound/web/glue.js+66-2| ... | ... | @@ -114,7 +114,8 @@ function storageWorker() { |
| 114 | 114 | files = await list(root, "", []); |
| 115 | 115 | } |
| 116 | 116 | postMessage(files, files.flatMap(([, bytes]) => (bytes ? [bytes.buffer] : []))); |
| 117 | } else | |
| 117 | } else if (data.kind === "settle") postMessage(null); | |
| 118 | else | |
| 118 | 119 | for (const [path, length, ranges] of data.changes) |
| 119 | 120 | if (length === undefined) await remove(path); |
| 120 | 121 | else if (length === null) await folder(names(path)); |
| ... | ... | @@ -130,11 +131,17 @@ export function loadFiles() { |
| 130 | 131 | storage = new Worker(source); |
| 131 | 132 | storage.postMessage({ kind: "load" }); |
| 132 | 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 | 138 | storage.onerror = (error) => reject(new Error(`The storage worker failed: ${error.message}`)); |
| 135 | 139 | }); |
| 136 | 140 | } |
| 137 | 141 | |
| 142 | // Callers waiting for the storage worker to finish what it was given. | |
| 143 | const settling = []; | |
| 144 | ||
| 138 | 145 | /** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries; |
| 139 | 146 | * those under a folder of the user's go there, a committed section only where nothing else |
| 140 | 147 | * wrote it since it was read. */ |
| ... | ... | @@ -313,6 +320,62 @@ export function fetchDictionary(name) { |
| 313 | 320 | .catch((error) => console.warn("Dictionary", name, error)); |
| 314 | 321 | } |
| 315 | 322 | |
| 323 | /** Resolves once every file handed over so far is written. */ | |
| 324 | function 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. | |
| 333 | const BUILD_FOLDER = /\bb\/([0-9a-f]+)\//; | |
| 334 | const BUILD = import.meta.url.match(BUILD_FOLDER)?.[1]; | |
| 335 | ||
| 336 | /** The build running, as its folder names it; undefined where it came from none. */ | |
| 337 | export 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. */ | |
| 344 | export 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 | ||
| 360 | const 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. */ | |
| 364 | export 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. */ | |
| 373 | export function resumed() { | |
| 374 | const resume = sessionStorage.getItem(RESUME); | |
| 375 | sessionStorage.removeItem(RESUME); | |
| 376 | return resume ?? undefined; | |
| 377 | } | |
| 378 | ||
| 316 | 379 | export function requestFrame() { |
| 317 | 380 | if (!framePending) { |
| 318 | 381 | framePending = true; |
| ... | ... | @@ -674,6 +737,7 @@ export function attach(module) { |
| 674 | 737 | if (others.length) wasm.files("place", await read(others)); |
| 675 | 738 | }); |
| 676 | 739 | addEventListener("pagehide", () => wasm.flush()); |
| 740 | addEventListener("focus", () => wasm.returned()); | |
| 677 | 741 | document.addEventListener("visibilitychange", () => { |
| 678 | 742 | if (document.visibilityState === "hidden") wasm.flush(); |
| 679 | 743 | }); |
crates/snowbound/web/index.html+3-1| ... | ... | @@ -180,9 +180,11 @@ |
| 180 | 180 | }; |
| 181 | 181 | |
| 182 | 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 | 184 | const owned = await new Promise((resolve) => { |
| 184 | 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 | 188 | resolve(Boolean(lock)); |
| 187 | 189 | return lock && new Promise(() => {}); |
| 188 | 190 | }); |
crates/ui/src/lib.rs+5| ... | ... | @@ -1159,6 +1159,11 @@ impl Ui { |
| 1159 | 1159 | self.popups.iter().any(|popup| popup.id == id) |
| 1160 | 1160 | } |
| 1161 | 1161 | |
| 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 | 1167 | /// Closes popup `id` and those opened from it, returning the focus it took; a submenu |
| 1163 | 1168 | /// closes the menus it opened from with it, as choosing from it ends them. |
| 1164 | 1169 | pub fn close_popup(&mut self, id: Id) { |
tools/release_web.py+5-2| ... | ... | @@ -24,6 +24,7 @@ import shutil |
| 24 | 24 | import subprocess |
| 25 | 25 | import sys |
| 26 | 26 | import tempfile |
| 27 | import time | |
| 27 | 28 | import urllib.request |
| 28 | 29 | |
| 29 | 30 | ROOT = Path(__file__).resolve().parents[1] |
| ... | ... | @@ -96,9 +97,11 @@ def environment(): |
| 96 | 97 | |
| 97 | 98 | |
| 98 | 99 | def 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 | 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 | 105 | if out.exists(): |
| 103 | 106 | shutil.rmtree(out) |
| 104 | 107 | out.mkdir(parents=True) |