oxedyne/daimond/www/js/breadcrumb.js
7.7 KiB, 1 run
created by r2519314175:1345, 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 | /* breadcrumb.js — a short, durable trail of what the app just did. |
| 2 | * |
| 3 | * WHY THIS EXISTS. A bug was reported from an iPhone: unlock with a passkey, |
| 4 | * the app appears for about a second, and then the lock screen is back. It has |
| 5 | * been reported three times across three sessions and diagnosed twice, both |
| 6 | * times from reading code rather than from evidence, and both diagnoses were |
| 7 | * wrong. Nobody could see the console, because seeing a console on iOS needs a |
| 8 | * Mac attached, so every report was a description and every fix was a guess. |
| 9 | * |
| 10 | * So the app writes its own trail. Twenty lines, in localStorage, surviving the |
| 11 | * reload that would otherwise erase the reason for it, shown on the lock screen |
| 12 | * behind a link that is only there when there is something to say. |
| 13 | * |
| 14 | * WHAT IS NEVER WRITTEN. No passphrase, no key, no token, no message text, no |
| 15 | * file name, no address. This is a list of EVENTS -- "unlocked", "gateway said |
| 16 | * 426", "forced reload refused" -- with a clock and nothing else. It is meant |
| 17 | * to be pasted into a bug report by a person who can read every word of it |
| 18 | * first, which is only true if there is nothing in it to leak. |
| 19 | */ |
| 20 | (function () { |
| 21 | 'use strict'; |
| 22 | |
| 23 | var KEY = 'daimond-trail'; |
| 24 | // The build this tab last confirmed it was running. `build.json` is fetched, |
| 25 | // so its id cannot be known at script load -- but the PREVIOUS boot's answer |
| 26 | // can, and on a looping device that is almost always the same one. |
| 27 | var BKEY = 'daimond-build-seen'; |
| 28 | // 200, not 40. Once the boot itself is instrumented a single cycle is about |
| 29 | // twenty rows, and forty held less than two -- so the trail would drop the |
| 30 | // beginning of the loop, which is the part that says what started it. |
| 31 | var MAX = 200; |
| 32 | var t0 = Date.now(); |
| 33 | |
| 34 | function read() { |
| 35 | try { return JSON.parse(localStorage.getItem(KEY) || '[]') || []; } |
| 36 | catch (e) { return []; } |
| 37 | } |
| 38 | |
| 39 | /// fe2o3's `err!` colours its output, so an error crossing the wasm boundary |
| 40 | /// arrives wrapped in ANSI escapes. In a console they are colour; in a text |
| 41 | /// box they are `[91m[1m` in front of the only words that matter, and the |
| 42 | /// first trail from the phone was three-quarters escape codes. |
| 43 | function plain(s) { |
| 44 | return String(s) |
| 45 | .replace(/\u001b\[[0-9;]*m/g, '') |
| 46 | .replace(/\[[0-9]{1,2}(;[0-9]{1,2})*m/g, '') |
| 47 | .replace(/\s+/g, ' ') |
| 48 | .trim(); |
| 49 | } |
| 50 | |
| 51 | /// Add one event. `what` is a short fixed string from the app's own code -- |
| 52 | /// never anything a user or a model typed, so a trail cannot be made to |
| 53 | /// carry content by writing it into a chat. |
| 54 | function note(what, detail) { |
| 55 | if (!what) return; |
| 56 | try { |
| 57 | var rows = read(); |
| 58 | rows.push({ |
| 59 | // Wall clock, because the question is always "what happened |
| 60 | // between the unlock and the lock", and a monotonic counter |
| 61 | // resets on the reload that is the thing under suspicion. |
| 62 | t: Date.now(), |
| 63 | // How long this PAGE had been alive. A reload shows as a small |
| 64 | // number after a large one, which is what makes a reload loop |
| 65 | // legible in the trail at a glance. |
| 66 | a: Date.now() - t0, |
| 67 | w: plain(what).slice(0, 40), |
| 68 | // 200, not 60: the first trail from the phone cut every error off |
| 69 | // at `opfs.rs:477` -- the file and line, and none of the message |
| 70 | // that says WHICH path was missing, which is the whole question. |
| 71 | d: detail == null ? undefined : plain(detail).slice(0, 200), |
| 72 | }); |
| 73 | if (rows.length > MAX) rows = rows.slice(rows.length - MAX); |
| 74 | localStorage.setItem(KEY, JSON.stringify(rows)); |
| 75 | } catch (e) { /* quota, or storage refused: a trail is never worth an error */ } |
| 76 | } |
| 77 | |
| 78 | /// The trail as text, for a person to read and paste. |
| 79 | function text() { |
| 80 | var rows = read(); |
| 81 | if (!rows.length) return ''; |
| 82 | var out = []; |
| 83 | for (var i = 0; i < rows.length; i++) { |
| 84 | var r = rows[i]; |
| 85 | var when = new Date(r.t).toISOString().slice(11, 23); |
| 86 | out.push(when + ' +' + String(Math.round((r.a || 0) / 100) / 10) + 's ' |
| 87 | + r.w + (r.d ? ' ' + r.d : '')); |
| 88 | } |
| 89 | return out.join('\n'); |
| 90 | } |
| 91 | |
| 92 | function clear() { try { localStorage.removeItem(KEY); } catch (e) {} } |
| 93 | |
| 94 | /// The build id, once `build.json` has actually been read. Called by |
| 95 | /// updater.js, which is the only thing that knows it. |
| 96 | /// |
| 97 | /// WHY THIS EXISTS. A trail arrived from the phone that could have come from |
| 98 | /// either of two releases, and there was no way to tell which -- so the one |
| 99 | /// line it was taken to answer ("does the new marker appear?") could not be |
| 100 | /// read at all, and a release cycle was spent finding that out. A trail that |
| 101 | /// cannot be attributed to a build is not evidence. |
| 102 | function setBuild(id) { |
| 103 | if (!id) return; |
| 104 | var was = null; |
| 105 | try { was = localStorage.getItem(BKEY); } catch (e) {} |
| 106 | try { localStorage.setItem(BKEY, String(id)); } catch (e) {} |
| 107 | // The id EVERY time, not only when it changes: a loop that cycles through |
| 108 | // two builds and a loop stuck on one look identical otherwise. |
| 109 | note('build', String(id) + (was && was !== id ? ' (was ' + was + ')' : '')); |
| 110 | } |
| 111 | |
| 112 | /// The build the LAST boot confirmed. A guess, marked as one with `~`. |
| 113 | function lastBuild() { |
| 114 | try { return localStorage.getItem(BKEY) || ''; } catch (e) { return ''; } |
| 115 | } |
| 116 | |
| 117 | // The boot itself, which is the line that makes a loop visible: several |
| 118 | // "boot" rows a second or two apart is a reloading tab, and no amount of |
| 119 | // describing it over a chat says it as plainly. |
| 120 | // |
| 121 | // The build carried here is the one the PREVIOUS boot confirmed, marked `~` |
| 122 | // because this one has not read `build.json` yet and may be a newer release. |
| 123 | // A confirmed `build` row follows within a second; this is what a boot that |
| 124 | // dies before then still says. |
| 125 | note('boot', (document.visibilityState || '?') |
| 126 | + ' ' + (window.matchMedia && matchMedia('(display-mode: standalone)').matches ? 'standalone' : 'browser') |
| 127 | + (lastBuild() ? ' ~' + lastBuild() : '')); |
| 128 | |
| 129 | // An unhandled error or rejection is exactly what a phone cannot show, and |
| 130 | // exactly what would explain a screen that goes away again. |
| 131 | window.addEventListener('error', function (e) { |
| 132 | // WHERE, not just what. `"Script error."` with no detail is the browser |
| 133 | // refusing to describe an error it considers cross-origin, and it is all |
| 134 | // the phone's trail has said for four rounds. `filename` and `lineno` |
| 135 | // survive that redaction in most engines; when they do not, an empty |
| 136 | // filename is itself the answer -- it says the error came from a script |
| 137 | // the page cannot see into, which is a different hunt entirely. |
| 138 | var where = ''; |
| 139 | if (e && e.filename) { |
| 140 | where = String(e.filename).replace(/^https?:\/\/[^/]+/, '') + ':' + (e.lineno || 0); |
| 141 | } else if (e) { |
| 142 | where = '(no filename: cross-origin or wasm)'; |
| 143 | } |
| 144 | // The KIND of error, when the engine still hands over the object behind |
| 145 | // the redacted message. `"Script error."` says nothing; `RuntimeError` |
| 146 | // beside it says the throw came out of wasm, and a wasm trap and a |
| 147 | // scripting mistake need completely different fixes. This has been the |
| 148 | // only line in four trails and it has never named its own class. |
| 149 | var kind = ''; |
| 150 | try { |
| 151 | var err = e && e.error; |
| 152 | if (err) { |
| 153 | kind = ' [' + (err.name || 'Error') + (err.message ? ': ' + err.message : '') + ']'; |
| 154 | var frame = (err.stack || '').split('\n')[1]; |
| 155 | if (frame) kind += ' ' + frame.trim().slice(0, 60); |
| 156 | } |
| 157 | } catch (e2) { /* an error object that will not be read is still an error */ } |
| 158 | note('page error', ((e && e.message) || '?') + ' @ ' + where + kind); |
| 159 | }); |
| 160 | window.addEventListener('unhandledrejection', function (e) { |
| 161 | var r = e && e.reason; |
| 162 | note('unhandled rejection', (r && (r.message || r.toString ? r.toString() : r)) || '?'); |
| 163 | }); |
| 164 | // The page going away, and why it might be about to. |
| 165 | window.addEventListener('pagehide', function (e) { |
| 166 | note('pagehide', e && e.persisted ? 'into the back/forward cache' : 'unloading'); |
| 167 | }); |
| 168 | |
| 169 | window.DaimondTrail = { |
| 170 | note: note, |
| 171 | text: text, |
| 172 | clear: clear, |
| 173 | rows: read, |
| 174 | setBuild: setBuild, |
| 175 | }; |
| 176 | })(); |