Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/pairing.js

43.3 KiB, 7 runs

created by r2519314175:1405, 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/* ============================================================
2 Daimond — device pairing (pairing.js)
3 ------------------------------------------------------------
4 Carry an identity to a second device so it becomes the SAME
5 account and can decrypt that account's sync blobs.
6
7 The logged-in device exports its identity bundle (salt + public
8 key + the passphrase-WRAPPED private key — no passphrase, no
9 derived key; see DaimondIdentity.exportBundle) and parks it on the
10 gateway under a short one-time code. The new device redeems the
11 code, imports the bundle, and unlocks with the same passphrase.
12
13 The gateway only ever holds passphrase-encrypted material for a
14 few minutes, keyed by a code that is single-use and short-lived —
15 the same opaque-parcel posture as the sync mailbox.
16
17 HOW IT LOOKS TRAVELS ONCE. A linked device should not have to be
18 dressed twice, so the link carries a snapshot of the parent's
19 presentation — theme, skin, language, display currency, reading size
20 and the whole panel layout — and the child writes it before it next
21 paints. It is a HANDOVER, not a synced setting: the screen
22 configuration is a fact about a screen, and a phone is not a 27-inch
23 monitor. That the snapshot applies exactly once falls out of where it
24 lives: it exists only inside one redeemed pairing parcel, which is
25 consumed in the act of redeeming it, so there is nothing left for a
26 later sync to re-impose.
27
28 AND A CODE IS NOT THE ONLY WAY IN. A device brought across by a
29 passkey, or one that holds the identity and is unlocked by typing the
30 passphrase, redeems no bundle and so got none of the above — it came
31 up in English on the default palette however long its owner had been
32 using another language. So five of those six now also ride the SYNC
33 PARCEL, which is the one channel every route ends at, and the same
34 "once" rule governs them: only a device that has never had a look of
35 its own puts one on. The layout is the one that does not travel that
36 way. See "The account's look" below, and dev/verify_look.mjs.
37
38 The snapshot rides INSIDE the bundle string. The gateway's pair
39 handler reads exactly one field out of the body -- `bundle`, as a
40 string -- and parks that; a sibling field would be dropped on the
41 floor without a word (see gateway/src/handlers/pair.rs). Nesting is
42 therefore the form that needs no gateway change at all, and
43 DaimondIdentity.importBundle ignores fields it does not know, so the
44 extra one costs the identity path nothing. The gateway does cap a
45 parked bundle at 8 KiB, so the snapshot is budgeted below that and
46 sheds the layout — much the largest part — rather than fail a link.
47
48 This module builds its own small dialogs so it needs no markup of
49 its own beyond one <script> tag; styles are injected once.
50 ============================================================ */
51(function () {
52 'use strict';
53
54 // The API version rides on DaimondGateway.clientApi() -- the ONE copy -- never a
55 // local constant: a private `var CLIENT_API = 1` here silently missed the v2
56 // floor bump and 426'd every pair/redeem, which is how device linking broke.
57
58 /// Where a name typed on THIS device while linking it waits for the device
59 /// roster to exist.
60 ///
61 /// Naming it here is the moment the user actually knows which device this is
62 /// -- they are holding it -- but the roster has no line for it yet: the line is
63 /// minted on the first collect, which happens after the reload below. So the
64 /// name is parked in storage and the mint consumes it (daimond.js
65 /// pendingDeviceLabel). It is the user's own words, so it goes nowhere near
66 /// the gateway: nothing reads this key but this browser.
67 var PAIR_LABEL_KEY = 'daimond-pair-label';
68 /// The roster's own ceiling on a name, kept in step (DEVICE_NAME_MAX).
69 var PAIR_LABEL_MAX = 64;
70
71 /// Park the name chosen while linking, or clear it when nothing was typed.
72 function stashName(name) {
73 var v = String(name == null ? '' : name).trim().slice(0, PAIR_LABEL_MAX);
74 try {
75 if (v) localStorage.setItem(PAIR_LABEL_KEY, v);
76 else localStorage.removeItem(PAIR_LABEL_KEY);
77 } catch (e) { /* private mode: the device keeps its own description */ }
78 return v;
79 }
80
81 /// How many presentation keys the last redeem brought over, so the dialog can
82 /// mention it. A user whose new device suddenly speaks German is owed a
83 /// sentence saying why.
84 var lastLookApplied = 0;
85
86 // ── How this device looks, carried to the next one ─────────
87 // A whitelist, in both directions. On the way out it is what "how it looks"
88 // means, written down; on the way in it is the only thing a redeemed parcel
89 // may write, so a bundle cannot reach into storage it has no business in.
90 //
91 // Every one of these is read at boot -- the theme, skin and language before
92 // first paint by the inline script in index.html, the reading size and the
93 // layout by their own modules as they load -- so writing them and letting the
94 // page start is the whole of applying them HERE, where a reload is happening
95 // anyway. Nothing may poke the live DOM instead: that would drift from what a
96 // boot produces. The parcel path below, which has no reload to ride on,
97 // applies its five through each setting's own public service, which is the
98 // same call the appearance menu makes and writes the same key this reads.
99 var LOOK_KEYS = [
100 'daimond-theme', // dark | light | lollypop
101 'daimond-skin', // sharp | warm
102 'daimond-locale', // the interface language
103 'daimond-currency', // the display currency (billing is unaffected)
104 'daimond-fs-scale', // the reading size (workspace.js)
105 'daimond-layout', // the dock's tiling, open and pinned panels, widths, splits
106 ];
107 /// One value's ceiling. The layout is the only large one and is a few hundred
108 /// bytes; anything past this is not a preference, it is a mistake.
109 var LOOK_VALUE_MAX = 4096;
110 /// What the whole parked bundle may weigh. The gateway refuses over 8 KiB
111 /// (MAX_BUNDLE_BYTES in pair.rs); this leaves room and is checked here so the
112 /// failure is a smaller snapshot rather than a link that will not form.
113 var PARK_BUDGET = 7 * 1024;
114
115 /// This device's presentation, as the keys that hold it. A key never set does
116 /// not travel, so the child keeps its own default rather than being told the
117 /// parent's absence of a choice.
118 function snapshotLook() {
119 var out = {};
120 for (var i = 0; i < LOOK_KEYS.length; i++) {
121 var k = LOOK_KEYS[i], v = null;
122 try { v = localStorage.getItem(k); }
123 catch (e) { v = null; } // private mode: nothing to carry
124 if (typeof v === 'string' && v !== '' && v.length <= LOOK_VALUE_MAX) out[k] = v;
125 }
126 return out;
127 }
128
129 /// Write a redeemed snapshot, once, before the page next starts.
130 ///
131 /// Only the whitelisted keys, only strings, and only on this one redeem. A
132 /// value the receiving device cannot store is skipped: arriving with a slightly
133 /// different look is a far better outcome than a link that fails on quota.
134 function applyLook(look) {
135 if (!look || typeof look !== 'object') return 0;
136 var n = 0;
137 for (var i = 0; i < LOOK_KEYS.length; i++) {
138 var k = LOOK_KEYS[i], v = look[k];
139 if (typeof v !== 'string' || v === '' || v.length > LOOK_VALUE_MAX) continue;
140 try { localStorage.setItem(k, v); n++; }
141 catch (e) { /* quota or private mode: this one stays as it was */ }
142 }
143 return n;
144 }
145
146 // ── The account's look, for every OTHER way a device arrives ───
147 //
148 // The handover above travels inside one pairing bundle, so it reaches exactly
149 // the devices that were linked by a code. A device brought across by a
150 // passkey (`DaimondPasskey.adoptWithPasskey`, which the catalogue advertises
151 // as bringing the account over "without a pairing code or a passphrase"), or
152 // one that simply holds the identity and is unlocked with the passphrase, got
153 // none of it: it came up in English, on the default palette, with the default
154 // reading size, on an account whose owner had chosen otherwise years ago.
155 //
156 // WHY THE PARCEL. The mailbox is the only channel every route ends at. A
157 // passkey adopt has no session until after it has unlocked; a passphrase
158 // sign-in tells the gateway nothing about presentation; only sync reaches all
159 // of them, and it is already sealed under the account's own key, which a
160 // preference the gateway can read would not be.
161 //
162 // WHY IT CANNOT RESTAMP. The parcel must be a fixed point -- applying one and
163 // collecting must give the same bytes back -- or two devices push at each
164 // other for ever, which is how a freshly paired iPhone behaved. So the record
165 // here is written by the user CHANGING something on this device, and never by
166 // receiving somebody else's:
167 //
168 // * `lookAdopt` takes the strictly-later record and stores it VERBATIM;
169 // * `lookRecord` returns what is stored unless this device's own values
170 // have moved away from what it last agreed to (`LOOK_BASE`), which only
171 // a person can do.
172 //
173 // A device that adopts a look it is not going to wear therefore reports that
174 // same record back unchanged, and the two devices agree in one round rather
175 // than arguing. See dev/verify_look.mjs, which drives exactly that.
176 //
177 // AND IT IS WORN ONCE. Which theme a screen is on is a decision about that
178 // screen, so a look is applied only by a device that has never had one --
179 // "view settings copy across on first login" and not one moment after.
180
181 /// The account's look: `{ t: <stamp>, v: { key: value } }`, held verbatim.
182 var LOOK_REC = 'daimond-look';
183 /// The values this device has AGREED to -- what it last published, or what it
184 /// was dressed in when it arrived. Its ABSENCE is the whole signal: a device
185 /// with no base has no look of its own, and is therefore one to dress.
186 var LOOK_BASE = 'daimond-look-base';
187
188 /// The keys that are facts about the PERSON rather than about the screen.
189 ///
190 /// Five of the six that pairing carries. The layout is deliberately not among
191 /// them: it is widths, splits and which panels are pinned, every one of them
192 /// measured against the window they were arranged in, and a 27-inch desk's
193 /// arrangement imposed on a phone is worse than no arrangement at all. The
194 /// handover still carries it, because there the user is holding both devices
195 /// and has just asked for this one to be like that one; a parcel arriving
196 /// days later at a device nobody is looking at has no such warrant.
197 ///
198 /// The other five all travel with the person. A language and a currency are
199 /// not properties of a screen at all; a palette and a skin are taste; and a
200 /// reading size, though it is partly about the screen, is mostly about the
201 /// eyes -- somebody who has turned the type up has done so because of how
202 /// they read, and needs it on the other machine too. All five are a starting
203 /// point the device may then change, not a setting it is stuck with.
204 var LOOK_TRAVELS = [
205 'daimond-theme',
206 'daimond-skin',
207 'daimond-locale',
208 'daimond-currency',
209 'daimond-fs-scale',
210 ];
211
212 function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
213 function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* private mode */ } }
214 function readJSON(k) {
215 try { return JSON.parse(lsGet(k) || 'null'); } catch (e) { return null; }
216 }
217
218 /// This device's travelling values, as they stand. Unset keys are absent, so
219 /// "never chosen" stays distinguishable from "chosen and equal to the default".
220 function travelValues() {
221 var out = {};
222 for (var i = 0; i < LOOK_TRAVELS.length; i++) {
223 var k = LOOK_TRAVELS[i], v = lsGet(k);
224 if (typeof v === 'string' && v !== '' && v.length <= LOOK_VALUE_MAX) out[k] = v;
225 }
226 return out;
227 }
228
229 /// A set of values in one canonical form, so two of them can be compared and
230 /// ordered. Sorted keys: an object literal's order must never be what decides
231 /// whether this device thinks the look has changed.
232 function canon(v) {
233 var out = {}, keys = Object.keys(v || {}).sort();
234 for (var i = 0; i < keys.length; i++) out[keys[i]] = v[keys[i]];
235 return JSON.stringify(out);
236 }
237
238 /// Only the whitelisted keys, only strings, only within the ceiling. Applied
239 /// to what ARRIVES as well as to what leaves: a parcel is opened with this
240 /// account's own key, but a record is still storage a device will act on.
241 function cleanLook(v) {
242 var out = {};
243 if (!v || typeof v !== 'object') return out;
244 for (var i = 0; i < LOOK_TRAVELS.length; i++) {
245 var k = LOOK_TRAVELS[i], x = v[k];
246 if (typeof x === 'string' && x !== '' && x.length <= LOOK_VALUE_MAX) out[k] = x;
247 }
248 return out;
249 }
250
251 /// Whether `a` beats `b`, under a total order both devices compute alike.
252 ///
253 /// The stamp first, and the values as the tie-break -- two devices whose
254 /// clocks agree to the millisecond would otherwise each refuse the other's
255 /// record for ever and never converge, which is the fault the handle work
256 /// found by having two renames land inside one second.
257 function beats(a, b) {
258 if (!b) return true;
259 if (a.t !== b.t) return a.t > b.t;
260 return canon(a.v) > canon(b.v);
261 }
262
263 /// Put a look ON this device, through the services that own each setting.
264 ///
265 /// NOT a reload, which is what the handover above can afford because it is
266 /// already on its way back to the unlock screen: an unlocked identity does not
267 /// survive a reload, so a device dressed by the parcel would be asked for its
268 /// passphrase again seconds after signing in. And NOT the DOM, which would
269 /// drift from what a boot produces -- each of these has a public setter, which
270 /// writes the same key the boot reads, so what arrives here is what the
271 /// appearance menu itself would have produced. A setting whose service is
272 /// missing is written to storage instead, and the next boot picks it up.
273 ///
274 /// Awaited, because the locale's setter fetches its table before it writes:
275 /// the caller records what this device now holds, and must not read that
276 /// before the last of it has landed.
277 async function dress(v) {
278 var apply = function (key, name, fn, val) {
279 try {
280 var svc = window[name];
281 if (svc && typeof svc[fn] === 'function') return svc[fn](val);
282 } catch (e) { /* the service refused; fall through to the key */ }
283 lsSet(key, String(val));
284 return null;
285 };
286 if (v['daimond-theme']) apply('daimond-theme', 'DaimondTheme', 'set', v['daimond-theme']);
287 if (v['daimond-skin']) apply('daimond-skin', 'DaimondSkin', 'set', v['daimond-skin']);
288 if (v['daimond-currency']) apply('daimond-currency', 'DaimondI18n', 'setCurrency', v['daimond-currency']);
289 // A number, not the string it is stored as: `setScale` refuses anything
290 // that is not one of its steps, and a string never is one.
291 if (v['daimond-fs-scale']) apply('daimond-fs-scale', 'DaimondWorkspace', 'setScale', parseFloat(v['daimond-fs-scale']));
292 // Last, because it repaints every marked node in the app.
293 if (v['daimond-locale']) {
294 var p = apply('daimond-locale', 'DaimondI18n', 'setLocale', v['daimond-locale']);
295 if (p && typeof p.then === 'function') { try { await p; } catch (e) { /* stays as it was */ } }
296 }
297 }
298
299 /// What the parcel carries. `null` when this device has nothing to say.
300 ///
301 /// `mayPublish` is the caller's word that this device has heard from the
302 /// mailbox at least once. A device that published before it had listened
303 /// would put its factory defaults over the account's real look with a fresh
304 /// stamp, and the next device to arrive would inherit those -- the same rule
305 /// that stops a chunk index being declared from a device that did not merge
306 /// one.
307 ///
308 /// `known` is the caller's word that this device had already synced this
309 /// account when the page loaded. See `lookAdopt` for why that, of all things,
310 /// is what says whether a device has a look of its own.
311 function lookRecord(mayPublish, known) {
312 var rec = readJSON(LOOK_REC);
313 var base = readJSON(LOOK_BASE);
314 var now = travelValues();
315 if (rec && (typeof rec.t !== 'number' || !rec.v)) rec = null; // not ours to carry
316 // No base: this device has never agreed to anything. It is either waiting
317 // to be dressed, or it is the first device of an account nobody has
318 // published a look for -- and only the caller knows which, because only
319 // the caller knows whether the mailbox has been read.
320 if (!base) {
321 // A device that has synced this account before HAS a look: it has been
322 // wearing one all along, and a build shipping is no reason to put the
323 // account's look up for grabs. Its own values become its base,
324 // quietly, and it publishes when its user next changes something.
325 if (known) { lsSet(LOOK_BASE, JSON.stringify(now)); return rec; }
326 if (!mayPublish || !Object.keys(now).length) return rec;
327 lsSet(LOOK_REC, JSON.stringify({ t: stampAfter(rec), v: now }));
328 lsSet(LOOK_BASE, JSON.stringify(now));
329 return readJSON(LOOK_REC);
330 }
331 // The one thing that publishes: this device's own values have moved away
332 // from what it last agreed to, which nothing but a person does. Receiving
333 // another device's record never comes through here, so applying a parcel
334 // cannot change what this device would send -- the fixed point.
335 if (canon(now) !== canon(base)) {
336 lsSet(LOOK_REC, JSON.stringify({ t: stampAfter(rec), v: now }));
337 lsSet(LOOK_BASE, JSON.stringify(now));
338 return readJSON(LOOK_REC);
339 }
340 return rec;
341 }
342
343 /// A stamp that always advances past the record being replaced, so two
344 /// changes inside one millisecond are still two changes.
345 function stampAfter(rec) {
346 var prev = (rec && typeof rec.t === 'number') ? rec.t : 0;
347 return Math.max(Date.now(), prev + 1);
348 }
349
350 /// Take a look out of a parcel: hold the later record, and WEAR it if this
351 /// device has never worn one.
352 ///
353 /// Stored verbatim, never restamped, and only ever replaced by a record that
354 /// strictly beats the one held. Applying this device's own parcel therefore
355 /// writes nothing at all.
356 ///
357 /// `known` says this device had already synced this account when the page
358 /// loaded, and it is the ONLY honest evidence that a device has a look of its
359 /// own. Not "some of these keys are set": daimond.js writes a default theme
360 /// and a default skin into two of them on every boot, so a phone that has done
361 /// nothing but show the sign-in screen already looks like one that chose. A
362 /// stored sync cursor cannot be produced by anything but this device having
363 /// read this account's mailbox before, which is exactly the thing a device
364 /// being dressed has never done.
365 ///
366 /// Resolves to what was done, for the verifier: `{held, dressed}`.
367 async function lookAdopt(rec, known) {
368 var out = { held: false, dressed: false };
369 if (!rec || typeof rec !== 'object' || typeof rec.t !== 'number' || rec.t <= 0) return out;
370 var clean = { t: rec.t, v: cleanLook(rec.v) };
371 if (!Object.keys(clean.v).length) return out;
372 var held = readJSON(LOOK_REC);
373 if (held && (typeof held.t !== 'number' || !held.v)) held = null;
374 if (!beats(clean, held)) return out;
375 lsSet(LOOK_REC, JSON.stringify(clean));
376 out.held = true;
377 // And now the once-only half. A device with a look of its own -- it chose
378 // one, or it was dressed when it arrived, or it has simply been syncing
379 // this account for a year -- records the account's news without wearing
380 // it. A phone and a desk are allowed to disagree about a palette; what
381 // they may not do is disagree about whose account this is.
382 if (readJSON(LOOK_BASE) || known) return out;
383 await dress(clean.v);
384 // Read BACK, rather than trusting what was asked for: a service may
385 // normalise what it was given, and a base that disagreed with storage
386 // would make the very next collect think the user had just changed
387 // something, and republish.
388 lsSet(LOOK_BASE, JSON.stringify(travelValues()));
389 out.dressed = true;
390 return out;
391 }
392
393 /// Whether this device has recorded a look of its own. The caller adds what it
394 /// knows about the sync cursor; see `lookAdopt`.
395 function lookDressed() { return !!readJSON(LOOK_BASE); }
396
397 // ── Transport ──────────────────────────────────────────────
398 //
399 // `create()` goes through `DaimondGateway.gwFetch`, which meets a 401 by
400 // renewing the session once and asking once more. The gateway's session lives
401 // an hour and only an unlock ever minted one, so an hour into a sitting
402 // `POST /api/pair` came back 401 and the dialog told the user to sign in on a
403 // device they were already signed in on -- with no control anywhere in the
404 // app that would do it.
405 //
406 // Safe to repeat, and this is why: `create_impl` in gateway/src/handlers/
407 // pair.rs checks the session BEFORE it parses the body, so a 401 leaves no
408 // parked bundle and mints no code. A retry cannot leave a second code
409 // standing.
410 //
411 // ONLY `create()`. `redeem()` must not -- see the note there. This file used
412 // to carry its own copy of the retry rule, one of five identical copies; the
413 // rule lives in gateway.js now, beside the renewal it drives.
414
415 /// Create a pairing: export this device's identity and park it. Returns
416 /// { code, expires_in }. Throws with a readable message on any failure.
417 async function create() {
418 if (!window.DaimondIdentity || !DaimondIdentity.exists()) {
419 throw new Error(t('pair.err_no_identity'));
420 }
421 var bundle = DaimondIdentity.exportBundle();
422 if (!bundle) throw new Error(t('pair.err_unreadable_local'));
423 bundle.look = snapshotLook();
424 var parked = JSON.stringify(bundle);
425 // Shed the layout first, then the snapshot entirely. The identity is the
426 // thing being carried and nothing about how the app looks may put it at
427 // risk of not fitting.
428 if (parked.length > PARK_BUDGET && bundle.look['daimond-layout']) {
429 delete bundle.look['daimond-layout'];
430 parked = JSON.stringify(bundle);
431 }
432 if (parked.length > PARK_BUDGET) {
433 delete bundle.look;
434 parked = JSON.stringify(bundle);
435 }
436 var r = await DaimondGateway.gwFetch('/api/pair', {
437 method: 'POST', credentials: 'same-origin',
438 headers: { 'content-type': 'application/json', 'x-daimond-api': String(DaimondGateway.clientApi()) },
439 body: JSON.stringify({ bundle: parked }),
440 });
441 var j = null; try { j = await r.json(); } catch (e) {}
442 if (r.status === 401) throw new Error(t('pair.err_sign_in_first'));
443 if (!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ('HTTP ' + r.status));
444 return { code: j.code, expiresIn: j.expires_in || 600 };
445 }
446
447 /// Redeem a code on a NEW device: fetch the bundle and import it, so this
448 /// device now holds the same (still-locked) identity. Returns true on
449 /// success. The caller then prompts for the passphrase to unlock.
450 ///
451 /// The parent's presentation is written here, after the identity and before
452 /// anything repaints, because the reload the dialog already does on the way to
453 /// the unlock screen is what applies it. Nothing else in the app writes these
454 /// keys from a bundle, so this is the one and only moment they arrive: from
455 /// here on the device's look is its own to change.
456 ///
457 /// DELIBERATELY NOT through `gwFetch`, on three counts. `redeem_impl` takes no
458 /// session at all -- the redeeming device has none, which is the whole point --
459 /// so a 401 here could not be a session that lapsed and re-authenticating
460 /// could not change the answer. There is nothing to re-authenticate WITH: this
461 /// device's identity arrives in the reply, so `reauth()` would find nothing
462 /// unlocked, return false, and leave `state.authed` stamped false on a device
463 /// whose gateway account is not yet a thing that exists. And a code is
464 /// single-use: it is consumed in the act of redeeming it, so a blanket retry
465 /// on any refusal is exactly the retry that must not exist here.
466 async function redeem(code) {
467 code = String(code || '').trim();
468 if (!code) throw new Error(t('pair.err_enter_code'));
469 var r = await fetch('/api/pair/redeem', {
470 method: 'POST', credentials: 'same-origin',
471 headers: { 'content-type': 'application/json', 'x-daimond-api': String(DaimondGateway.clientApi()) },
472 body: JSON.stringify({ code: code }),
473 });
474 var j = null; try { j = await r.json(); } catch (e) {}
475 if (r.status === 404) throw new Error(t('pair.err_bad_code'));
476 if (!r.ok || !j || j.ok === false || !j.bundle) throw new Error((j && j.error) || ('HTTP ' + r.status));
477 var bundle;
478 try { bundle = JSON.parse(j.bundle); } catch (e) { throw new Error(t('pair.err_bundle_unreadable')); }
479 if (!DaimondIdentity.importBundle(bundle)) throw new Error(t('pair.err_bundle_import'));
480 lastLookApplied = applyLook(bundle.look);
481 return true;
482 }
483
484 // ── Minimal UI ─────────────────────────────────────────────
485
486 function injectStyles() {
487 if (document.getElementById('pairing-styles')) return;
488 var s = document.createElement('style');
489 s.id = 'pairing-styles';
490 s.textContent =
491 '.pair-scrim{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;' +
492 'align-items:center;justify-content:center;z-index:9999;padding:16px}' +
493 '.pair-box{background:var(--bg-secondary,#1b1b1f);color:var(--text-primary,#eee);' +
494 'border:1px solid var(--border,#333);border-radius:12px;max-width:380px;width:100%;' +
495 'padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.5)}' +
496 '.pair-box h3{margin:0 0 8px;font-size:var(--fs-xl)}' +
497 '.pair-box p{margin:0 0 12px;font-size:var(--fs-base);line-height:1.4;opacity:.85}' +
498 '.pair-code{font-family:ui-monospace,monospace;font-size:var(--fs-5xl);letter-spacing:.15em;' +
499 'text-align:center;padding:12px;border:1px dashed var(--border,#444);border-radius:8px;' +
500 'margin:0 0 12px;user-select:all}' +
501 '.pair-input{width:100%;box-sizing:border-box;font-family:ui-monospace,monospace;' +
502 'font-size:var(--fs-3xl);letter-spacing:.1em;text-align:center;padding:10px;border-radius:8px;' +
503 'border:1px solid var(--border,#444);background:var(--bg-primary,#111);color:inherit;margin:0 0 12px}' +
504 // The name for this device: prose, not a code, so it is a plain field
505 // at reading size rather than the big spaced-out one above it.
506 '.pair-label{display:block;font-size:var(--fs-sm);opacity:.85;margin:0 0 4px}' +
507 '.pair-name{width:100%;box-sizing:border-box;font-size:var(--fs-base);padding:9px 10px;' +
508 'border-radius:8px;border:1px solid var(--border,#444);background:var(--bg-primary,#111);' +
509 'color:inherit;margin:0 0 12px}' +
510 '.pair-row{display:flex;gap:8px;justify-content:flex-end}' +
511 '.pair-btn{padding:8px 14px;border-radius:8px;border:1px solid var(--border,#444);' +
512 'background:var(--accent,#4a7);color:#fff;cursor:pointer;font-size:var(--fs-base)}' +
513 '.pair-btn.ghost{background:transparent;color:inherit}' +
514 // --danger, not a literal: #e66 was chosen against a dark card and reads
515 // at about 3:1 on the light and lollypop ones, which is under the floor
516 // for the one line on this dialog that says something went wrong.
517 '.pair-err{color:var(--danger);font-size:var(--fs-sm);min-height:1.1em;margin:0 0 8px}' +
518 '.pair-note{font-size:var(--fs-xs);opacity:.7;margin:8px 0 0}' +
519 '.pair-qr{display:block;margin:0 auto 12px;width:220px;height:220px;max-width:80%;' +
520 'image-rendering:pixelated;border-radius:8px;background:#fff;padding:8px;box-sizing:border-box}';
521 document.head.appendChild(s);
522 }
523
524 /// Draw a pairing URL as a QR onto a crisp canvas, using the wasm encoder.
525 ///
526 /// Returns the canvas, or null when the text could not be encoded -- the
527 /// caller then shows the typed code alone. The symbol is always dark-on-white
528 /// with the standard 4-module quiet zone, whatever the theme, because a camera
529 /// needs that contrast to read it.
530 function qrCanvas(text) {
531 var QR = window.DaimondQR;
532 if (!QR || !QR.matrix) return null;
533 var cells = QR.matrix(text);
534 if (!cells || !cells.length) return null;
535 var n = Math.round(Math.sqrt(cells.length));
536 if (n * n !== cells.length || n < 21) return null;
537 var quiet = 4; // the standard quiet zone, in modules
538 var dim = n + quiet * 2;
539 var scale = 6; // device pixels per module, for a crisp image
540 var size = dim * scale;
541 var c = el('canvas', 'pair-qr');
542 c.width = size;
543 c.height = size;
544 var ctx = c.getContext('2d');
545 if (!ctx) return null;
546 ctx.fillStyle = '#ffffff';
547 ctx.fillRect(0, 0, size, size);
548 ctx.fillStyle = '#000000';
549 for (var y = 0; y < n; y++) {
550 for (var x = 0; x < n; x++) {
551 if (cells[y * n + x]) {
552 ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
553 }
554 }
555 }
556 return c;
557 }
558
559 function overlay(build) {
560 injectStyles();
561 var scrim = document.createElement('div');
562 scrim.className = 'pair-scrim';
563 var box = document.createElement('div');
564 box.className = 'pair-box';
565 scrim.appendChild(box);
566 // Where the keyboard was before this went up, so it can be given back.
567 var prev = document.activeElement;
568 function close() {
569 document.removeEventListener('keydown', onKey, true);
570 try { document.body.removeChild(scrim); } catch (e) { /* already gone */ }
571 if (prev && prev.focus && prev.getClientRects && prev.getClientRects().length) {
572 try { prev.focus(); } catch (e) { /* gone with the redraw */ }
573 }
574 }
575 /// The controls in here that can actually take focus.
576 function stops() {
577 return [].filter.call(box.querySelectorAll('button,input,a[href],[tabindex]:not([tabindex="-1"])'),
578 function (n) { return !n.disabled && n.getClientRects().length; });
579 }
580 // Escape, and a Tab that stays put. This dialog answered only to the scrim
581 // and to Done: a keyboard user had no way to put it down, and Tab walked
582 // straight past it into an app they could not see behind the scrim.
583 function onKey(e) {
584 if (e.key === 'Escape') { e.preventDefault(); close(); return; }
585 if (e.key !== 'Tab') return;
586 var f = stops();
587 if (!f.length) return;
588 var first = f[0], last = f[f.length - 1];
589 if (!box.contains(document.activeElement)) { e.preventDefault(); first.focus(); return; }
590 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
591 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
592 }
593 document.addEventListener('keydown', onKey, true);
594 scrim.addEventListener('click', function (e) { if (e.target === scrim) close(); });
595 build(box, close);
596 // The way out, in the corner. The only pointer dismissal this dialog
597 // offered was a "Done" 64x37 at its foot -- under the thumb's floor, and
598 // at the wrong end of a card that on a phone is 358px of a 390px screen.
599 // The heading is lifted into the closer's row rather than a second title
600 // being invented, so the card still names itself once.
601 if (window.DaimondCloser) {
602 var h3 = box.querySelector('h3');
603 var row = h3
604 ? DaimondCloser.head(h3.textContent || '', { titleEl: h3, onClose: close })
605 : DaimondCloser.head('', { name: t('common.close'), onClose: close });
606 box.insertBefore(row, box.firstChild);
607 }
608 document.body.appendChild(scrim);
609 // Once it is in the document and can be focused: the first control in it,
610 // so the keyboard starts inside the thing covering the screen. Not the
611 // closer, which is first in the document now -- landing on it would offer
612 // the way out before the code the dialog exists to show.
613 var f0 = stops().filter(function (n) { return !n.classList.contains('ui-close'); })[0]
614 || stops()[0];
615 if (f0) { try { f0.focus(); } catch (e) { /* not focusable */ } }
616 return close;
617 }
618
619 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
620
621 function el(tag, cls, text) {
622 var e = document.createElement(tag);
623 if (cls) e.className = cls;
624 if (text != null) e.textContent = text;
625 return e;
626 }
627
628 /// Device A: create a pairing and show the code to carry to the other device.
629 function showLink() {
630 overlay(function (box, close) {
631 box.appendChild(el('h3', null, t('pair.link_another')));
632 var p = el('p', null, t('pair.making_code'));
633 box.appendChild(p);
634 var err = el('div', 'pair-err');
635 box.appendChild(err);
636 // No Done at the foot. This dialog shows a code and decides nothing, so
637 // its way out is the cross in the corner -- and the old button was a
638 // 64x37 ghost at the bottom right, below the thumb's floor and at the
639 // far end of a card that fills a phone.
640 create().then(function (res) {
641 // The friction-free path: a QR of the pairing URL that the other
642 // phone's own camera opens. Falls back to the typed code below it
643 // wherever a QR cannot be shown or scanned.
644 var url = location.origin + '/#pair=' + encodeURIComponent(res.code);
645 var qr = qrCanvas(url);
646 if (qr) {
647 p.textContent = t('pair.scan_lead');
648 box.insertBefore(qr, err);
649 var or = el('p', 'pair-note', t('pair.no_camera'));
650 box.insertBefore(or, err);
651 } else {
652 p.textContent = t('pair.type_lead');
653 }
654 var code = el('div', 'pair-code', res.code);
655 box.insertBefore(code, err);
656 var mins = Math.round((res.expiresIn || 600) / 60);
657 var note = el('p', 'pair-note', t('pair.code_expiry', { mins: mins }));
658 box.insertBefore(note, err);
659 }).catch(function (e) {
660 p.textContent = '';
661 err.textContent = e.message || t('pair.err_create');
662 });
663 });
664 }
665
666 /// Device B: enter a code, import the identity, then hand off to unlock.
667 ///
668 /// `prefill` is the code carried in a `#pair=` deep link (from scanning the
669 /// QR), so a scan lands here with the field already filled and only the tap
670 /// to confirm left.
671 function showRedeem(prefill) {
672 var scanned = typeof prefill === 'string' && !!prefill;
673 overlay(function (box, close) {
674 box.appendChild(el('h3', null, t('pair.link_this')));
675 if (scanned) {
676 // Arrived by scanning the QR: the code is already filled in, so the
677 // only thing left is to tap the button. Say exactly that, and that
678 // the code is shown only so it can be checked against the other
679 // device -- otherwise a code and a button read as "type this
680 // somewhere", which is what confused people.
681 box.appendChild(el('p', null, t('pair.scanned_lead')));
682 } else {
683 box.appendChild(el('p', null, t('pair.manual_lead')));
684 }
685 var input = el('input', 'pair-input');
686 input.setAttribute('placeholder', t('pair.code_ph'));
687 input.setAttribute('autocapitalize', 'off');
688 input.setAttribute('autocomplete', 'off');
689 input.setAttribute('spellcheck', 'false');
690 if (scanned) { input.value = prefill; input.readOnly = true; }
691 box.appendChild(input);
692 if (scanned) {
693 box.appendChild(el('p', 'pair-note', t('pair.code_check')));
694 }
695 // What to call this device. Asked HERE because this is the moment the
696 // user knows the answer -- the device is in their hands -- and skippable
697 // because a device that is never named is still a device that syncs.
698 // The placeholder is what it will be called if nothing is typed, so the
699 // empty field is an honest preview rather than a blank.
700 var derived = '';
701 try { derived = (window.DaimondCore && DaimondCore.deviceSelfName && DaimondCore.deviceSelfName()) || ''; }
702 catch (e) { derived = ''; }
703 var lab = el('label', 'pair-label', t('pair.name_this'));
704 lab.setAttribute('for', 'pair-name-input');
705 box.appendChild(lab);
706 var nameIn = el('input', 'pair-name');
707 nameIn.id = 'pair-name-input';
708 nameIn.setAttribute('placeholder', derived || t('pair.name_ph'));
709 nameIn.setAttribute('maxlength', String(PAIR_LABEL_MAX));
710 nameIn.setAttribute('autocomplete', 'off');
711 box.appendChild(nameIn);
712 var err = el('div', 'pair-err');
713 box.appendChild(err);
714 var row = el('div', 'pair-row');
715 var cancel = el('button', 'pair-btn ghost', t('common.cancel'));
716 cancel.addEventListener('click', close);
717 var go = el('button', 'pair-btn', t('pair.link_this'));
718 row.appendChild(cancel);
719 row.appendChild(go);
720 box.appendChild(row);
721
722 function submit() {
723 err.textContent = '';
724 go.disabled = true;
725 redeem(input.value).then(function () {
726 // Park the name for this device before the reload, for the roster
727 // to take up when it first mints this device's line.
728 var named = stashName(nameIn.value);
729 // Name the account, and leave a note the unlock screen picks up
730 // after the reload -- on a phone the passphrase box reappears
731 // with a different name on it, and it must be clear that the
732 // passphrase to type is the ONE FROM THE OTHER DEVICE, not a new
733 // one for this phone.
734 var who = '';
735 try { who = (window.DaimondIdentity && DaimondIdentity.displayName()) || ''; } catch (e) { /* none */ }
736 try { sessionStorage.setItem('daimond-just-linked', who || '1'); } catch (e) { /* private mode */ }
737 box.innerHTML = '';
738 box.appendChild(el('h3', null, t('pair.linked')));
739 box.appendChild(el('p', null, who
740 ? t('pair.linked_named', { name: who })
741 : t('pair.linked_note')));
742 if (named) box.appendChild(el('p', 'pair-note', t('pair.named_note', { name: named })));
743 if (lastLookApplied > 0) box.appendChild(el('p', 'pair-note', t('pair.look_carried')));
744 var r2 = el('div', 'pair-row');
745 var ok = el('button', 'pair-btn', t('identity.unlock'));
746 ok.addEventListener('click', function () { close(); location.reload(); });
747 r2.appendChild(ok);
748 box.appendChild(r2);
749 }).catch(function (e) {
750 go.disabled = false;
751 err.textContent = e.message || t('pair.err_link');
752 });
753 }
754 go.addEventListener('click', submit);
755 input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
756 nameIn.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
757 setTimeout(function () { try { input.focus(); } catch (e) {} }, 50);
758 });
759 }
760
761 // ── Entry points (injected, so no shared markup to edit) ────
762
763 function injectEntryPoints() {
764 // The injected buttons carry `.pair-btn` classes, so their styles must be
765 // present from the start, not only once a dialog first opens.
766 injectStyles();
767 // Device B: a way in from the locked identity screen.
768 var modal = document.getElementById('identity-modal');
769 if (modal && !document.getElementById('pair-redeem-entry')) {
770 var b = el('button', 'pair-btn ghost', t('pair.have_code'));
771 b.id = 'pair-redeem-entry';
772 b.type = 'button';
773 b.style.cssText = 'margin-top:12px;width:100%';
774 b.addEventListener('click', showRedeem);
775 // Place it inside the card, at the end. The identity modal's card is
776 // `.modal-card`; without it in this list the button fell back to the
777 // modal itself and became a second flex child, splitting the row and
778 // squeezing the card until its wordmark and inputs clipped on a phone.
779 var content = modal.querySelector('.modal-card, .modal-content, .id-content, form') || modal;
780 content.appendChild(b);
781 }
782 // Device A: a link button in the top actions, shown once there is a session.
783 var actions = document.getElementById('top-actions') || document.querySelector('.top-actions');
784 if (actions && !document.getElementById('pair-link-btn')) {
785 var l = el('button', 'icon-btn');
786 // A line icon matching the header's own (a phone, with a small arc to
787 // a second device), rather than an emoji that clashes with them.
788 l.innerHTML = '<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">'
789 + '<rect x="3" y="7" width="9" height="14" rx="1.6"/>'
790 + '<path d="M7.5 18h0"/>'
791 + '<path d="M15 4.2a6 6 0 015 5M15 8a2.4 2.4 0 012 2"/></svg>';
792 l.id = 'pair-link-btn';
793 l.type = 'button';
794 // Bound rather than set: a name written once at mount is fixed in
795 // whichever language the button happened to be built in, and stays
796 // there through every later `setLocale`. It is spoken text, so it is
797 // the kind nobody sees go wrong.
798 if (window.DaimondI18n && DaimondI18n.bind) {
799 DaimondI18n.bind(l, 'title', 'pair.link_another');
800 DaimondI18n.bind(l, 'aria-label', 'pair.link_another');
801 } else {
802 l.title = t('pair.link_another');
803 l.setAttribute('aria-label', l.title);
804 }
805 // HIDDEN, NOT ABSENT. It waits for a session and then appears, and while
806 // it appeared out of nothing it took 32px of the top bar with it and
807 // moved every chip and every icon left of it -- measured at 122px on a
808 // 1440px window. Its space is reserved from the first paint instead; see
809 // the rule in css/workspace.css.
810 l.style.visibility = 'hidden';
811 l.addEventListener('click', function () {
812 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) return;
813 showLink();
814 });
815 var guide = document.getElementById('guide-btn');
816 if (guide && guide.parentNode === actions) actions.insertBefore(l, guide);
817 else actions.appendChild(l);
818 }
819 // Reveal the link button once a session exists.
820 window.addEventListener('daimond:authed', function () {
821 var lb = document.getElementById('pair-link-btn');
822 if (lb) lb.style.visibility = '';
823 });
824 }
825
826 /// The pairing code carried in the URL, if this load came from a scanned QR
827 /// (`…/#pair=<code>`). Empty when there is none.
828 function pendingPairCode() {
829 var m = /[#&]pair=([^&]+)/.exec(location.hash || '');
830 return m ? decodeURIComponent(m[1]) : '';
831 }
832
833 /// Strip `pair=` from the URL so a reload does not reopen the dialog and the
834 /// one-time code does not linger in history.
835 function consumePairHash() {
836 try {
837 var h = (location.hash || '').replace(/[#&]?pair=[^&]*/, '');
838 if (h === '#') h = '';
839 history.replaceState({}, '', location.pathname + location.search + h);
840 } catch (e) {}
841 }
842
843 /// Open the redeem dialog for a `#pair=` code in the URL, once, code filled
844 /// in. Handles both arrival paths: a fresh load from a scanned QR, and a hash
845 /// change on a tab that was already open when the QR was scanned.
846 function maybeOpenFromHash() {
847 var code = pendingPairCode();
848 if (!code) return;
849 if (document.querySelector('.pair-scrim')) return; // a dialog is already up
850 consumePairHash();
851 showRedeem(code);
852 }
853
854 function start() {
855 injectEntryPoints();
856 maybeOpenFromHash(); // arrived via a fresh load
857 window.addEventListener('hashchange', maybeOpenFromHash); // or an already-open tab
858 }
859
860 // ── Public surface ─────────────────────────────────────────
861 // `stashName` is published because the naming and the roster are two modules:
862 // this one takes the name, daimond.js consumes it when the device's line is
863 // minted, and the seam between them is worth being able to exercise.
864 //
865 // `look` is the half sync.js drives: `record()` is what the parcel carries,
866 // `adopt()` is what a parcel brings, and `dressed()` says whether this device
867 // already has a look of its own -- the fact the once-only rule turns on.
868 //
869 // `ui` is the dialog frame and the QR drawer, published because trust.js
870 // needs both and neither should exist twice. The frame is a scrim, a card, a
871 // focus trap and an Escape key, and a second copy of it in another file is a
872 // second copy that will forget the Tab handling -- which is what this one had
873 // to be taught. THE QR DRAWER ESPECIALLY: it is the one caller of the wasm
874 // encoder, dark on white whatever the theme because a camera needs that
875 // contrast, and a rival drawer in a theme-aware colour would produce symbols
876 // that look right and do not scan. Neither is really about PAIRING, and both
877 // belong in a small shared surface of the app's own; they are here because
878 // this is where they were first needed.
879 window.DaimondPairing = { create: create, redeem: redeem, showLink: showLink, showRedeem: showRedeem,
880 stashName: stashName,
881 ui: {
882 overlay: overlay,
883 qrCanvas: qrCanvas,
884 injectStyles: injectStyles,
885 },
886 look: {
887 record: lookRecord,
888 adopt: lookAdopt,
889 dressed: lookDressed,
890 travels: function () { return LOOK_TRAVELS.slice(); },
891 } };
892
893 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
894 else start();
895})();