oxedyne/daimond/www/js/release.js
12.0 KiB, 1 run
created by r2519314175:1429, 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 | // release.js — which Daimond you are running, and what came before it. |
| 2 | // |
| 3 | // The app could always tell you a NEW version existed -- the refresh chip in the |
| 4 | // header does that -- but never which one you were on, when it arrived, or what |
| 5 | // changed in it. This is that missing half. |
| 6 | // |
| 7 | // The history is the transparency log itself, the same file the delivery check |
| 8 | // verifies, rather than a changelog kept beside it. A second file would be a |
| 9 | // second thing to maintain and the first thing to fall out of date; and the log |
| 10 | // already has an entry per release, dated, in order, and impossible to rewrite |
| 11 | // quietly. The human "what changed" line was added to each entry OUTSIDE its |
| 12 | // hashed preimage, so the notes can be written and corrected without disturbing |
| 13 | // what the chain attests. |
| 14 | // |
| 15 | // What this deliberately does NOT offer is a way back. A user pinned to an old |
| 16 | // build walks straight into the gateway's own "too old" refusal, can hold a |
| 17 | // build with a security fault that has since been fixed, and risks an old build |
| 18 | // meeting newer local data. Change-aversion is answered at the change -- by |
| 19 | // saying plainly what changed -- not by keeping every past version alive. |
| 20 | (function () { |
| 21 | 'use strict'; |
| 22 | |
| 23 | /// What the app says. |
| 24 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 25 | function tn(k, n, v) { return window.DaimondI18n ? DaimondI18n.tn(k, n, v) : k; } |
| 26 | |
| 27 | // The same public log the delivery check reads, on an origin this server does |
| 28 | // not control, and overridable by the same meta tag so a fork points at its |
| 29 | // own. It is NOT served from inside the bundle: the log entry for a build is |
| 30 | // written after that build's manifest has been hashed, so a copy sealed |
| 31 | // alongside would always be one release stale and would fail the check it |
| 32 | // was meant to support. |
| 33 | var LOG_DEFAULT = 'https://raw.githubusercontent.com/oxedyne-com/daimond/main/verify/transparency.jsonl'; |
| 34 | /// Resolved when the history is read rather than when this file loads, so a |
| 35 | /// test can point it at a log of its own without a network round trip. |
| 36 | function logUrl() { |
| 37 | var m = document.querySelector('meta[name="daimond-log"]'); |
| 38 | return (m && m.content) || LOG_DEFAULT; |
| 39 | } |
| 40 | /// Overridable the same way the log is, and resolved just as late, so a test |
| 41 | /// can declare a release without writing a file into the served tree. |
| 42 | function releasesUrl() { |
| 43 | var m = document.querySelector('meta[name="daimond-releases"]'); |
| 44 | return (m && m.content) || 'releases.json'; |
| 45 | } |
| 46 | var STAMP = 'build.json'; |
| 47 | |
| 48 | var state = { entries: [], releases: null, build: null, loaded: false }; |
| 49 | |
| 50 | function el(tag, cls, text) { |
| 51 | var e = document.createElement(tag); |
| 52 | if (cls) e.className = cls; |
| 53 | if (text != null) e.textContent = text; |
| 54 | return e; |
| 55 | } |
| 56 | |
| 57 | async function getJson(url) { |
| 58 | var r = await fetch(url, { cache: 'no-store' }); |
| 59 | if (!r.ok) throw new Error(url + ' → ' + r.status); |
| 60 | return await r.json(); |
| 61 | } |
| 62 | |
| 63 | /// Load the history once. A failure here must not take the status strip with |
| 64 | /// it: not knowing the version is a smaller problem than a blank panel. |
| 65 | async function load() { |
| 66 | if (state.loaded) return state; |
| 67 | try { |
| 68 | var text = await (await fetch(logUrl(), { cache: 'no-store' })).text(); |
| 69 | state.entries = text.split('\n').map(function (l) { return l.trim(); }) |
| 70 | .filter(Boolean) |
| 71 | .map(function (l) { try { return JSON.parse(l); } catch (e) { return null; } }) |
| 72 | .filter(Boolean); |
| 73 | } catch (e) { state.entries = []; } |
| 74 | try { state.releases = await getJson(releasesUrl()); } catch (e) { state.releases = null; } |
| 75 | try { state.build = await getJson(STAMP); } catch (e) { state.build = null; } |
| 76 | state.loaded = true; |
| 77 | return state; |
| 78 | } |
| 79 | |
| 80 | /// The milestone a sequence number falls under, or nothing if it predates |
| 81 | /// every named one. |
| 82 | function milestoneAt(seq) { |
| 83 | var ms = (state.releases && state.releases.milestones) || []; |
| 84 | // Sorted here rather than assumed: the lookup takes the LAST milestone a |
| 85 | // sequence falls into, which is only the right answer if they ascend. A |
| 86 | // file authored newest-first would otherwise return the wrong name, and |
| 87 | // silently. |
| 88 | var sorted = ms.slice().sort(function (a, b) { return (a.from || 0) - (b.from || 0); }); |
| 89 | var found = null; |
| 90 | sorted.forEach(function (m) { if (seq >= m.from) found = m; }); |
| 91 | return found; |
| 92 | } |
| 93 | |
| 94 | /// The build THIS TAB is running, which is not the same thing as the newest |
| 95 | /// one published. A tab open since before the last deploy is running an older |
| 96 | /// build, and that is exactly the case this whole surface exists to make |
| 97 | /// visible. |
| 98 | function runningBuild() { |
| 99 | try { |
| 100 | if (window.DaimondUpdater && DaimondUpdater.booted()) return DaimondUpdater.booted(); |
| 101 | } catch (e) { /* the updater may not have polled yet */ } |
| 102 | return null; |
| 103 | } |
| 104 | |
| 105 | /// The log entry for the running build, or nothing if it is not in the log. |
| 106 | /// |
| 107 | /// Taking the last entry instead -- the newest RELEASE -- was wrong, and |
| 108 | /// wrong in the direction that flatters: a user who had not refreshed since a |
| 109 | /// deploy would be told they were on the newest build, on the same screen |
| 110 | /// where the update chip was telling them a new one existed. |
| 111 | function current() { |
| 112 | var id = runningBuild(); |
| 113 | if (!id) return null; |
| 114 | for (var i = state.entries.length - 1; i >= 0; i--) { |
| 115 | if (state.entries[i].build === id) return state.entries[i]; |
| 116 | } |
| 117 | return null; // running something not in the published log |
| 118 | } |
| 119 | |
| 120 | /// The newest published release, whether or not it is the one running. |
| 121 | function newest() { |
| 122 | return state.entries.length ? state.entries[state.entries.length - 1] : null; |
| 123 | } |
| 124 | |
| 125 | /// "today", "yesterday", "3 days ago". Days rather than hours, because the |
| 126 | /// question this answers is how current you are, not what time it landed. |
| 127 | function ago(iso) { |
| 128 | var then = new Date(iso), now = new Date(); |
| 129 | if (isNaN(then)) return ''; |
| 130 | // Compare calendar days, so a build from 23:50 last night reads as |
| 131 | // yesterday rather than as today. |
| 132 | var d0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()); |
| 133 | var d1 = new Date(then.getFullYear(), then.getMonth(), then.getDate()); |
| 134 | var days = Math.round((d0 - d1) / 86400000); |
| 135 | if (days <= 0) return t('rel.today'); |
| 136 | if (days === 1) return t('rel.yesterday'); |
| 137 | if (days < 31) return tn('rel.days_ago', days); |
| 138 | if (days < 365) return tn('rel.months_ago', Math.round(days / 30)); |
| 139 | return tn('rel.years_ago', Math.round(days / 365)); |
| 140 | } |
| 141 | |
| 142 | function dateOf(iso) { |
| 143 | var d = new Date(iso); |
| 144 | if (isNaN(d)) return ''; |
| 145 | return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); |
| 146 | } |
| 147 | |
| 148 | // ── The status row ────────────────────────────────────────────────── |
| 149 | |
| 150 | /// Fill the row in the status strip: the release you are on, and how long it |
| 151 | /// has been there. |
| 152 | async function paintRow() { |
| 153 | var r = document.getElementById('astat-release'); |
| 154 | if (!r) return; |
| 155 | await load(); |
| 156 | var cur = current(); |
| 157 | var tip = newest(); |
| 158 | var m = cur ? milestoneAt(cur.seq) : null; |
| 159 | // Behind means the tab is running something older than the tip. Saying so |
| 160 | // is the point: the alternative is a row that reassures a stale tab. |
| 161 | var behind = !!(cur && tip && cur.seq < tip.seq); |
| 162 | // Before any release is declared, the app says so and names the build. It |
| 163 | // must not present a deployment as a release: several are sealed a day, |
| 164 | // and none of them is an announcement. |
| 165 | var name = m ? m.name : (cur ? t('rel.prerelease') : (runningBuild() || t('rel.unsealed'))); |
| 166 | var when = cur ? ago(cur.ts) : ''; |
| 167 | |
| 168 | r.innerHTML = ''; |
| 169 | r.appendChild(el('span', 'astat-dot' + (cur ? (behind ? ' warn' : ' ok') : ''))); |
| 170 | r.appendChild(el('span', 'astat-label', t('rel.version'))); |
| 171 | r.appendChild(el('span', 'astat-val', name)); |
| 172 | r.appendChild(el('span', 'astat-aside', |
| 173 | cur ? (behind ? t('rel.update_ready') : (m ? when : cur.build)) : t('rel.not_published'))); |
| 174 | r.title = cur |
| 175 | ? t(m ? 'rel.title_named' : 'rel.title_prerelease', |
| 176 | { name: name, build: cur.build, date: dateOf(cur.ts) }) |
| 177 | + (behind ? ' ' + t('rel.title_behind') : '') |
| 178 | + ' ' + t('rel.title_click') |
| 179 | : t('rel.title_unlogged'); |
| 180 | } |
| 181 | |
| 182 | // ── The timeline ──────────────────────────────────────────────────── |
| 183 | |
| 184 | /// Build the history: what is coming, what you are on, and what came before. |
| 185 | /// |
| 186 | /// The planned entry is drawn first and drawn differently, because it is the |
| 187 | /// one line here that is a promise rather than a record. Everything below it |
| 188 | /// is sealed and checkable; it is neither, and must not borrow their |
| 189 | /// authority. |
| 190 | async function render(into) { |
| 191 | await load(); |
| 192 | into.innerHTML = ''; |
| 193 | |
| 194 | var cur = current(); |
| 195 | var planned = state.releases && state.releases.planned; |
| 196 | var ms = (state.releases && state.releases.milestones) || []; |
| 197 | |
| 198 | if (planned) { |
| 199 | var pl = el('div', 'rel-row rel-planned'); |
| 200 | var ph = el('div', 'rel-head'); |
| 201 | ph.appendChild(el('span', 'rel-name', planned.name)); |
| 202 | ph.appendChild(el('span', 'rel-tag', ms.length ? t('rel.planned') : t('rel.next'))); |
| 203 | pl.appendChild(ph); |
| 204 | if (planned.blurb) pl.appendChild(el('div', 'rel-blurb', planned.blurb)); |
| 205 | pl.appendChild(el('div', 'rel-meta', t('rel.no_date'))); |
| 206 | into.appendChild(pl); |
| 207 | } |
| 208 | |
| 209 | if (!state.entries.length) { |
| 210 | into.appendChild(el('div', 'rel-empty', t('rel.no_history'))); |
| 211 | return; |
| 212 | } |
| 213 | |
| 214 | // Releases, newest first. A release is something declared, not something |
| 215 | // deployed: builds are sealed several times a day and listing them all |
| 216 | // would bury the few entries a reader actually wants. |
| 217 | var byNewest = ms.slice().sort(function (a, b) { return (b.from || 0) - (a.from || 0); }); |
| 218 | byNewest.forEach(function (m) { |
| 219 | var first = state.entries.filter(function (e) { return e.seq >= m.from; })[0]; |
| 220 | var here = !!(cur && milestoneAt(cur.seq) && milestoneAt(cur.seq).name === m.name); |
| 221 | var row = el('div', 'rel-row' + (here ? ' rel-current' : '')); |
| 222 | var head = el('div', 'rel-head'); |
| 223 | head.appendChild(el('span', 'rel-name', m.name)); |
| 224 | if (here) head.appendChild(el('span', 'rel-tag rel-here', t('rel.you_are_here'))); |
| 225 | if (first) head.appendChild(el('span', 'rel-when', dateOf(first.ts))); |
| 226 | row.appendChild(head); |
| 227 | if (m.blurb) row.appendChild(el('div', 'rel-blurb', m.blurb)); |
| 228 | into.appendChild(row); |
| 229 | }); |
| 230 | |
| 231 | if (!ms.length) { |
| 232 | var none = el('div', 'rel-row rel-current'); |
| 233 | var nh = el('div', 'rel-head'); |
| 234 | nh.appendChild(el('span', 'rel-name', t('rel.prerelease'))); |
| 235 | nh.appendChild(el('span', 'rel-tag rel-here', t('rel.you_are_here'))); |
| 236 | if (cur) nh.appendChild(el('span', 'rel-when', dateOf(cur.ts))); |
| 237 | none.appendChild(nh); |
| 238 | none.appendChild(el('div', 'rel-blurb', t('rel.none_declared'))); |
| 239 | if (cur) { |
| 240 | var nm = el('div', 'rel-meta'); |
| 241 | nm.appendChild(el('code', null, cur.build)); |
| 242 | nm.appendChild(el('span', null, ' \u00b7 ' + t('rel.sealed_no', { n: cur.seq }))); |
| 243 | none.appendChild(nm); |
| 244 | } |
| 245 | into.appendChild(none); |
| 246 | } |
| 247 | |
| 248 | // Every sealed build, behind a disclosure. They are the verifiable part |
| 249 | // and must stay reachable, but they are a deployment record rather than |
| 250 | // a version history, so they do not lead. |
| 251 | var det = document.createElement('details'); |
| 252 | det.className = 'rel-builds'; |
| 253 | var sum = document.createElement('summary'); |
| 254 | sum.textContent = tn('rel.sealed_builds', state.entries.length); |
| 255 | det.appendChild(sum); |
| 256 | state.entries.slice().reverse().forEach(function (e) { |
| 257 | var b = el('div', 'rel-build' + (cur && e.seq === cur.seq ? ' rel-build-here' : '')); |
| 258 | var bh = el('div', 'rel-build-head'); |
| 259 | bh.appendChild(el('code', null, e.build)); |
| 260 | bh.appendChild(el('span', 'rel-when', dateOf(e.ts))); |
| 261 | b.appendChild(bh); |
| 262 | if (e.note) b.appendChild(el('div', 'rel-build-note', e.note)); |
| 263 | det.appendChild(b); |
| 264 | }); |
| 265 | into.appendChild(det); |
| 266 | |
| 267 | var foot = el('div', 'rel-foot'); |
| 268 | foot.textContent = t('rel.foot'); |
| 269 | into.appendChild(foot); |
| 270 | } |
| 271 | |
| 272 | window.DaimondRelease = { |
| 273 | reset: function () { state = { entries: [], releases: null, build: null, loaded: false }; }, |
| 274 | paintRow: paintRow, |
| 275 | render: render, |
| 276 | load: load, |
| 277 | current: current, |
| 278 | milestoneAt: milestoneAt, |
| 279 | ago: ago, |
| 280 | }; |
| 281 | })(); |