cosyncing 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +57 -4
  4. package/THIRD_PARTY_NOTICES.txt +582 -0
  5. package/bin/cosyncing +1829 -0
  6. package/bin/cosyncing-web-0.2.0/assets/AssetManifest.bin +1 -0
  7. package/bin/cosyncing-web-0.2.0/assets/AssetManifest.bin.json +1 -0
  8. package/bin/cosyncing-web-0.2.0/assets/FontManifest.json +1 -0
  9. package/bin/cosyncing-web-0.2.0/assets/NOTICES +34313 -0
  10. package/bin/cosyncing-web-0.2.0/assets/fonts/MaterialIcons-Regular.otf +0 -0
  11. package/bin/cosyncing-web-0.2.0/assets/packages/cupertino_icons/assets/CupertinoIcons.ttf +0 -0
  12. package/bin/cosyncing-web-0.2.0/assets/packages/flutter_local_notifications_web/web/notifications_service_worker.js +77 -0
  13. package/bin/cosyncing-web-0.2.0/assets/shaders/ink_sparkle.frag +127 -0
  14. package/bin/cosyncing-web-0.2.0/assets/shaders/stretch_effect.frag +64 -0
  15. package/bin/cosyncing-web-0.2.0/canvaskit/canvaskit.js +193 -0
  16. package/bin/cosyncing-web-0.2.0/canvaskit/canvaskit.wasm +0 -0
  17. package/bin/cosyncing-web-0.2.0/canvaskit/chromium/canvaskit.js +193 -0
  18. package/bin/cosyncing-web-0.2.0/canvaskit/chromium/canvaskit.wasm +0 -0
  19. package/bin/cosyncing-web-0.2.0/canvaskit/experimental_webparagraph/canvaskit.js +171 -0
  20. package/bin/cosyncing-web-0.2.0/canvaskit/experimental_webparagraph/canvaskit.wasm +0 -0
  21. package/bin/cosyncing-web-0.2.0/canvaskit/skwasm.js +146 -0
  22. package/bin/cosyncing-web-0.2.0/canvaskit/skwasm.wasm +0 -0
  23. package/bin/cosyncing-web-0.2.0/canvaskit/skwasm_heavy.js +146 -0
  24. package/bin/cosyncing-web-0.2.0/canvaskit/skwasm_heavy.wasm +0 -0
  25. package/bin/cosyncing-web-0.2.0/canvaskit/wimp.js +135 -0
  26. package/bin/cosyncing-web-0.2.0/canvaskit/wimp.wasm +0 -0
  27. package/bin/cosyncing-web-0.2.0/cosyncing-build-identity.json +20 -0
  28. package/bin/cosyncing-web-0.2.0/cosyncing-cache-manifest.json +85 -0
  29. package/bin/cosyncing-web-0.2.0/drift_worker.js +13185 -0
  30. package/bin/cosyncing-web-0.2.0/favicon.ico +0 -0
  31. package/bin/cosyncing-web-0.2.0/favicon.svg +8 -0
  32. package/bin/cosyncing-web-0.2.0/flutter.js +31 -0
  33. package/bin/cosyncing-web-0.2.0/flutter_bootstrap.js +62 -0
  34. package/bin/cosyncing-web-0.2.0/icons/apple-touch-icon-180.png +0 -0
  35. package/bin/cosyncing-web-0.2.0/icons/pwa-icon-192.png +0 -0
  36. package/bin/cosyncing-web-0.2.0/icons/pwa-icon-512.png +0 -0
  37. package/bin/cosyncing-web-0.2.0/icons/pwa-maskable-192.png +0 -0
  38. package/bin/cosyncing-web-0.2.0/icons/pwa-maskable-512.png +0 -0
  39. package/bin/cosyncing-web-0.2.0/icons/pwa-monochrome-192.png +0 -0
  40. package/bin/cosyncing-web-0.2.0/icons/pwa-monochrome-512.png +0 -0
  41. package/bin/cosyncing-web-0.2.0/index.html +1890 -0
  42. package/bin/cosyncing-web-0.2.0/main.dart.js +206823 -0
  43. package/bin/cosyncing-web-0.2.0/manifest.json +49 -0
  44. package/bin/cosyncing-web-0.2.0/sqlite3.wasm +0 -0
  45. package/bin/cosyncing-web-0.2.0/sw.js +597 -0
  46. package/bin/cosyncing-web-0.2.0/version.json +1 -0
  47. package/package.json +39 -6
  48. package/index.js +0 -1
