Oregami
Repositories/oxedyne/daimond

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})();