| 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,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 takes | 203 | 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 hidden | 212 | - 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 hidden | 213 | 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 | } |
| 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 | 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 | } |
| 1111 | 1136 | ||
| 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 build | 3 | //! `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 Update | 4 | //! 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))] |
| 9 | 9 | ||
| 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 { |
| 124 | 124 | ||
| 125 | /// The running build as Options and About name it. | 125 | /// The running build as Options and About name it. |
| 126 | pub fn describe_running() -> String { | 126 | pub 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 | } |
| 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 | #[cfg(target_arch = "wasm32")] | 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 | /// `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 | } |
| 96 | 110 | ||
| 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] = [ |
| 154 | const EMOJI: &str = "Noto-COLRv1.ttf.gz"; | 168 | const 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. |
| 156 | const STORE_AFTER: Duration = Duration::from_millis(500); | 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 | /// What the page's input brought, waiting for the next frame. | 179 | /// What the page's input brought, waiting for the next frame. |
| 159 | enum Input { | 180 | enum 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 | } |
| 214 | 245 | ||
| 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` eases | 980 | // 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 | } |
| 1139 | 1184 | ||
| 1140 | fn queue(input: Input) { | 1185 | fn 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 | } |
| 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 | #[wasm_bindgen] | 1346 | #[wasm_bindgen] |
| 1294 | pub fn appearance_changed(dark: bool) { | 1347 | pub 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 | } |
| 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 | /// 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. |
| 1524 | fn fetch_fallbacks(text: &str) { | 1713 | fn 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 | } else | 117 | } 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 | } |
| 137 | 141 | ||
| 142 | // Callers waiting for the storage worker to finish what it was given. | ||
| 143 | const 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 else | 146 | * 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 | } |
| 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 | export function requestFrame() { | 379 | export 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 | }; |
| 181 | 181 | ||
| 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 | } |
| 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 | /// Closes popup `id` and those opened from it, returning the focus it took; a submenu | 1167 | /// 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 |
| 24 | import subprocess | 24 | import subprocess |
| 25 | import sys | 25 | import sys |
| 26 | import tempfile | 26 | import tempfile |
| 27 | import time | ||
| 27 | import urllib.request | 28 | import urllib.request |
| 28 | 29 | ||
| 29 | ROOT = Path(__file__).resolve().parents[1] | 30 | ROOT = Path(__file__).resolve().parents[1] |
| ... | @@ -96,9 +97,11 @@ def environment(): | ... | @@ -96,9 +97,11 @@ def environment(): |
| 96 | 97 | ||
| 97 | 98 | ||
| 98 | def build(out): | 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 | 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) |