Oregami
Repositories/oxedyne/daimond

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