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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +57 -4
- package/THIRD_PARTY_NOTICES.txt +582 -0
- package/bin/cosyncing +1829 -0
- package/bin/cosyncing-web-0.2.0/assets/AssetManifest.bin +1 -0
- package/bin/cosyncing-web-0.2.0/assets/AssetManifest.bin.json +1 -0
- package/bin/cosyncing-web-0.2.0/assets/FontManifest.json +1 -0
- package/bin/cosyncing-web-0.2.0/assets/NOTICES +34313 -0
- package/bin/cosyncing-web-0.2.0/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/bin/cosyncing-web-0.2.0/assets/packages/cupertino_icons/assets/CupertinoIcons.ttf +0 -0
- package/bin/cosyncing-web-0.2.0/assets/packages/flutter_local_notifications_web/web/notifications_service_worker.js +77 -0
- package/bin/cosyncing-web-0.2.0/assets/shaders/ink_sparkle.frag +127 -0
- package/bin/cosyncing-web-0.2.0/assets/shaders/stretch_effect.frag +64 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/canvaskit.js +193 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/canvaskit.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/chromium/canvaskit.js +193 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/chromium/canvaskit.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/experimental_webparagraph/canvaskit.js +171 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/experimental_webparagraph/canvaskit.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/skwasm.js +146 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/skwasm.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/skwasm_heavy.js +146 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/skwasm_heavy.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/wimp.js +135 -0
- package/bin/cosyncing-web-0.2.0/canvaskit/wimp.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/cosyncing-build-identity.json +20 -0
- package/bin/cosyncing-web-0.2.0/cosyncing-cache-manifest.json +85 -0
- package/bin/cosyncing-web-0.2.0/drift_worker.js +13185 -0
- package/bin/cosyncing-web-0.2.0/favicon.ico +0 -0
- package/bin/cosyncing-web-0.2.0/favicon.svg +8 -0
- package/bin/cosyncing-web-0.2.0/flutter.js +31 -0
- package/bin/cosyncing-web-0.2.0/flutter_bootstrap.js +62 -0
- package/bin/cosyncing-web-0.2.0/icons/apple-touch-icon-180.png +0 -0
- package/bin/cosyncing-web-0.2.0/icons/pwa-icon-192.png +0 -0
- package/bin/cosyncing-web-0.2.0/icons/pwa-icon-512.png +0 -0
- package/bin/cosyncing-web-0.2.0/icons/pwa-maskable-192.png +0 -0
- package/bin/cosyncing-web-0.2.0/icons/pwa-maskable-512.png +0 -0
- package/bin/cosyncing-web-0.2.0/icons/pwa-monochrome-192.png +0 -0
- package/bin/cosyncing-web-0.2.0/icons/pwa-monochrome-512.png +0 -0
- package/bin/cosyncing-web-0.2.0/index.html +1890 -0
- package/bin/cosyncing-web-0.2.0/main.dart.js +206823 -0
- package/bin/cosyncing-web-0.2.0/manifest.json +49 -0
- package/bin/cosyncing-web-0.2.0/sqlite3.wasm +0 -0
- package/bin/cosyncing-web-0.2.0/sw.js +597 -0
- package/bin/cosyncing-web-0.2.0/version.json +1 -0
- package/package.json +39 -6
- 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>
|