oxedyne/daimond/www/js/updater.js
18.0 KiB, 9 runs
created by r2519314175:1467, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* updater.js — pull a new version into a running tab, safely and quietly. |
| 2 | * |
| 3 | * A browser tab loads Daimond's code once and would otherwise run it untouched for days, long |
| 4 | * after a newer version was deployed. This watches for one, and applies it at a moment that |
| 5 | * costs the user nothing: when the tab is in the background and idle. It never reloads over a |
| 6 | * turn in flight or a half-typed prompt. |
| 7 | * |
| 8 | * The signal is `build.json` at the site root -- a tiny file whose `build` id changes with every |
| 9 | * deploy (see dev/stamp-build.mjs). The tab reads it once at boot to learn the version it is |
| 10 | * running, then re-reads it on a timer and whenever the tab is shown, and compares. A different |
| 11 | * id means a newer build is live. |
| 12 | * |
| 13 | * "Safe" here is only about not losing work; authenticity is not in question, because the code |
| 14 | * comes from Daimond's own origin over TLS -- there is no third party in this path. The reload |
| 15 | * is lossless because the durability journal already makes every boot a clean recovery; this |
| 16 | * just chooses a good time to do it, and never interrupts a running turn to do it. |
| 17 | * |
| 18 | * There is deliberately no way to REFUSE a version. A web app cannot coherently run an old build |
| 19 | * against a new server, and the new build is the same app, from the same people, the user is |
| 20 | * already trusting. The only question is WHEN, never WHETHER: the chip offers "now" on a click, |
| 21 | * and otherwise waits for a quiet, hidden moment. |
| 22 | */ |
| 23 | (function () { |
| 24 | 'use strict'; |
| 25 | |
| 26 | /// What the app says. |
| 27 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 28 | |
| 29 | /// One line in the durable trail, for a bug that can only be seen on a phone. |
| 30 | function trail(w, d) { try { window.DaimondTrail.note(w, d); } catch (e) {} } |
| 31 | |
| 32 | var SRC = 'build.json'; // the version stamp, at the site root |
| 33 | var POLL_MS = 120000; // re-check on this timer while in the foreground |
| 34 | var KEY = 'daimond-updated-to'; |
| 35 | var FKEY = 'daimond-forced-from'; // the build a forced reload last left, to break loops |
| 36 | // The last-resort cap on forced reloads, in localStorage. |
| 37 | // |
| 38 | // A forced reload is the one thing in this file that can loop, and the guard |
| 39 | // against it was `sessionStorage[FKEY] === booted` -- which is right, and |
| 40 | // which ONLY `onStale` consulted. The `daimond:idle` handler called |
| 41 | // `apply(true)` directly, so once `stale` was true every turn that ended |
| 42 | // forced another reload, with no loop-breaker at all, three lines below the |
| 43 | // loop-breaker. Both doors go through `force()` now. |
| 44 | // |
| 45 | // The cap below is what the per-build guard cannot do: `booted` is null while |
| 46 | // build.json has not been read and stays null if it cannot be read at all, |
| 47 | // which is the state a phone on a bad connection is in; and a standalone PWA |
| 48 | // on iOS can start a fresh session on each launch, so a loop that reloads the |
| 49 | // app is a loop that clears a sessionStorage guard. Three forced reloads in |
| 50 | // ninety seconds is not an update arriving, it is a tab that cannot settle. |
| 51 | var TKEY = 'daimond-forced-at'; |
| 52 | var COOLDOWN = 90000; |
| 53 | var MAX_FORCED = 3; // in one cooldown window, before it stops for good |
| 54 | var NKEY = 'daimond-forced-n'; |
| 55 | |
| 56 | var booted = null; // the build id this tab is running |
| 57 | var pending = null; // a newer build id, once seen |
| 58 | var note = ''; // a one-line "what changed", if the stamp carries one |
| 59 | var stale = false; // the gateway has declared this tab too old to serve |
| 60 | var applying = false; |
| 61 | var chip = null; |
| 62 | |
| 63 | // An ACTIVE session should not be reloaded out from under the user. A soft |
| 64 | // update waits for a hidden tab OR a foreground one left untouched this long; a |
| 65 | // forced one (the gateway refusing the tab) never reloads mid-turn and waits a |
| 66 | // short pause after the last keystroke rather than yank the page mid-sentence. |
| 67 | // `lastActive` is user INPUT only -- a turn ending is not input, so a forced |
| 68 | // reload applies as soon as a turn finishes (unless a key was just pressed). |
| 69 | var QUIESCE_MS = 600000; // ~10 min of no input: a safe moment for a soft update |
| 70 | var GRACE_MS = 20000; // a short pause after the last keystroke before a forced reload |
| 71 | var lastActive = Date.now(); // epoch-ms of the last keydown/pointerdown (a fresh tab counts as just-active) |
| 72 | var graceTimer = null; |
| 73 | function quietFor() { return Date.now() - lastActive; } |
| 74 | |
| 75 | /// Read the stamp, never from cache -- the whole point is to see the server's current truth. |
| 76 | /// Any failure (offline, no stamp deployed, bad JSON) resolves to null and is simply ignored; |
| 77 | /// a broken check must never break the app or nag the user. |
| 78 | /// |
| 79 | /// The shell worker is told every id this reads. It caches code, so it must |
| 80 | /// never hold a build the server has moved past, and this is the one place in |
| 81 | /// the app that knows -- so the worker takes ITS answer rather than forming a |
| 82 | /// second opinion on a timer of its own. See www/sw.js. |
| 83 | function readStamp() { |
| 84 | return fetch(SRC, { cache: 'no-store' }) |
| 85 | .then(function (r) { return r.ok ? r.json() : null; }) |
| 86 | .then(function (j) { return (j && typeof j.build === 'string') ? j : null; }) |
| 87 | .then(function (j) { |
| 88 | if (j) { try { window.DaimondPWA.tellBuild(j.build); } catch (e) {} } |
| 89 | return j; |
| 90 | }) |
| 91 | .catch(function () { return null; }); |
| 92 | } |
| 93 | |
| 94 | function busy() { |
| 95 | var C = window.DaimondCore; |
| 96 | return !!(C && C.busy && C.busy()); |
| 97 | } |
| 98 | function composerHasText() { |
| 99 | var C = window.DaimondCore; |
| 100 | return !!(C && C.composerHasText && C.composerHasText()); |
| 101 | } |
| 102 | |
| 103 | /// Apply the pending update by reloading. `force` is a user click: it may reload a foreground |
| 104 | /// tab, but even then it will NOT interrupt a running turn -- work in flight is never lost to |
| 105 | /// an update. The automatic path is stricter still: only a hidden, idle tab, with nothing |
| 106 | /// half-typed, so the user never sees a page reload out from under them. |
| 107 | /// Returns true only when it actually reloaded, so a caller can tell a reload |
| 108 | /// from one deferred until the turn ends. The forced path counts on that: a |
| 109 | /// guard spent on an attempt that was deferred is a guard that then refuses |
| 110 | /// the reload it was waiting for. |
| 111 | function apply(force) { |
| 112 | if (applying || !pending) return false; |
| 113 | if (busy()) return false; // never interrupt a running turn or agent |
| 114 | if (!force) { |
| 115 | // A soft update applies at a quiet moment: a hidden tab, or a foreground |
| 116 | // one left untouched for QUIESCE_MS -- never over a half-typed prompt, and |
| 117 | // (via the busy() check above) never over a running turn. |
| 118 | if (!document.hidden && quietFor() < QUIESCE_MS) return false; |
| 119 | if (composerHasText()) return false; |
| 120 | } |
| 121 | applying = true; |
| 122 | try { sessionStorage.setItem(KEY, pending); } catch (e) {} |
| 123 | try { if (window.DaimondJournal) DaimondJournal.flush(); } catch (e) {} |
| 124 | location.reload(); |
| 125 | return true; |
| 126 | } |
| 127 | |
| 128 | var checking = false; |
| 129 | /// A user-initiated check. If a newer build turns up it becomes "ready"; if |
| 130 | /// not, a brief tick confirms the tab is current, so the click always answers. |
| 131 | function manualCheck() { |
| 132 | if (checking || stale) return; |
| 133 | checking = true; |
| 134 | chip.title = t('update.checking'); |
| 135 | readStamp().then(function (j) { |
| 136 | checking = false; |
| 137 | onFound(j); |
| 138 | if (!pending && !stale) { |
| 139 | chip.dataset.state = 'done'; |
| 140 | chip.title = t('update.latest'); |
| 141 | chip.hidden = false; |
| 142 | setTimeout(reflect, 1400); |
| 143 | } |
| 144 | }); |
| 145 | } |
| 146 | |
| 147 | function setChip(state) { |
| 148 | if (!chip) return; |
| 149 | chip.dataset.state = state; |
| 150 | var label = { |
| 151 | current: t('topbar.up_to_date'), |
| 152 | // {note} is the new version's own label, when the gateway named one. |
| 153 | ready: t('update.ready') + (note ? ' — ' + note : '') + ' ' + t('update.click_now'), |
| 154 | busy: t('update.ready_help'), |
| 155 | done: t('update.updated') + (note ? ' — ' + note : ''), |
| 156 | stale: t('update.stale'), |
| 157 | }[state] || ''; |
| 158 | chip.title = label; |
| 159 | chip.setAttribute('aria-label', label); |
| 160 | chip.hidden = false; |
| 161 | } |
| 162 | |
| 163 | /// The update state, reflected on the chip. Stale (the gateway refuses this tab) is the loudest |
| 164 | /// and outranks the rest; otherwise ready when it could apply, "busy" while a turn must finish. |
| 165 | function reflect() { |
| 166 | if (stale) { setChip('stale'); return; } |
| 167 | if (!pending) { setChip('current'); return; } |
| 168 | setChip(busy() ? 'busy' : 'ready'); |
| 169 | } |
| 170 | |
| 171 | function onFound(j) { |
| 172 | if (!j || j.build === booted || j.build === pending) return; |
| 173 | pending = j.build; |
| 174 | note = typeof j.note === 'string' ? j.note : ''; |
| 175 | reflect(); |
| 176 | apply(false); // try now; may simply wait for a hidden moment |
| 177 | } |
| 178 | |
| 179 | /// One check. Once an update is known, stop asking and just watch for a safe moment to apply. |
| 180 | function poll() { |
| 181 | if (pending) { apply(false); return; } |
| 182 | readStamp().then(onFound); |
| 183 | } |
| 184 | |
| 185 | /// The gateway has refused this tab as too old (426, or it advertised a floor above our version). |
| 186 | /// This is not "an update is available", it is "you cannot keep working" -- so it reloads as soon |
| 187 | /// as the tab is idle, in the foreground too, but still never over a running turn. A once-per-build |
| 188 | /// guard stops a reload loop during the brief window where a new gateway is live but the new bundle |
| 189 | /// is not yet on disk: after one try from a given build, it leaves the chip red for the user. |
| 190 | /// May a FORCED reload happen right now? |
| 191 | /// |
| 192 | /// One per cooldown, counted in localStorage so it survives the reload it is |
| 193 | /// guarding against. Returns false and leaves the chip red when it will not: |
| 194 | /// if reloading did not clear the staleness the first time, reloading again |
| 195 | /// is a loop, and a red chip the user can press is strictly better than an |
| 196 | /// app that will not stay open long enough to be used. |
| 197 | /// May a forced reload happen? Asked before every one, and it SPENDS NOTHING |
| 198 | /// -- see `spendForce`, which is called only once a reload really starts. |
| 199 | function mayForce() { |
| 200 | // THE PRIMARY GUARD IS PER BUILD, and it was already right: one forced |
| 201 | // reload from a given build, because if reloading did not change the |
| 202 | // build there is nothing a second reload can do. What was wrong was that |
| 203 | // only ONE of the two doors consulted it. |
| 204 | var guarded = false; |
| 205 | try { guarded = sessionStorage.getItem(FKEY) === booted; } catch (e) {} |
| 206 | if (guarded && booted) return false; |
| 207 | |
| 208 | // A LAST RESORT, in localStorage so it survives the reload it guards |
| 209 | // against. The per-build guard above cannot help when `booted` is null -- |
| 210 | // build.json unreadable, which is the state a phone on a bad connection |
| 211 | // is in -- and a standalone PWA on iOS can start a fresh session on each |
| 212 | // launch, so a loop that reloads the app is a loop that clears a |
| 213 | // sessionStorage guard. Three in ninety seconds is not an update |
| 214 | // arriving; it is a tab that cannot settle. |
| 215 | var now = Date.now(), at = 0, n = 0; |
| 216 | try { at = parseInt(localStorage.getItem(TKEY), 10) || 0; } catch (e) {} |
| 217 | try { n = parseInt(localStorage.getItem(NKEY), 10) || 0; } catch (e) {} |
| 218 | if (now - at > COOLDOWN) n = 0; // a quiet window: start counting again |
| 219 | if (n >= MAX_FORCED) return false; |
| 220 | return true; |
| 221 | } |
| 222 | |
| 223 | /// Record a forced reload that is HAPPENING. Split from `mayForce` because a |
| 224 | /// forced reload is often deferred -- `apply` refuses over a running turn -- |
| 225 | /// and marking the guard on the attempt made the tab refuse the very reload |
| 226 | /// it was waiting for the turn to end for. `verify_updates` caught exactly |
| 227 | /// that: "stale applies the moment the turn ends" went red. |
| 228 | function spendForce() { |
| 229 | var now = Date.now(), at = 0, n = 0; |
| 230 | try { at = parseInt(localStorage.getItem(TKEY), 10) || 0; } catch (e) {} |
| 231 | try { n = parseInt(localStorage.getItem(NKEY), 10) || 0; } catch (e) {} |
| 232 | if (now - at > COOLDOWN) n = 0; |
| 233 | try { localStorage.setItem(TKEY, String(now)); } catch (e) {} |
| 234 | try { localStorage.setItem(NKEY, String(n + 1)); } catch (e) {} |
| 235 | try { if (booted) sessionStorage.setItem(FKEY, booted); } catch (e) {} |
| 236 | } |
| 237 | |
| 238 | /// The one door a forced reload goes through. Both callers -- the gateway |
| 239 | /// refusing this tab, and a turn ending while it is already refused -- come |
| 240 | /// here, so neither can reload past the guard. |
| 241 | function force() { |
| 242 | if (!mayForce()) { |
| 243 | trail('forced reload REFUSED', 'loop guard held'); |
| 244 | setChip('stale'); |
| 245 | return false; |
| 246 | } |
| 247 | // Never reload mid-keystroke. If the composer holds text and a key was |
| 248 | // pressed within the grace, wait out the remaining pause and retry -- the tab |
| 249 | // stays refused (the red chip says so) but the reload lands at the next |
| 250 | // natural break, not the middle of a sentence. A running turn is handled by |
| 251 | // apply(true), which defers until daimond:idle. |
| 252 | if (composerHasText() && quietFor() < GRACE_MS) { |
| 253 | if (graceTimer) clearTimeout(graceTimer); |
| 254 | graceTimer = setTimeout(force, GRACE_MS - quietFor() + 100); |
| 255 | return false; |
| 256 | } |
| 257 | if (!apply(true)) return false; // deferred over a running turn |
| 258 | trail('forced reload', 'the gateway refused this build'); |
| 259 | spendForce(); |
| 260 | return true; |
| 261 | } |
| 262 | |
| 263 | /// A MISMATCHED PAIR: the wasm the page fetched and the JS glue running beside it |
| 264 | /// were built at different times, so an import the module needs is not the one the |
| 265 | /// glue defines. wasm-bindgen derives every one of those names from a signature, so |
| 266 | /// they all move with a build -- which makes this unrecoverable in the page and |
| 267 | /// trivially repairable by taking both files again. |
| 268 | /// |
| 269 | /// It exists because the cache logic protects an invariant one file short of the real |
| 270 | /// one. `www/sw.js` is careful never to leave two builds in ONE CACHE, and that is |
| 271 | /// true and not sufficient: a tab that loaded build A's JS, and then fetches the wasm |
| 272 | /// after a deploy has landed, never puts two builds in a cache at all. The mismatch is |
| 273 | /// in memory, between a file already executing and a file just arrived. |
| 274 | /// |
| 275 | /// The caches go first. A reload that kept them would be served the same stale glue |
| 276 | /// and fail again, which is a loop rather than a repair. |
| 277 | function repair(why) { |
| 278 | if (!mayForce()) { trail('repair reload REFUSED', 'loop guard held'); return false; } |
| 279 | spendForce(); |
| 280 | trail('repair reload', why || 'a mismatched engine pair'); |
| 281 | var go = function () { try { location.reload(); } catch (e) {} }; |
| 282 | try { |
| 283 | if (window.caches && caches.keys) { |
| 284 | caches.keys() |
| 285 | .then(function (ns) { |
| 286 | return Promise.all(ns.map(function (n) { return caches.delete(n); })); |
| 287 | }) |
| 288 | .then(go, go); |
| 289 | return true; |
| 290 | } |
| 291 | } catch (e) { /* no Cache Storage; the reload alone is still worth taking */ } |
| 292 | go(); |
| 293 | return true; |
| 294 | } |
| 295 | |
| 296 | /// A reload that WORKED clears the counter. Called from `init` when the build |
| 297 | /// on disk is not the one this tab last forced away from: whatever was wrong |
| 298 | /// is over, and the next genuine update must not be refused because of it. |
| 299 | function forgetForced() { |
| 300 | try { |
| 301 | localStorage.removeItem(TKEY); |
| 302 | localStorage.removeItem(NKEY); |
| 303 | } catch (e) {} |
| 304 | } |
| 305 | |
| 306 | function onStale() { |
| 307 | trail('gateway says stale', booted || 'build unknown'); |
| 308 | stale = true; |
| 309 | reflect(); |
| 310 | readStamp().then(function (j) { |
| 311 | pending = (j && j.build) || pending || (booted ? booted + '!' : 'stale'); |
| 312 | if (j && typeof j.note === 'string') note = j.note; |
| 313 | reflect(); |
| 314 | force(); |
| 315 | }); |
| 316 | } |
| 317 | |
| 318 | async function init() { |
| 319 | chip = document.getElementById('update-chip'); |
| 320 | // Pending → apply it. Otherwise it is a manual "check now", with a tick of |
| 321 | // feedback, so the chip never feels like a dead button. |
| 322 | if (chip) chip.addEventListener('click', function () { |
| 323 | if (pending) { apply(true); return; } |
| 324 | manualCheck(); |
| 325 | }); |
| 326 | |
| 327 | // Did this very load just replace an older build? Say so, briefly. |
| 328 | var was = null; |
| 329 | try { was = sessionStorage.getItem(KEY); } catch (e) {} |
| 330 | try { if (was) sessionStorage.removeItem(KEY); } catch (e) {} |
| 331 | |
| 332 | var first = await readStamp(); |
| 333 | booted = first ? first.build : null; |
| 334 | // Into the trail, and into storage for the next boot's `boot` row. Without |
| 335 | // it a trail from a device cannot be attributed to a release, and one |
| 336 | // already could not be -- which cost a whole cycle to discover. |
| 337 | try { window.DaimondTrail.setBuild(booted); } catch (e) {} |
| 338 | |
| 339 | // A reload that landed on a DIFFERENT build did its job, so the forced |
| 340 | // counter starts again. Without this, one bad afternoon leaves a phone |
| 341 | // refusing the next genuine update until the cooldown expires. |
| 342 | var forcedFrom = null; |
| 343 | try { forcedFrom = sessionStorage.getItem(FKEY); } catch (e) {} |
| 344 | if (booted && forcedFrom && forcedFrom !== booted) forgetForced(); |
| 345 | |
| 346 | if (booted && was && was === booted) { |
| 347 | note = first && typeof first.note === 'string' ? first.note : ''; |
| 348 | setChip('done'); |
| 349 | setTimeout(function () { if (!pending) setChip('current'); }, 6000); |
| 350 | } else if (booted) { |
| 351 | setChip('current'); |
| 352 | } else if (chip) { |
| 353 | chip.hidden = true; // no stamp deployed yet: no version system, stay silent |
| 354 | } |
| 355 | |
| 356 | // User input, for the quiescence and typing-grace thresholds above. Capture |
| 357 | // phase so it counts even when a downstream handler stops propagation. |
| 358 | var bump = function () { lastActive = Date.now(); }; |
| 359 | document.addEventListener('keydown', bump, true); |
| 360 | document.addEventListener('pointerdown', bump, true); |
| 361 | |
| 362 | setInterval(poll, POLL_MS); |
| 363 | document.addEventListener('visibilitychange', function () { |
| 364 | if (!document.hidden) poll(); // shown: re-check, and reflect any pending state |
| 365 | else if (pending) apply(false); // hidden: the ideal moment to apply invisibly |
| 366 | }); |
| 367 | window.addEventListener('focus', poll); |
| 368 | // When a turn ends the app is idle again; a deferred update can go, and the chip settles. |
| 369 | window.addEventListener('daimond:idle', function () { |
| 370 | // THROUGH `force`, not `apply(true)`. This line used to force a reload |
| 371 | // on every idle event for as long as `stale` was true, with no |
| 372 | // loop-breaker at all -- so a tab the gateway kept refusing reloaded |
| 373 | // again every time a turn ended, for ever. |
| 374 | if (stale) { force(); return; } // was only waiting on the turn |
| 375 | if (pending) { reflect(); apply(false); } |
| 376 | }); |
| 377 | // The gateway declared this tab too old: escalate to a forced reload. |
| 378 | window.addEventListener('daimond:stale', onStale); |
| 379 | } |
| 380 | |
| 381 | if (document.readyState === 'loading') { |
| 382 | document.addEventListener('DOMContentLoaded', init); |
| 383 | } else { |
| 384 | init(); |
| 385 | } |
| 386 | |
| 387 | // A small surface for tests and for the app to nudge a check. |
| 388 | window.DaimondUpdater = { |
| 389 | pending: function () { return pending; }, |
| 390 | booted: function () { return booted; }, |
| 391 | check: poll, |
| 392 | repair: repair, |
| 393 | }; |
| 394 | })(); |