oxedyne/daimond/www/js/sync.js
108 KiB, 24 runs
created by r2519314175:1445, 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 — cross-device sync (sync.js) |
| 3 | ------------------------------------------------------------ |
| 4 | Carries a user's work from one device to the next through the |
| 5 | gateway's opaque, end-to-end-encrypted mailbox (/api/sync). |
| 6 | |
| 7 | The gateway never sees the content. This module seals the state |
| 8 | with DaimondIdentity.wrap() — AES-GCM under the passphrase-derived |
| 9 | key — before it leaves the browser, and opens it with unwrap() |
| 10 | after it arrives. What the server stores is ciphertext it holds no |
| 11 | key for; it is a parcel office, not a filing cabinet. |
| 12 | |
| 13 | Two devices sharing one account share one salt (the identity |
| 14 | travels whole, salt included — see DaimondIdentity.exportBundle), |
| 15 | so both derive the same wrapping key and each can open the other's |
| 16 | blob. A device holding a different identity is a different account |
| 17 | with a different mailbox and never sees this one's parcels. |
| 18 | |
| 19 | CONCURRENCY. The gateway stores one blob at a monotonic version and |
| 20 | accepts a push only if it names the version it was based on |
| 21 | (compare-and-set). A stale push comes back 409 with the current |
| 22 | blob; this module pulls it, MERGES — union the transcripts, freshest |
| 23 | scalar wins, tombstones honoured, exactly as the cross-tab path does |
| 24 | — and retries. So two devices editing at once converge rather than |
| 25 | clobber. Two rules keep that honest: a merge that did not finish is |
| 26 | never pushed over (the retry would replace the other device's version |
| 27 | with one that never took its work), and running out of retries is |
| 28 | reported rather than logged. |
| 29 | |
| 30 | A push never runs over a live turn (that state is still settling) |
| 31 | and only fires when the app is idle, mirroring updater.js. A PULL |
| 32 | also fires when the window is focused, throttled: a device left open |
| 33 | on a desk otherwise never learned about the other one's work until |
| 34 | somebody reloaded it, and coming back to a window is exactly when its |
| 35 | owner expects to see what happened elsewhere. |
| 36 | |
| 37 | AND A PUSH WITH NOTHING TO SEND PULLS INSTEAD. Two windows on two |
| 38 | machines, both open and both focused, raise no focus event between |
| 39 | them and end no turns; the only trigger still running on the device |
| 40 | nobody is typing at is the push, and a push whose parcel matches the |
| 41 | last one used to return without asking the gateway anything. So the |
| 42 | device being worked on sent its work and the device being read never |
| 43 | looked, indefinitely. That skip is now a throttled pull. |
| 44 | |
| 45 | WHEN IT CANNOT WORK, IT SAYS SO. Three refusals are permanent until |
| 46 | something changes -- 402 (the tier is not held), 413 (the parcel is |
| 47 | over the gateway's ceiling) and a 401 that a fresh session could not |
| 48 | clear -- and all three are reported on the status chip and nowhere |
| 49 | else: state on the chip, reason on hover, never a dialog over the app, |
| 50 | since nobody asked for the round that failed. The 413 used to log to |
| 51 | the console alone, so sync stopped and the app went on looking exactly |
| 52 | as it does when sync is working. |
| 53 | |
| 54 | THE 401 WAS THE ONE THIS LIST NEVER ENUMERATED. The gateway's session |
| 55 | lives an hour and nothing renewed it, so every request after that was |
| 56 | refused: the pull called restStatus() and HID the chip, the push fell |
| 57 | past the 402/413 arms into one console line, and the wake channel |
| 58 | reconnected on a backoff for ever. A real account spent four hours and |
| 59 | fifty minutes that way, seven pushes of the user's work discarded with |
| 60 | the app positively claiming to be connected. A 401 now takes a fresh |
| 61 | session and sends the request again (see call()), and only says so |
| 62 | when that could not be done. |
| 63 | |
| 64 | A jam is the last thing the chip says, and it is the same rule |
| 65 | applied to the reconcile: retries that ran out, or a parcel that |
| 66 | arrived and could not be merged, both leave this device's work |
| 67 | sitting here, and both used to leave "Synced" on the chip -- put |
| 68 | there by the pull that was only ever half of the round. |
| 69 | |
| 70 | THE PARCEL CARRIES THE PAUSE TREE. Which Diamonds, mailboxes and |
| 71 | folders may spend is a fact about the ACCOUNT, not about the |
| 72 | browser it was set in: a device paused on one desk that spends |
| 73 | freely on the other is the control not working. pause.js holds |
| 74 | that state and answers for it, so it is attached here, at the |
| 75 | wire, rather than reached for from the collector. Its snapshot is |
| 76 | a SORTED list and a stamp that moves only when the set does -- |
| 77 | which is the whole of what keeps two collects byte-identical, and |
| 78 | the reason nothing in this file may stamp on the way in. |
| 79 | |
| 80 | AND NOW THE GATEWAY SAYS WHEN. Every trigger above is something |
| 81 | that happened on THIS device, so a window left open and unfocused |
| 82 | on a second desk had none: no turn ends there, nothing is renamed, |
| 83 | nobody comes back to it, and the catch-up in push() is throttled to |
| 84 | a trickle. It converged when somebody touched it, and not before -- |
| 85 | which is how it was reported from a live account. So the device no |
| 86 | longer has to guess. It holds a channel open to the gateway, and |
| 87 | the gateway taps it the moment another device's push lands. What |
| 88 | crosses that channel is one integer, the new version, and the |
| 89 | device answers it with the pull it would have run on focus. See |
| 90 | the wake channel below. |
| 91 | |
| 92 | AND WHERE THE GATEWAY CANNOT SAY, THE DEVICE ASKS. The channel is |
| 93 | a WebSocket, or a parked request, through whatever front door the |
| 94 | account is reached by, and a door that carries neither shuts it for |
| 95 | the life of the page. What was left then was the triggers of the |
| 96 | first kind again -- and a second browser open on a desk raises none |
| 97 | of them, so it sat on state from whenever it was last touched. Two |
| 98 | reports, one cause: turns taken in one browser did not appear in the |
| 99 | other, and two views of one account showed two different spend |
| 100 | tallies. So there is a catch-up now, gated on the channel being |
| 101 | quiet: a device that will be told pays nothing for it. See catchUp. |
| 102 | ============================================================ */ |
| 103 | (function () { |
| 104 | 'use strict'; |
| 105 | |
| 106 | var PATH = '/api/sync'; |
| 107 | var WS_PATH = '/api/sync/ws'; // The wake channel's WebSocket form. |
| 108 | // The contract version this build speaks is gateway.js's to own, and it is |
| 109 | // read from there (`DaimondGateway.clientApi()`) rather than copied: two |
| 110 | // constants that have to match are two constants that will eventually not. |
| 111 | |
| 112 | var PUSH_DEBOUNCE_MS = 2500; // Coalesce a flurry of changes into one push. |
| 113 | var MAX_CONFLICT_RETRIES = 8; // Bound the pull-merge-retry loop (was 4): more headroom under 3-device churn. |
| 114 | var CONFLICT_BACKOFF_MS = 200; // Jittered wait between conflict retries so busy devices do not collide every attempt. |
| 115 | // Focus arrives in bursts -- a click into the window raises focus on the |
| 116 | // window and a visibilitychange with it -- so the pull is debounced into one, |
| 117 | // and then rate-limited. |
| 118 | var FOCUS_DEBOUNCE_MS = 400; |
| 119 | // THIRTY SECONDS WAS TOO LONG, AND THE NUMBER WAS THE WHOLE DEFECT. Working in |
| 120 | // one browser and glancing at the other is something people do all afternoon, |
| 121 | // and a glance that landed inside the window showed whatever the previous one |
| 122 | // had left -- which is indistinguishable from sync not working, and was |
| 123 | // reported as exactly that. What a throttle is for here is a click storm, and |
| 124 | // the debounce above already deals with one; what is left is a single small |
| 125 | // GET per return to a window, which is cheap, and a return to a window is |
| 126 | // precisely when its owner expects to see the other device's work. |
| 127 | var FOCUS_PULL_MIN_MS = 3000; |
| 128 | // A push with nothing to send asks anyway, at most this often. See push(). |
| 129 | var IDLE_PULL_MIN_MS = 5000; |
| 130 | // ── Wake channel ─────────────────────────────────────────── |
| 131 | // A wake is EVIDENCE that the mailbox moved, which the speculative triggers |
| 132 | // above are not, so it has a throttle of its own and a much shorter one: the |
| 133 | // only pull a wake needs to stand down for is one that has just this second |
| 134 | // asked the same question. |
| 135 | var WAKE_PULL_MIN_MS = 1000; |
| 136 | // How long the gateway is asked to hold a parked request. Under a minute, so |
| 137 | // no intermediary decides it has stalled; the gateway clamps it anyway. |
| 138 | var WAKE_POLL_MS = 45000; |
| 139 | // A floor under the poll loop, so a gateway answering instantly (or a proxy |
| 140 | // answering for it) can never become a hot loop. |
| 141 | var WAKE_POLL_FLOOR_MS = 800; |
| 142 | // Reconnect backoff after a socket that HAD opened went away. Jittered, so a |
| 143 | // gateway restart does not bring every device back in the same millisecond. |
| 144 | var WAKE_RETRY_MIN_MS = 1000; |
| 145 | var WAKE_RETRY_MAX_MS = 30000; |
| 146 | // Consecutive sockets that closed without ever opening before the channel |
| 147 | // gives up on WebSocket and parks plain requests instead. Two: one to be |
| 148 | // unlucky, one to be sure. |
| 149 | var WAKE_WS_TRIES = 2; |
| 150 | // The park the channel makes before it reaches for a socket. Short: it is |
| 151 | // asking whether there is a gateway there, not waiting for news. |
| 152 | var WAKE_PROBE_MS = 1000; |
| 153 | // How often the channel is checked against what the app is doing -- signed |
| 154 | // in or not, entitled or not. Cheap, and it means no other file has to raise |
| 155 | // an event this one listens for. |
| 156 | var WAKE_WATCH_MS = 10000; |
| 157 | // ── The catch-up ─────────────────────────────────────────── |
| 158 | // Every trigger above is either something that happened on THIS device or the |
| 159 | // gateway's own tap, and the tap is a WebSocket -- or a parked request -- |
| 160 | // through whatever front door the account is reached by. Where that door |
| 161 | // carries neither, `wakeMode` goes to 'off' for the life of the page, and the |
| 162 | // second device is back to triggers of the first kind. A window nobody is |
| 163 | // typing at has none of them: no turn ends there, nothing is renamed, nobody |
| 164 | // comes back to it. It converged when somebody touched it, and not before. |
| 165 | // |
| 166 | // That was reported twice from one real account and read as two faults -- |
| 167 | // turns taken in one desktop browser not appearing in the other, and two views |
| 168 | // of one account showing two different token cost tallies. Both are the one |
| 169 | // thing: the reading device never asked. |
| 170 | // |
| 171 | // So a device that cannot be TOLD, asks. Only then: a channel that is carrying |
| 172 | // makes this cost nothing, which is why it is gated on the channel rather than |
| 173 | // run unconditionally -- a pull on every open tab on a timer is a real bill on |
| 174 | // a real account, and the wake channel exists so that nobody pays it. |
| 175 | var CATCHUP_MS = 20000; // How stale a device with no channel may get. |
| 176 | // How often that is checked, which is NOT the same number: a tick equal to the |
| 177 | // threshold puts the real ceiling at twice it. |
| 178 | var CATCHUP_TICK_MS = 5000; |
| 179 | var K_VERSION = 'daimond-sync-version'; // Per-account (accounts.js prefixes it). |
| 180 | var K_LAST = 'daimond-sync-last'; // When a sync last succeeded, for the chip. |
| 181 | // The digest of the parcel this device last got into the mailbox, so the FIRST |
| 182 | // push of a new page can tell that it has nothing to say. Same `daimond-` |
| 183 | // prefix as the two above and for the same reason: accounts.js namespaces |
| 184 | // every one of these, so a second account answers its own question. |
| 185 | var K_SIG = 'daimond-sync-sig'; |
| 186 | // What this build writes into it. A stored value that does not say this is |
| 187 | // from another format and reads as no fixed point at all -- which sends. |
| 188 | var SIG_V = 1; |
| 189 | |
| 190 | // ── State ────────────────────────────────────────────────── |
| 191 | var serverVersion = 0; // The version this device last saw on the server. |
| 192 | var lastPushed = null; // JSON of the state last pushed, to skip no-op pushes. |
| 193 | // THE SAME FACT, CARRIED ACROSS A RELOAD, and consulted by the first push of a |
| 194 | // page and by nothing else. |
| 195 | // |
| 196 | // `lastPushed` above is memory, so it begins every page as null and the guard |
| 197 | // in push() could not match after a refresh -- the whole parcel went up |
| 198 | // whether or not a byte had changed. The owner saw it as the sync chip cycling |
| 199 | // twice a couple of seconds apart after a hard refresh: the boot pull, and |
| 200 | // then a push with nothing in it to send. Measured at 163 KB on an account |
| 201 | // holding one chat, on every reload. |
| 202 | // |
| 203 | // DELIBERATELY ONLY THE FIRST PUSH. Once this page has sent something, |
| 204 | // `lastPushed` is the exact answer and this is not consulted again -- so |
| 205 | // nothing about the steady state of a running tab is changed by it, and the |
| 206 | // digest is computed once per page rather than once per push. A wider version |
| 207 | // of this cost six checks in dev/verify_sync.mjs's park-fallback section: the |
| 208 | // guard reached pushes it had never reached before, and a device that skipped |
| 209 | // one left the mailbox where it was and the other device's parked request |
| 210 | // unanswered. |
| 211 | var bootSig = ''; // '' means no fixed point, which always sends. |
| 212 | var entitled = true; // Cleared to false on a 402; stops pointless pushes. |
| 213 | var tooLarge = false; // Set on a 413; the parcel will not fit as it stands. |
| 214 | // Set on a 401 that a fresh session could not clear. Standing, like the two |
| 215 | // above: until there is a session again nothing leaves this device. |
| 216 | var sessionGone = false; |
| 217 | // A reconcile that could not finish: '' | 'busy' (the retries ran out) | |
| 218 | // 'merge' (what arrived could not be merged here). Both mean this device's |
| 219 | // work did NOT leave, and both are cleared by the next round that works. |
| 220 | var jammed = ''; |
| 221 | var lastFailed = []; // Sections the last merge could not apply. |
| 222 | var lastSynced = 0; // ms of the last successful pull or push. |
| 223 | var pushTimer = null; // Debounce handle. |
| 224 | var focusTimer = null; // Focus-pull debounce handle. |
| 225 | var lastFocusPull = 0; // ms of the last pull a focus caused. |
| 226 | // ms of the last pull that reached the gateway, whatever asked for it. The |
| 227 | // catch-up below is measured against THIS rather than against its own last |
| 228 | // go: a device that pulled a second ago because its window was focused has |
| 229 | // nothing to learn from asking again, and a second reason to ask is not a |
| 230 | // second thing to know. |
| 231 | var lastPullAt = 0; |
| 232 | var inFlight = false; // One sync operation at a time. |
| 233 | var started = false; // The engine has attached its listeners. |
| 234 | var catchupTimer = null; // The catch-up supervisor, for a device with no channel. |
| 235 | // This device has read the mailbox and knows what is in it -- a parcel it |
| 236 | // merged, or an empty mailbox. Only then may it publish an account-wide fact |
| 237 | // nobody has told it, which at the moment means the look and nothing else. |
| 238 | // See collectParcel. |
| 239 | var pulledOk = false; |
| 240 | // Whether this device had already synced THIS account when the page loaded. |
| 241 | // Read once, at start, before this session's own rounds move the cursor, and |
| 242 | // it is the only honest evidence that a device is not new to the account: |
| 243 | // storage full of `daimond-` keys is not, since the app writes a default |
| 244 | // theme and skin on every boot including the first. What turns on it is |
| 245 | // whether a look that arrives is worn or merely recorded -- see pairing.js. |
| 246 | var knownDevice = false; |
| 247 | |
| 248 | // ── Wake channel state ───────────────────────────────────── |
| 249 | // This tab's own channel id, named on the channel AND on every push, so the |
| 250 | // gateway can wake the account's other devices without waking this one. It |
| 251 | // starts with a letter so it is unambiguously a string in a query. |
| 252 | var WAKE_ID = 'wk' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); |
| 253 | var wakeMode = ''; // '' | 'ws' | 'poll' | 'off' |
| 254 | var wakeSock = null; // The live WebSocket, if there is one. |
| 255 | var wakeTimer = null; // Reconnect handle. |
| 256 | var wakeWatcher = null; // The supervisor interval. |
| 257 | var wakeFails = 0; // Sockets that closed without ever opening. |
| 258 | var wakeWorked = false; // A socket has opened at least once on this page. |
| 259 | var wakeBackoff = WAKE_RETRY_MIN_MS; |
| 260 | var wakePolling = false; // A park loop is running. |
| 261 | var wakeProbing = false; // The one-shot park that decides the transport is out. |
| 262 | var wakeGen = 0; // Bumped on teardown, so an in-flight loop stands down. |
| 263 | // WHICH GENERATION each of those two belongs to, and the whole reason they are |
| 264 | // here: a teardown can stand a loop down but it cannot take back the request |
| 265 | // that loop is parked on, and the gateway holds one of those for three |
| 266 | // quarters of a minute. For all that time `wakePolling` was true of a loop |
| 267 | // that had already stopped listening -- so the re-armed channel turned round |
| 268 | // at its own front door (`if (wakePolling) return`) and parked NOTHING, and |
| 269 | // `wake()` reported a channel that was open on the strength of the same flag. |
| 270 | // A generation beside each flag is what tells a live park from an abandoned |
| 271 | // one. Start below zero, which is no generation at all. |
| 272 | var wakePollGen = -1; |
| 273 | var wakeProbeGen = -1; |
| 274 | var wakeTarget = 0; // The highest version the channel has heard about. |
| 275 | var wakeSoon = null; // The coalescing timer for the pull a wake asks for. |
| 276 | // Whether the channel was shut ON PURPOSE, which is a different fact from |
| 277 | // `wakeMode === 'off'`. The road refusing to carry a channel is exactly what |
| 278 | // the catch-up is for; somebody asking for this device to go quiet is exactly |
| 279 | // what it must not talk over. See `wakeVia` and `catchUp`. |
| 280 | var wakeShut = false; |
| 281 | var wakes = 0; // Wakes acted on, for the verifier and for debugging. |
| 282 | |
| 283 | function log(/* ...args */) { |
| 284 | try { if (window.console && console.debug) console.debug.apply(console, ['[sync]'].concat([].slice.call(arguments))); } |
| 285 | catch (e) { /* ignore */ } |
| 286 | } |
| 287 | |
| 288 | /// One line in the durable trail, for a bug only a phone can see. |
| 289 | function trail(w, d) { try { window.DaimondTrail.note(w, d); } catch (e) {} } |
| 290 | |
| 291 | /// Lift a safe start, and reload so the engine gets its boot back. |
| 292 | /// |
| 293 | /// A reload rather than a `start()` here: everything this file does at a boot |
| 294 | /// has already not happened, and half-starting it into a running page would |
| 295 | /// leave listeners registered twice. Asked first, because a mis-tap on a chip |
| 296 | /// must not throw away what the user is in the middle of. |
| 297 | async function turnSyncBackOn() { |
| 298 | var ok = true; |
| 299 | try { |
| 300 | if (window.DaimondCore && DaimondCore.confirm) { |
| 301 | ok = await DaimondCore.confirm(t('safe.turn_on_ask'), t('safe.turn_on_ok'), |
| 302 | { title: t('safe.turn_on_title'), danger: false }); |
| 303 | } |
| 304 | } catch (e) { ok = false; } // no dialog available: do nothing rather than reload |
| 305 | if (!ok) return; |
| 306 | DaimondSafe.set(false, 'user'); |
| 307 | location.reload(); |
| 308 | } |
| 309 | |
| 310 | /// Whether sync can run at all right now: an unlocked identity (for the key) |
| 311 | /// and an authenticated gateway session (for the mailbox). |
| 312 | /// |
| 313 | /// A SAFE START is refused here and nowhere else. Every entry point in this |
| 314 | /// file already asks -- pull, push, the debounce, the wake channel, the |
| 315 | /// re-check after a tier change -- so one gate stops all of them, and there is |
| 316 | /// no second copy of the rule to fall out of step with this one. See safe.js |
| 317 | /// for why the app can be asked to start without sync at all. |
| 318 | function ready() { |
| 319 | if (window.DaimondSafe && DaimondSafe.on()) return false; |
| 320 | return !!(window.DaimondIdentity && DaimondIdentity.isUnlocked() |
| 321 | && window.DaimondGateway && DaimondGateway.state && DaimondGateway.state().authed |
| 322 | && window.DaimondCore && DaimondCore.collectSync); |
| 323 | } |
| 324 | |
| 325 | /// A short label for this device, shown on the other device as "last saved |
| 326 | /// from …". Not trusted by the gateway; purely for display. The gateway |
| 327 | /// stores it in the clear beside the sealed blob, so it must describe the |
| 328 | /// BROWSER, never the user: the account's chosen name is the user's own |
| 329 | /// words, and sending it here was the one readable thing sync leaked. |
| 330 | function deviceLabel() { |
| 331 | try { |
| 332 | var n = window.DaimondCore && DaimondCore.deviceSelfName && DaimondCore.deviceSelfName(); |
| 333 | return (n && String(n).trim()) || 'a device'; |
| 334 | } catch (e) { return 'a device'; } |
| 335 | } |
| 336 | |
| 337 | // ── Transport ────────────────────────────────────────────── |
| 338 | |
| 339 | /// One request, with the one refusal this engine can put right by itself. |
| 340 | /// |
| 341 | /// The gateway's session lasts an hour and nothing renewed it, so an hour into |
| 342 | /// a sitting every request here became a 401 -- and a 401 fell past the 409, |
| 343 | /// 402 and 413 arms into a `console.debug` line. Seven pushes of a real user's |
| 344 | /// work were refused and discarded that way in one afternoon, with the chip |
| 345 | /// showing nothing and the account dot claiming to be connected. |
| 346 | /// |
| 347 | /// So a 401 asks the gateway for a new session and sends the request again -- |
| 348 | /// through `DaimondGateway.gwFetch`, which is the ONE place that rule lives. |
| 349 | /// This file used to hold its own copy of it, one of five identical copies |
| 350 | /// across the app; a rule about not losing the user's work is not a rule that |
| 351 | /// should exist in five places. Renew once, retry once, and otherwise the |
| 352 | /// original 401 comes back and the chip says so, because an identity that |
| 353 | /// genuinely cannot authenticate must surface rather than spin against a door |
| 354 | /// that is not going to open. |
| 355 | /// |
| 356 | /// NOT DaimondGateway.post: sync's 402/409/413 are outcomes to act on, not |
| 357 | /// errors to throw, so this keeps its own shape -- {status, json} -- and reads |
| 358 | /// the reply itself. The version contract is honoured on the way past, by |
| 359 | /// `gwFetch`: a tab too old for the gateway is told to reload rather than go |
| 360 | /// on talking to it. |
| 361 | async function call(method, body, query) { |
| 362 | var opts = { |
| 363 | method: method, |
| 364 | credentials: 'same-origin', |
| 365 | headers: { 'x-daimond-api': String(DaimondGateway.clientApi()) }, |
| 366 | }; |
| 367 | if (body !== undefined) { |
| 368 | opts.headers['content-type'] = 'application/json'; |
| 369 | opts.body = JSON.stringify(body); |
| 370 | } |
| 371 | var r = await DaimondGateway.gwFetch(PATH + (query || ''), opts); |
| 372 | if (r.status === 426) return { status: 426, json: null }; |
| 373 | var j = null; |
| 374 | try { j = await r.json(); } catch (e) { j = null; } |
| 375 | var res = { status: r.status, json: j }; |
| 376 | if (r.status !== 401) { clearSessionGone(r.status); return res; } |
| 377 | // Still refused after a renewal that either failed or did not help. This |
| 378 | // device's work is not travelling and the user has to be able to find |
| 379 | // that out; see restStatus. |
| 380 | if (!sessionGone) { sessionGone = true; restStatus(); } |
| 381 | return res; |
| 382 | } |
| 383 | |
| 384 | /// A request that was served is proof the session is back. Only a round that |
| 385 | /// actually reached the mailbox counts -- a 502 from a gateway that is |
| 386 | /// restarting says nothing about whether this device is signed in. |
| 387 | function clearSessionGone(status) { |
| 388 | if (!sessionGone) return; |
| 389 | if (status !== 200 && status !== 402 && status !== 409 && status !== 413) return; |
| 390 | sessionGone = false; |
| 391 | restStatus(); |
| 392 | } |
| 393 | |
| 394 | // ── The account's public handle ──────────────────────────── |
| 395 | // |
| 396 | // Two halves live here because both are the wire. The parcel carries the |
| 397 | // handle between the account's own devices (see collectParcel), and these |
| 398 | // two functions are how the device talks to the party that OWNS the name: |
| 399 | // the gateway mints it, reserves it, and is the only thing that can say |
| 400 | // whether a name is free. |
| 401 | // |
| 402 | // Not in identity.js, which is a crypto module and makes no requests; not in |
| 403 | // gateway.js, whose account call is the authentication and must never answer |
| 404 | // its own 401 by authenticating again. Here, beside the other thing that |
| 405 | // keeps two devices agreeing about one account. |
| 406 | |
| 407 | var ACCOUNT_PATH = '/api/account'; |
| 408 | |
| 409 | /// Whether there is a session to ask about the handle through. |
| 410 | /// |
| 411 | /// Deliberately NOT `ready()`, which also requires the sync tier: every |
| 412 | /// account has a handle, including the ones that will never buy Pro, and a |
| 413 | /// name that only paying accounts could see would be no use to a rating. |
| 414 | function handleReady() { |
| 415 | if (window.DaimondSafe && DaimondSafe.on()) return false; |
| 416 | return !!(window.DaimondIdentity && DaimondIdentity.isUnlocked() |
| 417 | && window.DaimondGateway && DaimondGateway.state && DaimondGateway.state().authed); |
| 418 | } |
| 419 | |
| 420 | /// One request to the account endpoint. `{status, json}`, never a throw. |
| 421 | /// |
| 422 | /// Through `gwFetch` like everything else here, though with one difference |
| 423 | /// worth knowing: `/api/account` is on gateway.js's authentication path, so |
| 424 | /// a 401 comes straight back rather than triggering a renewal. That is |
| 425 | /// right -- a handle is not worth re-authenticating for, and the next unlock |
| 426 | /// asks again. |
| 427 | async function accountCall(method, body, query) { |
| 428 | var opts = { |
| 429 | method: method, |
| 430 | credentials: 'same-origin', |
| 431 | headers: { 'x-daimond-api': String(DaimondGateway.clientApi()) }, |
| 432 | }; |
| 433 | if (body !== undefined) { |
| 434 | opts.headers['content-type'] = 'application/json'; |
| 435 | opts.body = JSON.stringify(body); |
| 436 | } |
| 437 | try { |
| 438 | var r = await DaimondGateway.gwFetch(ACCOUNT_PATH + (query || ''), opts); |
| 439 | var j = null; |
| 440 | try { j = await r.json(); } catch (e) { j = null; } |
| 441 | return { status: r.status, json: j }; |
| 442 | } catch (e) { |
| 443 | // The gateway is optional: an account works offline on a BYOK key, |
| 444 | // and a name it cannot ask about is not a failure worth showing. |
| 445 | log('account call failed', e); |
| 446 | return { status: 0, json: null }; |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | /// Ask the gateway what this account is called, and adopt the answer. |
| 451 | /// |
| 452 | /// The gateway mints a handle for an account that has none -- including one |
| 453 | /// registered before handles existed -- so this both learns the name and is |
| 454 | /// how an older account comes to have one. |
| 455 | /// |
| 456 | /// The answer is adopted through `adoptHandle`, which takes the LARGER |
| 457 | /// record and writes it verbatim. Hearing the same name again therefore |
| 458 | /// changes nothing and schedules no push: the stamp came from the gateway |
| 459 | /// both times, so the two records are equal rather than merely equivalent. |
| 460 | async function refreshHandle() { |
| 461 | if (!handleReady()) return null; |
| 462 | var r = await accountCall('GET'); |
| 463 | if (r.status !== 200 || !r.json || r.json.ok === false) return null; |
| 464 | var rec = { h: r.json.handle || '', t: r.json.handle_ts || 0 }; |
| 465 | if (!rec.h) return null; |
| 466 | var moved = false; |
| 467 | try { moved = DaimondIdentity.adoptHandle(rec); } catch (e) { log('adoptHandle threw', e); } |
| 468 | // A handle that moved is account state like any other, and the other |
| 469 | // devices are entitled to hear about it. Only on a real change, so a |
| 470 | // refresh that confirmed what we knew sends nothing. |
| 471 | if (moved) nudge(); |
| 472 | return DaimondIdentity.handle(); |
| 473 | } |
| 474 | |
| 475 | /// Ask for a different handle. `{ok, reason, message, handle}`. |
| 476 | /// |
| 477 | /// The refusals are the reason this returns a shape rather than a boolean. |
| 478 | /// A name somebody else holds, a name that is not a name, and a name the |
| 479 | /// operator keeps are three different things to tell a user, and a caller |
| 480 | /// that could only see failure would have to invent which. |
| 481 | /// |
| 482 | /// The gateway's own English is ignored in favour of the catalogue: the |
| 483 | /// sentence a user reads has to be in their language, and the wire carries a |
| 484 | /// token (`reason`) precisely so it can be. |
| 485 | async function claimHandle(wanted) { |
| 486 | if (!handleReady()) return { ok: false, reason: 'offline', message: t('handle.failed') }; |
| 487 | var r = await accountCall('POST', { handle: String(wanted || '') }, '?op=handle'); |
| 488 | var j = r.json || {}; |
| 489 | if (r.status === 200 && j.ok) { |
| 490 | // `setHandle`, not the merge: this is the gateway answering the |
| 491 | // question this device just asked, so it is the authority. A merge |
| 492 | // would refuse it if this device happened to hold a stamp further |
| 493 | // ahead, and the rename would be reported as having worked while the |
| 494 | // old name stayed on screen. |
| 495 | try { DaimondIdentity.setHandle({ h: j.handle, t: j.handle_ts }); } |
| 496 | catch (e) { log('setHandle threw', e); } |
| 497 | nudge(); // the other devices are owed the new name |
| 498 | return { ok: true, reason: j.reason || 'claimed', handle: DaimondIdentity.handle() }; |
| 499 | } |
| 500 | var reason = j.reason || 'failed'; |
| 501 | return { ok: false, reason: reason, message: handleMessage(reason) }; |
| 502 | } |
| 503 | |
| 504 | /// The sentence behind a refusal, in the user's language. |
| 505 | function handleMessage(reason) { |
| 506 | if (reason === 'taken') return t('handle.taken'); |
| 507 | if (reason === 'invalid') return t('handle.invalid'); |
| 508 | if (reason === 'reserved') return t('handle.reserved'); |
| 509 | return t('handle.failed'); |
| 510 | } |
| 511 | |
| 512 | /// `refreshHandle`, fired and forgotten, with the rejection swallowed. |
| 513 | /// |
| 514 | /// Nothing waits for a name, and an unhandled rejection from a background |
| 515 | /// request is a console error the whole suite reads as a page fault. |
| 516 | function askHandle() { |
| 517 | try { refreshHandle().catch(function (e) { log('handle refresh failed', e); }); } |
| 518 | catch (e) { log('handle refresh threw', e); } |
| 519 | } |
| 520 | |
| 521 | /// Look up somebody else's handle. `{found, handle, fingerprint}`. |
| 522 | /// |
| 523 | /// The half that makes a handle worth having: a name is only a name if |
| 524 | /// somebody other than its owner can resolve it. Nothing in the app calls |
| 525 | /// this yet -- sharing and ratings are the callers it is waiting for -- and |
| 526 | /// it is here rather than deferred so that what those features need already |
| 527 | /// exists and has been proved to work. |
| 528 | async function lookupHandle(wanted) { |
| 529 | if (!handleReady()) return { found: false }; |
| 530 | var q = '?handle=' + encodeURIComponent(String(wanted || '')); |
| 531 | var r = await accountCall('GET', undefined, q); |
| 532 | var j = r.json || {}; |
| 533 | if (r.status !== 200 || !j.ok || !j.found) return { found: false }; |
| 534 | return { found: true, handle: j.handle || '', fingerprint: j.fingerprint || '' }; |
| 535 | } |
| 536 | |
| 537 | // ── Status indicator ─────────────────────────────────────── |
| 538 | // The rail's status strip carries one row for sync: "Syncing…" while a push |
| 539 | // or pull is in flight, "Synced" briefly after, "Sync off" if the tier is not |
| 540 | // held, and a standing refusal for as long as one stands. When there is none |
| 541 | // of that, the row says when a sync last worked (see `paintRest`), so the row |
| 542 | // is never empty and never has to be waited for. |
| 543 | // |
| 544 | // IT WAS A PILL IN THE TOP BAR, and it moved everything beside it. The bar's |
| 545 | // right-hand group shrank to its contents, so a chip appearing there took |
| 546 | // 86px out of the chip row and out of the icon buttons -- measured 2026-08-28 |
| 547 | // at 1440px -- twice a round, at moments nobody controls. A status that |
| 548 | // arrives and departs does not belong among things people press. The strip is |
| 549 | // where this app already puts "the state of the machine, at a glance and |
| 550 | // without asking", and every row in it is the answer to one question. |
| 551 | // |
| 552 | // The element keeps its id, its `data-state`, its `.sdot`/`.stext` children, |
| 553 | // its hover title and its click: what changed is where it hangs and how it is |
| 554 | // drawn. Its rules are with the other status rows in css/app.css rather than |
| 555 | // injected here, now that there is a row in the markup for it to sit in. |
| 556 | var _statusChip = null, _statusTimer = null; |
| 557 | /// The row the chip lives in, and the resting line it shares the row with. |
| 558 | function statusRow() { |
| 559 | return document.getElementById('astat-sync'); |
| 560 | } |
| 561 | function statusChip() { |
| 562 | if (_statusChip) return _statusChip; |
| 563 | var host = statusRow() || document.getElementById('admin-status') |
| 564 | || document.querySelector('.admin-status'); |
| 565 | if (!host) return null; |
| 566 | var c = document.createElement('div'); |
| 567 | c.id = 'sync-chip'; |
| 568 | // The INLINE style carries "is it saying anything", because that is what |
| 569 | // six verifiers read (`c.style.display !== 'none'`). The stylesheet's |
| 570 | // `display: none` would leave it empty until the first `setStatus`, so a |
| 571 | // chip built at boot and asked before it had anything to report would |
| 572 | // answer that it was showing. |
| 573 | c.style.display = 'none'; |
| 574 | // It goes syncing -> synced -> stalled -> off on its own, with nothing the |
| 575 | // user pressed to cause it. `role="status"` is enough here: it changes |
| 576 | // rarely and says one short thing, which is the case a polite live region |
| 577 | // is actually for. |
| 578 | c.setAttribute('role', 'status'); |
| 579 | c.innerHTML = '<span class="sdot"></span><span class="stext"></span>'; |
| 580 | // "Sync off" is the one state the user can do something about, and until now |
| 581 | // the chip said so and stopped there -- the offer it was pointing at was |
| 582 | // three clicks away in a drawer they had no reason to open. Clicking it goes |
| 583 | // where the sentence leads. The other states are reports rather than offers, |
| 584 | // so they stay inert: a chip that opened a drawer whatever it said would be |
| 585 | // a trap sitting next to the pairing button. |
| 586 | c.addEventListener('click', function () { |
| 587 | if (c.dataset.state !== 'off') return; |
| 588 | // A safe start is the one "off" the user can lift themselves, so the |
| 589 | // press has to lift it rather than sell them a tier they may already |
| 590 | // hold. It takes effect on the next start, because everything this |
| 591 | // engine does at a boot has already not happened. |
| 592 | if (window.DaimondSafe && DaimondSafe.on()) { |
| 593 | turnSyncBackOn(); |
| 594 | return; |
| 595 | } |
| 596 | if (window.DaimondAdmin && DaimondAdmin.credits) DaimondAdmin.credits(t('sync.off_pitch')); |
| 597 | }); |
| 598 | host.appendChild(c); |
| 599 | _statusChip = c; |
| 600 | return c; |
| 601 | } |
| 602 | |
| 603 | /// Say when a sync last worked, in the row, while the chip has nothing to say. |
| 604 | /// |
| 605 | /// The chip used to fade 1.8 seconds after "Synced" and leave the bar with no |
| 606 | /// sync state on it at all, which is fine for a pill nobody was looking at and |
| 607 | /// no use as an answer to "has my work travelled". The row cannot fade -- it |
| 608 | /// would take its neighbours up the strip with it -- so what it does instead is |
| 609 | /// fall back to the fact that is always true and always worth having. |
| 610 | function paintRest(show) { |
| 611 | var row = statusRow(); |
| 612 | if (!row) return; |
| 613 | var dot = document.getElementById('sync-rest-dot'); |
| 614 | var text = document.getElementById('sync-rest'); |
| 615 | if (dot) { |
| 616 | dot.style.display = show ? '' : 'none'; |
| 617 | // Green once something has actually travelled; grey until it has. The |
| 618 | // same three classes the rows above this one use. |
| 619 | dot.className = 'astat-dot' + (lastSynced ? ' ok' : ' off'); |
| 620 | } |
| 621 | if (text) { |
| 622 | text.style.display = show ? '' : 'none'; |
| 623 | if (show) text.textContent = lastSyncedLine(); |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | /// Show the chip. `title` is the hover explanation, cleared unless given -- |
| 628 | /// carried here because the chip is the only place a state like "off" is |
| 629 | /// reported, so its reason has to travel with it rather than into a dialog. |
| 630 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 631 | |
| 632 | function setStatus(state, text, holdMs, title) { |
| 633 | var c = statusChip(); |
| 634 | if (!c) return; |
| 635 | if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; } |
| 636 | // `style.display` still carries "is the chip saying anything", because that |
| 637 | // is what six verifiers read and what `restStatus` means by an empty state. |
| 638 | // What is new is the other half of the row taking over when it is not. |
| 639 | if (!state) { c.style.display = 'none'; paintRest(true); return; } |
| 640 | paintRest(false); |
| 641 | c.dataset.state = state; |
| 642 | c.querySelector('.stext').textContent = text; |
| 643 | // The hover text always ends with when a sync last worked. On a stall that |
| 644 | // is the most useful sentence there is -- "paused" means nothing without |
| 645 | // knowing whether the last good sync was a minute or a fortnight ago -- and |
| 646 | // on a good one it costs a line nobody has to read. |
| 647 | c.title = [title || '', lastSyncedLine()].filter(Boolean).join('\n'); |
| 648 | c.style.display = 'flex'; |
| 649 | if (holdMs) _statusTimer = setTimeout(function () { |
| 650 | c.style.display = 'none'; |
| 651 | paintRest(true); |
| 652 | }, holdMs); |
| 653 | } |
| 654 | |
| 655 | /// A short relative age, in the app's own language. |
| 656 | function whenAgo(ms) { |
| 657 | var s = Math.max(0, Math.round((Date.now() - ms) / 1000)); |
| 658 | if (s < 60) return t('sync.when_just_now'); |
| 659 | var m = Math.round(s / 60); |
| 660 | if (m < 60) return t('sync.when_mins', { n: m }); |
| 661 | var h = Math.round(m / 60); |
| 662 | if (h < 24) return t('sync.when_hours', { n: h }); |
| 663 | return t('sync.when_days', { n: Math.round(h / 24) }); |
| 664 | } |
| 665 | |
| 666 | /// "Last synced 4m ago." -- or the honest admission that nothing ever has. |
| 667 | function lastSyncedLine() { |
| 668 | if (!lastSynced) return t('sync.last_never'); |
| 669 | return t('sync.last_synced', { when: whenAgo(lastSynced) }); |
| 670 | } |
| 671 | |
| 672 | /// Note a round that worked, so the chip has a moment to report. |
| 673 | function noteSynced() { |
| 674 | lastSynced = Date.now(); |
| 675 | try { localStorage.setItem(K_LAST, String(lastSynced)); } catch (e) { /* private mode */ } |
| 676 | } |
| 677 | |
| 678 | /// Put the too-large refusal on the chip, and leave it there. No hold time: it |
| 679 | /// is true until the parcel changes, and a chip that faded would be the same |
| 680 | /// silence this exists to end. |
| 681 | function showTooLarge() { |
| 682 | setStatus('stalled', t('sync.too_big'), 0, t('sync.too_big_reason')); |
| 683 | } |
| 684 | |
| 685 | /// Note that a reconcile did not finish, and say so on the chip. |
| 686 | /// |
| 687 | /// Both causes end the same way -- this device's work is still here and the |
| 688 | /// mailbox does not have it -- and both used to end in one console.debug |
| 689 | /// line, with the chip left showing the "Synced" that the reconciling PULL |
| 690 | /// had just put there. A device whose work never left looked exactly like a |
| 691 | /// device that had just saved, which is the one thing this chip exists to |
| 692 | /// prevent. |
| 693 | function jam(why) { |
| 694 | jammed = why; |
| 695 | restStatus(); |
| 696 | } |
| 697 | |
| 698 | /// Nothing is standing in the way any more: the round that just worked |
| 699 | /// clears whatever the last one could not do. |
| 700 | function unjam() { |
| 701 | jammed = ''; |
| 702 | lastFailed = []; |
| 703 | } |
| 704 | |
| 705 | /// Why a reconcile stopped, for the chip's hover. |
| 706 | function jamReason() { |
| 707 | return jammed === 'merge' ? t('sync.merge_reason') : t('sync.busy_reason'); |
| 708 | } |
| 709 | |
| 710 | /// Put the chip back to what is TRUE when nothing is in flight. |
| 711 | /// |
| 712 | /// The three standing refusals outlive the round that discovered them, so |
| 713 | /// every path that stops showing "Syncing…" has to come through here rather |
| 714 | /// than hiding the chip: a pull failing on the network used to blank a "Sync off" |
| 715 | /// that was still perfectly true, and a pull SUCCEEDING used to show "Synced" |
| 716 | /// on a device whose pushes were paused by a 402 -- which is the one lie this |
| 717 | /// chip exists to prevent. |
| 718 | /// |
| 719 | /// They are ordered rather than allowed to overwrite each other. Not entitled |
| 720 | /// beats too large: an account that may not sync at all cannot act on a parcel |
| 721 | /// being oversized, and telling it to go and shrink a Diamond would send it to |
| 722 | /// do work that changes nothing. |
| 723 | function restStatus() { |
| 724 | // ABOVE EVERYTHING. A safe start is the app deliberately not syncing, and |
| 725 | // it must never be silent: a device that quietly stopped saving to the |
| 726 | // account would be a worse bug than the one it was armed against. It is |
| 727 | // also the only state here the user can lift with one press, which is why |
| 728 | // it outranks refusals they can do nothing about. |
| 729 | if (window.DaimondSafe && DaimondSafe.on()) { |
| 730 | setStatus('off', t('safe.chip'), 0, t('safe.chip_reason') + '\n' + t('safe.chip_click')); |
| 731 | return; |
| 732 | } |
| 733 | if (!entitled) { setStatus('off', t('sync.off'), 0, offReason()); return; } |
| 734 | if (tooLarge) { showTooLarge(); return; } |
| 735 | // Below both of those. An account that may not sync at all, and a parcel |
| 736 | // that will not fit, are true whatever the session is doing; a session |
| 737 | // that has gone is the narrower fact and would be noise over either. |
| 738 | if (sessionGone) { setStatus('stalled', t('sync.signed_out'), 0, t('sync.signed_out_reason')); return; } |
| 739 | // And above nothing at all: a jam is this round's failure rather than a |
| 740 | // state of this device, so all three standing refusals outrank it. |
| 741 | if (jammed) { setStatus('stalled', t('sync.paused'), 0, jamReason()); return; } |
| 742 | setStatus(''); |
| 743 | } |
| 744 | |
| 745 | /// Why sync is off, and what to do about it -- the chip is clickable in this |
| 746 | /// state, and a hover that did not say so would leave that undiscovered. |
| 747 | function offReason() { |
| 748 | return t('sync.off_reason') + '\n' + t('sync.off_click'); |
| 749 | } |
| 750 | |
| 751 | // ── The parcel ───────────────────────────────────────────── |
| 752 | // Everything daimond.js owns comes from `collectSync`/`applySync`. The pause |
| 753 | // tree does not: pause.js holds it, and hanging it here keeps the collector |
| 754 | // free of a module it has no other business with. Both functions are the ONLY |
| 755 | // way a parcel is packed or unpacked in this file, so what a verifier drives |
| 756 | // and what a push sends cannot drift apart. |
| 757 | |
| 758 | /// What push() sends: the core parcel with the pause tree on the end. |
| 759 | /// |
| 760 | /// `snapshot()` sorts and stamps only on a real change, so two collects with |
| 761 | /// nothing between them are byte-identical -- which is the whole contract the |
| 762 | /// no-op guard in push() rests on. Attached last, so its position in the |
| 763 | /// serialisation never moves either. |
| 764 | async function collectParcel() { |
| 765 | var state = await DaimondCore.collectSync(); |
| 766 | try { if (window.DaimondPause) state.pause = DaimondPause.snapshot(); } |
| 767 | catch (e) { log('pause snapshot failed', e); } |
| 768 | // THE LEASE IS NOT IN THE PARCEL any more. Which device runs a turn is a fact |
| 769 | // about the account, but riding it in the parcel made a lease CLAIM a |
| 770 | // whole-parcel compare-and-set that stormed under multi-device churn (see the |
| 771 | // lease door in this file). It now travels on its own lightweight CAS door |
| 772 | // (leaseGet / leaseCommit) and is adopted through adoptLeaseDoor -- on every |
| 773 | // ordinary pull (where `j.lease` is read) -- off the parcel entirely, exactly |
| 774 | // as presence was moved below. |
| 775 | // PRESENCE IS NOT IN THE PARCEL. Which devices are awake used to ride here as |
| 776 | // a freshest-scalar section, but its moving lastSeen made the parcel a moving |
| 777 | // target -- never a fixed point -- and re-uploaded the whole ~163K parcel |
| 778 | // every beat, waking every other device for a fact that wakes nobody. It now |
| 779 | // travels on the gateway's own lightweight, non-waking presence path |
| 780 | // (DaimondSync.beatPresence / refreshPresence) and is adopted through |
| 781 | // DaimondPresence.ingest, off the parcel entirely. |
| 782 | // Where the Diamonds sit in the graph, under the same rule: sorted keys, |
| 783 | // three fields each, stamped per Diamond rather than once over the map -- |
| 784 | // two devices that each moved a different Diamond must keep both moves, |
| 785 | // where a whole-map stamp would let the later one silently replace the |
| 786 | // other's whole arrangement. The pan is deliberately NOT carried: it is a |
| 787 | // scroll offset into a picture whose size depends on this window. |
| 788 | try { if (window.DaimondGraph) state.graph = DaimondGraph.snapshot(); } |
| 789 | catch (e) { log('graph snapshot failed', e); } |
| 790 | // WHAT IS IN THE TRASH, which is a fact about the ACCOUNT and not about |
| 791 | // the browser it was deleted in. Deleting already propagates through |
| 792 | // tombstones, so a trash that stayed local would be strictly worse than |
| 793 | // no trash at all: a restore on this device would be silently undone by |
| 794 | // the other one, which had buried the same chat and never heard |
| 795 | // otherwise. Attached here, beside the pause tree and the graph, because |
| 796 | // trash.js holds the state and answers for it. |
| 797 | // |
| 798 | // Its snapshot is a SORTED map of two stamps per id and moves only when a |
| 799 | // stamp does, which is the whole of what keeps two collects |
| 800 | // byte-identical -- the same contract the pause tree keeps above. |
| 801 | try { if (window.DaimondTrash) state.trash = DaimondTrash.snapshot(); } |
| 802 | catch (e) { log('trash snapshot failed', e); } |
| 803 | // THE ACCOUNT'S PUBLIC HANDLE -- the name other people see, as opposed to |
| 804 | // `displayName()`, which labels this device's keypair and travels |
| 805 | // nowhere. It is a fact about the account, so a second device that shows |
| 806 | // a different one is showing a name its owner does not have. |
| 807 | // |
| 808 | // The gateway is the authority: it mints the handle, it owns the |
| 809 | // namespace, and every stamp on the record is its clock. This carries a |
| 810 | // copy so a device that is offline, or newly adopted by pairing, still |
| 811 | // knows the account's name -- and identity.js writes what arrives |
| 812 | // verbatim, so nothing on this path can stamp. See `handleSnapshot`. |
| 813 | try { if (window.DaimondIdentity) state.handle = DaimondIdentity.handleSnapshot(); } |
| 814 | catch (e) { log('handle snapshot failed', e); } |
| 815 | // AND HOW THE ACCOUNT LOOKS, for the device that has not been dressed. |
| 816 | // A pairing bundle carries this to a device linked by a code; nothing |
| 817 | // carried it to one brought across by a passkey, or to one that simply |
| 818 | // holds the identity and was unlocked with the passphrase. The mailbox is |
| 819 | // the only channel all three end at. pairing.js holds the state and |
| 820 | // answers for it, as pause.js and trash.js do above. |
| 821 | // |
| 822 | // `pulledOk` is the same rule the chunk index is committed under: a device |
| 823 | // may not publish a look it has not been told about until it has heard |
| 824 | // from the mailbox once, or a new device's factory defaults would go over |
| 825 | // the account's real look with a fresh stamp. |
| 826 | try { |
| 827 | if (window.DaimondPairing && DaimondPairing.look) { |
| 828 | var look = DaimondPairing.look.record(pulledOk, knownDevice); |
| 829 | if (look) state.look = look; |
| 830 | } |
| 831 | } catch (e) { log('look snapshot failed', e); } |
| 832 | // Private messages, and WHY THE NULL MATTERS: `snapshot()` answers null while |
| 833 | // the identity is locked, and a section left off is a section the other device |
| 834 | // keeps. An empty record here would read to the merge as a deletion. |
| 835 | try { |
| 836 | if (window.DaimondPost) { |
| 837 | var pst = DaimondPost.snapshot(); |
| 838 | if (pst) state.post = pst; |
| 839 | } |
| 840 | } catch (e) { log('post snapshot failed', e); } |
| 841 | // THE FORGE VOICE, wrapped under the account's shared identity so it is |
| 842 | // decryptable on every paired device but was never carried to one. It is |
| 843 | // a fact about the account like the handle above, not about this browser. |
| 844 | // The wrapped record travels verbatim -- voice.js never unwraps it -- and |
| 845 | // `null` (no voice held) is omitted, so a device with no voice does not |
| 846 | // read to the merge as one deleting it. |
| 847 | try { |
| 848 | if (window.DaimondVoice && DaimondVoice.snapshot) { |
| 849 | var vce = DaimondVoice.snapshot(); |
| 850 | if (vce) state.voice = vce; |
| 851 | } |
| 852 | } catch (e) { log('voice snapshot failed', e); } |
| 853 | return state; |
| 854 | } |
| 855 | |
| 856 | /// Merge a parcel into this device. Returns the sections that would not apply. |
| 857 | /// |
| 858 | /// Pause goes FIRST, because a merge that cannot finish must not also lose the |
| 859 | /// news about what may spend: a Diamond section that fails costs a name, a |
| 860 | /// pause that fails costs money. And nothing here may stamp on the way in -- |
| 861 | /// `adopt()` moves the stamp only for a record that is later or larger, so |
| 862 | /// applying a parcel this device already agrees with leaves the next parcel |
| 863 | /// unchanged. A section that restamped itself on apply is exactly the |
| 864 | /// `touchSelfDevice` bug that had a freshly paired phone always holding news, |
| 865 | /// and two devices pushing at each other about once a second. |
| 866 | async function applyParcel(state) { |
| 867 | var failed = []; |
| 868 | if (window.DaimondPause) { |
| 869 | try { DaimondPause.adopt(state && state.pause); } |
| 870 | catch (e) { log('pause adopt failed', e); failed.push('pause'); } |
| 871 | } |
| 872 | // The lease is NOT adopted here any more: it left the parcel (see |
| 873 | // collectParcel) and is adopted from its own gateway door through |
| 874 | // adoptLeaseDoor -- on every ordinary pull, where `j.lease` is read -- by the |
| 875 | // same take-if-vacant merge (DaimondLease.adopt), off the parcel entirely. |
| 876 | // Presence is NOT adopted here any more: it left the parcel (see |
| 877 | // collectParcel) and is ingested from the gateway's own presence path |
| 878 | // through DaimondPresence.ingest -- on every ordinary pull (see pullOnce, |
| 879 | // where `j.presence` is read) and on each beat. |
| 880 | // Always through `adopt`, never by writing `daimond-graph`: graph.js caches |
| 881 | // the record in memory and re-reads it only on a cross-tab `storage` event |
| 882 | // or an account switch, so a same-tab write is invisible to it and the next |
| 883 | // save overwrites it. |
| 884 | if (window.DaimondGraph) { |
| 885 | try { DaimondGraph.adopt(state && state.graph); } |
| 886 | catch (e) { log('graph adopt failed', e); failed.push('graph'); } |
| 887 | } |
| 888 | // BEFORE the chats and the Diamonds, and that ordering is the whole of it. |
| 889 | // `applySync` below rebuilds both lists from their stores, and what those |
| 890 | // lists may contain is decided by this record: adopting it afterwards |
| 891 | // would put a chat the other device deleted back on the rail until |
| 892 | // something else happened to redraw it. |
| 893 | // |
| 894 | // The merge itself takes the LATER of each stamp independently, so a |
| 895 | // deletion cannot resurrect and a restore cannot be buried whichever |
| 896 | // order the parcels arrive in -- see js/trash.js. |
| 897 | if (window.DaimondTrash) { |
| 898 | try { DaimondTrash.adopt(state && state.trash); } |
| 899 | catch (e) { log('trash adopt failed', e); failed.push('trash'); } |
| 900 | } |
| 901 | // The forge voice, under the same rule as everything above it: the record |
| 902 | // with the newer `at` wins, so a re-issued voice propagates and an older |
| 903 | // one never buries a newer local one. voice.js writes it verbatim, `s` |
| 904 | // still wrapped, at the key it reads from. |
| 905 | if (window.DaimondVoice && DaimondVoice.adopt) { |
| 906 | try { DaimondVoice.adopt(state && state.voice); } |
| 907 | catch (e) { log('voice adopt failed', e); failed.push('voice'); } |
| 908 | } |
| 909 | if (window.DaimondPost) { |
| 910 | try { DaimondPost.adopt(state && state.post); } |
| 911 | catch (e) { log('post adopt failed', e); failed.push('post'); } |
| 912 | } |
| 913 | // The account's public handle, under the same rule as everything above |
| 914 | // it: `adoptHandle` takes the larger record and writes it VERBATIM, so a |
| 915 | // parcel this device already agrees with moves nothing and the next |
| 916 | // parcel is the one that arrived, byte for byte. |
| 917 | if (window.DaimondIdentity && DaimondIdentity.adoptHandle) { |
| 918 | try { DaimondIdentity.adoptHandle(state && state.handle); } |
| 919 | catch (e) { log('handle adopt failed', e); failed.push('handle'); } |
| 920 | } |
| 921 | // How the account looks, under the same rule again -- the later record, |
| 922 | // stored verbatim -- with one thing on top of it: a device that has never |
| 923 | // had a look of its own PUTS THIS ON. Awaited, because dressing sets the |
| 924 | // language, and the language is fetched before it is written. |
| 925 | if (window.DaimondPairing && DaimondPairing.look) { |
| 926 | try { await DaimondPairing.look.adopt(state && state.look, knownDevice); } |
| 927 | catch (e) { log('look adopt failed', e); failed.push('look'); } |
| 928 | } |
| 929 | var report = null; |
| 930 | try { report = await DaimondCore.applySync(state); } |
| 931 | catch (e) { log('applySync threw', e); report = { failed: ['all'] }; } |
| 932 | var core = (report && Array.isArray(report.failed)) ? report.failed : []; |
| 933 | return failed.concat(core); |
| 934 | } |
| 935 | |
| 936 | // ── Pull ─────────────────────────────────────────────────── |
| 937 | |
| 938 | /// Fetch the current blob, decrypt it, and merge it into local state. |
| 939 | /// Returns the server version now known, or -1 on a failure that should not |
| 940 | /// advance anything. A decrypt failure is swallowed: better to keep local |
| 941 | /// state than to clobber it with something we cannot read. |
| 942 | /// |
| 943 | /// `quiet` is for the pull INSIDE a reconcile: the round is not over, so it |
| 944 | /// must not paint "Synced" over a push that has not landed yet. |
| 945 | /// |
| 946 | /// Whether the merge finished is recorded in `lastFailed`, because a merge |
| 947 | /// that did not is a reason not to push over the parcel it came from. |
| 948 | /// Announce that a pull has RUN -- landed, found nothing, or failed on the |
| 949 | /// wire. Once per boot, and the distinction that matters is "this device has |
| 950 | /// asked the other ones", not "the answer was good news". |
| 951 | /// |
| 952 | /// The retention sweep waits on this. A device coming back after a month |
| 953 | /// holds trash records that may have been restored elsewhere meanwhile, and |
| 954 | /// destroying on them before hearing is how a restore is defeated by a |
| 955 | /// tombstone -- so the sweep is held until the mailbox has been read. A |
| 956 | /// failed pull releases it too: a device that cannot reach the gateway must |
| 957 | /// still eventually destroy what its own records say is due, or an account |
| 958 | /// whose gateway is down would keep everything for ever. |
| 959 | var announcedPull = false; |
| 960 | function notePulled() { |
| 961 | if (announcedPull) return; |
| 962 | announcedPull = true; |
| 963 | try { window.dispatchEvent(new Event('daimond:pulled')); } catch (e) { /* no window */ } |
| 964 | } |
| 965 | |
| 966 | async function pull(quiet) { |
| 967 | if (!ready()) return -1; |
| 968 | try { return await pullOnce(quiet); } |
| 969 | finally { notePulled(); } |
| 970 | } |
| 971 | |
| 972 | async function pullOnce(quiet) { |
| 973 | lastFailed = []; // what follows is the only merge this answers for. |
| 974 | setStatus('syncing', t('sync.syncing')); |
| 975 | // What the cursor held before this read left. A push that moves it past this |
| 976 | // while the read is in flight makes the version this read returns with stale, |
| 977 | // and it must not overwrite the push's. See `adoptVersion`. |
| 978 | var preRead = serverVersion; |
| 979 | var res; |
| 980 | try { res = await call('GET'); } |
| 981 | catch (e) { log('pull network error', e); restStatus(); return -1; } |
| 982 | if (res.status !== 200 || !res.json) { log('pull status', res.status); restStatus(); return -1; } |
| 983 | lastPullAt = Date.now(); // asked, and answered: see the catch-up in push(). |
| 984 | var j = res.json; |
| 985 | // PRESENCE RIDES ALONGSIDE THE PARCEL, in the clear. The gateway stamps a |
| 986 | // last_seen per awake device in its own clock and includes `now` so this |
| 987 | // client can convert to its own frame; `ingest` REPLACES the local view |
| 988 | // (the gateway is the source of truth). Adopted here for free on every pull, |
| 989 | // whether or not there is a parcel to open below, and off the sealed blob |
| 990 | // entirely -- presence never touches the parcel now. See beatPresence. |
| 991 | try { |
| 992 | if (window.DaimondPresence && j && j.presence) DaimondPresence.ingest(j.presence, j.now); |
| 993 | } catch (e) { log('presence ingest failed', e); } |
| 994 | // The lease, folded into the same pull off its own door (like presence), so a |
| 995 | // device that only watches a hand-off it dispatched still advances its footer. |
| 996 | try { if (j && j.lease) await adoptLeaseDoor(j.lease); } |
| 997 | catch (e) { log('lease door adopt failed', e); } |
| 998 | // An empty mailbox is an answer: this device has heard, and there was |
| 999 | // nothing to hear. See `pulledOk`. |
| 1000 | if (!j.present) { adoptVersion(0, preRead); pulledOk = true; restStatus(); return serverVersion; } |
| 1001 | var state; |
| 1002 | try { |
| 1003 | // The size of what arrived, before it is opened. Three forms of this |
| 1004 | // pass through in a moment -- the sealed blob, the plain text, and the |
| 1005 | // object graph `JSON.parse` builds from it -- but each is released as |
| 1006 | // soon as the next exists (see below), so no more than two are ever |
| 1007 | // live at once and only the graph survives into the merge. On a phone |
| 1008 | // this is still the single largest allocation the app makes. Bytes |
| 1009 | // only: no content. |
| 1010 | trail('sync pull', Math.round((j.blob || '').length / 1024) + 'K sealed'); |
| 1011 | var plain = await DaimondIdentity.unwrap(j.blob); // throws on a wrong key. |
| 1012 | // The sealed copy has done its work: release it the moment the plain |
| 1013 | // text exists, so the blob and the object graph never coexist. On a |
| 1014 | // phone the three of them together are the single largest allocation |
| 1015 | // the app makes, and iOS kills the tab before they all fit. `j.version` |
| 1016 | // is still read below, so only the blob field goes -- what is applied |
| 1017 | // and the order it is applied in do not change by a byte. |
| 1018 | j.blob = null; |
| 1019 | trail('sync parcel', Math.round(plain.length / 1024) + 'K plain'); |
| 1020 | state = JSON.parse(plain); |
| 1021 | // Same again: the plain text is redundant to the graph now, and |
| 1022 | // applyParcel below is the memory-heavy phase, so free it before that |
| 1023 | // runs rather than leaving it alive across the merge. |
| 1024 | plain = null; |
| 1025 | trail('sync parsed'); |
| 1026 | } catch (e) { |
| 1027 | // Not readable at all, which is a DIFFERENT thing from readable and |
| 1028 | // not mergeable, and the two must not be handled alike. What cannot |
| 1029 | // be opened is unusable to every device that holds this identity, so |
| 1030 | // the version is adopted and this device's own good state goes over |
| 1031 | // the top of it -- that is how an account recovers from a corrupt or |
| 1032 | // half-written blob at all. Refusing to push here instead would leave |
| 1033 | // the mailbox unreadable and every device silently stuck behind it. |
| 1034 | // `lastFailed` is for sections that ARRIVED and could not be merged; |
| 1035 | // this is not one. |
| 1036 | log('pull decrypt/parse failed; keeping local state'); |
| 1037 | adoptVersion(j.version | 0, preRead); |
| 1038 | if (!quiet) restStatus(); |
| 1039 | return serverVersion; |
| 1040 | } |
| 1041 | lastFailed = await applyParcel(state); |
| 1042 | pulledOk = true; // a parcel was read; see `pulledOk`. |
| 1043 | adoptVersion(j.version | 0, preRead); |
| 1044 | noteSynced(); |
| 1045 | // A merge that could not finish is not a sync that worked, and it is the |
| 1046 | // user's business: their other device's work is sitting in the mailbox |
| 1047 | // unread on this one. |
| 1048 | if (lastFailed.length) { |
| 1049 | log('pulled version', serverVersion, 'but could not merge', lastFailed.join(',')); |
| 1050 | if (!quiet) jam('merge'); |
| 1051 | return serverVersion; |
| 1052 | } |
| 1053 | unjam(); |
| 1054 | // A pull working says nothing about whether this device's own parcel will |
| 1055 | // EVER leave -- a GET is served to everyone, a push is not -- so a standing |
| 1056 | // refusal stays on the chip rather than being painted over with "Synced". |
| 1057 | if (quiet) { /* the push that called this is still running */ } |
| 1058 | else if (!entitled || tooLarge) restStatus(); |
| 1059 | else setStatus('synced', t('sync.synced'), 1800); |
| 1060 | log('pulled version', serverVersion, 'from', j.device || '?'); |
| 1061 | return serverVersion; |
| 1062 | } |
| 1063 | |
| 1064 | // ── Push ─────────────────────────────────────────────────── |
| 1065 | |
| 1066 | /// Encrypt and push local state under compare-and-set, reconciling a |
| 1067 | /// conflict by pulling, merging and retrying. A no-op when nothing has |
| 1068 | /// changed since the last push, so an idle app is quiet on the wire. |
| 1069 | async function push() { |
| 1070 | if (!ready() || !entitled) return; |
| 1071 | if (window.DaimondCore.busy && DaimondCore.busy()) { schedule(); return; } // never over a live turn. |
| 1072 | if (inFlight) { schedule(); return; } |
| 1073 | inFlight = true; |
| 1074 | try { |
| 1075 | for (var attempt = 0; attempt < MAX_CONFLICT_RETRIES; attempt++) { |
| 1076 | var state = await collectParcel(); |
| 1077 | var plain = JSON.stringify(state); |
| 1078 | // `lastPushed === null` is "this page has not sent anything yet", |
| 1079 | // which is the only moment the carried digest is asked about. Note |
| 1080 | // the short-circuit: on every push after the first, `sigOf` is |
| 1081 | // never called at all. |
| 1082 | var known = (plain === lastPushed) |
| 1083 | || (lastPushed === null && !!bootSig && (await sigOf(plain)) === bootSig); |
| 1084 | if (known && serverVersion > 0) { |
| 1085 | // Nothing new to send -- but the round is not wasted, and this |
| 1086 | // is the trigger that has to catch up. |
| 1087 | // |
| 1088 | // A window that is open and FOCUSED raises no focus event and |
| 1089 | // ends no turn, so on a device nobody is typing at, this push |
| 1090 | // is the only thing that still runs. It used to return here |
| 1091 | // without asking the gateway anything at all, so two devices |
| 1092 | // on two desks never learned about each other: the one being |
| 1093 | // worked on pushed, and the one being read never looked. That |
| 1094 | // is a device that is not editing NEVER converging, which is |
| 1095 | // how it was reported. |
| 1096 | // |
| 1097 | // Throttled against the last pull of ANY kind, because a |
| 1098 | // device that is quiet is quiet for a long time and this |
| 1099 | // must not become a poll -- nor a second GET on the heels |
| 1100 | // of the one a focus just made. |
| 1101 | if (Date.now() - lastPullAt >= IDLE_PULL_MIN_MS) await pull(); |
| 1102 | return; |
| 1103 | } |
| 1104 | |
| 1105 | var blob; |
| 1106 | try { blob = await DaimondIdentity.wrap(plain); } |
| 1107 | catch (e) { log('encrypt failed', e); return; } |
| 1108 | |
| 1109 | setStatus('syncing', t('sync.syncing')); |
| 1110 | var res; |
| 1111 | // `w` names this tab's wake channel, so the gateway taps the |
| 1112 | // account's OTHER devices and not this one: a device that pulled |
| 1113 | // in answer to its own push would double every round. |
| 1114 | try { res = await call('POST', { base_version: serverVersion, device: deviceLabel(), blob: blob, w: WAKE_ID }); } |
| 1115 | catch (e) { log('push network error', e); restStatus(); return; } |
| 1116 | |
| 1117 | if (res.status === 200 && res.json && res.json.ok) { |
| 1118 | serverVersion = res.json.version | 0; |
| 1119 | lastPushed = plain; |
| 1120 | saveVersion(); |
| 1121 | // Beside the version, and only here: this is the one place a |
| 1122 | // parcel is known to have reached the mailbox. A parcel the |
| 1123 | // gateway refused is not one this device has sent, so the 413 |
| 1124 | // arm below deliberately does not write it -- storing that |
| 1125 | // digest would have the next page skip a push that never |
| 1126 | // happened. |
| 1127 | saveSig(await sigOf(plain)); |
| 1128 | // The pushed state is now the shared fork point for the file merge. |
| 1129 | try { if (DaimondCore.syncCommitBaseline) await DaimondCore.syncCommitBaseline(); } |
| 1130 | catch (e) { /* baseline advances next time */ } |
| 1131 | // Declare the live chunk set that this state references and let |
| 1132 | // the gateway sweep everything it no longer does. The version |
| 1133 | // is named because the gateway refuses to sweep on behalf of a |
| 1134 | // device working from a stale view of the world — an index |
| 1135 | // built without knowing about someone else's file would |
| 1136 | // otherwise delete it. |
| 1137 | // |
| 1138 | // And ONLY from a device that merged the index it is about to |
| 1139 | // declare. `applyChunked` refuses the merge whenever the |
| 1140 | // workspace is not syncable -- a real folder is open, the tools |
| 1141 | // are not up -- and this device then held nothing but its own |
| 1142 | // view. Committing that view named none of the other device's |
| 1143 | // files and the gateway swept every one of them. The same |
| 1144 | // condition gates both, so what cannot be merged cannot be |
| 1145 | // declared. |
| 1146 | var mayCommit = !!(DaimondCore.syncMayCommitChunks && DaimondCore.syncMayCommitChunks()); |
| 1147 | if (!mayCommit) log('chunk index not merged on this device — not committing a live set'); |
| 1148 | else { |
| 1149 | try { |
| 1150 | if (window.DaimondChunks && state.chunked) { |
| 1151 | var tiers = window.DaimondCloud ? DaimondCloud.tierPlan(DaimondCloud.allowance()) : null; |
| 1152 | // A refusal is a swept-or-not answer nobody heard: the |
| 1153 | // gateway can decline this commit, and a client that |
| 1154 | // throws the result away cannot tell a sweep that |
| 1155 | // happened from one that did not. |
| 1156 | var swept = await DaimondChunks.commit(state.chunked, serverVersion, tiers); |
| 1157 | if (!swept) log('chunk commit refused at version', serverVersion); |
| 1158 | } |
| 1159 | } |
| 1160 | catch (e) { log('chunk commit failed', e); } |
| 1161 | } |
| 1162 | tooLarge = false; // whatever would not fit, fits now |
| 1163 | unjam(); // and whatever would not reconcile, has |
| 1164 | noteSynced(); |
| 1165 | setStatus('synced', t('sync.synced'), 2200); |
| 1166 | log('pushed version', serverVersion); |
| 1167 | return; |
| 1168 | } |
| 1169 | if (res.status === 409) { |
| 1170 | // Another device moved the blob on. Pull it, merge, retry |
| 1171 | // against the version we just learned. `quiet`: the round is |
| 1172 | // still running, so the pull must not report "Synced" over a |
| 1173 | // push that has not landed. |
| 1174 | log('conflict at base', serverVersion, '— pulling and retrying'); |
| 1175 | var v = await pull(true); |
| 1176 | if (v < 0) { jam('busy'); return; } // could not reconcile; say so. |
| 1177 | // A merge that did not finish must NOT be pushed over. The |
| 1178 | // retry sends what this device holds, and what this device |
| 1179 | // holds is precisely the state that failed to take the other |
| 1180 | // device's work: pushing it replaces their version in the |
| 1181 | // mailbox with one that never saw it. |
| 1182 | if (lastFailed.length) { |
| 1183 | log('merge incomplete (', lastFailed.join(','), ') — not pushing over it'); |
| 1184 | jam('merge'); |
| 1185 | return; |
| 1186 | } |
| 1187 | lastPushed = null; // local state changed under us; force a fresh send. |
| 1188 | // Space the retries with a jittered backoff so three busy devices do |
| 1189 | // not collide on every attempt and exhaust in a burst ("work has not |
| 1190 | // been sent"). Same shape as the lease-take fix: only the retry cadence |
| 1191 | // changes; the pull-merge that converges is untouched. |
| 1192 | if (attempt + 1 < MAX_CONFLICT_RETRIES) { |
| 1193 | await new Promise(function (r) { |
| 1194 | setTimeout(r, Math.round(CONFLICT_BACKOFF_MS * (0.5 + Math.random()))); |
| 1195 | }); |
| 1196 | } |
| 1197 | continue; |
| 1198 | } |
| 1199 | if (res.status === 402) { |
| 1200 | // Not on the sync tier. Nobody asked for this push -- it is the |
| 1201 | // engine's own idle round -- so the refusal is reported where a |
| 1202 | // user can find it and nowhere else. It used to raise a dialog |
| 1203 | // over the whole app and open Credits, which interrupted people |
| 1204 | // who had one device and had never wanted sync. |
| 1205 | entitled = false; // stop trying until re-checked. |
| 1206 | restStatus(); // and it outranks a stall: see restStatus. |
| 1207 | log('sync not entitled (402); pausing pushes'); |
| 1208 | return; |
| 1209 | } |
| 1210 | if (res.status === 413) { |
| 1211 | // The parcel is over the gateway's ceiling, so this device's work |
| 1212 | // stops travelling until something in it gets smaller. That is a |
| 1213 | // thing the user can act on -- almost always one enormous Diamond |
| 1214 | // or one enormous workspace file -- and for it to be actionable it |
| 1215 | // has to be visible. It used to be a console line. |
| 1216 | tooLarge = true; |
| 1217 | lastPushed = plain; // don't spin on the same oversize state. |
| 1218 | restStatus(); |
| 1219 | log('blob too large (413); not retrying this payload'); |
| 1220 | return; |
| 1221 | } |
| 1222 | // Anything else: the round is over, so the chip stops claiming to be |
| 1223 | // syncing and goes back to whatever is standing. |
| 1224 | log('push status', res.status, '— giving up this round'); |
| 1225 | restStatus(); |
| 1226 | return; |
| 1227 | } |
| 1228 | // Out of attempts. The mailbox moved under every one of them, so this |
| 1229 | // device's work is still only here -- which is exactly the state the |
| 1230 | // chip exists to report. It is not re-armed from here: the next |
| 1231 | // change, the next turn ending, the next focus and the next tab |
| 1232 | // switch all try again, and a loop that retried on its own would |
| 1233 | // spin two busy devices against each other with nobody the wiser. |
| 1234 | log('conflict retries exhausted; this device’s work has not been sent'); |
| 1235 | jam('busy'); |
| 1236 | } finally { |
| 1237 | inFlight = false; |
| 1238 | } |
| 1239 | } |
| 1240 | |
| 1241 | // ── Presence ─────────────────────────────────────────────── |
| 1242 | // A separate, lightweight door from push/pull. A beat WRITES this device's |
| 1243 | // last_seen and READS the account's whole fresh map back in one round; it bumps |
| 1244 | // no blob version and wakes no other device, so it can fire every ~45s without |
| 1245 | // the cost push() carries. That is the whole point of moving presence off the |
| 1246 | // content parcel: the moving timestamp no longer re-uploads ~163K and taps every |
| 1247 | // device. The map comes back stamped in the SERVER clock with a `now`, and |
| 1248 | // `DaimondPresence.ingest` converts it into this client's frame. |
| 1249 | |
| 1250 | /// Beat this device's presence and adopt the authoritative map. `deviceId` and |
| 1251 | /// `name` are passed in by the caller (daimond.js), so this file need not reach |
| 1252 | /// for identity. A missed beat is safe -- the freshness window and the lease |
| 1253 | /// catch a peer that actually slept -- so an error is swallowed rather than |
| 1254 | /// surfaced. Answers the response JSON, or null. |
| 1255 | async function beatPresence(deviceId, name, attended) { |
| 1256 | if (!ready() || !entitled) return null; |
| 1257 | try { |
| 1258 | // `attended` is the attention signal (foreground + recent interaction) a |
| 1259 | // live consent routes on. Sent so a gateway that stores it can relay it to a |
| 1260 | // runner; a gateway that does not carry it ignores the field, and a runner |
| 1261 | // then sees no attended peer and parks (the fail-safe the design requires). |
| 1262 | var res = await call('POST', |
| 1263 | { device_id: String(deviceId || ''), name: String(name || ''), attended: !!attended }, '?presence=1'); |
| 1264 | if (res.status === 200 && res.json && res.json.presence && window.DaimondPresence) { |
| 1265 | DaimondPresence.ingest(res.json.presence, res.json.now); |
| 1266 | } |
| 1267 | return res.json || null; |
| 1268 | } catch (e) { log('presence beat failed', e); return null; } |
| 1269 | } |
| 1270 | |
| 1271 | /// Read the account's presence map WITHOUT writing a beat -- a GET to |
| 1272 | /// `?presence=1` -- and adopt it, for a dispatch-time refresh so the decision |
| 1273 | /// sees the freshest peers. Quiet on error, like the beat. |
| 1274 | async function refreshPresence() { |
| 1275 | if (!ready() || !entitled) return null; |
| 1276 | try { |
| 1277 | var res = await call('GET', undefined, '?presence=1'); |
| 1278 | if (res.status === 200 && res.json && res.json.presence && window.DaimondPresence) { |
| 1279 | DaimondPresence.ingest(res.json.presence, res.json.now); |
| 1280 | } |
| 1281 | return res.json || null; |
| 1282 | } catch (e) { log('presence refresh failed', e); return null; } |
| 1283 | } |
| 1284 | |
| 1285 | // ── The lease door ───────────────────────────────────────── |
| 1286 | // WHICH DEVICE IS RUNNING A TURN used to ride the content parcel as a section, |
| 1287 | // so a lease CLAIM was a whole-parcel compare-and-set: under three busy devices |
| 1288 | // the parcel version churned faster than a claim could land, and the loser of a |
| 1289 | // hand-off race stormed the gateway with 409s (up to the take loop times the |
| 1290 | // push loop) before it stood down. The lease now has its own lightweight CAS |
| 1291 | // door on the gateway (`?lease=1`), exactly as presence took its own door: a |
| 1292 | // claim is a ~100-byte compare-and-set that does not touch the parcel and does |
| 1293 | // not contend with content churn. The arbitration is unchanged -- it still lives |
| 1294 | // in DaimondLease's take-if-vacant merge and the merge-trust re-read, so exactly |
| 1295 | // one runner still wins a turn; only the CAS substrate moved off the parcel. |
| 1296 | // |
| 1297 | // The blob is the lease map, AES-GCM-sealed under the account key with the lease |
| 1298 | // purpose bound in (so the gateway holds an opaque record and a lease blob is |
| 1299 | // cryptographically distinct from a parcel or an envelope). A tiny marker inside |
| 1300 | // guards against ever reading some other blob as a lease. |
| 1301 | var LEASE_AAD = 'daimond/peer/lease/1'; |
| 1302 | var LEASE_MARK = 'dlease1'; |
| 1303 | var _leaseVer = 0; // the door's version this device last saw. |
| 1304 | |
| 1305 | // Base64 of raw bytes and back -- the door blob is bytes, unlike the parcel |
| 1306 | // which travels as a string through DaimondIdentity.wrap. |
| 1307 | function b64FromBytes(bytes) { |
| 1308 | var b = (bytes instanceof Uint8Array) ? bytes : new Uint8Array(bytes); |
| 1309 | var s = ''; |
| 1310 | for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); |
| 1311 | return btoa(s); |
| 1312 | } |
| 1313 | function bytesFromB64(s) { |
| 1314 | var raw = atob(String(s)); |
| 1315 | var out = new Uint8Array(raw.length); |
| 1316 | for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| 1317 | return out; |
| 1318 | } |
| 1319 | |
| 1320 | /// Seal a lease map for the door, or '' when there is nothing (or no key) to |
| 1321 | /// send -- an empty blob is a vacant door, which the gateway stores verbatim. |
| 1322 | async function leaseSeal(map) { |
| 1323 | if (!map || !Object.keys(map).length) return ''; |
| 1324 | if (!window.DaimondIdentity || !DaimondIdentity.wrapBytesAad |
| 1325 | || (DaimondIdentity.isUnlocked && !DaimondIdentity.isUnlocked())) return ''; |
| 1326 | var plain = new TextEncoder().encode(JSON.stringify({ k: LEASE_MARK, v: map })); |
| 1327 | return b64FromBytes(await DaimondIdentity.wrapBytesAad(plain, LEASE_AAD)); |
| 1328 | } |
| 1329 | |
| 1330 | /// Open a door blob back to a lease map, or null when it is empty, unopenable, |
| 1331 | /// or not a lease record (the marker did not match). |
| 1332 | async function leaseUnseal(b64) { |
| 1333 | if (!b64) return null; |
| 1334 | if (!window.DaimondIdentity || !DaimondIdentity.unwrapBytesAad |
| 1335 | || (DaimondIdentity.isUnlocked && !DaimondIdentity.isUnlocked())) return null; |
| 1336 | try { |
| 1337 | var pt = await DaimondIdentity.unwrapBytesAad(bytesFromB64(b64), LEASE_AAD); |
| 1338 | var obj = JSON.parse(new TextDecoder().decode(pt)); |
| 1339 | return (obj && obj.k === LEASE_MARK && obj.v) ? obj.v : null; |
| 1340 | } catch (e) { return null; } |
| 1341 | } |
| 1342 | |
| 1343 | /// Read the lease door: its version and the decrypted lease map. Empty map on a |
| 1344 | /// vacant or unopenable door. Caches the version so a later fallback getter and |
| 1345 | /// the claim path agree on the base. |
| 1346 | async function leaseGet() { |
| 1347 | var res = await call('GET', undefined, '?lease=1'); |
| 1348 | var j = res && res.json; |
| 1349 | var ver = (j && j.version) | 0; |
| 1350 | _leaseVer = ver; |
| 1351 | var leases = (j && j.blob) ? (await leaseUnseal(j.blob)) : null; |
| 1352 | return { version: ver, leases: leases || {} }; |
| 1353 | } |
| 1354 | |
| 1355 | /// Compare-and-set the lease door: seal `proposed`, push it against `base`. |
| 1356 | /// Answers the shape DaimondLease's CAS expects -- `{ ok, version, leases }` -- |
| 1357 | /// so a 409 hands back the door's current version and map for the retry. |
| 1358 | async function leaseCommit(base, proposed) { |
| 1359 | var blob = await leaseSeal(proposed); |
| 1360 | var res = await call('POST', { base_version: base | 0, blob: blob, w: WAKE_ID }, '?lease=1'); |
| 1361 | var j = res && res.json; |
| 1362 | if (res && res.status === 200 && j && j.ok) { |
| 1363 | _leaseVer = (j.version) | 0; |
| 1364 | return { ok: true, version: _leaseVer }; |
| 1365 | } |
| 1366 | // 409 (or any refusal): report the door's current state for the re-read. |
| 1367 | var ver = (j && j.version) | 0; |
| 1368 | _leaseVer = ver; |
| 1369 | return { ok: false, version: ver, leases: (j && j.blob) ? (await leaseUnseal(j.blob)) || {} : {} }; |
| 1370 | } |
| 1371 | |
| 1372 | /// Adopt the lease map folded into an ordinary pull (like presence), so a device |
| 1373 | /// that dispatched -- and is only WATCHING, never claiming -- still sees the peer |
| 1374 | /// take and run the turn and advances its footer (D4). `j.lease` is the door's |
| 1375 | /// {version, blob}; a moved merge fires DaimondLease.onChange for the redraw. |
| 1376 | async function adoptLeaseDoor(lease) { |
| 1377 | if (!lease || !window.DaimondLease) return; |
| 1378 | _leaseVer = (lease.version) | 0; |
| 1379 | var map = lease.blob ? (await leaseUnseal(lease.blob)) : null; |
| 1380 | try { DaimondLease.adopt(map || {}); } catch (e) { log('lease adopt failed', e); } |
| 1381 | } |
| 1382 | |
| 1383 | // ── Wake channel ─────────────────────────────────────────── |
| 1384 | // The trigger that was missing. Every other trigger in this file is something |
| 1385 | // that happened HERE -- a turn ended, the window came back, a Diamond was |
| 1386 | // renamed -- so a window left open and unfocused had none at all, and sat on |
| 1387 | // stale state until somebody touched it. This one comes from the gateway, |
| 1388 | // which is the only party that knows when the mailbox moved. |
| 1389 | // |
| 1390 | // WHAT ARRIVES IS A NUMBER. The gateway sends the account's new blob version |
| 1391 | // and nothing else: no content, no device label, no account name. A version |
| 1392 | // higher than the one this device holds runs the SAME pull the focus path |
| 1393 | // runs, over the same authenticated request. The end-to-end story does not |
| 1394 | // change by a byte, because nothing new crosses the wire. |
| 1395 | // |
| 1396 | // TWO WAYS IN, AND IT ASKS BEFORE IT PICKS. The first thing the channel does |
| 1397 | // is park one short plain request, which answers whether there is a gateway |
| 1398 | // there, whether it speaks this, and whether it already has news. Only then |
| 1399 | // does it reach for a WebSocket; where the front door will not carry one, it |
| 1400 | // goes on parking requests for three quarters of a minute at a time, which |
| 1401 | // any proxy in the world will forward. Parking is not a consolation prize: a |
| 1402 | // completed response wakes a throttled background tab exactly as a frame |
| 1403 | // does, which is the property that matters here. |
| 1404 | // |
| 1405 | // If neither works the channel turns itself off and the app is exactly what |
| 1406 | // it was before -- focus, settling, and the throttled catch-up in push(). |
| 1407 | |
| 1408 | /// Note a version the channel heard about, and pull for it -- once, soon, and |
| 1409 | /// not on the heels of a pull that has just asked the same question. |
| 1410 | function wakeTo(v) { |
| 1411 | v = v | 0; |
| 1412 | if (v > wakeTarget) wakeTarget = v; |
| 1413 | if (v <= serverVersion) return; // already have it. |
| 1414 | if (wakeSoon) return; // a pull is already coming. |
| 1415 | var wait = Math.max(0, WAKE_PULL_MIN_MS - (Date.now() - lastPullAt)); |
| 1416 | wakeSoon = setTimeout(function () { wakeSoon = null; wakePull(); }, wait); |
| 1417 | } |
| 1418 | |
| 1419 | /// The pull a wake asks for. Held behind the same `inFlight` gate as every |
| 1420 | /// other round, and re-armed rather than dropped if one is under way: the |
| 1421 | /// news is real, so it must not be lost to a coincidence of timing. |
| 1422 | async function wakePull() { |
| 1423 | if (!ready()) return; |
| 1424 | if (wakeTarget <= serverVersion) return; |
| 1425 | if (inFlight) { |
| 1426 | if (!wakeSoon) wakeSoon = setTimeout(function () { wakeSoon = null; wakePull(); }, 500); |
| 1427 | return; |
| 1428 | } |
| 1429 | wakes++; |
| 1430 | inFlight = true; |
| 1431 | try { await pull(); } |
| 1432 | finally { inFlight = false; } |
| 1433 | } |
| 1434 | |
| 1435 | /// Whether the channel should be running at all: sync can run, and this |
| 1436 | /// account is allowed to push. A 402 stops the channel with the pushes -- an |
| 1437 | /// account that may not sync has nothing to be woken for. |
| 1438 | function wakeWanted() { |
| 1439 | return ready() && entitled && wakeMode !== 'off'; |
| 1440 | } |
| 1441 | |
| 1442 | /// Open the channel, by whichever transport is still on the table. |
| 1443 | function wakeStart() { |
| 1444 | if (!wakeWanted()) return; |
| 1445 | if (wakeMode === 'poll') { wakePoll(); return; } |
| 1446 | if (wakeMode === '') { wakeProbe(); return; } |
| 1447 | wakeSocket(); |
| 1448 | } |
| 1449 | |
| 1450 | /// Ask once, over plain HTTP, before reaching for a socket. |
| 1451 | /// |
| 1452 | /// A short parked request settles three questions in one go: whether there is |
| 1453 | /// a gateway there at all, whether it understands the channel, and whether it |
| 1454 | /// already has news. Only then is a WebSocket attempted. |
| 1455 | /// |
| 1456 | /// The order matters for a reason that has nothing to do with the protocol: a |
| 1457 | /// WebSocket that cannot connect writes a line to the browser's console that |
| 1458 | /// no application code can suppress. Opening one speculatively -- against a |
| 1459 | /// gateway that is not running, or a stubbed one in a test -- fills the console |
| 1460 | /// with failures of a thing that was working as designed. Asking first costs |
| 1461 | /// one request and about a second. |
| 1462 | async function wakeProbe() { |
| 1463 | if (wakeSock || wakeTimer) return; |
| 1464 | // A probe belonging to a torn-down generation is not this channel's: it |
| 1465 | // stood down at the teardown, and the request it is parked on will answer |
| 1466 | // to nobody. Only a probe of the CURRENT generation is a reason not to |
| 1467 | // make another one, or a re-arm waits out a park it has already abandoned. |
| 1468 | if (wakeProbing && wakeProbeGen === wakeGen) return; |
| 1469 | var gen = wakeGen; |
| 1470 | wakeProbing = true; |
| 1471 | wakeProbeGen = gen; |
| 1472 | try { |
| 1473 | var res; |
| 1474 | try { |
| 1475 | res = await call('GET', undefined, |
| 1476 | '?above=' + (serverVersion | 0) + '&ms=' + WAKE_PROBE_MS + '&w=' + encodeURIComponent(WAKE_ID)); |
| 1477 | } catch (e) { |
| 1478 | if (gen === wakeGen) wakeRetry(); // nothing answering; try again later. |
| 1479 | return; |
| 1480 | } |
| 1481 | // THE NEWS FIRST, WHATEVER GENERATION HEARD IT. That the mailbox has |
| 1482 | // moved is a fact about the ACCOUNT, not about the channel that |
| 1483 | // happened to be holding the question, so a teardown arriving between |
| 1484 | // the asking and the answering is no reason to throw it away. Only |
| 1485 | // `wakeWanted()` may refuse it: a device that has signed out, or been |
| 1486 | // put deliberately on 'off', has no business pulling. |
| 1487 | if (res.status === 200 && res.json && res.json.waited === true |
| 1488 | && res.json.changed && wakeWanted()) { |
| 1489 | wakeTo(res.json.version | 0); |
| 1490 | } |
| 1491 | // Everything below decides what the channel does NEXT, which is the |
| 1492 | // live generation's business and nobody else's. |
| 1493 | if (gen !== wakeGen || !wakeWanted()) return; |
| 1494 | if (res.status !== 200) { wakeRetry(); return; } |
| 1495 | if (!res.json || res.json.waited !== true) { |
| 1496 | log('wake channel: this gateway does not park requests; channel off'); |
| 1497 | wakeMode = 'off'; |
| 1498 | return; |
| 1499 | } |
| 1500 | wakeBackoff = WAKE_RETRY_MIN_MS; |
| 1501 | wakeMode = 'ws'; |
| 1502 | wakeSocket(); |
| 1503 | } finally { |
| 1504 | // Only the probe that still OWNS the flag may clear it. A stale one |
| 1505 | // finishing late would otherwise report the live one's park as over, |
| 1506 | // and the supervisor would open a second. |
| 1507 | if (wakeProbeGen === gen) wakeProbing = false; |
| 1508 | } |
| 1509 | } |
| 1510 | |
| 1511 | /// Open the WebSocket. Only ever reached once the probe above has shown there |
| 1512 | /// is a gateway on the other end that speaks this. |
| 1513 | function wakeSocket() { |
| 1514 | if (!wakeWanted()) return; |
| 1515 | if (wakeSock || wakeTimer) return; |
| 1516 | var url; |
| 1517 | try { |
| 1518 | url = (location.protocol === 'https:' ? 'wss://' : 'ws://') |
| 1519 | + location.host + WS_PATH + '?w=' + encodeURIComponent(WAKE_ID); |
| 1520 | } catch (e) { wakeMode = 'poll'; wakePoll(); return; } |
| 1521 | |
| 1522 | var sock, opened = false, gen = wakeGen; |
| 1523 | try { sock = new WebSocket(url); } |
| 1524 | catch (e) { wakeGiveUpOnSockets(); return; } |
| 1525 | wakeSock = sock; |
| 1526 | sock.onopen = function () { |
| 1527 | if (gen !== wakeGen) { try { sock.close(); } catch (e) {} return; } |
| 1528 | opened = true; |
| 1529 | wakeMode = 'ws'; |
| 1530 | wakeFails = 0; |
| 1531 | wakeWorked = true; |
| 1532 | wakeBackoff = WAKE_RETRY_MIN_MS; |
| 1533 | log('wake channel open (ws)'); |
| 1534 | }; |
| 1535 | sock.onmessage = function (ev) { |
| 1536 | if (gen !== wakeGen) return; |
| 1537 | var v = parseInt(ev.data, 10); |
| 1538 | if (isFinite(v)) wakeTo(v); |
| 1539 | }; |
| 1540 | sock.onerror = function () { /* a close always follows; handled there. */ }; |
| 1541 | sock.onclose = function () { |
| 1542 | if (wakeSock === sock) wakeSock = null; |
| 1543 | if (gen !== wakeGen) return; |
| 1544 | if (!opened && !wakeWorked) { |
| 1545 | // Never opened, and none ever has here. Two of these and the front |
| 1546 | // door is not carrying upgrades, whatever the reason, so stop |
| 1547 | // asking it to. A socket that HAS worked on this page is a |
| 1548 | // different story -- the gateway is restarting, or the network |
| 1549 | // went -- and that is waited out, not given up on. |
| 1550 | wakeFails++; |
| 1551 | if (wakeFails >= WAKE_WS_TRIES) { wakeGiveUpOnSockets(); return; } |
| 1552 | } |
| 1553 | // Go back through the plain probe rather than straight at another |
| 1554 | // socket. A refused UPGRADE is the one failure this channel cannot |
| 1555 | // read: the browser hands back a close with no status, so a session |
| 1556 | // that had gone looked exactly like a network that had. This device |
| 1557 | // reconnected on a jittered backoff for four hours and fifty minutes |
| 1558 | // against a gateway answering 401 to every one -- about two hundred |
| 1559 | // and forty refusals an hour, and not one of them said why. The probe |
| 1560 | // is an ordinary request through call(), which takes a fresh session |
| 1561 | // when that is what is wrong and gives up loudly when it cannot. |
| 1562 | if (wakeMode === 'ws') wakeMode = ''; |
| 1563 | wakeRetry(); |
| 1564 | }; |
| 1565 | } |
| 1566 | |
| 1567 | /// The WebSocket is not going to work here. Park plain requests instead -- |
| 1568 | /// same wake, same latency, and nothing between here and the gateway has to |
| 1569 | /// understand anything but HTTP. |
| 1570 | function wakeGiveUpOnSockets() { |
| 1571 | if (wakeMode === 'off') return; |
| 1572 | log('wake channel: no websocket through this front door; parking requests instead'); |
| 1573 | wakeMode = 'poll'; |
| 1574 | wakePoll(); |
| 1575 | } |
| 1576 | |
| 1577 | /// Come back to the socket after a pause that grows, with jitter on it. |
| 1578 | function wakeRetry() { |
| 1579 | if (wakeTimer || !wakeWanted()) return; |
| 1580 | var wait = Math.min(WAKE_RETRY_MAX_MS, wakeBackoff); |
| 1581 | wakeBackoff = Math.min(WAKE_RETRY_MAX_MS, wakeBackoff * 2); |
| 1582 | var jittered = wait * (0.5 + Math.random()); |
| 1583 | wakeTimer = setTimeout(function () { wakeTimer = null; wakeStart(); }, jittered); |
| 1584 | } |
| 1585 | |
| 1586 | /// Park a request at the gateway naming the version this device holds, and |
| 1587 | /// let it answer when there is a newer one. Loops until the channel is torn |
| 1588 | /// down or the gateway shows it does not park. |
| 1589 | async function wakePoll() { |
| 1590 | var gen = wakeGen; |
| 1591 | // Only a loop of the CURRENT generation stands in the way of another. One |
| 1592 | // left over from a teardown is parked on a request that may not answer for |
| 1593 | // forty-five seconds, and treating that as "a park loop is running" is |
| 1594 | // what left a re-armed channel with nothing parked at all until the |
| 1595 | // supervisor's next tick -- half a minute of a device hearing nothing, |
| 1596 | // measured. See `wakePollGen`. |
| 1597 | if (wakePolling && wakePollGen === gen) return; |
| 1598 | wakePolling = true; |
| 1599 | wakePollGen = gen; |
| 1600 | try { |
| 1601 | while (gen === wakeGen && wakeWanted() && wakeMode === 'poll') { |
| 1602 | var began = Date.now(); |
| 1603 | var res; |
| 1604 | try { |
| 1605 | // A stale loop stops here rather than sleeping and asking |
| 1606 | // again: the backoff it would grow belongs to the live one. |
| 1607 | if (gen !== wakeGen) break; |
| 1608 | res = await call('GET', undefined, |
| 1609 | '?above=' + (serverVersion | 0) + '&ms=' + WAKE_POLL_MS + '&w=' + encodeURIComponent(WAKE_ID)); |
| 1610 | } catch (e) { |
| 1611 | // The gateway is down or the network went. Wait, growing, |
| 1612 | // rather than spinning against a closed door. |
| 1613 | if (gen !== wakeGen) break; |
| 1614 | await wakeSleep(Math.min(WAKE_RETRY_MAX_MS, wakeBackoff) * (0.5 + Math.random())); |
| 1615 | wakeBackoff = Math.min(WAKE_RETRY_MAX_MS, wakeBackoff * 2); |
| 1616 | continue; |
| 1617 | } |
| 1618 | // THE NEWS FIRST, WHATEVER GENERATION HEARD IT -- see wakeProbe. |
| 1619 | // This is the half that made the re-arm cost news rather than just |
| 1620 | // time: the answer to the abandoned park says the mailbox moved, |
| 1621 | // and the loop used to break on the generation two lines above |
| 1622 | // reading it and discard the very thing it had been waiting for. |
| 1623 | if (res.status === 200 && res.json && res.json.waited === true |
| 1624 | && res.json.changed && wakeWanted()) { |
| 1625 | wakeTo(res.json.version | 0); |
| 1626 | } |
| 1627 | if (gen !== wakeGen) break; |
| 1628 | if (res.status !== 200) { |
| 1629 | // A refusal, or a 502 from a gateway that is restarting: both |
| 1630 | // temporary, and neither a reason to give the channel up. Wait, |
| 1631 | // growing, and ask again. Turning the channel off here is what a |
| 1632 | // restart used to do to it -- the device went quiet for good over |
| 1633 | // an outage that lasted twenty seconds. A 401 does not reach here |
| 1634 | // on the first go: call() answers it with a fresh session, and |
| 1635 | // only a renewal that failed comes back refused -- at which point |
| 1636 | // `wakeWanted()` is false and the loop below ends rather than |
| 1637 | // parking against a door that is shut. |
| 1638 | await wakeSleep(Math.min(WAKE_RETRY_MAX_MS, wakeBackoff) * (0.5 + Math.random())); |
| 1639 | wakeBackoff = Math.min(WAKE_RETRY_MAX_MS, wakeBackoff * 2); |
| 1640 | continue; |
| 1641 | } |
| 1642 | if (!res.json || res.json.waited !== true) { |
| 1643 | // Answered, and did not park. Either the gateway is too old to |
| 1644 | // know how, or something between here and it dropped the query |
| 1645 | // and served an ordinary pull. That is a property of the road, |
| 1646 | // not of the moment, so this one does end the channel -- one |
| 1647 | // such answer per page load is the whole cost of finding out. |
| 1648 | // |
| 1649 | // AND IT IS THE ONLY DOOR OUT OF THIS CHANNEL THAT DOES NOT |
| 1650 | // COME BACK. Everything else recovers: a socket that had |
| 1651 | // opened and went away is waited out, two that never opened |
| 1652 | // fall through to parking, a 401 takes a fresh session and a |
| 1653 | // 5xx from a restarting gateway backs off and asks again. |
| 1654 | // `wakeMode = 'off'` alone makes `wakeWanted()` false, and |
| 1655 | // with it the supervisor, the retry and `onAuthed`'s own |
| 1656 | // `wakeStart()` all decline -- so nothing but a reload or |
| 1657 | // `wakeVia` re-arms it. That is right for a road that strips |
| 1658 | // queries and wrong for a 200 that was not a park for some |
| 1659 | // passing reason, and the catch-up below is what now bounds |
| 1660 | // the second case at twenty seconds instead of the session. |
| 1661 | // |
| 1662 | // OXEDYNE'S OWN ROAD DOES CARRY IT, checked 2026-08-28 rather |
| 1663 | // than assumed: jarrah's `daimond.oxedyne.com` vhost reaches |
| 1664 | // the gateway through a Steel `proxy_route` on `/api/`, which |
| 1665 | // re-appends the query verbatim on the plain hop and on the |
| 1666 | // upgrade, and tunnels the WebSocket. It is Steel's OTHER |
| 1667 | // shape that would break this -- an `api_route` in proxy mode |
| 1668 | // forwards a configured path and never reads the query at all |
| 1669 | // -- so a front door moved onto one would take every device's |
| 1670 | // channel with it and say nothing. |
| 1671 | log('wake channel: this gateway does not park requests; channel off'); |
| 1672 | wakeMode = 'off'; |
| 1673 | break; |
| 1674 | } |
| 1675 | wakeBackoff = WAKE_RETRY_MIN_MS; |
| 1676 | // The news itself was acted on above, before the generation was |
| 1677 | // consulted, because it is true of the account either way. |
| 1678 | // However fast that answered, the next one is not immediate. |
| 1679 | var spent = Date.now() - began; |
| 1680 | if (spent < WAKE_POLL_FLOOR_MS) await wakeSleep(WAKE_POLL_FLOOR_MS - spent); |
| 1681 | } |
| 1682 | } finally { |
| 1683 | // Only the loop that still OWNS the flag may clear it, or a stale one |
| 1684 | // finishing late would declare the live one's park over. |
| 1685 | if (wakePollGen === gen) wakePolling = false; |
| 1686 | } |
| 1687 | } |
| 1688 | |
| 1689 | function wakeSleep(ms) { |
| 1690 | return new Promise(function (r) { setTimeout(r, ms); }); |
| 1691 | } |
| 1692 | |
| 1693 | /// Shut the channel. Everything in flight stands down on the generation |
| 1694 | /// counter, so a loop that is mid-await cannot come back and reopen it. |
| 1695 | function wakeStop() { |
| 1696 | wakeGen++; |
| 1697 | if (wakeTimer) { clearTimeout(wakeTimer); wakeTimer = null; } |
| 1698 | if (wakeSoon) { clearTimeout(wakeSoon); wakeSoon = null; } |
| 1699 | if (wakeSock) { try { wakeSock.close(); } catch (e) { /* already gone */ } wakeSock = null; } |
| 1700 | } |
| 1701 | |
| 1702 | /// Is the channel in a position to be told when the mailbox moves? |
| 1703 | /// |
| 1704 | /// One rule, one copy: `wake()` reports it and `catchUp()` stands down on it, |
| 1705 | /// and a second copy of it is a second thing to fall out of step with this |
| 1706 | /// one. A park that belongs to a torn-down generation is not this channel |
| 1707 | /// being open, however long the gateway goes on holding it. |
| 1708 | function wakeOpen() { |
| 1709 | return !!(wakeSock && wakeSock.readyState === 1) |
| 1710 | || (wakePolling && wakePollGen === wakeGen); |
| 1711 | } |
| 1712 | |
| 1713 | /// Whether a park or a probe of the CURRENT generation is outstanding. |
| 1714 | /// |
| 1715 | /// The question the supervisor actually wants answered. A park left over from |
| 1716 | /// a teardown is not the channel doing anything -- it is a request the gateway |
| 1717 | /// has not finished holding -- and counting it as one is what left this device |
| 1718 | /// with no channel, and no complaint, for the length of a park. |
| 1719 | function wakeLive() { |
| 1720 | return (wakePolling && wakePollGen === wakeGen) |
| 1721 | || (wakeProbing && wakeProbeGen === wakeGen); |
| 1722 | } |
| 1723 | |
| 1724 | /// Keep the channel matching what the app is doing. |
| 1725 | /// |
| 1726 | /// A poll rather than an event, because the two things that end a channel -- |
| 1727 | /// locking the identity and logging out of the gateway -- are done in other |
| 1728 | /// files that raise nothing. Ten seconds is far inside a session's life and |
| 1729 | /// costs two boolean reads. |
| 1730 | function wakeWatch() { |
| 1731 | if (wakeWanted()) { |
| 1732 | if (!wakeSock && !wakeTimer && !wakeLive()) wakeStart(); |
| 1733 | } else if (wakeSock || wakeTimer || wakeLive()) { |
| 1734 | log('wake channel closing: sync cannot run here just now'); |
| 1735 | wakeStop(); |
| 1736 | } |
| 1737 | } |
| 1738 | |
| 1739 | // ── Scheduling ───────────────────────────────────────────── |
| 1740 | |
| 1741 | /// Push after a quiet period, coalescing rapid triggers into one send. |
| 1742 | function schedule() { |
| 1743 | if (pushTimer) return; |
| 1744 | pushTimer = setTimeout(function () { pushTimer = null; push(); }, PUSH_DEBOUNCE_MS); |
| 1745 | } |
| 1746 | |
| 1747 | /// Coming back to the window: catch up on what the other device did. |
| 1748 | /// |
| 1749 | /// A pull, not a push -- the point is to LEARN something, and the idle and |
| 1750 | /// tab-hidden triggers already cover contributing. Debounced, because one |
| 1751 | /// click into the window raises several of these; and rate-limited, because |
| 1752 | /// alt-tabbing is something people do all afternoon. |
| 1753 | function scheduleFocusPull() { |
| 1754 | if (focusTimer) return; |
| 1755 | focusTimer = setTimeout(function () { focusTimer = null; focusPull(); }, FOCUS_DEBOUNCE_MS); |
| 1756 | } |
| 1757 | |
| 1758 | async function focusPull() { |
| 1759 | if (!ready()) return; |
| 1760 | if (inFlight) return; // a round is already under way; it is fresher than ours |
| 1761 | if (Date.now() - lastFocusPull < FOCUS_PULL_MIN_MS) return; |
| 1762 | lastFocusPull = Date.now(); |
| 1763 | // Held for the duration, so a push arriving mid-pull waits its turn rather |
| 1764 | // than sending state that is halfway through being replaced. |
| 1765 | inFlight = true; |
| 1766 | try { await pull(); } |
| 1767 | finally { inFlight = false; } |
| 1768 | } |
| 1769 | |
| 1770 | /// Ask the gateway what it is holding, on a device nothing else will prompt. |
| 1771 | /// |
| 1772 | /// Measured against the last pull of ANY kind rather than against its own last |
| 1773 | /// go -- the same rule the idle branch of `push()` keeps, and for the same |
| 1774 | /// reason: a device that pulled a second ago because its window was focused |
| 1775 | /// has nothing to learn from asking again, and a second reason to ask is not a |
| 1776 | /// second thing to know. |
| 1777 | async function catchUp() { |
| 1778 | if (!ready() || !entitled) return; |
| 1779 | if (wakeShut) return; // somebody asked this device to be quiet |
| 1780 | // The gateway will say. Asking as well only spends the account's money on |
| 1781 | // news it is already going to be given. |
| 1782 | if (wakeOpen() || wakeLive()) return; |
| 1783 | if (inFlight) return; // a round is running, and it is fresher than this one |
| 1784 | if (Date.now() - lastPullAt < CATCHUP_MS) return; |
| 1785 | // Held for the duration, exactly as the focus pull holds it, so a push |
| 1786 | // arriving mid-pull waits its turn rather than sending state that is |
| 1787 | // halfway through being replaced. |
| 1788 | inFlight = true; |
| 1789 | try { await pull(); } |
| 1790 | finally { inFlight = false; } |
| 1791 | } |
| 1792 | |
| 1793 | /// A stored thing changed outside a turn: push it soon. |
| 1794 | /// |
| 1795 | /// The two triggers above are a turn ENDING and the tab going AWAY, and most |
| 1796 | /// of what a person does to a Diamond is neither. Renaming one, tagging it, |
| 1797 | /// linking it, editing its crystal by hand, deleting it — none of those take |
| 1798 | /// a turn, so a user who renamed a Diamond and then left the tab open and |
| 1799 | /// focused scheduled no push at all, and the other device's focus pull found |
| 1800 | /// nothing to fetch. The rename simply never travelled. |
| 1801 | /// |
| 1802 | /// It rides the same debounce as every other trigger, so a burst of edits |
| 1803 | /// leaves as one parcel, and it costs nothing when there is nothing to send: |
| 1804 | /// an unchanged parcel is already skipped before any request is made. |
| 1805 | /// |
| 1806 | /// Dropped outright when the engine could not push anyway — no identity, no |
| 1807 | /// session, or a standing 402 — rather than arming a timer to find that out. |
| 1808 | /// A stall (413) is NOT in that list: the nudge after the user shrinks |
| 1809 | /// whatever would not fit is exactly the push that clears it. |
| 1810 | function nudge() { |
| 1811 | if (!ready() || !entitled) return; |
| 1812 | schedule(); |
| 1813 | } |
| 1814 | |
| 1815 | // ── Surviving a passphrase change ────────────────────────── |
| 1816 | // |
| 1817 | // THE PARCEL IS SEALED AT REST TOO, so this file takes part — but it is the |
| 1818 | // one participant with nothing to read out and nothing to hold. The blob is |
| 1819 | // built from live state on every push (`collectParcel` + `JSON.stringify`), so |
| 1820 | // it is a DERIVED COPY: there is no secret here that exists only in the |
| 1821 | // ciphertext, and re-sealing it means nothing more than sending it again. |
| 1822 | // |
| 1823 | // Sending it again is not automatic, which is why this is a participant and |
| 1824 | // not an exemption. `push()` skips a parcel identical to the one it last sent |
| 1825 | // — and a passphrase change does not change the parcel, only the key it goes |
| 1826 | // under. So without this the blob in the mailbox stays sealed under a key |
| 1827 | // nobody has any more: the account's cloud copy is dead, silently, until some |
| 1828 | // unrelated edit happens to change the state. Forgetting what was last pushed |
| 1829 | // is the whole of the fix, and the next round re-seals it. |
| 1830 | // |
| 1831 | // WHAT THIS DOES NOT FIX, deliberately: a SECOND device still on the old |
| 1832 | // passphrase cannot read this blob, adopts its version, and pushes its own |
| 1833 | // over the top — after which the two clobber each other for ever and nothing |
| 1834 | // tells anyone. That is a known defect of the merge path, it is out of this |
| 1835 | // file's rekey participation, and it is not made better or worse by re-sending |
| 1836 | // here. |
| 1837 | |
| 1838 | /// Re-seal the mailbox copy: forget what was last sent, so the next push |
| 1839 | /// genuinely sends, and ask for that push. |
| 1840 | function resealAfterRekey() { |
| 1841 | lastPushed = null; |
| 1842 | // On disk as well. The blob in the mailbox is sealed under a key nobody |
| 1843 | // has any more, and a digest that survived the reload would have the next |
| 1844 | // page agree there was nothing to send -- leaving the account's cloud copy |
| 1845 | // dead and silent, which is the whole failure this participation exists to |
| 1846 | // prevent. |
| 1847 | saveSig(''); |
| 1848 | schedule(); |
| 1849 | return { failed: [] }; |
| 1850 | } |
| 1851 | |
| 1852 | if (window.DaimondRekey) { |
| 1853 | DaimondRekey.register({ |
| 1854 | name: 'sync', |
| 1855 | reseal: resealAfterRekey, |
| 1856 | }); |
| 1857 | } |
| 1858 | |
| 1859 | function saveVersion() { |
| 1860 | try { localStorage.setItem(K_VERSION, String(serverVersion)); } catch (e) { /* ignore */ } |
| 1861 | } |
| 1862 | |
| 1863 | /// Take the version a pull read off the mailbox, unless a push moved the cursor |
| 1864 | /// on WHILE that read was in flight. |
| 1865 | /// |
| 1866 | /// `serverVersion` is one cursor and both the pull and the push mutate it. A |
| 1867 | /// pull reads the mailbox, then merges what it found -- the heaviest step the |
| 1868 | /// app has -- and only then writes the version it saw. A push that lands in |
| 1869 | /// that gap sets the cursor to the newer version first; the pull then overwrites |
| 1870 | /// it with the OLDER one it read before the push existed. The device's own |
| 1871 | /// just-sent work is then reported as never sent, its version a step behind the |
| 1872 | /// mailbox -- a lost update, and under load it is what left a renewed session's |
| 1873 | /// push looking like it never landed. |
| 1874 | /// |
| 1875 | /// The refusal is narrow. A downgrade is dropped ONLY when a push actually |
| 1876 | /// advanced the cursor during this read (`serverVersion > preRead`); a reset |
| 1877 | /// lowers the version with no push behind it, so `preRead` still equals the |
| 1878 | /// cursor and the lower version is taken as it must be. |
| 1879 | function adoptVersion(v, preRead) { |
| 1880 | if (v < serverVersion && serverVersion > preRead) return; // a stale read raced a push; keep the push's cursor. |
| 1881 | serverVersion = v; |
| 1882 | saveVersion(); |
| 1883 | } |
| 1884 | function loadVersion() { |
| 1885 | serverVersion = parseInt(localStorage.getItem(K_VERSION) || '0', 10) || 0; |
| 1886 | lastSynced = parseInt(localStorage.getItem(K_LAST) || '0', 10) || 0; |
| 1887 | loadSig(); |
| 1888 | } |
| 1889 | |
| 1890 | // ── The carried fixed point ──────────────────────────────── |
| 1891 | // |
| 1892 | // EVERY PATH HERE FAILS TOWARDS SENDING, and that is the whole rule. A digest |
| 1893 | // that cannot be taken, cannot be read, or was written by a build that did not |
| 1894 | // mean this one reads as '' -- no fixed point -- and '' never matches, so the |
| 1895 | // parcel goes. Sending one that was not needed costs bytes, which is the |
| 1896 | // behaviour this replaces; skipping one that WAS needed leaves the user's work |
| 1897 | // on this device with nothing anywhere saying so. |
| 1898 | // |
| 1899 | // AND IT IS READ BY THE PUSH AND BY NOTHING ELSE. `pullOnce` fetches and merges |
| 1900 | // unconditionally and must go on doing so: a device that consulted a stored |
| 1901 | // fixed point before deciding whether to LOOK would conclude it need not, and |
| 1902 | // sit on its own stale copy while another device's work waited in the mailbox. |
| 1903 | // That failure was hypothesised and disproved on 2026-08-27; it must not be |
| 1904 | // introduced by the cure for a different one. |
| 1905 | |
| 1906 | /// The digest of a parcel, or '' where one could not be taken. |
| 1907 | /// |
| 1908 | /// `DaimondCloud.sha256` rather than a fourth copy of six lines that already |
| 1909 | /// exist in cloud.js and chunks.js. A build without cloud.js therefore carries |
| 1910 | /// no fixed point and pushes on every reload, which is what this file did |
| 1911 | /// before there was one. |
| 1912 | async function sigOf(plain) { |
| 1913 | try { |
| 1914 | if (!window.DaimondCloud || !DaimondCloud.sha256) return ''; |
| 1915 | return await DaimondCloud.sha256(plain); |
| 1916 | } catch (e) { log('could not digest the parcel', e); return ''; } |
| 1917 | } |
| 1918 | |
| 1919 | /// Write the carried fixed point down, or clear it when given ''. |
| 1920 | function saveSig(sig) { |
| 1921 | bootSig = sig || ''; |
| 1922 | try { |
| 1923 | if (bootSig) localStorage.setItem(K_SIG, JSON.stringify({ v: SIG_V, sig: bootSig })); |
| 1924 | else localStorage.removeItem(K_SIG); |
| 1925 | } catch (e) { /* private mode: this page keeps its own copy and that is all */ } |
| 1926 | } |
| 1927 | |
| 1928 | /// Take up the one a previous page left, if it is one this build wrote. |
| 1929 | function loadSig() { |
| 1930 | bootSig = ''; |
| 1931 | try { |
| 1932 | var raw = localStorage.getItem(K_SIG); |
| 1933 | if (!raw) return; |
| 1934 | var rec = JSON.parse(raw); |
| 1935 | if (!rec || rec.v !== SIG_V || typeof rec.sig !== 'string') return; |
| 1936 | bootSig = rec.sig; |
| 1937 | } catch (e) { /* unreadable is the same as absent, and absent sends */ } |
| 1938 | } |
| 1939 | |
| 1940 | // ── Lifecycle ────────────────────────────────────────────── |
| 1941 | |
| 1942 | /// First reconcile once a session exists: pull the other devices' work, |
| 1943 | /// then push this device's, so a returning device both catches up and |
| 1944 | /// contributes in one pass. |
| 1945 | async function onAuthed() { |
| 1946 | if (!ready()) return; |
| 1947 | entitled = true; // a fresh session may have just bought the tier. |
| 1948 | sessionGone = false; // and there is demonstrably a session again. |
| 1949 | loadVersion(); |
| 1950 | await pull(); |
| 1951 | schedule(); // push whatever this device adds over the pulled base. |
| 1952 | // And open the channel that means the next catch-up needs no trigger here |
| 1953 | // at all. After the first pull, so it parks on a version this device has |
| 1954 | // actually reconciled rather than on a stale cursor. |
| 1955 | wakeStart(); |
| 1956 | } |
| 1957 | |
| 1958 | function start() { |
| 1959 | if (started) return; |
| 1960 | started = true; |
| 1961 | loadVersion(); |
| 1962 | // The row is in the markup and empty until something writes to it, and on a |
| 1963 | // device that never syncs nothing ever would: the honest admission that |
| 1964 | // nothing has travelled is itself the answer. |
| 1965 | // |
| 1966 | // The chip is built HERE rather than on the first status it has to report. |
| 1967 | // It cost nothing to defer while it was injecting a stylesheet and finding |
| 1968 | // a place in the top bar; now that it has a row waiting for it, deferring |
| 1969 | // only means a device that never reaches a gateway has no `#sync-chip` in |
| 1970 | // the DOM at all -- and `dev/verify_sweep_seen.mjs` says in as many words |
| 1971 | // that it could not test the one element the owner actually reported, |
| 1972 | // because a world with no gateway never holds one. |
| 1973 | statusChip(); |
| 1974 | paintRest(true); |
| 1975 | // Before anything this session pulls: a cursor that is already here can |
| 1976 | // only have been left by this device reading this account's mailbox on an |
| 1977 | // earlier visit. See `knownDevice`. |
| 1978 | knownDevice = serverVersion > 0; |
| 1979 | // The app settling (a turn or agent run just ended) is the moment to |
| 1980 | // push: state is consistent and the user is between actions. |
| 1981 | window.addEventListener('daimond:idle', schedule); |
| 1982 | // Leaving the tab is a natural save point; coming back to it is a natural |
| 1983 | // moment to catch up. The one listener covers both directions. |
| 1984 | document.addEventListener('visibilitychange', function () { |
| 1985 | if (document.hidden) schedule(); |
| 1986 | else scheduleFocusPull(); |
| 1987 | }); |
| 1988 | window.addEventListener('focus', scheduleFocusPull); |
| 1989 | // Pausing something is a change to what this account may spend, and nothing |
| 1990 | // else here would notice one: it ends no turn, touches no Diamond and |
| 1991 | // leaves the tab where it was. It only announces on a REAL move -- `set` |
| 1992 | // returns false and stays quiet when the set is unchanged, and so does an |
| 1993 | // `adopt` that took nothing new -- so a pull that agreed with us schedules |
| 1994 | // no push, which is what stops the two devices telling each other. |
| 1995 | try { if (window.DaimondPause) DaimondPause.subscribe(nudge); } |
| 1996 | catch (e) { /* no pause module in this build */ } |
| 1997 | // A session becoming available (unlock → gateway bootstrap) starts it all. |
| 1998 | // The handle is asked for separately, and on the event rather than inside |
| 1999 | // `onAuthed`: that path returns early without the sync tier, and an |
| 2000 | // account without Pro still has a name. |
| 2001 | window.addEventListener('daimond:authed', function () { askHandle(); onAuthed(); }); |
| 2002 | // The channel is torn down when the page goes, so the gateway is not left |
| 2003 | // holding a socket for a tab that has closed. `pagehide` and not `unload`: |
| 2004 | // a page restored from the back/forward cache raises `pageshow`, and the |
| 2005 | // supervisor opens it again on its next tick. |
| 2006 | window.addEventListener('pagehide', wakeStop); |
| 2007 | // Keep the channel matching the app. See wakeWatch. |
| 2008 | wakeWatcher = setInterval(wakeWatch, WAKE_WATCH_MS); |
| 2009 | // And the one trigger that needs neither this device nor the gateway to |
| 2010 | // raise anything. See catchUp: it stands down whenever the channel is |
| 2011 | // carrying, which on a device that can reach the gateway is always. |
| 2012 | catchupTimer = setInterval(catchUp, CATCHUP_TICK_MS); |
| 2013 | // If we booted already authed (a returning unlocked tab), reconcile now. |
| 2014 | if (ready()) onAuthed(); |
| 2015 | askHandle(); |
| 2016 | // A safe start reaches nothing that would paint the chip -- `ready()` is |
| 2017 | // false, so every path above returns before `restStatus`. Say it here, or |
| 2018 | // the one state the user has to be told about is the one state that never |
| 2019 | // appears. Deferred a tick because the rail's status strip is built by |
| 2020 | // daimond.js. |
| 2021 | if (window.DaimondSafe && DaimondSafe.on()) setTimeout(restStatus, 0); |
| 2022 | log('started'); |
| 2023 | } |
| 2024 | |
| 2025 | // ── Public surface ───────────────────────────────────────── |
| 2026 | /// Re-enable sync after a tier change -- a Pro purchase just landed -- and |
| 2027 | /// reconcile at once. A 402 earlier set `entitled = false` and stopped the |
| 2028 | /// pushes; this lifts that without waiting for the next unlock. |
| 2029 | function recheck() { |
| 2030 | if (!ready()) return; |
| 2031 | entitled = true; |
| 2032 | onAuthed(); |
| 2033 | } |
| 2034 | |
| 2035 | window.DaimondSync = { |
| 2036 | pull: pull, |
| 2037 | push: function () { return push(); }, |
| 2038 | nudge: nudge, |
| 2039 | recheck: recheck, |
| 2040 | /// The presence path, off the content parcel: `beatPresence(deviceId, name)` |
| 2041 | /// writes this device's last_seen and adopts the account's fresh map (bumping |
| 2042 | /// no version and waking nobody); `refreshPresence()` reads that map without a |
| 2043 | /// beat, for a dispatch-time refresh. Both ingest through DaimondPresence. |
| 2044 | beatPresence: beatPresence, |
| 2045 | refreshPresence: refreshPresence, |
| 2046 | /// The lease door, off the content parcel: `leaseGet()` reads the door's |
| 2047 | /// {version, leases}; `leaseCommit(base, proposed)` compare-and-sets it. The |
| 2048 | /// peer lease CAS (daimond.js peerSyncShim) binds to these, and `leaseVersion` |
| 2049 | /// is the last version seen, for the CAS's synchronous fallback getter. |
| 2050 | leaseGet: leaseGet, |
| 2051 | leaseCommit: leaseCommit, |
| 2052 | leaseVersion: function () { return _leaseVer | 0; }, |
| 2053 | /// Exactly what a push would send, and exactly what a pull would merge. |
| 2054 | /// |
| 2055 | /// A verifier comparing `DaimondCore.collectSync()` is comparing the core |
| 2056 | /// parcel only, and would miss anything hung on it here -- so the fixed |
| 2057 | /// point has to be measured through these two rather than around them. |
| 2058 | parcel: function () { return collectParcel(); }, |
| 2059 | apply: function (state) { return applyParcel(state); }, |
| 2060 | /// The account's public handle, and the three things anyone does with |
| 2061 | /// it. `handle()` is what this device knows; `refreshHandle()` asks the |
| 2062 | /// gateway, which mints one if the account has none; `claimHandle()` |
| 2063 | /// renames, and says which kind of no it got; `lookupHandle()` resolves |
| 2064 | /// somebody ELSE's name, which is the half that makes it a public name |
| 2065 | /// rather than a label. |
| 2066 | handle: function () { |
| 2067 | try { return DaimondIdentity.handle(); } catch (e) { return ''; } |
| 2068 | }, |
| 2069 | refreshHandle: refreshHandle, |
| 2070 | claimHandle: claimHandle, |
| 2071 | lookupHandle: lookupHandle, |
| 2072 | version: function () { return serverVersion; }, |
| 2073 | entitled: function () { return entitled; }, |
| 2074 | /// The wake channel, as it stands. Nothing in the app turns on this; it |
| 2075 | /// is what a verifier reads to tell "converged because it was told" from |
| 2076 | /// "converged because something happened to the window". |
| 2077 | wake: function () { |
| 2078 | return { |
| 2079 | mode: wakeMode, // '' | 'ws' | 'poll' | 'off' |
| 2080 | id: WAKE_ID, |
| 2081 | // A park that belongs to a torn-down generation is not this |
| 2082 | // channel being open, however long the gateway goes on holding |
| 2083 | // it -- reporting it as open is how a device with no live park |
| 2084 | // looked exactly like one that had just made a fresh one. |
| 2085 | open: wakeOpen(), |
| 2086 | probing: wakeProbing && wakeProbeGen === wakeGen, |
| 2087 | heard: wakeTarget, // highest version the channel reported |
| 2088 | wakes: wakes, // pulls this channel has caused |
| 2089 | }; |
| 2090 | }, |
| 2091 | /// Force the channel onto one transport, or shut it. |
| 2092 | /// |
| 2093 | /// `'poll'` parks plain requests, so the fallback can be seen working |
| 2094 | /// rather than waited for; `'off'` puts this device back to what it was |
| 2095 | /// before there was a channel at all, which is what a test asserts the |
| 2096 | /// absence of convergence against; anything else starts over with the |
| 2097 | /// socket. |
| 2098 | wakeVia: function (mode) { |
| 2099 | wakeStop(); |
| 2100 | wakeMode = (mode === 'poll' || mode === 'off') ? mode : ''; |
| 2101 | // 'off' here is a request, not a diagnosis, so the catch-up honours it: |
| 2102 | // this verb is what a test asserts the absence of convergence against, |
| 2103 | // and a timer that went on asking would answer that test itself. |
| 2104 | wakeShut = wakeMode === 'off'; |
| 2105 | wakeFails = 0; |
| 2106 | wakeWorked = false; |
| 2107 | wakeBackoff = WAKE_RETRY_MIN_MS; |
| 2108 | if (wakeMode !== 'off') wakeStart(); |
| 2109 | return wakeMode; |
| 2110 | }, |
| 2111 | /// What the engine would say if asked -- the same facts the chip shows, for |
| 2112 | /// anything that needs them in words rather than as a coloured pill. |
| 2113 | state: function () { |
| 2114 | return { |
| 2115 | // Anything standing between this device's work and the mailbox: a |
| 2116 | // parcel that will not fit, a session that has gone, or a reconcile |
| 2117 | // that gave up. Ordered as the chip orders them, so what this says |
| 2118 | // and what the chip shows can never disagree. |
| 2119 | stalled: tooLarge || sessionGone || !!jammed, |
| 2120 | stalledWhy: tooLarge ? 'too_big' : (sessionGone ? 'signed_out' : (jammed || '')), |
| 2121 | failedParts: lastFailed.slice(), |
| 2122 | entitled: entitled, |
| 2123 | /// Whether a 401 is standing that a fresh session could not clear. |
| 2124 | sessionGone: sessionGone, |
| 2125 | lastSyncedAt: lastSynced, |
| 2126 | lastSynced: lastSyncedLine(), |
| 2127 | version: serverVersion, |
| 2128 | /// Is the engine doing nothing, and is nothing armed to start? |
| 2129 | /// |
| 2130 | /// `inFlight` alone is not the question. A round that has FINISHED may have |
| 2131 | /// left a debounce armed, and a caller that waited only for the flag to drop |
| 2132 | /// would go on to act in the gap before the timer fires. All three, so "quiet" |
| 2133 | /// means no round is running and none is coming. |
| 2134 | /// |
| 2135 | /// Nothing in the app reads this; it is here for the same reason `wake()` is, |
| 2136 | /// and for a defect it fixes. `dev/verify_mailfolders.mjs` deletes a mailbox |
| 2137 | /// behind the app's back and pushes a census that no longer names it. If a |
| 2138 | /// pull was already in flight when it did, that pull adopts the mail back |
| 2139 | /// AFTER the fixture has checked -- correctly, since a file present at the |
| 2140 | /// gateway and absent here is one this device has not seen. The fixture read |
| 2141 | /// its own success and the run then measured the PREVIOUS run's mail. It cost |
| 2142 | /// two failures in eight cold runs on 2026-08-24, each blamed on the product. |
| 2143 | /// Waiting for this removes the race; polling for the mailbox to stay gone |
| 2144 | /// only narrows it. |
| 2145 | quiet: !inFlight && !pushTimer && !focusTimer, |
| 2146 | busyWith: inFlight ? 'a round is running' |
| 2147 | : (pushTimer ? 'a push is armed' |
| 2148 | : (focusTimer ? 'a focus pull is armed' : '')), |
| 2149 | }; |
| 2150 | }, |
| 2151 | }; |
| 2152 | |
| 2153 | if (document.readyState === 'loading') { |
| 2154 | document.addEventListener('DOMContentLoaded', start); |
| 2155 | } else { |
| 2156 | start(); |
| 2157 | } |
| 2158 | })(); |