@@ -0,0 +1,1890 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <!--
5
+ If you are serving your web app in a path other than the root, change the
6
+ href value below to reflect the base path you are serving from.
7
+
8
+ The path provided below has to start and end with a slash "/" in order for
9
+ it to work correctly.
10
+
11
+ For more details:
12
+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
13
+
14
+ This is a placeholder for base href that will be replaced by the value of
15
+ the `--base-href` argument provided to `flutter build`.
16
+ -->
17
+ <base href="/cosy/">
18
+
19
+ <meta charset="UTF-8">
20
+ <meta content="IE=Edge" http-equiv="X-UA-Compatible">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
22
+ <meta name="description" content="Cross-platform client for Cosyncing brokers">
23
+
24
+ <!-- iOS meta tags & icons -->
25
+ <meta name="mobile-web-app-capable" content="yes">
26
+ <meta name="apple-mobile-web-app-status-bar-style" content="black">
27
+ <meta name="apple-mobile-web-app-title" content="Cosyncing">
28
+ <link rel="apple-touch-icon" href="icons/apple-touch-icon-180.png">
29
+
30
+ <!-- Favicon: SVG for modern browsers, multi-resolution ICO (16 pixel-hinted
31
+ micro-mark, 32/48 two-tone tile) as the universal fallback. -->
32
+ <link rel="icon" type="image/svg+xml" href="favicon.svg"/>
33
+ <link rel="icon" type="image/x-icon" href="favicon.ico" sizes="16x16 32x32 48x48"/>
34
+
35
+ <!--
36
+ Browser chrome colour, per scheme. These match the app's default theme
37
+ canvas (`teal-obsidian`: #F2F5F4 light / #0B0E14 dark), so the URL bar, the
38
+ startup shell below and Flutter's first painted surface are one colour and
39
+ the handover never shows an uncoloured frame.
40
+ -->
41
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2F5F4">
42
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0B0E14">
43
+
44
+ <title>Cosyncing</title>
45
+ <link rel="manifest" href="manifest.json">
46
+
47
+ <!--
48
+ N3 startup shell.
49
+
50
+ Everything needed to paint is inline: no remote CSS, font, image or script
51
+ is referenced, so this renders from the first HTML response alone — before
52
+ flutter_bootstrap.js, CanvasKit/Wasm, Drift, credentials or the broker do
53
+ anything. `prefers-color-scheme` is resolved by the browser at parse time,
54
+ so the very first paint is already in the right scheme.
55
+ -->
56
+ <style>
57
+ :root {
58
+ color-scheme: light dark;
59
+ --cosync-canvas: #F2F5F4;
60
+ --cosync-text: #1E2927;
61
+ --cosync-muted: #5F7370;
62
+ --cosync-accent: #0F766E;
63
+ --cosync-accent-ink: #FFFFFF;
64
+ --cosync-track: rgba(15, 118, 110, 0.18);
65
+ --cosync-mark-a: #0B0E14;
66
+ --cosync-mark-b: #0F766E;
67
+ }
68
+
69
+ @media (prefers-color-scheme: dark) {
70
+ :root {
71
+ --cosync-canvas: #0B0E14;
72
+ --cosync-text: #E2E8F0;
73
+ --cosync-muted: #94A3B8;
74
+ --cosync-accent: #2DD4BF;
75
+ --cosync-accent-ink: #0B0E14;
76
+ --cosync-track: rgba(45, 212, 191, 0.20);
77
+ --cosync-mark-a: #F2F5F4;
78
+ --cosync-mark-b: #2DD4BF;
79
+ }
80
+ }
81
+
82
+ html, body {
83
+ margin: 0;
84
+ padding: 0;
85
+ height: 100%;
86
+ /* The canvas colour lives on the document itself, so no frame between
87
+ shell removal and Flutter's composite can be white. */
88
+ background: var(--cosync-canvas);
89
+ color: var(--cosync-text);
90
+ }
91
+
92
+ #cosyncing-startup-shell {
93
+ position: fixed;
94
+ inset: 0;
95
+ z-index: 2147483000;
96
+ display: flex;
97
+ flex-direction: column;
98
+ align-items: center;
99
+ justify-content: center;
100
+ gap: 20px;
101
+ padding: 24px;
102
+ box-sizing: border-box;
103
+ background: var(--cosync-canvas);
104
+ font: 400 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto,
105
+ "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
106
+ text-align: center;
107
+ /* Overlay only: it never participates in the app's layout, so removing
108
+ it cannot reflow anything Flutter has drawn. */
109
+ transition: opacity 180ms linear;
110
+ opacity: 1;
111
+ }
112
+
113
+ #cosyncing-startup-shell[data-dismissing="true"] { opacity: 0; }
114
+
115
+ .cosyncing-shell-mark {
116
+ width: 64px;
117
+ height: 64px;
118
+ display: block;
119
+ }
120
+
121
+ .cosyncing-shell-mark .mark-a { fill: var(--cosync-mark-a); }
122
+ .cosyncing-shell-mark .mark-b { fill: var(--cosync-mark-b); }
123
+
124
+ .cosyncing-shell-wordmark {
125
+ font-size: 17px;
126
+ font-weight: 600;
127
+ letter-spacing: 0.01em;
128
+ color: var(--cosync-text);
129
+ }
130
+
131
+ .cosyncing-shell-status {
132
+ max-width: 34ch;
133
+ color: var(--cosync-muted);
134
+ font-size: 14px;
135
+ margin: 0;
136
+ }
137
+
138
+ .cosyncing-shell-track {
139
+ width: 176px;
140
+ max-width: 60vw;
141
+ height: 4px;
142
+ border-radius: 2px;
143
+ background: var(--cosync-track);
144
+ overflow: hidden;
145
+ }
146
+
147
+ .cosyncing-shell-track::after {
148
+ content: "";
149
+ display: block;
150
+ width: 40%;
151
+ height: 100%;
152
+ border-radius: 2px;
153
+ background: var(--cosync-accent);
154
+ animation: cosyncing-shell-slide 1.4s ease-in-out infinite;
155
+ }
156
+
157
+ @keyframes cosyncing-shell-slide {
158
+ 0% { transform: translateX(-100%); }
159
+ 100% { transform: translateX(250%); }
160
+ }
161
+
162
+ /* Failure state hides the progress track and reveals the action. */
163
+ #cosyncing-startup-shell[data-state="failed"] .cosyncing-shell-track { display: none; }
164
+ #cosyncing-startup-shell:not([data-state="failed"]) .cosyncing-shell-actions { display: none; }
165
+
166
+ .cosyncing-shell-actions { min-height: 36px; }
167
+
168
+ .cosyncing-shell-button {
169
+ appearance: none;
170
+ border: 0;
171
+ border-radius: 6px;
172
+ padding: 8px 16px;
173
+ min-height: 36px;
174
+ font: inherit;
175
+ font-weight: 600;
176
+ color: var(--cosync-accent-ink);
177
+ background: var(--cosync-accent);
178
+ cursor: pointer;
179
+ }
180
+
181
+ .cosyncing-shell-button:focus-visible {
182
+ outline: 2px solid var(--cosync-accent);
183
+ outline-offset: 2px;
184
+ }
185
+
186
+ @media (prefers-reduced-motion: reduce) {
187
+ #cosyncing-startup-shell { transition: none; }
188
+ .cosyncing-shell-track::after { animation: none; width: 100%; }
189
+ }
190
+
191
+ /* Compact phones: the mark shrinks, nothing wraps off-screen. */
192
+ @media (max-width: 420px) {
193
+ .cosyncing-shell-mark { width: 52px; height: 52px; }
194
+ #cosyncing-startup-shell { gap: 16px; }
195
+ }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <!--
200
+ Painted by the HTML response itself. Removed only after Dart reports a
201
+ rendered frame (see the handshake below) — never on loader completion,
202
+ engine initialization or runApp() alone, none of which mean a frame exists.
203
+ -->
204
+ <div id="cosyncing-startup-shell" data-state="booting" role="status" aria-live="polite" aria-busy="true">
205
+ <svg class="cosyncing-shell-mark" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
206
+ <path class="mark-a" d="M86,40 V74 A18,18 0 0 1 74,86 H40 V74 H66 A8,8 0 0 0 74,66 V40 Z"/>
207
+ <path class="mark-b" d="M14,60 V26 A18,18 0 0 1 26,14 H60 V26 H34 A8,8 0 0 0 26,34 V60 Z"/>
208
+ </svg>
209
+ <div class="cosyncing-shell-wordmark">cosyncing</div>
210
+ <p class="cosyncing-shell-status" id="cosyncing-startup-shell-status">Starting Cosyncing…</p>
211
+ <div class="cosyncing-shell-track" id="cosyncing-startup-shell-track" aria-hidden="true"></div>
212
+ <div class="cosyncing-shell-actions" id="cosyncing-startup-shell-actions"></div>
213
+ </div>
214
+
215
+ <!--
216
+ Browser-native zoom.
217
+
218
+ On web the app deliberately does not intercept Ctrl/Cmd +/- or Ctrl/Cmd+wheel
219
+ (see router.dart `_browserOwnsZoom`); the browser's own zoom owns them and
220
+ scales the whole page, unbounded. Flutter's engine, however, calls
221
+ preventDefault on every `wheel` event for its own scrolling, which also
222
+ cancels the browser's Ctrl+wheel zoom. This capture-phase listener runs
223
+ before the engine's and, when the zoom modifier is held, calls
224
+ stopImmediatePropagation so the engine never sees the event and the browser
225
+ performs its default zoom. It never calls preventDefault (that would cancel
226
+ the zoom) and leaves unmodified wheel events untouched so normal scrolling
227
+ still reaches Flutter. Registered before flutter_bootstrap.js so it is in
228
+ place before the engine attaches its own wheel listeners.
229
+ -->
230
+ <script>
231
+ window.addEventListener('wheel', function (event) {
232
+ if (event.ctrlKey || event.metaKey) {
233
+ event.stopImmediatePropagation();
234
+ }
235
+ }, { capture: true, passive: false });
236
+ </script>
237
+
238
+ <!--
239
+ Startup shell controller (N3).
240
+
241
+ Contract:
242
+ * The shell is removed ONLY by `window.cosyncingStartupShellReady()`, which
243
+ Dart calls from a post-frame callback — i.e. after a frame has actually
244
+ been rendered. Loader completion / engine init / runApp() are not enough.
245
+ * Removal fades the overlay out over one transition while the document keeps
246
+ the same background colour, so no uncovered frame is ever shown and no app
247
+ layout is disturbed (the shell is `position: fixed`).
248
+ * If bootstrap errors, or no frame arrives inside the bounded threshold, the
249
+ shell shows a plain localized retry action. Technical detail goes to the
250
+ console only.
251
+ * Retry is bounded per browsing session and is always user-initiated: this
252
+ script never reloads on its own, so it cannot spin.
253
+ -->
254
+ <script>
255
+ (function () {
256
+ 'use strict';
257
+
258
+ var SLOW_MS = 8000; // "still starting" note
259
+ var FAIL_MS = 25000; // offer the retry action
260
+ var MAX_RELOAD_ATTEMPTS = 2; // reload offers per browsing session
261
+ var ATTEMPT_KEY = 'cosyncing.startup.attempts';
262
+
263
+ // Recovery is scoped to THIS installation, never to the origin. An
264
+ // origin can host another app, or a second Cosyncing mount, and a /cosy/
265
+ // reset must not touch either. Both values mirror sw.js exactly.
266
+ var APP_SCOPE = new URL('./', document.baseURI).href;
267
+ var CACHE_PREFIX = 'cosyncing-app:' + new URL('./', document.baseURI).pathname + ':';
268
+
269
+ var COPY = {
270
+ en: {
271
+ loading: 'Starting Cosyncing…',
272
+ slow: 'Still starting. On a slow connection this can take a moment.',
273
+ failed: 'Cosyncing didn’t finish loading.',
274
+ retry: 'Retry',
275
+ reset: 'Clear cached files and retry',
276
+ exhausted: 'Reloading didn’t help. Check that your broker is running, then reload this page.'
277
+ },
278
+ zh: {
279
+ loading: '正在启动 Cosyncing…',
280
+ slow: '仍在启动。网络较慢时可能需要一些时间。',
281
+ failed: 'Cosyncing 未能完成加载。',
282
+ retry: '重试',
283
+ reset: '清除缓存文件并重试',
284
+ exhausted: '重新加载仍未成功。请确认 broker 正在运行,然后刷新此页面。'
285
+ }
286
+ };
287
+
288
+ // Minimal static browser-language dictionary. Flutter-side copy still
289
+ // goes through ARB localization; this covers only the pre-Flutter shell.
290
+ function pickCopy() {
291
+ var tags = (navigator.languages && navigator.languages.length)
292
+ ? navigator.languages
293
+ : [navigator.language || 'en'];
294
+ for (var i = 0; i < tags.length; i++) {
295
+ var primary = String(tags[i] || '').toLowerCase().split('-')[0];
296
+ if (COPY[primary]) return COPY[primary];
297
+ }
298
+ return COPY.en;
299
+ }
300
+
301
+ var copy = pickCopy();
302
+ var shell = document.getElementById('cosyncing-startup-shell');
303
+ var statusNode = document.getElementById('cosyncing-startup-shell-status');
304
+ var actionsNode = document.getElementById('cosyncing-startup-shell-actions');
305
+ var slowTimer = null;
306
+ var failTimer = null;
307
+ var settled = false;
308
+
309
+ if (!shell) return;
310
+
311
+ statusNode.textContent = copy.loading;
312
+ document.documentElement.lang = (copy === COPY.zh) ? 'zh' : 'en';
313
+
314
+ function attempts() {
315
+ try {
316
+ return parseInt(sessionStorage.getItem(ATTEMPT_KEY) || '0', 10) || 0;
317
+ } catch (error) {
318
+ return 0; // Storage denied (private mode): treat as a first attempt.
319
+ }
320
+ }
321
+
322
+ function recordAttempt(next) {
323
+ try { sessionStorage.setItem(ATTEMPT_KEY, String(next)); } catch (error) { /* ignore */ }
324
+ }
325
+
326
+ function clearAttempts() {
327
+ try { sessionStorage.removeItem(ATTEMPT_KEY); } catch (error) { /* ignore */ }
328
+ }
329
+
330
+ function clearTimers() {
331
+ if (slowTimer !== null) { clearTimeout(slowTimer); slowTimer = null; }
332
+ if (failTimer !== null) { clearTimeout(failTimer); failTimer = null; }
333
+ }
334
+
335
+ // Last-resort recovery for a cached shell that cannot boot: drop THIS
336
+ // installation's static caches and worker, then reload once. Offered only
337
+ // on the final attempt, and only from a user click.
338
+ //
339
+ // Scope-exact on both halves. Deleting every `cosyncing-app*` cache or
340
+ // unregistering every worker on the origin would break a co-hosted app,
341
+ // or a second Cosyncing mount, that this page has no business touching.
342
+ function resetStaticCaches() {
343
+ var jobs = [];
344
+ try {
345
+ if (window.caches && caches.keys) {
346
+ jobs.push(caches.keys().then(function (keys) {
347
+ return Promise.all(keys.map(function (key) {
348
+ return key.indexOf(CACHE_PREFIX) === 0 ? caches.delete(key) : null;
349
+ }));
350
+ }));
351
+ }
352
+ } catch (error) { /* ignore */ }
353
+ try {
354
+ if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
355
+ jobs.push(navigator.serviceWorker.getRegistrations().then(function (registrations) {
356
+ return Promise.all(registrations.map(function (registration) {
357
+ // Only the registration whose scope IS this app mount. A
358
+ // broader-scope worker (e.g. one registered at the origin root)
359
+ // belongs to something else.
360
+ return registration.scope === APP_SCOPE
361
+ ? registration.unregister()
362
+ : null;
363
+ }));
364
+ }));
365
+ }
366
+ } catch (error) { /* ignore */ }
367
+ return Promise.all(jobs).catch(function () { return null; });
368
+ }
369
+
370
+ function showFailure(reason) {
371
+ if (settled) return;
372
+ clearTimers();
373
+ if (reason) {
374
+ // Primary copy stays plain; the detail belongs in the console.
375
+ console.error('[cosyncing] startup shell: ' + reason);
376
+ }
377
+ shell.setAttribute('data-state', 'failed');
378
+ shell.setAttribute('aria-busy', 'false');
379
+ statusNode.textContent = copy.failed;
380
+ actionsNode.textContent = '';
381
+
382
+ var used = attempts();
383
+ if (used > MAX_RELOAD_ATTEMPTS) {
384
+ // Every offered attempt has been spent this session. Stop offering an
385
+ // action rather than inviting an unbounded reload cycle.
386
+ statusNode.textContent = copy.exhausted;
387
+ return;
388
+ }
389
+
390
+ var hardReset = used === MAX_RELOAD_ATTEMPTS;
391
+ var button = document.createElement('button');
392
+ button.type = 'button';
393
+ button.className = 'cosyncing-shell-button';
394
+ button.id = 'cosyncing-startup-shell-retry';
395
+ button.textContent = hardReset ? copy.reset : copy.retry;
396
+ button.addEventListener('click', function () {
397
+ button.disabled = true;
398
+ recordAttempt(used + 1);
399
+ if (!hardReset) { window.location.reload(); return; }
400
+ resetStaticCaches().then(function () { window.location.reload(); });
401
+ });
402
+ actionsNode.appendChild(button);
403
+ }
404
+
405
+ function showSlow() {
406
+ if (settled || shell.getAttribute('data-state') === 'failed') return;
407
+ shell.setAttribute('data-state', 'slow');
408
+ statusNode.textContent = copy.slow;
409
+ }
410
+
411
+ slowTimer = setTimeout(showSlow, SLOW_MS);
412
+ failTimer = setTimeout(function () {
413
+ showFailure('no rendered frame within ' + FAIL_MS + 'ms');
414
+ }, FAIL_MS);
415
+
416
+ // The one authorised dismissal path. Dart calls this from a post-frame
417
+ // callback, so by definition a frame has been rendered.
418
+ window.cosyncingStartupShellReady = function () {
419
+ if (settled) return;
420
+ settled = true;
421
+ clearTimers();
422
+ clearAttempts();
423
+ // Two animation frames: the first lets the browser composite the frame
424
+ // Dart just reported, the second starts the fade over painted content.
425
+ requestAnimationFrame(function () {
426
+ requestAnimationFrame(function () {
427
+ shell.setAttribute('data-state', 'ready');
428
+ shell.setAttribute('aria-busy', 'false');
429
+ shell.setAttribute('data-dismissing', 'true');
430
+ var remove = function () {
431
+ if (shell.parentNode) shell.parentNode.removeChild(shell);
432
+ };
433
+ shell.addEventListener('transitionend', remove, { once: true });
434
+ // Transitions do not fire in a hidden tab or under reduced motion;
435
+ // the shell must still go away.
436
+ setTimeout(remove, 400);
437
+ });
438
+ });
439
+ };
440
+
441
+ // A bootstrap script that fails to load can never render a frame, so do
442
+ // not wait out the full threshold for it.
443
+ window.addEventListener('error', function (event) {
444
+ var target = event && event.target;
445
+ if (!target || target === window || !target.tagName) return;
446
+ var src = target.src || target.href || '';
447
+ if (String(src).indexOf('flutter_bootstrap.js') === -1) return;
448
+ showFailure('flutter_bootstrap.js failed to load');
449
+ }, true);
450
+
451
+ // Exposed for the bootstrap/browser regression tests and for manual
452
+ // diagnosis; failing the shell never mutates app state.
453
+ window.cosyncingStartupShellFail = showFailure;
454
+ })();
455
+ </script>
456
+
457
+ <!--
458
+ Static-asset service worker (N3 part B).
459
+
460
+ Registered here rather than through the Flutter loader: the loader's
461
+ `serviceWorkerSettings` path is deprecated and registers Flutter's
462
+ unregister-only stub at this same scope, which would evict this worker and
463
+ then navigate every client — a reload cycle. `web/flutter_bootstrap.js`
464
+ therefore calls `_flutter.loader.load()` with no service-worker settings.
465
+
466
+ Scope is the app's base href (`/cosy/` in release builds), so the broker's
467
+ `/api/*`, WebSocket and every other runtime route is outside this worker's
468
+ scope by construction; sw.js additionally refuses them at runtime.
469
+ -->
470
+ <!-- cosyncing-handoff-coordinator -->
471
+ <script>
472
+ /*
473
+ * N3b — automatic verified web-update handoff.
474
+ *
475
+ * THE PROBLEM
476
+ * -----------
477
+ * sw.js never calls skipWaiting, because activating a replacement over a
478
+ * live page is how one document ends up executing two builds. So a verified
479
+ * replacement sits in `waiting` until the previous worker controls no
480
+ * clients. RG1 exposed that state as a notice asking the user to close every
481
+ * Cosyncing tab — correct, and unacceptable as a user experience.
482
+ *
483
+ * THE MOVE
484
+ * --------
485
+ * Nothing about the safety model changes. What changes is WHO closes the
486
+ * tabs: the tabs do, to a page one path segment above their own scope, and
487
+ * they come back by themselves.
488
+ *
489
+ * 1. Each tab notices `registration.waiting` and asks that worker its
490
+ * build identity. An unanswered or identical identity is not an update.
491
+ * 2. Every tab that saw it broadcasts a claim; the lowest id wins. Exactly
492
+ * one coordinator per build, whatever the order of the update checks.
493
+ * 3. The coordinator asks the ACTIVE worker how many window clients it
494
+ * controls. That census is the only trustworthy count of Cosyncing tabs
495
+ * — a page cannot enumerate its origin's tabs, a worker can enumerate
496
+ * its own controllees.
497
+ * 4. The coordinator broadcasts `prepare`. Each tab independently
498
+ * re-verifies the waiting build, flushes its durable local state
499
+ * through the Dart hook, and answers `ack` or `defer`.
500
+ * 5. Only if acknowledgements EQUAL the census, with no deferral and no
501
+ * change in the census, does the coordinator broadcast `go`. A frozen
502
+ * tab is counted and cannot answer, so it defers the handoff by
503
+ * existing; an actively edited tab defers it deliberately.
504
+ * 6. Each tab `location.replace`s to the handoff page carrying its own
505
+ * exact route. `replace`, not `assign`: it removes the app entry from
506
+ * this tab's history, which both keeps the history stack the same shape
507
+ * across the swap and guarantees the document is destroyed rather than
508
+ * retained in the back/forward cache — a bfcached document is still a
509
+ * controllee, and would keep the old worker alive forever.
510
+ * 7. The old worker retires with no controllees, the browser activates the
511
+ * waiting worker, and the handoff page returns each tab to its route.
512
+ *
513
+ * WHAT THIS SCRIPT NEVER DOES
514
+ * ---------------------------
515
+ * Call skipWaiting, unregister a worker, open or delete a cache, reload the
516
+ * page, or show anything. Routine updates are silent. Only a handoff that
517
+ * was actually attempted and did not land, repeatedly, publishes the
518
+ * recovery state the app renders.
519
+ */
520
+ (function () {
521
+ 'use strict';
522
+
523
+ /* ---------------- bounds; every loop below is under one ------------- */
524
+
525
+ var ELECTION_MS = 400; // claim window before a coordinator is settled
526
+ var PREPARE_MS = 3000; // longest a tab's own durable flush may take
527
+ // Ceilings only. Successful phases settle as soon as every expected tab
528
+ // answers, and a refusal settles immediately. The acknowledgement ceiling
529
+ // includes an initial silent worker query, the peer-local grace, and the
530
+ // app flush: 1500 + 750 + 3000 = 5250ms, plus scheduling margin.
531
+ var ACK_MS = 6500; // hard deadline for peer acknowledgements
532
+ var COMMIT_MS = 6500; // hard deadline for final durable commits
533
+ var LOCK_MS = 12000; // longest a tab stays frozen without a `go`
534
+ var WORKER_REPLY_MS = 1500; // longest a worker question may take
535
+ var RETRY_MS = 60000; // cooldown between deferred rounds
536
+ var MAX_DEFER_ROUNDS = 8; // deferred rounds at the fast cadence
537
+ var SLOW_RETRY_MS = 900000; // cooldown once the fast cadence is spent
538
+ var MAX_SLOW_ROUNDS = 8; // deferred rounds at the slow cadence
539
+ var READY_HINT_MS = 250; // debounce on the app's readiness hint
540
+ /**
541
+ * How long a peer waits for a worker it has been told to expect.
542
+ *
543
+ * A tab that has not finished registering, or whose worker has not
544
+ * answered yet, is not refusing — it has not looked yet. Deferring on
545
+ * that costs a whole RETRY_MS cooldown for a race measured in hundreds
546
+ * of milliseconds.
547
+ *
548
+ * The bound is arithmetic, not taste: the peer's WHOLE reply — its first
549
+ * worker query, this wait, and its own durable flush — has to land inside
550
+ * the coordinator's acknowledgement deadline. WORKER_REPLY_MS(1500) +
551
+ * 750 + PREPARE_MS(3000) = 5250, inside ACK_MS(6500).
552
+ */
553
+ var PEER_GRACE_MS = 750;
554
+ var GRACE_POLL_MS = 50; // backup cadence while waiting for a worker
555
+ var RECHECK_MS = 2000; // pause before re-reading an unsettled answer
556
+ var MAX_RECHECKS = 10; // bounded: this is not a polling loop
557
+ var MAX_ATTEMPTS = 3; // real moves per build before recovery copy
558
+ var MAX_PEERS = 64; // retained peer records per round
559
+ var MAX_ROUTE_CHARS = 512; // longest route carried across the swap
560
+ var MAX_VERSION_CHARS = 64;
561
+ var MAX_ROUND_CHARS = 64;
562
+ var MAX_DIAGNOSTICS = 64; // retained observability records
563
+ var MAX_DETAIL_CHARS = 128; // longest string inside a diagnostic record
564
+ var MAX_DETAIL_KEYS = 8; // widest object a diagnostic record may hold
565
+
566
+ /* ---------------- identity and scope, resolved locally -------------- */
567
+
568
+ var APP_SCOPE = new URL('./', document.baseURI);
569
+ // Sibling of the scope, never inside it: a document under /cosy/ would be
570
+ // a controlled client and could not be a destination. Derived rather than
571
+ // hard-coded so a mounted prefix works without configuration.
572
+ var HANDOFF_URL = new URL('../cosy-handoff', APP_SCOPE);
573
+ // Scope-local so two Cosyncing mounts on one origin never coordinate with
574
+ // each other, exactly like the cache prefix in sw.js.
575
+ var CHANNEL_NAME = 'cosyncing-handoff:' + APP_SCOPE.pathname;
576
+ var ATTEMPT_PREFIX = 'cosyncing.handoff.attempts:';
577
+ var DIAGNOSTICS_KEY = 'cosyncing.handoff.diagnostics';
578
+ var DEFER_REASONS = ['no-waiting', 'exhausted', 'busy'];
579
+
580
+ // An app mounted at the origin root has no sibling path outside its own
581
+ // scope, so no handoff destination exists. Release builds are /cosy/; this
582
+ // is the honest degradation rather than a redirect into our own scope.
583
+ var HANDOFF_SUPPORTED = HANDOFF_URL.href.indexOf(APP_SCOPE.href) !== 0;
584
+
585
+ var tabId = (function () {
586
+ try {
587
+ if (window.crypto && typeof crypto.randomUUID === 'function') {
588
+ return crypto.randomUUID();
589
+ }
590
+ } catch (error) { /* fall through */ }
591
+ return 'tab-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
592
+ })();
593
+
594
+ /* ---------------- published state (read by Dart) -------------------- */
595
+
596
+ // A verified replacement build exists. Not a reason to show anything.
597
+ window.cosyncingWebUpdateReady = false;
598
+ // A handoff was really attempted MAX_ATTEMPTS times and never landed.
599
+ // This is the ONLY state that may surface user-visible recovery copy.
600
+ window.cosyncingWebUpdateHandoffFailed = false;
601
+ // Bounded, in-page record of what the protocol did. Diagnosis and tests
602
+ // read it; nothing depends on it.
603
+ //
604
+ // Hydrated, not reset. A handoff destroys this document and loads a
605
+ // replacement, and the replacement starting from an empty array meant its
606
+ // very first event overwrote the stored copy — erasing the deferred round
607
+ // that is the only part worth reading. Every field is re-validated
608
+ // because sessionStorage is same-origin but not trusted, and the same
609
+ // bound applies to what is read back as to what is written.
610
+ /**
611
+ * Reduce a record's detail to the shapes this protocol actually writes.
612
+ *
613
+ * Every `note()` detail is one of: nothing, a version string, or a flat
614
+ * pair like `{ v: version, attempt: n }`. Copying `d` through unchanged
615
+ * left the one field nobody checked: sessionStorage is same-origin but
616
+ * not trusted, so anything on this origin could park a megabyte of string
617
+ * or a deeply nested object under this key, and it would then be parsed
618
+ * on load and re-serialized on every note for the life of the tab.
619
+ * Anything outside the closed set is dropped rather than carried.
620
+ */
621
+ function sanitizeDetail(value) {
622
+ if (value === null || value === undefined) return null;
623
+ var type = typeof value;
624
+ if (type === 'number') return isFinite(value) ? value : null;
625
+ if (type === 'boolean') return value;
626
+ if (type === 'string') return value.slice(0, MAX_DETAIL_CHARS);
627
+ if (type !== 'object' || Array.isArray(value)) return null;
628
+ var keys = Object.keys(value);
629
+ if (keys.length > MAX_DETAIL_KEYS) return null;
630
+ var flat = {};
631
+ for (var k = 0; k < keys.length; k += 1) {
632
+ var key = keys[k];
633
+ if (key.length > MAX_DETAIL_CHARS) return null;
634
+ var item = value[key];
635
+ var itemType = typeof item;
636
+ if (itemType === 'string') flat[key] = item.slice(0, MAX_DETAIL_CHARS);
637
+ else if (itemType === 'number') flat[key] = isFinite(item) ? item : null;
638
+ else if (itemType === 'boolean') flat[key] = item;
639
+ else if (item === null || item === undefined) flat[key] = null;
640
+ // Nesting is not a shape this writes, so a nested value means the
641
+ // record did not come from here. Drop the whole detail, not the key.
642
+ else return null;
643
+ }
644
+ return flat;
645
+ }
646
+
647
+ window.cosyncingHandoffDiagnostics = (function () {
648
+ try {
649
+ var stored = JSON.parse(sessionStorage.getItem(DIAGNOSTICS_KEY) || '[]');
650
+ if (!Array.isArray(stored)) return [];
651
+ var restored = [];
652
+ for (var i = 0; i < stored.length; i += 1) {
653
+ var entry = stored[i];
654
+ if (!entry || typeof entry.k !== 'string' || typeof entry.t !== 'number') continue;
655
+ if (!isFinite(entry.t)) continue;
656
+ restored.push({
657
+ k: entry.k.slice(0, MAX_ROUND_CHARS),
658
+ d: sanitizeDetail(entry.d),
659
+ t: entry.t,
660
+ });
661
+ }
662
+ return restored.length > MAX_DIAGNOSTICS
663
+ ? restored.slice(restored.length - MAX_DIAGNOSTICS)
664
+ : restored;
665
+ } catch (e) {
666
+ return [];
667
+ }
668
+ })();
669
+
670
+ function note(kind, detail) {
671
+ var log = window.cosyncingHandoffDiagnostics;
672
+ // Sanitized on the way in as well as out, so the bound holds for the
673
+ // in-page array too and not only for what survives a reload.
674
+ log.push({
675
+ k: String(kind).slice(0, MAX_ROUND_CHARS),
676
+ d: sanitizeDetail(detail),
677
+ t: Date.now(),
678
+ });
679
+ if (log.length > MAX_DIAGNOSTICS) log.splice(0, log.length - MAX_DIAGNOSTICS);
680
+ // Mirrored so the record survives the swap. The point of the handoff is
681
+ // that this document is destroyed and replaced, which also destroys the
682
+ // in-page log — so anything read afterwards describes only the *new*
683
+ // document, and why an earlier round deferred is exactly the part that
684
+ // got thrown away. Session-scoped, bounded, read by diagnosis and tests
685
+ // only; nothing depends on it.
686
+ try {
687
+ sessionStorage.setItem(DIAGNOSTICS_KEY, JSON.stringify(log));
688
+ } catch (e) {
689
+ /* full, disabled or partitioned storage is not worth failing over */
690
+ }
691
+ }
692
+
693
+ function publish() {
694
+ window.dispatchEvent(new Event('cosyncing-web-update-state'));
695
+ }
696
+
697
+ /* ---------------- bounded session-scoped attempt budget ------------- */
698
+
699
+ function attemptKey(version) { return ATTEMPT_PREFIX + version; }
700
+
701
+ function attemptsFor(version) {
702
+ try {
703
+ return parseInt(sessionStorage.getItem(attemptKey(version)) || '0', 10) || 0;
704
+ } catch (error) {
705
+ return 0; // Storage denied (private mode): treat as a first attempt.
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Records one real move, and drops every other build's counter.
711
+ *
712
+ * Written BEFORE the navigation, so a crash on the handoff page still
713
+ * costs an attempt and cannot produce an unbounded move loop. The sweep is
714
+ * what bounds retained records: at most one counter exists per tab.
715
+ */
716
+ function recordAttempt(version, value) {
717
+ try {
718
+ var stale = [];
719
+ for (var i = 0; i < sessionStorage.length; i++) {
720
+ var key = sessionStorage.key(i);
721
+ if (key && key.indexOf(ATTEMPT_PREFIX) === 0 && key !== attemptKey(version)) {
722
+ stale.push(key);
723
+ }
724
+ }
725
+ for (var j = 0; j < stale.length; j++) sessionStorage.removeItem(stale[j]);
726
+ sessionStorage.setItem(attemptKey(version), String(value));
727
+ } catch (error) { /* ignore */ }
728
+ }
729
+
730
+ /* ---------------- small bounded primitives -------------------------- */
731
+
732
+ function sleep(ms) {
733
+ return new Promise(function (resolve) { setTimeout(resolve, ms); });
734
+ }
735
+
736
+ /** Resolves to `fallback` if `promise` has not settled inside `ms`. */
737
+ function withTimeout(promise, ms, fallback) {
738
+ return new Promise(function (resolve) {
739
+ var settled = false;
740
+ var timer = setTimeout(function () {
741
+ if (settled) return;
742
+ settled = true;
743
+ resolve(fallback);
744
+ }, ms);
745
+ Promise.resolve(promise).then(function (value) {
746
+ if (settled) return;
747
+ settled = true;
748
+ clearTimeout(timer);
749
+ resolve(value);
750
+ }, function () {
751
+ if (settled) return;
752
+ settled = true;
753
+ clearTimeout(timer);
754
+ resolve(fallback);
755
+ });
756
+ });
757
+ }
758
+
759
+ /** Asks one worker a read-only question. Never throws, never hangs. */
760
+ function ask(worker, request) {
761
+ return new Promise(function (resolve) {
762
+ if (!worker) return resolve(null);
763
+ var settled = false;
764
+ var timer = setTimeout(function () {
765
+ if (settled) return;
766
+ settled = true;
767
+ resolve(null);
768
+ }, WORKER_REPLY_MS);
769
+ try {
770
+ var channel = new MessageChannel();
771
+ channel.port1.onmessage = function (event) {
772
+ if (settled) return;
773
+ settled = true;
774
+ clearTimeout(timer);
775
+ resolve(event.data || null);
776
+ };
777
+ worker.postMessage(request, [channel.port2]);
778
+ } catch (error) {
779
+ if (settled) return;
780
+ settled = true;
781
+ clearTimeout(timer);
782
+ resolve(null);
783
+ }
784
+ });
785
+ }
786
+
787
+ function validVersion(value) {
788
+ return typeof value === 'string'
789
+ && value.length > 0
790
+ && value.length <= MAX_VERSION_CHARS
791
+ && /^[0-9a-z]+$/.test(value);
792
+ }
793
+
794
+ /* ---------------- route capture ------------------------------------- */
795
+
796
+ /**
797
+ * This tab's exact route, or the app root when it cannot be carried.
798
+ *
799
+ * A route longer than the bound is NOT truncated: a truncated route is a
800
+ * different route, and silently reopening the wrong session is worse than
801
+ * reopening the root.
802
+ */
803
+ function currentRoute() {
804
+ var href = location.href;
805
+ if (href.indexOf(APP_SCOPE.href) !== 0) return APP_SCOPE.pathname;
806
+ var route = location.pathname + location.search + location.hash;
807
+ return route.length > MAX_ROUTE_CHARS ? APP_SCOPE.pathname : route;
808
+ }
809
+
810
+ function handoffHref(version, route) {
811
+ var url = new URL(HANDOFF_URL.href);
812
+ url.searchParams.set('v', version);
813
+ url.searchParams.set('r', route);
814
+ return url.href;
815
+ }
816
+
817
+ /* ---------------- the app's own readiness --------------------------- */
818
+
819
+ /**
820
+ * Calls one of the app's handoff hooks under a bound.
821
+ *
822
+ * Dart installs these only while a surface owns state a handoff could
823
+ * destroy (see platform/update/web_handoff_participants.dart). Their
824
+ * absence therefore means this tab has nothing to lose — a pre-Flutter
825
+ * page, a failed boot, no editor open — and it can move immediately.
826
+ *
827
+ * A hung or throwing hook is a refusal, not a hang.
828
+ */
829
+ function askApp(name, bound) {
830
+ var hook = window[name];
831
+ if (typeof hook !== 'function') return Promise.resolve(true);
832
+ var invoked;
833
+ try {
834
+ invoked = Promise.resolve(hook());
835
+ } catch (error) {
836
+ return Promise.resolve(false);
837
+ }
838
+ return withTimeout(
839
+ invoked.then(function (ok) { return ok === true; }, function () { return false; }),
840
+ bound,
841
+ false,
842
+ );
843
+ }
844
+
845
+ /** Phase one: would this tab be willing to move? Flushes what it can. */
846
+ function prepareThisTab() {
847
+ return askApp('cosyncingHandoffPrepare', PREPARE_MS);
848
+ }
849
+
850
+ /**
851
+ * Phase two: freeze this tab and make its FINAL value durable.
852
+ *
853
+ * This is what closes the gap phase one cannot. Seconds pass between a tab
854
+ * acknowledging and being told to go, and the composer stays editable
855
+ * throughout — so the value flushed at acknowledgement is not necessarily
856
+ * the value on screen at `go`. The app's commit hook stops accepting input
857
+ * synchronously, captures what is there, persists it, and verifies nothing
858
+ * slipped past. Only a tab that answers true is allowed to leave.
859
+ */
860
+ function commitThisTab() {
861
+ return askApp('cosyncingHandoffCommit', PREPARE_MS);
862
+ }
863
+
864
+ /* ---------------- commit-window input guard ------------------------- */
865
+
866
+ // The app's own freeze is declarative and lands on the NEXT framework
867
+ // frame, but the commit snapshot is taken in THIS task — a keystroke,
868
+ // paste, or tap delivered in between would reach a surface the round
869
+ // already verified. These capture-phase listeners sit above every
870
+ // framework handler, so a guarded event is swallowed at the first place
871
+ // it exists.
872
+ //
873
+ // Registered ONCE, here at boot, before flutter_bootstrap.js loads:
874
+ // capture phase does not outrank listeners already on the SAME target —
875
+ // same-target listeners run in registration order — so a guard installed
876
+ // at commit time would run after everything the framework had already
877
+ // attached to this window. Raising and dropping only toggle the flag,
878
+ // exactly like the Ctrl+wheel listener above the coordinator.
879
+ var GUARD_EVENTS = [
880
+ 'keydown', 'keypress', 'keyup', 'beforeinput', 'input', 'paste',
881
+ 'cut', 'drop', 'compositionstart', 'pointerdown', 'pointerup',
882
+ 'mousedown', 'mouseup', 'click', 'dblclick', 'contextmenu',
883
+ 'touchstart', 'touchend', 'pointercancel'
884
+ ];
885
+ var guardRaised = false;
886
+ var passingSyntheticCancel = false;
887
+ // Interactions whose START the framework has already seen. A terminal
888
+ // event for one of these must not simply vanish behind the guard, or
889
+ // the framework is left believing a key or pointer is still pressed
890
+ // after an aborted round.
891
+ var unguardedKeys = {};
892
+ var unguardedPointers = {};
893
+
894
+ function keyIdOf(event) {
895
+ return String(event.code || event.key || 'key');
896
+ }
897
+
898
+ function onGuardEvent(event) {
899
+ if (passingSyntheticCancel) return;
900
+ var type = event.type;
901
+ if (!guardRaised) {
902
+ // Bookkeeping only; everything passes.
903
+ if (type === 'keydown') unguardedKeys[keyIdOf(event)] = true;
904
+ else if (type === 'keyup') delete unguardedKeys[keyIdOf(event)];
905
+ else if (type === 'pointerdown') unguardedPointers[String(event.pointerId)] = event.target || null;
906
+ else if (type === 'pointerup' || type === 'pointercancel') delete unguardedPointers[String(event.pointerId)];
907
+ return;
908
+ }
909
+ if (type === 'keyup' && unguardedKeys[keyIdOf(event)]) {
910
+ // Cleanup, not input: the framework saw this key go down before the
911
+ // guard rose, and a keyup mutates nothing. Swallowing it would
912
+ // leave the key held forever after an aborted round.
913
+ delete unguardedKeys[keyIdOf(event)];
914
+ return;
915
+ }
916
+ if (type === 'pointercancel') {
917
+ // Also pure cleanup; the browser is abandoning the interaction.
918
+ delete unguardedPointers[String(event.pointerId)];
919
+ return;
920
+ }
921
+ try {
922
+ event.stopImmediatePropagation();
923
+ event.preventDefault();
924
+ } catch (error) { /* a non-cancelable event still stops propagating */ }
925
+ }
926
+
927
+ for (var guardIndex = 0; guardIndex < GUARD_EVENTS.length; guardIndex++) {
928
+ window.addEventListener(GUARD_EVENTS[guardIndex], onGuardEvent, { capture: true, passive: false });
929
+ }
930
+
931
+ /**
932
+ * A tap or drag that began before the guard rose must neither complete
933
+ * as an action — its release would land on a page mid-handoff — nor be
934
+ * left half-pressed by having its terminal event swallowed. Cancelling
935
+ * it into the framework ends the gesture cleanly, with no action fired
936
+ * and no stuck pointer.
937
+ */
938
+ function cancelUnguardedPointers() {
939
+ for (var id in unguardedPointers) {
940
+ if (!Object.prototype.hasOwnProperty.call(unguardedPointers, id)) continue;
941
+ var target = unguardedPointers[id];
942
+ delete unguardedPointers[id];
943
+ if (!target || typeof target.dispatchEvent !== 'function') continue;
944
+ if (typeof PointerEvent !== 'function') continue;
945
+ try {
946
+ passingSyntheticCancel = true;
947
+ target.dispatchEvent(new PointerEvent('pointercancel', {
948
+ pointerId: Number(id), bubbles: true, cancelable: false
949
+ }));
950
+ } catch (error) { /* the framework's own sanitizer covers the rest */ }
951
+ passingSyntheticCancel = false;
952
+ }
953
+ }
954
+
955
+ function raiseInputGuard() {
956
+ if (guardRaised) return;
957
+ guardRaised = true;
958
+ cancelUnguardedPointers();
959
+ }
960
+
961
+ function dropInputGuard() {
962
+ guardRaised = false;
963
+ }
964
+
965
+ /**
966
+ * Calls the app's release hook, and cancels any commit still in flight.
967
+ *
968
+ * Unconditional on purpose. The freeze happens SYNCHRONOUSLY at the start
969
+ * of the commit hook and the flush that follows it takes as long as a
970
+ * durable write takes, so for most of a commit the surface is already
971
+ * frozen while this page has not yet recorded it. Anything that undoes a
972
+ * round has to undo that too, and bumping the generation is what makes the
973
+ * in-flight commit resolve to a refusal instead of re-freezing a surface
974
+ * nobody is going to move.
975
+ */
976
+ function releaseSurface() {
977
+ dropInputGuard();
978
+ commitSeq += 1;
979
+ committed = false;
980
+ var hook = window.cosyncingHandoffRelease;
981
+ if (typeof hook !== 'function') return;
982
+ try { hook(); } catch (error) { /* the surface is gone; nothing to undo */ }
983
+ }
984
+
985
+ /** Undoes a commit — taken or still being taken — for a dead round. */
986
+ function releaseThisTab() {
987
+ if (lockTimer !== null) { clearTimeout(lockTimer); lockTimer = null; }
988
+ if (!committed && !commitInFlight) return;
989
+ releaseSurface();
990
+ }
991
+
992
+ /**
993
+ * Freezes this tab, with a deadline.
994
+ *
995
+ * A coordinator that dies between `commit` and `go` would otherwise leave
996
+ * every peer permanently unable to type. The timer is the guarantee that a
997
+ * freeze is temporary no matter what happens to anyone else.
998
+ */
999
+ function commitAndHold(version, round) {
1000
+ if (commitInFlight) return Promise.resolve(false);
1001
+ var generation = commitSeq;
1002
+ commitInFlight = true;
1003
+ // Same task as the Dart freeze, before the hook: nothing the user does
1004
+ // from here on can reach the app until release or navigation.
1005
+ raiseInputGuard();
1006
+ note('commit-hook-start', { v: version, r: round, tab: tabId });
1007
+ return commitThisTab().then(function (ok) {
1008
+ note('commit-hook-finished', {
1009
+ v: version, r: round, tab: tabId, ok: ok
1010
+ });
1011
+ commitInFlight = false;
1012
+ // Either the round was abandoned while the app was still flushing, or
1013
+ // the app refused — including by exceeding the bound, in which case
1014
+ // whether it froze is unknowable from here. Every one of those leaves
1015
+ // a surface that may be frozen with nobody left to unfreeze it, so the
1016
+ // release runs whether or not this tab ever reached `committed`.
1017
+ if (!ok || commitSeq !== generation) {
1018
+ if (commitSeq !== generation) note('commit-cancelled', { v: version, r: round });
1019
+ releaseSurface();
1020
+ return false;
1021
+ }
1022
+ committed = true;
1023
+ if (lockTimer !== null) clearTimeout(lockTimer);
1024
+ lockTimer = setTimeout(function () {
1025
+ lockTimer = null;
1026
+ note('lock-expired', { v: version, r: round });
1027
+ releaseThisTab();
1028
+ abandonRound();
1029
+ }, LOCK_MS);
1030
+ return true;
1031
+ });
1032
+ }
1033
+
1034
+ /* ---------------- peer channel -------------------------------------- */
1035
+
1036
+ var channel = null;
1037
+ try {
1038
+ if (typeof BroadcastChannel === 'function') channel = new BroadcastChannel(CHANNEL_NAME);
1039
+ } catch (error) {
1040
+ channel = null;
1041
+ }
1042
+
1043
+ function post(message) {
1044
+ if (!channel) return;
1045
+ try { channel.postMessage(message); } catch (error) { /* ignore */ }
1046
+ }
1047
+
1048
+ /* ---------------- per-round state ----------------------------------- */
1049
+
1050
+ var moved = false; // this tab has committed to the handoff page
1051
+ var electionVersion = null; // build this tab is currently claiming for
1052
+ var bids = []; // claims seen this round, bounded by MAX_PEERS
1053
+ var replies = null; // Map(tab -> 'ack' | 'defer') while collecting
1054
+ var commits = null; // Map(tab -> 'done' | 'abort') while committing
1055
+ var repliesChanged = null; // wakes an event-driven acknowledgement wait
1056
+ var commitsChanged = null; // wakes an event-driven commit wait
1057
+ var pendingMove = null; // { version, round } once this tab has acked
1058
+ var busy = false; // one round at a time in this tab
1059
+ var sentPrepare = false; // this tab is the acting coordinator
1060
+ var peerCoordinator = null; // a peer broadcast `prepare` first
1061
+ var committed = false; // this tab is frozen for a commit
1062
+ var commitInFlight = false; // a freeze was taken; its flush has not landed
1063
+ var commitSeq = 0; // bumped by every release; cancels a commit
1064
+ var lockTimer = null; // deadline on that freeze
1065
+ var ownRound = null; // round id this tab is coordinating
1066
+ var roundCounter = 0; // makes each of this tab's rounds distinct
1067
+ var deferRounds = 0; // fast-cadence rounds spent
1068
+ var slowRounds = 0; // slow-cadence rounds spent
1069
+ var retryTimer = null;
1070
+ var recheckTimer = null;
1071
+ var rechecks = 0;
1072
+ var recheckGeneration = 0;
1073
+ var hintTimer = null;
1074
+ var considerAgain = false;
1075
+
1076
+ function resetRound() {
1077
+ electionVersion = null;
1078
+ bids = [];
1079
+ replies = null;
1080
+ commits = null;
1081
+ repliesChanged = null;
1082
+ commitsChanged = null;
1083
+ sentPrepare = false;
1084
+ peerCoordinator = null;
1085
+ }
1086
+
1087
+ /**
1088
+ * Waits until a peer phase is complete or cannot succeed.
1089
+ *
1090
+ * The deadline is only a ceiling. A healthy round advances on the message
1091
+ * that completes its expected set, while one refusal abandons immediately.
1092
+ * This avoids paying ACK_MS and COMMIT_MS as fixed sleeps on every update.
1093
+ */
1094
+ function waitForPeerPhase(map, expected, deadline, refusal, phase) {
1095
+ return new Promise(function (resolve) {
1096
+ var settled = false;
1097
+ var timer = null;
1098
+
1099
+ function finish() {
1100
+ if (settled) return;
1101
+ settled = true;
1102
+ if (timer !== null) clearTimeout(timer);
1103
+ if (phase === 'ack' && repliesChanged === changed) repliesChanged = null;
1104
+ if (phase === 'commit' && commitsChanged === changed) commitsChanged = null;
1105
+ resolve();
1106
+ }
1107
+
1108
+ function changed() {
1109
+ if (settled) return;
1110
+ var refused = false;
1111
+ map.forEach(function (value) {
1112
+ if (refusal(value)) refused = true;
1113
+ });
1114
+ if (refused || map.size >= expected) finish();
1115
+ }
1116
+
1117
+ if (phase === 'ack') repliesChanged = changed;
1118
+ else commitsChanged = changed;
1119
+ timer = setTimeout(finish, deadline);
1120
+ changed();
1121
+ });
1122
+ }
1123
+
1124
+ function waitForReplies(expected) {
1125
+ var observed = replies;
1126
+ if (!observed) return Promise.resolve();
1127
+ return waitForPeerPhase(
1128
+ observed,
1129
+ expected,
1130
+ ACK_MS,
1131
+ function (value) { return value !== 'ack'; },
1132
+ 'ack'
1133
+ );
1134
+ }
1135
+
1136
+ function waitForCommits(expected) {
1137
+ var observed = commits;
1138
+ if (!observed) return Promise.resolve();
1139
+ return waitForPeerPhase(
1140
+ observed,
1141
+ expected,
1142
+ COMMIT_MS,
1143
+ function (value) { return value !== 'done'; },
1144
+ 'commit'
1145
+ );
1146
+ }
1147
+
1148
+ /**
1149
+ * Ends a round this tab is part of without moving.
1150
+ *
1151
+ * Unfreezing is the load-bearing half: a tab that committed and is then
1152
+ * abandoned must become editable again immediately, or the user is left
1153
+ * staring at a composer that silently refuses input.
1154
+ */
1155
+ function abandonRound() {
1156
+ releaseThisTab();
1157
+ pendingMove = null;
1158
+ resetRound();
1159
+ }
1160
+
1161
+ /**
1162
+ * Commits this tab to the handoff page. Idempotent.
1163
+ *
1164
+ * The route is read HERE, not at acknowledgement time. Up to a few seconds
1165
+ * pass between a tab agreeing to move and being told to go, and a user who
1166
+ * navigates inside that window means the recorded route is no longer the
1167
+ * route they are on. `pendingMove` records that this tab agreed to move
1168
+ * for this build; where it comes back to is whatever it is showing when it
1169
+ * actually leaves.
1170
+ */
1171
+ function moveThisTab(version, round) {
1172
+ if (moved) return;
1173
+ if (!pendingMove || pendingMove.version !== version) return;
1174
+ if (pendingMove.round !== round) return; // a stale round may not move us
1175
+ if (!committed) return; // never leave a surface that was not frozen
1176
+ moved = true;
1177
+ if (lockTimer !== null) { clearTimeout(lockTimer); lockTimer = null; }
1178
+ var next = attemptsFor(version) + 1;
1179
+ recordAttempt(version, next);
1180
+ note('move', { v: version, attempt: next });
1181
+ location.replace(handoffHref(version, currentRoute()));
1182
+ }
1183
+
1184
+ function validRound(value) {
1185
+ return typeof value === 'string'
1186
+ && value.length > 0
1187
+ && value.length <= MAX_ROUND_CHARS;
1188
+ }
1189
+
1190
+ /**
1191
+ * A round identifier, independent of the build it is for.
1192
+ *
1193
+ * Folding the version in would let a long-but-legal build identity push
1194
+ * the id past MAX_ROUND_CHARS, and every peer would then reject every
1195
+ * message in the round through its own validRound — a round that cannot
1196
+ * complete and shows no error. A round id only has to distinguish this
1197
+ * tab's rounds from each other and from a peer's; the build is carried
1198
+ * separately in `v` and re-verified locally anyway. The result is checked
1199
+ * against the bound rather than assumed to fit.
1200
+ */
1201
+ function newRoundId() {
1202
+ var candidate = tabId.slice(0, 32) + ':' + String(++roundCounter);
1203
+ return validRound(candidate) ? candidate : String(roundCounter);
1204
+ }
1205
+
1206
+ /** Whether a message belongs to the round this tab is actually in. */
1207
+ function inThisRound(message) {
1208
+ return pendingMove !== null
1209
+ && pendingMove.version === message.v
1210
+ && pendingMove.round === message.r;
1211
+ }
1212
+
1213
+ if (channel) {
1214
+ channel.onmessage = function (event) {
1215
+ var message = event.data;
1216
+ if (!message || typeof message !== 'object') return;
1217
+ if (!validVersion(message.v)) return;
1218
+ if (typeof message.tab === 'string' && message.tab.length > 64) return;
1219
+ if (message.k === 'claim') {
1220
+ if (message.v !== electionVersion) return;
1221
+ if (bids.length >= MAX_PEERS) return;
1222
+ if (typeof message.tab !== 'string') return;
1223
+ if (bids.indexOf(message.tab) === -1) bids.push(message.tab);
1224
+ return;
1225
+ }
1226
+ // Everything past the election is round-qualified, so a message from a
1227
+ // round this tab already left can never freeze it or move it.
1228
+ if (!validRound(message.r)) return;
1229
+ if (message.k === 'prepare') { onPrepare(message.v, message.r); return; }
1230
+ if (message.k === 'ack' || message.k === 'defer') {
1231
+ if (!replies || replies.size >= MAX_PEERS) return;
1232
+ if (message.r !== ownRound) return;
1233
+ if (typeof message.tab !== 'string') return;
1234
+ // The reason is retained for diagnosis only, and only from a fixed
1235
+ // set: everything on this channel is same-origin but untrusted.
1236
+ replies.set(
1237
+ message.tab,
1238
+ message.k === 'ack'
1239
+ ? 'ack'
1240
+ : DEFER_REASONS.indexOf(message.why) === -1
1241
+ ? 'defer:unknown'
1242
+ : 'defer:' + message.why
1243
+ );
1244
+ note('ack-received', { tab: message.tab, k: message.k, r: message.r });
1245
+ if (repliesChanged) repliesChanged();
1246
+ return;
1247
+ }
1248
+ if (message.k === 'commit') { onCommit(message); return; }
1249
+ if (message.k === 'done' || message.k === 'abort') {
1250
+ if (!commits || commits.size >= MAX_PEERS) return;
1251
+ if (message.r !== ownRound) return;
1252
+ if (typeof message.tab !== 'string') return;
1253
+ commits.set(message.tab, message.k);
1254
+ note('commit-reply-received', {
1255
+ tab: message.tab, k: message.k, r: message.r
1256
+ });
1257
+ if (commitsChanged) commitsChanged();
1258
+ return;
1259
+ }
1260
+ if (message.k === 'go') { moveThisTab(message.v, message.r); return; }
1261
+ if (message.k === 'release') {
1262
+ if (inThisRound(message)) abandonRound();
1263
+ return;
1264
+ }
1265
+ };
1266
+ }
1267
+
1268
+ /**
1269
+ * A peer asked this tab to freeze and finalise (phase two).
1270
+ *
1271
+ * Answering `done` means: input is refused from this moment, whatever was
1272
+ * on screen is durable, and this tab is safe to navigate away. Answering
1273
+ * `abort` means the user typed something between acknowledging and now, so
1274
+ * nobody moves.
1275
+ */
1276
+ function onCommit(message) {
1277
+ if (moved) return;
1278
+ if (commitInFlight) return; // one freeze at a time; a repeat is a no-op
1279
+ if (!inThisRound(message)) return;
1280
+ note('commit-received', { v: message.v, r: message.r, tab: tabId });
1281
+ commitAndHold(message.v, message.r).then(function (ok) {
1282
+ if (moved) return;
1283
+ if (!inThisRound(message)) return;
1284
+ post({ k: ok ? 'done' : 'abort', v: message.v, r: message.r, tab: tabId });
1285
+ note('done-sent', {
1286
+ v: message.v, r: message.r, tab: tabId, k: ok ? 'done' : 'abort'
1287
+ });
1288
+ if (!ok) {
1289
+ note('commit-refused', message.v);
1290
+ abandonRound();
1291
+ }
1292
+ });
1293
+ }
1294
+
1295
+ /**
1296
+ * A peer asked this tab to get ready.
1297
+ *
1298
+ * The request is re-verified locally rather than trusted: this tab checks
1299
+ * for itself that a waiting worker with that exact identity really exists.
1300
+ * The channel is same-origin, so this is not a trust boundary — it is what
1301
+ * stops a stale broadcast, or a tab that has already moved on, from
1302
+ * pushing an up-to-date tab out of its route for nothing.
1303
+ */
1304
+ function onPrepare(version, round) {
1305
+ if (moved) return;
1306
+ // Someone else got there first. Recorded before anything async so this
1307
+ // tab's own election, if one is in flight, can stand down rather than
1308
+ // becoming a second coordinator for the same build.
1309
+ if (!sentPrepare) peerCoordinator = version;
1310
+ resolveExpectedWorker(version).then(function (found) {
1311
+ if (moved) return;
1312
+ if (found.version !== version) {
1313
+ note('no-waiting-state', { s: found.state, v: version });
1314
+ post({ k: 'defer', v: version, r: round, tab: tabId, why: 'no-waiting' });
1315
+ return;
1316
+ }
1317
+ if (attemptsFor(version) >= MAX_ATTEMPTS) {
1318
+ post({ k: 'defer', v: version, r: round, tab: tabId, why: 'exhausted' });
1319
+ surfaceFailure(version);
1320
+ return;
1321
+ }
1322
+ return prepareThisTab().then(function (ready) {
1323
+ if (moved) return;
1324
+ if (!ready) {
1325
+ post({ k: 'defer', v: version, r: round, tab: tabId, why: 'busy' });
1326
+ note('defer-local', version);
1327
+ return;
1328
+ }
1329
+ // Joining the round, not agreeing to leave. The composer stays
1330
+ // editable until `commit` freezes it, so what is recorded here is
1331
+ // membership; the value that gets saved is captured later.
1332
+ pendingMove = { version: version, round: round };
1333
+ post({ k: 'ack', v: version, r: round, tab: tabId });
1334
+ note('ack-local', version);
1335
+ });
1336
+ });
1337
+ }
1338
+
1339
+ /* ---------------- observation --------------------------------------- */
1340
+
1341
+ var registrationRef = null;
1342
+
1343
+ /**
1344
+ * The identity of a verified waiting build, or null.
1345
+ *
1346
+ * "Waiting" already means installed: sw.js precaches atomically and hash-
1347
+ * verifies every entry, so a worker only reaches `waiting` if its whole
1348
+ * build is present and belongs together. This adds the one thing that
1349
+ * state does not carry — WHICH build — and refuses a worker that cannot
1350
+ * answer or answers with the identity already in control.
1351
+ */
1352
+ function waitingIdentity() {
1353
+ return inspectWaiting().then(function (found) {
1354
+ return found.version;
1355
+ });
1356
+ }
1357
+
1358
+ /**
1359
+ * The same question, with the reason attached when the answer is null.
1360
+ *
1361
+ * A bare null collapsed five different situations into one, and they do
1362
+ * not want the same treatment. Three are transient — this document has
1363
+ * not been told about the worker yet, or the worker has not answered yet
1364
+ * — and are worth waiting a moment for. Two are settled: a DIFFERENT
1365
+ * build is waiting, or the build in question is already the active one.
1366
+ * Waiting on those achieves nothing, so the caller must be able to tell
1367
+ * them apart.
1368
+ *
1369
+ * no-registration the worker registration has not resolved yet
1370
+ * absent no waiting worker is visible to this document
1371
+ * silent a waiting worker exists and did not answer in time
1372
+ * invalid it answered with something that is not a build id
1373
+ * unknown-active the ACTIVE worker did not answer, so "different
1374
+ * build" cannot be established
1375
+ * invalid-active the active worker answered without a valid build id
1376
+ * superseded the active worker already reports that identity
1377
+ * ready a distinct, verified waiting build
1378
+ */
1379
+ function inspectWaiting() {
1380
+ var registration = registrationRef;
1381
+ if (!registration) {
1382
+ return Promise.resolve({ version: null, state: 'no-registration' });
1383
+ }
1384
+ if (!registration.waiting) {
1385
+ return Promise.resolve({ version: null, state: 'absent' });
1386
+ }
1387
+ return Promise.all([
1388
+ ask(registration.waiting, { type: 'cosyncing-build-identity' }),
1389
+ ask(registration.active, { type: 'cosyncing-build-identity' }),
1390
+ ]).then(function (answers) {
1391
+ var next = answers[0];
1392
+ var current = answers[1];
1393
+ if (!next) return { version: null, state: 'silent' };
1394
+ if (!validVersion(next.version)) {
1395
+ return { version: null, state: 'invalid' };
1396
+ }
1397
+ // An active worker that did not answer proves nothing, and this used
1398
+ // to read as proof. The guard below was `current && ...`, so a null
1399
+ // answer skipped it and a waiting worker was reported as an update
1400
+ // even when it was the build this tab is already running. On first
1401
+ // load the active worker is busy precaching several megabytes and
1402
+ // routinely misses the 1.5s reply window, so a tab would elect
1403
+ // itself coordinator for its OWN build, run a doomed round — every
1404
+ // peer that has not registered yet answers `no-waiting` — and arm
1405
+ // the 60s cooldown that then delays the real update.
1406
+ //
1407
+ // Inconclusive, not negative: `consider()` runs again on
1408
+ // `updatefound`, on `controllerchange`, on the app's readiness hint
1409
+ // and on the retry cadence, so nothing is lost by declining to
1410
+ // decide now.
1411
+ if (!current) return { version: null, state: 'unknown-active' };
1412
+ if (!validVersion(current.version)) {
1413
+ return { version: null, state: 'invalid-active' };
1414
+ }
1415
+ if (current.version === next.version) {
1416
+ return { version: null, state: 'superseded' };
1417
+ }
1418
+ return { version: next.version, state: 'ready' };
1419
+ });
1420
+ }
1421
+
1422
+ /**
1423
+ * States that mean "not yet", as opposed to "no".
1424
+ *
1425
+ * Only these are worth waiting on. A DIFFERENT valid build waiting, an
1426
+ * identity the active worker already reports, or an unreadable answer
1427
+ * are all settled: waiting cannot change them, and pretending otherwise
1428
+ * would just delay an honest deferral.
1429
+ */
1430
+ var UNSETTLED_STATES = {
1431
+ 'no-registration': true,
1432
+ absent: true,
1433
+ silent: true,
1434
+ 'unknown-active': true,
1435
+ };
1436
+
1437
+ /**
1438
+ * States worth looking at again unprompted.
1439
+ *
1440
+ * A narrower set than the one above, and the difference is who asked. A
1441
+ * peer's `prepare` is evidence that a worker exists, so `absent` there
1442
+ * means "not shown to me yet" and is worth waiting on. Unprompted,
1443
+ * `absent` is just a tab with no update — the overwhelmingly common
1444
+ * state — and re-checking it would turn every idle tab into a poller.
1445
+ * What remains are the answers that say something is there and could not
1446
+ * be read.
1447
+ */
1448
+ var UNREADABLE_STATES = {
1449
+ 'no-registration': true,
1450
+ silent: true,
1451
+ 'unknown-active': true,
1452
+ };
1453
+
1454
+ /**
1455
+ * Look for the worker a peer says exists, allowing briefly for the fact
1456
+ * that this document may not have been told about it yet.
1457
+ *
1458
+ * Deliberately local. A peer's `prepare` is a claim about the peer, and
1459
+ * the only thing acted on here is what this tab can verify for itself —
1460
+ * so this waits for THE EXACT expected identity and nothing else. It is
1461
+ * also not a reason to retry anything globally: a peer message must
1462
+ * never be able to drive this tab's retry cadence, or a chatty peer
1463
+ * becomes a retry storm.
1464
+ */
1465
+ function resolveExpectedWorker(version) {
1466
+ return inspectWaiting().then(function (found) {
1467
+ if (found.version === version) return found;
1468
+ if (!UNSETTLED_STATES[found.state]) return found;
1469
+ return waitForExpectedWorker(version, found);
1470
+ });
1471
+ }
1472
+
1473
+ function waitForExpectedWorker(version, initial) {
1474
+ return new Promise(function (resolve) {
1475
+ var settled = false;
1476
+ var last = initial;
1477
+ var poll = null;
1478
+ var expiry = null;
1479
+ var detach = [];
1480
+ var checking = false;
1481
+
1482
+ function finish(found) {
1483
+ if (settled) return;
1484
+ settled = true;
1485
+ if (poll !== null) clearTimeout(poll);
1486
+ if (expiry !== null) clearTimeout(expiry);
1487
+ for (var i = 0; i < detach.length; i += 1) detach[i]();
1488
+ resolve(found);
1489
+ }
1490
+
1491
+ function check() {
1492
+ if (settled || checking) return;
1493
+ checking = true;
1494
+ inspectWaiting().then(function (found) {
1495
+ checking = false;
1496
+ last = found;
1497
+ // Only the expected identity ends the wait early. Anything else
1498
+ // — including a different build that arrives meanwhile — leaves
1499
+ // the deadline to decide, so a late surprise cannot be mistaken
1500
+ // for the thing this round asked about.
1501
+ if (found.version === version) finish(found);
1502
+ }, function () {
1503
+ checking = false;
1504
+ });
1505
+ }
1506
+
1507
+ // Lifecycle first: the browser says the moment a worker installs, so
1508
+ // the common case costs one event rather than a poll interval.
1509
+ var registration = registrationRef;
1510
+ if (registration && typeof registration.addEventListener === 'function') {
1511
+ var onUpdateFound = function () {
1512
+ var installing = registration.installing;
1513
+ if (installing && typeof installing.addEventListener === 'function') {
1514
+ var onStateChange = function () {
1515
+ if (installing.state === 'installed') check();
1516
+ };
1517
+ installing.addEventListener('statechange', onStateChange);
1518
+ detach.push(function () {
1519
+ if (typeof installing.removeEventListener === 'function') {
1520
+ installing.removeEventListener('statechange', onStateChange);
1521
+ }
1522
+ });
1523
+ }
1524
+ check();
1525
+ };
1526
+ registration.addEventListener('updatefound', onUpdateFound);
1527
+ detach.push(function () {
1528
+ if (typeof registration.removeEventListener === 'function') {
1529
+ registration.removeEventListener('updatefound', onUpdateFound);
1530
+ }
1531
+ });
1532
+ }
1533
+
1534
+ // Backup, because the events are not sufficient: this document may
1535
+ // have no registration yet (nothing to listen to), and a worker that
1536
+ // installed before this document existed fires no event at all.
1537
+ //
1538
+ // Self-rescheduling rather than an interval: one timer at a time
1539
+ // cannot overlap its own checks, and `setTimeout` is the only timer
1540
+ // this page needs, which keeps it drivable by a controlled clock.
1541
+ function tick() {
1542
+ if (settled) return;
1543
+ check();
1544
+ if (settled) return;
1545
+ poll = setTimeout(tick, GRACE_POLL_MS);
1546
+ }
1547
+ expiry = setTimeout(function () { finish(last); }, PEER_GRACE_MS);
1548
+ tick();
1549
+ });
1550
+ }
1551
+
1552
+ function surfaceFailure(version) {
1553
+ if (window.cosyncingWebUpdateHandoffFailed) return;
1554
+ window.cosyncingWebUpdateHandoffFailed = true;
1555
+ note('failed', version);
1556
+ publish();
1557
+ }
1558
+
1559
+ /**
1560
+ * Schedules the next attempt after a deferral, on a two-tier cadence.
1561
+ *
1562
+ * The fast tier answers "the user is mid-sentence, try again shortly".
1563
+ * Spending it does not mean giving up: a tab that deferred for eight
1564
+ * minutes and then went quiet must still pick the update up, so the slow
1565
+ * tier keeps checking for two more hours. Beyond that the readiness hint
1566
+ * below is the live path — the app tells the page the moment it stops
1567
+ * being busy — and this stops scheduling rather than polling forever.
1568
+ */
1569
+ /**
1570
+ * Look again shortly, a bounded number of times.
1571
+ *
1572
+ * Separate from `armRetry`, which is the cooldown after a tab REFUSED a
1573
+ * round. This is for a tab that could not tell whether there was a round
1574
+ * to run, so the wait is short and the budget small: the point is to
1575
+ * outlast a worker that is briefly too busy to answer, not to poll.
1576
+ */
1577
+ function armRecheck() {
1578
+ var generation = recheckGeneration;
1579
+ if (recheckTimer !== null) return;
1580
+ if (rechecks >= MAX_RECHECKS) return;
1581
+ rechecks += 1;
1582
+ recheckTimer = setTimeout(function () {
1583
+ recheckTimer = null;
1584
+ if (generation !== recheckGeneration) return;
1585
+ consider();
1586
+ }, RECHECK_MS);
1587
+ }
1588
+
1589
+ function resetRechecks() {
1590
+ rechecks = 0;
1591
+ if (recheckTimer !== null) {
1592
+ clearTimeout(recheckTimer);
1593
+ recheckTimer = null;
1594
+ }
1595
+ }
1596
+
1597
+ /** Starts a fresh bounded observation budget for a newly found worker. */
1598
+ function beginUpdateObservation() {
1599
+ recheckGeneration += 1;
1600
+ resetRechecks();
1601
+ }
1602
+
1603
+ function armRetry() {
1604
+ if (retryTimer !== null) return;
1605
+ var delay;
1606
+ if (deferRounds < MAX_DEFER_ROUNDS) {
1607
+ delay = RETRY_MS;
1608
+ } else if (slowRounds < MAX_SLOW_ROUNDS) {
1609
+ delay = SLOW_RETRY_MS;
1610
+ } else {
1611
+ note('retries-spent', null);
1612
+ return;
1613
+ }
1614
+ retryTimer = setTimeout(function () {
1615
+ retryTimer = null;
1616
+ if (deferRounds >= MAX_DEFER_ROUNDS) slowRounds += 1;
1617
+ consider();
1618
+ }, delay);
1619
+ }
1620
+
1621
+ /**
1622
+ * The app reporting that this tab may have become movable.
1623
+ *
1624
+ * Called when an editor closes or the composer loses focus — the two ways
1625
+ * a deferring tab stops deferring. Without it a user who types for a while
1626
+ * and then stops would wait out the retry cadence for an update that is
1627
+ * already downloaded and verified, or, once the cadence is spent, until
1628
+ * some unrelated navigation.
1629
+ *
1630
+ * Debounced, and it resets the cadence rather than bypassing it: a burst
1631
+ * of registrations cannot turn this into an unbounded retry loop, and the
1632
+ * round it triggers re-verifies everything from scratch.
1633
+ */
1634
+ window.cosyncingHandoffReadyHint = function () {
1635
+ if (moved || busy) return;
1636
+ if (hintTimer !== null) return;
1637
+ hintTimer = setTimeout(function () {
1638
+ hintTimer = null;
1639
+ if (moved || busy) return;
1640
+ if (!window.cosyncingWebUpdateReady) return; // nothing is waiting
1641
+ deferRounds = 0;
1642
+ slowRounds = 0;
1643
+ if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }
1644
+ note('ready-hint', null);
1645
+ consider();
1646
+ }, READY_HINT_MS);
1647
+ };
1648
+
1649
+ /**
1650
+ * One bounded handoff round, from this tab's point of view.
1651
+ *
1652
+ * Returns without doing anything visible in every path that is not a
1653
+ * completed handoff. A deferral is not a failure and never shows.
1654
+ */
1655
+ function consider() {
1656
+ if (moved) return;
1657
+ if (busy) { considerAgain = true; return; }
1658
+ var registration = registrationRef;
1659
+ if (!registration) return;
1660
+ busy = true;
1661
+ runRound(registration).then(function (outcome) {
1662
+ busy = false;
1663
+ note('round', outcome);
1664
+ if (considerAgain) {
1665
+ considerAgain = false;
1666
+ consider();
1667
+ return;
1668
+ }
1669
+ if (outcome === 'moving' || outcome === 'none') return;
1670
+ if (outcome === 'exhausted') return;
1671
+ deferRounds += 1;
1672
+ armRetry();
1673
+ }, function () {
1674
+ busy = false;
1675
+ if (considerAgain) {
1676
+ considerAgain = false;
1677
+ consider();
1678
+ return;
1679
+ }
1680
+ deferRounds += 1;
1681
+ armRetry();
1682
+ });
1683
+ }
1684
+
1685
+ function runRound(registration) {
1686
+ return inspectWaiting().then(function (found) {
1687
+ var version = found.version;
1688
+ if (!version) {
1689
+ // "Not yet" is not "no". An unsettled answer — no registration, no
1690
+ // visible worker, a worker or an active worker that did not reply
1691
+ // — leaves this tab with nothing scheduled: `updatefound` has
1692
+ // already fired, the readiness hint is gated on a verified build
1693
+ // it has not got, and the retry cadence is only armed by a
1694
+ // deferral. Without a re-check the tab would sit out an update it
1695
+ // simply could not read yet.
1696
+ if (UNREADABLE_STATES[found.state]) armRecheck();
1697
+ else resetRechecks();
1698
+ return 'none';
1699
+ }
1700
+
1701
+ // A settled observation replenishes this generation's transient
1702
+ // query budget. A later updatefound starts a new generation as well.
1703
+ resetRechecks();
1704
+
1705
+ window.cosyncingWebUpdateReady = true;
1706
+ publish();
1707
+
1708
+ if (attemptsFor(version) >= MAX_ATTEMPTS) {
1709
+ surfaceFailure(version);
1710
+ return 'exhausted';
1711
+ }
1712
+ // No destination and no peer channel are both permanent for this page.
1713
+ // Report them as a real failure so the app can say something truthful
1714
+ // instead of retrying a handoff that can never run.
1715
+ if (!HANDOFF_SUPPORTED || !channel) {
1716
+ surfaceFailure(version);
1717
+ return 'exhausted';
1718
+ }
1719
+
1720
+ // 1. Elect. Every tab that has seen this build claims; lowest wins.
1721
+ electionVersion = version;
1722
+ bids = [tabId];
1723
+ peerCoordinator = null;
1724
+ post({ k: 'claim', v: version, tab: tabId });
1725
+ return sleep(ELECTION_MS).then(function () {
1726
+ bids.sort();
1727
+ if (bids[0] !== tabId || peerCoordinator === version) {
1728
+ resetRound();
1729
+ return 'follower'; // a peer coordinates; this tab answers `prepare`
1730
+ }
1731
+ note('coordinator', version);
1732
+
1733
+ // 2. Census from the ACTIVE worker: how many tabs must agree.
1734
+ return ask(navigator.serviceWorker.controller, {
1735
+ type: 'cosyncing-client-census',
1736
+ }).then(function (census) {
1737
+ if (!census || typeof census.windows !== 'number'
1738
+ || census.windows < 1 || census.windows > MAX_PEERS) {
1739
+ resetRound();
1740
+ return 'no-census';
1741
+ }
1742
+ // A tab whose election started later can still have reached
1743
+ // `prepare` while this one was taking its census. Whoever spoke
1744
+ // first coordinates; standing down here costs one deferred round
1745
+ // and keeps "exactly one coordinator" true rather than merely
1746
+ // likely.
1747
+ if (peerCoordinator === version) {
1748
+ resetRound();
1749
+ return 'follower';
1750
+ }
1751
+
1752
+ // 3. Ask everyone, including this tab, to become safe to move.
1753
+ replies = new Map();
1754
+ sentPrepare = true;
1755
+ ownRound = newRoundId();
1756
+ var round = ownRound;
1757
+ post({ k: 'prepare', v: version, r: round });
1758
+ return prepareThisTab().then(function (ready) {
1759
+ if (!ready) {
1760
+ resetRound();
1761
+ return 'coordinator-busy';
1762
+ }
1763
+ pendingMove = { version: version, round: round };
1764
+ replies.set(tabId, 'ack');
1765
+ return waitForReplies(census.windows).then(function () {
1766
+ var acks = 0;
1767
+ var defers = 0;
1768
+ var reasons = [];
1769
+ replies.forEach(function (value) {
1770
+ if (value === 'ack') { acks += 1; return; }
1771
+ defers += 1;
1772
+ // Which peer reason it was decides whether a deferral is a
1773
+ // race worth closing or a tab legitimately busy, and the
1774
+ // round that defers is the one whose record the swap
1775
+ // destroys. Recording it here is the only place it lasts.
1776
+ if (reasons.indexOf(value) === -1) reasons.push(value);
1777
+ });
1778
+ // A single deferral, or a single tab that never answered,
1779
+ // stops the round. Moving a subset would leave the old worker
1780
+ // controlling the rest and strand the movers on a handoff page
1781
+ // waiting for an activation that cannot happen.
1782
+ if (defers > 0) {
1783
+ note('peer-defer-reasons', reasons.join(','));
1784
+ abandonRound();
1785
+ return 'peer-deferred';
1786
+ }
1787
+ if (acks !== census.windows) { abandonRound(); return 'unresponsive'; }
1788
+
1789
+ // 4. Re-census: a tab opened during the round would not have
1790
+ // been asked, and would keep the old worker alive.
1791
+ return ask(navigator.serviceWorker.controller, {
1792
+ type: 'cosyncing-client-census',
1793
+ }).then(function (again) {
1794
+ if (!again || again.windows !== census.windows) {
1795
+ abandonRound();
1796
+ return 'census-changed';
1797
+ }
1798
+
1799
+ // 5. COMMIT. Everyone freezes and finalises before anyone
1800
+ // leaves. The acknowledgement above is seconds old by now
1801
+ // and the user has been able to type the whole time, so
1802
+ // what was flushed then is not necessarily what is on
1803
+ // screen now. This is the phase that makes the handoff
1804
+ // lossless, and it is why `go` is a separate message: a
1805
+ // tab may only be moved after it has stopped accepting
1806
+ // input and proved its final value is durable.
1807
+ commits = new Map();
1808
+ post({ k: 'commit', v: version, r: round });
1809
+ note('commit-received', { v: version, r: round, tab: tabId });
1810
+ return commitAndHold(version, round).then(function (mine) {
1811
+ if (!mine) {
1812
+ post({ k: 'release', v: version, r: round });
1813
+ abandonRound();
1814
+ return 'coordinator-edited';
1815
+ }
1816
+ commits.set(tabId, 'done');
1817
+ note('done-sent', {
1818
+ v: version, r: round, tab: tabId, k: 'done'
1819
+ });
1820
+ return waitForCommits(census.windows).then(function () {
1821
+ var done = 0;
1822
+ var aborted = 0;
1823
+ commits.forEach(function (value) {
1824
+ if (value === 'done') done += 1; else aborted += 1;
1825
+ });
1826
+ // Anyone still typing, or silent, releases everyone.
1827
+ // Frozen tabs must never be left frozen.
1828
+ if (aborted > 0 || done !== census.windows) {
1829
+ post({ k: 'release', v: version, r: round });
1830
+ abandonRound();
1831
+ return aborted > 0 ? 'peer-edited' : 'commit-unresponsive';
1832
+ }
1833
+ // 6. Go. Every tab is frozen and durable.
1834
+ post({ k: 'go', v: version, r: round });
1835
+ moveThisTab(version, round);
1836
+ return 'moving';
1837
+ });
1838
+ });
1839
+ });
1840
+ });
1841
+ });
1842
+ });
1843
+ });
1844
+ });
1845
+ }
1846
+
1847
+ /* ---------------- registration ------------------------------------- */
1848
+
1849
+ function watch(registration) {
1850
+ registrationRef = registration;
1851
+ beginUpdateObservation();
1852
+ consider();
1853
+ registration.addEventListener('updatefound', function () {
1854
+ beginUpdateObservation();
1855
+ var installing = registration.installing;
1856
+ if (!installing) return;
1857
+ installing.addEventListener('statechange', function () {
1858
+ if (installing.state === 'installed') consider();
1859
+ });
1860
+ });
1861
+ navigator.serviceWorker.addEventListener('controllerchange', function () {
1862
+ consider();
1863
+ });
1864
+ }
1865
+
1866
+ if ('serviceWorker' in navigator) {
1867
+ window.addEventListener('load', function () {
1868
+ navigator.serviceWorker
1869
+ .register('sw.js', { scope: './', updateViaCache: 'none' })
1870
+ .then(watch)
1871
+ .catch(function (error) {
1872
+ // Static caching is an optimisation; startup must not depend on it.
1873
+ console.warn('[cosyncing] static cache worker not registered:', error);
1874
+ });
1875
+ });
1876
+ }
1877
+ })();
1878
+ </script>
1879
+
1880
+ <!--
1881
+ You can customize the "flutter_bootstrap.js" script.
1882
+ This is useful to provide a custom configuration to the Flutter loader
1883
+ or to give the user feedback during the initialization process.
1884
+
1885
+ For more details:
1886
+ * https://docs.flutter.dev/platform-integration/web/initialization
1887
+ -->
1888
+ <script src="flutter_bootstrap.js" async></script>
1889
+ </body>
1890
+ </html>