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