Oregami
Repositories/oxedyne/daimond

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