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,49 @@
1
+ {
2
+ "name": "Cosyncing",
3
+ "short_name": "cosy",
4
+ "start_url": ".",
5
+ "display": "standalone",
6
+ "background_color": "#0B0E14",
7
+ "theme_color": "#0B0E14",
8
+ "description": "Cross-platform client for Cosyncing brokers",
9
+ "orientation": "portrait-primary",
10
+ "prefer_related_applications": false,
11
+ "icons": [
12
+ {
13
+ "src": "icons/pwa-icon-192.png",
14
+ "sizes": "192x192",
15
+ "type": "image/png",
16
+ "purpose": "any"
17
+ },
18
+ {
19
+ "src": "icons/pwa-icon-512.png",
20
+ "sizes": "512x512",
21
+ "type": "image/png",
22
+ "purpose": "any"
23
+ },
24
+ {
25
+ "src": "icons/pwa-maskable-192.png",
26
+ "sizes": "192x192",
27
+ "type": "image/png",
28
+ "purpose": "maskable"
29
+ },
30
+ {
31
+ "src": "icons/pwa-maskable-512.png",
32
+ "sizes": "512x512",
33
+ "type": "image/png",
34
+ "purpose": "maskable"
35
+ },
36
+ {
37
+ "src": "icons/pwa-monochrome-192.png",
38
+ "sizes": "192x192",
39
+ "type": "image/png",
40
+ "purpose": "monochrome"
41
+ },
42
+ {
43
+ "src": "icons/pwa-monochrome-512.png",
44
+ "sizes": "512x512",
45
+ "type": "image/png",
46
+ "purpose": "monochrome"
47
+ }
48
+ ]
49
+ }
@@ -0,0 +1,597 @@
1
+ /* Cosyncing static-asset service worker (N3 part B).
2
+ *
3
+ * SOURCE OF TRUTH. `flutter build web` copies this file verbatim into
4
+ * build/web/, and `scripts/client/build-web-cache.ts` then replaces the three
5
+ * __COSYNCING_*__ placeholders below with the manifest computed from the real
6
+ * build output. The build output is generated; edit this file, never that one.
7
+ *
8
+ * What it caches
9
+ * --------------
10
+ * Only immutable/versioned static build artefacts, split into two sets:
11
+ *
12
+ * * PRECACHE — everything that carries application identity (the HTML shell,
13
+ * flutter_bootstrap.js, flutter.js, main.dart.js, the asset bundle, icons,
14
+ * manifest, version.json). Fetched atomically during `install`, each entry
15
+ * verified against the hash this worker was stamped with. If any entry fails
16
+ * or does not match, the install fails, its partial cache is removed, the
17
+ * new worker never activates, and the previous version keeps serving its own
18
+ * complete cache. That is what makes a failed update incapable of producing
19
+ * a mixed-version app: app code is never half-replaced.
20
+ *
21
+ * * RUNTIME — the large engine/runtime binaries whose URL set is fixed by the
22
+ * same manifest but which are only needed once the browser has chosen a
23
+ * variant (canvaskit/skwasm builds, sqlite3.wasm, drift_worker.js). These
24
+ * are stored into the SAME version-scoped cache on first successful fetch,
25
+ * so a repeat/offline start after one successful visit works without
26
+ * downloading ~40 MB of unused variants at install time.
27
+ *
28
+ * Both sets are CLOSED: a URL that is in neither list is never read from nor
29
+ * written to the cache. That is the bound on cache size — it can never exceed
30
+ * the bytes of one build's listed assets — and it is what keeps runtime data
31
+ * out by construction rather than by convention.
32
+ *
33
+ * What it must never touch
34
+ * ------------------------
35
+ * Broker API responses, WebSocket traffic, credentials, profiles, drafts,
36
+ * outbox/notification mutations and every other runtime request. Three
37
+ * independent guards:
38
+ *
39
+ * 1. Scope. The worker is registered from the app's base href (`/cosy/` in
40
+ * release builds), so `/api/*`, `/pi/bridge/*`, `/claude/hook/*` and the
41
+ * `/api/sessions/:tool/:id/stream` upgrade are outside its scope and never
42
+ * reach this file at all.
43
+ * 2. Method/mode. Anything that is not a same-origin GET — and any request
44
+ * whose destination is a WebSocket or EventSource — is passed straight
45
+ * through untouched.
46
+ * 3. Allowlist. Even inside scope, only URLs present in the build manifest are
47
+ * served from or written to the cache.
48
+ *
49
+ * `scripts/client/tests/test-web-static-cache.ts` drives this file's real
50
+ * handlers against a fake ServiceWorker environment and fails if any runtime
51
+ * route is cached, if an obsolete cache survives activation, or if a
52
+ * non-manifest URL is stored.
53
+ */
54
+ 'use strict';
55
+
56
+ /** Content hash of this build's manifest; the cache name is derived from it. */
57
+ const BUILD_VERSION = 'fc1373a7465da6b2';
58
+
59
+ /** Atomic install set: everything carrying application identity. */
60
+ const PRECACHE_URLS = [
61
+ "assets/AssetManifest.bin",
62
+ "assets/AssetManifest.bin.json",
63
+ "assets/FontManifest.json",
64
+ "assets/fonts/MaterialIcons-Regular.otf",
65
+ "assets/packages/cupertino_icons/assets/CupertinoIcons.ttf",
66
+ "assets/packages/flutter_local_notifications_web/web/notifications_service_worker.js",
67
+ "assets/shaders/ink_sparkle.frag",
68
+ "assets/shaders/stretch_effect.frag",
69
+ "favicon.ico",
70
+ "favicon.svg",
71
+ "flutter.js",
72
+ "flutter_bootstrap.js",
73
+ "icons/apple-touch-icon-180.png",
74
+ "icons/pwa-icon-192.png",
75
+ "icons/pwa-icon-512.png",
76
+ "icons/pwa-maskable-192.png",
77
+ "icons/pwa-maskable-512.png",
78
+ "icons/pwa-monochrome-192.png",
79
+ "icons/pwa-monochrome-512.png",
80
+ "index.html",
81
+ "main.dart.js",
82
+ "manifest.json",
83
+ "version.json"
84
+ ];
85
+
86
+ /** Lazily filled, same version-scoped cache: large immutable engine binaries. */
87
+ const RUNTIME_URLS = [
88
+ "canvaskit/canvaskit.js",
89
+ "canvaskit/canvaskit.wasm",
90
+ "canvaskit/chromium/canvaskit.js",
91
+ "canvaskit/chromium/canvaskit.wasm",
92
+ "canvaskit/experimental_webparagraph/canvaskit.js",
93
+ "canvaskit/experimental_webparagraph/canvaskit.wasm",
94
+ "canvaskit/skwasm.js",
95
+ "canvaskit/skwasm.wasm",
96
+ "canvaskit/skwasm_heavy.js",
97
+ "canvaskit/skwasm_heavy.wasm",
98
+ "canvaskit/wimp.js",
99
+ "canvaskit/wimp.wasm",
100
+ "drift_worker.js",
101
+ "sqlite3.wasm"
102
+ ];
103
+
104
+ /**
105
+ * SHA-256 of every cacheable file, keyed by manifest path.
106
+ *
107
+ * Flutter web content-hashes no URL, so `canvaskit/canvaskit.wasm` addresses
108
+ * different bytes before and after a deployment. A page still running the
109
+ * previous build that lazy-loads such a URL would otherwise receive — and this
110
+ * worker would store, into the previous build's own cache — bytes belonging to
111
+ * the NEW build. Waiting to activate does not help: the fetch handler is the
112
+ * old worker's, and the server has already moved on. Every response is
113
+ * therefore checked against the hash THIS worker was stamped with, and a
114
+ * mismatch is refused rather than served.
115
+ */
116
+ const ASSET_HASHES = {
117
+ "assets/AssetManifest.bin": "10791c72232a3fd92d034fda70bc1cb683836bbc7d7569e01bb8e5baa47876cb",
118
+ "assets/AssetManifest.bin.json": "7074293d95be262c6a08bb767c4d03c3223a2cf73bac60e06c74dfb6a6063472",
119
+ "assets/FontManifest.json": "cd7e03645bc44b2dd47b7cb626f51c4ecbf55a197ab77241628b47ac165fbe21",
120
+ "assets/fonts/MaterialIcons-Regular.otf": "a77c8de49a4781410435aebe7b0ce03e09398689454f2f6eac3ceabbdbc552a4",
121
+ "assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "3d90c370aa4cf00dc57ee2b902f6652147c0f81e03e483df584a9c08b1687c9d",
122
+ "assets/packages/flutter_local_notifications_web/web/notifications_service_worker.js": "566f47dccc0f417f0164a3d0852334c340d0941472af43fc65ee50a314555058",
123
+ "assets/shaders/ink_sparkle.frag": "2dca5ab93d4ec29e963f996f3916320ba60825e9537dfb149a68008c7a16b026",
124
+ "assets/shaders/stretch_effect.frag": "ab412f07a5b9b50b67a885b24dbe16929738ae28630407d6490c924caf0a3220",
125
+ "canvaskit/canvaskit.js": "7361b744487f98262f77ac507006b3eae37830430048ab7811a626b79f34a52c",
126
+ "canvaskit/canvaskit.wasm": "6bdfaa51c76845c02ea90153d44f56f99da51de44a7ee0ea15ccaeb33ed8f476",
127
+ "canvaskit/chromium/canvaskit.js": "d2aceb0e24ead163ab289a165ca0e26a36fa26d7118f5a295b8854d59d892896",
128
+ "canvaskit/chromium/canvaskit.wasm": "c2fc2864763540922c71d464e983bebf97b3a5312fd01c6a8988969747d13314",
129
+ "canvaskit/experimental_webparagraph/canvaskit.js": "803f7bab5f280fb6793495573bc83e6f70110916f75878a79c41e30b86585df8",
130
+ "canvaskit/experimental_webparagraph/canvaskit.wasm": "6b916e684369558b9a4fdd774fedf2717d7481cf0b7dffb28e084129512bbd3f",
131
+ "canvaskit/skwasm.js": "e0316a798a929008cf9221d9ae0e8a4e0a5f07869ff2c0ad68a71f56ab9998a4",
132
+ "canvaskit/skwasm.wasm": "084a99454e405ad9e396803f5c02369562c92210ad9ff83a053ca68a1047a8f4",
133
+ "canvaskit/skwasm_heavy.js": "fbf1c3374cd28b38ea4c48f8189012263c40e78e0a55323a557443c97a58c58c",
134
+ "canvaskit/skwasm_heavy.wasm": "8b8279650b1847d8259ad4591c5cb7cb635b513134ec7565f85b1aa4271d896c",
135
+ "canvaskit/wimp.js": "3f2cc5ae040789508b6914897a9858389a496b3a7cadcc71ef120cca99f70586",
136
+ "canvaskit/wimp.wasm": "781cd10fd4a893f0c30529b5e2a58822f91082da2dc135e2be696dfff85c6a73",
137
+ "drift_worker.js": "7d4c84aff50fd4df31b43f1ab7276e25b224c8a3c4d89a46f3c316511ee5739f",
138
+ "favicon.ico": "402ac00a1c1bbe8700ea6db14552172a754fd675c7604e00c4f93970b6b2c1fb",
139
+ "favicon.svg": "18628abcb89d6140d688e6a90bb4857beaf34fbff76008bce484fe16b7c2f37d",
140
+ "flutter.js": "a483fd28f51ed2fadd0da3fade5b672eba56310d549d736ce62eabf624a6a578",
141
+ "flutter_bootstrap.js": "f01de006790f1bc06b5d64ce65b83c1a6a111646777f351709288e6466a87bd9",
142
+ "icons/apple-touch-icon-180.png": "cc44adfe4cbf905c87b1cc10e0062fa3afdd5e2ce031602dee4f5d20ed1e7960",
143
+ "icons/pwa-icon-192.png": "dd47c13a50f5bc28835ce486313c74c99cd11573188c1820587566ee7f96bfba",
144
+ "icons/pwa-icon-512.png": "382a8a8db480a63352caa2f77574fe84c3b31ddace02e18ab916bf64ce43a4c6",
145
+ "icons/pwa-maskable-192.png": "dd47c13a50f5bc28835ce486313c74c99cd11573188c1820587566ee7f96bfba",
146
+ "icons/pwa-maskable-512.png": "382a8a8db480a63352caa2f77574fe84c3b31ddace02e18ab916bf64ce43a4c6",
147
+ "icons/pwa-monochrome-192.png": "25307f415e32c989195a18521400757dfacbb0e0cd550bc6acfcb8124c3734af",
148
+ "icons/pwa-monochrome-512.png": "9bf04b7c999104450f7cc85a691cf536157bf2dd9c4d27975c576e8f073fbd22",
149
+ "index.html": "edfb26b7bb979b98e5ec2d7223ab60d51572c7a6c6f9bf90d0687da5d235f140",
150
+ "main.dart.js": "e2777e1ad5009a648fd09a11f1e75ef8747c760b7a2a2516a1d0a1130eeb7eee",
151
+ "manifest.json": "5e5ba9c53061e4c6e1515b79a92f290837198192891d7001b69c9128b7ff2f6e",
152
+ "sqlite3.wasm": "c41558c74d0ad954c2ece9d8c40ae9c3f247b4ae1c022eeb9899de44b37b9a39",
153
+ "version.json": "69e2df0448883b4b4c6469e8c621f527487908e7635c3859d645877cdd3a0b2d"
154
+ };
155
+
156
+ /** Scope URL, used to resolve manifest entries and to recognise navigations. */
157
+ const SCOPE_URL = new URL(self.registration ? self.registration.scope : './', self.location.href);
158
+
159
+ /**
160
+ * Cache ownership is scoped, not origin-global.
161
+ *
162
+ * A single origin can host more than one app, and more than one Cosyncing
163
+ * installation (`/cosy/`, `/staging/cosy/`, a reverse-proxied prefix). A bare
164
+ * `cosyncing-app-*` prefix would make every one of them a candidate for another
165
+ * one's cleanup, so the normalized scope path is part of the name and every
166
+ * sweep below matches on it. Nothing outside this scope is ever deleted.
167
+ */
168
+ const CACHE_PREFIX = `cosyncing-app:${SCOPE_URL.pathname}:`;
169
+
170
+ /** This build's cache. A new build means a new name, hence a clean swap. */
171
+ const CACHE_NAME = CACHE_PREFIX + BUILD_VERSION;
172
+
173
+ /**
174
+ * Caches Flutter's now-deprecated bundled worker may have left behind.
175
+ *
176
+ * These names are generic and origin-global: another app on this origin can own
177
+ * one. They are therefore never deleted outright — `pruneLegacyCache` removes
178
+ * only the entries that are inside THIS scope, and drops the cache itself only
179
+ * when that leaves it empty (i.e. it demonstrably held nothing else).
180
+ */
181
+ const LEGACY_CACHE_NAMES = ['flutter-app-cache', 'flutter-temp-cache'];
182
+
183
+ /** Absolute URL of the app shell that answers every in-scope navigation. */
184
+ const SHELL_URL = new URL('index.html', SCOPE_URL).href;
185
+
186
+ /**
187
+ * Cache key written when this build's worker activates.
188
+ *
189
+ * Not a real request — nothing ever fetches it — only a durable mark that this
190
+ * cache belonged to a worker the browser actually promoted. An install sweeps
191
+ * every unmarked cache with this scope's prefix, which is what stops a chain of
192
+ * superseded or failed installs leaving abandoned build caches behind.
193
+ */
194
+ const ACTIVATION_MARKER_URL = new URL('__cosyncing_activated__', SCOPE_URL).href;
195
+
196
+ function absolute(relative) {
197
+ return new URL(relative, SCOPE_URL).href;
198
+ }
199
+
200
+ const PRECACHE_SET = new Set(PRECACHE_URLS.map(absolute));
201
+ const RUNTIME_SET = new Set(RUNTIME_URLS.map(absolute));
202
+ const HASH_BY_URL = new Map(
203
+ Object.keys(ASSET_HASHES).map((path) => [absolute(path), ASSET_HASHES[path]]),
204
+ );
205
+
206
+ /** Whether this exact URL is a cacheable artefact of the current build. */
207
+ function isCacheableAsset(url) {
208
+ return PRECACHE_SET.has(url) || RUNTIME_SET.has(url);
209
+ }
210
+
211
+ /** Lowercase hex SHA-256 of a buffer. */
212
+ async function sha256Hex(buffer) {
213
+ const digest = await crypto.subtle.digest('SHA-256', buffer);
214
+ const bytes = new Uint8Array(digest);
215
+ let hex = '';
216
+ for (let index = 0; index < bytes.length; index++) {
217
+ hex += bytes[index].toString(16).padStart(2, '0');
218
+ }
219
+ return hex;
220
+ }
221
+
222
+ /**
223
+ * Fetches one manifest URL and proves it is THIS build's copy of it.
224
+ *
225
+ * Returns the verified bytes, or null when the response is unusable: a
226
+ * non-200/non-basic response, an unlisted URL, or — the case this exists for —
227
+ * content whose hash is not the one this worker was stamped with, which means
228
+ * the server is already serving a different build.
229
+ */
230
+ async function fetchVerified(url) {
231
+ const expected = HASH_BY_URL.get(url);
232
+ if (expected === undefined) return null;
233
+ // `reload` so an intermediate HTTP cache cannot satisfy this with a copy of
234
+ // some other build; the hash check would only reject it a moment later.
235
+ const response = await fetch(url, { cache: 'reload' });
236
+ if (!response || response.status !== 200 || response.type !== 'basic') {
237
+ return null;
238
+ }
239
+ const buffer = await response.arrayBuffer();
240
+ const actual = await sha256Hex(buffer);
241
+ if (actual !== expected) {
242
+ console.warn(
243
+ `cosyncing: refusing ${url} — it belongs to a different build ` +
244
+ `(expected ${expected.slice(0, 12)}, got ${actual.slice(0, 12)})`,
245
+ );
246
+ return null;
247
+ }
248
+ return { buffer, headers: response.headers, statusText: response.statusText };
249
+ }
250
+
251
+ /** Builds a fresh Response over verified bytes. */
252
+ function responseFor(verified) {
253
+ return new Response(verified.buffer, {
254
+ status: 200,
255
+ statusText: verified.statusText,
256
+ headers: verified.headers,
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Whether this request may be considered for the static cache at all.
262
+ *
263
+ * Deliberately conservative: a request has to be a same-origin, in-scope,
264
+ * plain GET for a document or a static subresource. Everything else — POST,
265
+ * PUT, PATCH, DELETE, WebSocket/EventSource destinations, `range` requests,
266
+ * cross-origin fetches, anything with a query string (broker calls carry
267
+ * parameters; build artefacts do not) — is left to the network untouched.
268
+ */
269
+ function isStaticCandidate(request) {
270
+ if (request.method !== 'GET') return false;
271
+ if (request.destination === 'websocket') return false;
272
+ let url;
273
+ try {
274
+ url = new URL(request.url);
275
+ } catch (error) {
276
+ return false;
277
+ }
278
+ if (url.origin !== SCOPE_URL.origin) return false;
279
+ if (!url.href.startsWith(SCOPE_URL.href)) return false;
280
+ if (url.search !== '') return false;
281
+ return true;
282
+ }
283
+
284
+ /** Navigations inside scope are answered by the precached shell. */
285
+ function isShellNavigation(request) {
286
+ return request.mode === 'navigate' && request.destination === 'document';
287
+ }
288
+
289
+ self.addEventListener('install', (event) => {
290
+ event.waitUntil(
291
+ (async () => {
292
+ await deleteAbandonedCaches();
293
+ const cache = await caches.open(CACHE_NAME);
294
+ try {
295
+ // Atomic: one rejection here aborts the install, so a partially fetched
296
+ // new version is never promoted over a complete old one. Every entry is
297
+ // hash-verified, so a deployment landing mid-install aborts too rather
298
+ // than precaching a mixture of two builds.
299
+ await Promise.all(
300
+ PRECACHE_URLS.map(absolute).map(async (url) => {
301
+ const verified = await fetchVerified(url);
302
+ if (!verified) throw new Error(`cosyncing: cannot precache ${url}`);
303
+ await cache.put(url, responseFor(verified));
304
+ }),
305
+ );
306
+ } catch (error) {
307
+ // Leaving the partial cache behind would accumulate one abandoned cache
308
+ // per failed update, none of which any worker will ever activate to
309
+ // clean up. It is ours and it is incomplete, so it goes now.
310
+ await caches.delete(CACHE_NAME);
311
+ throw error;
312
+ }
313
+ // DELIBERATELY NO skipWaiting().
314
+ //
315
+ // A page that is already open is running the previous build's
316
+ // main.dart.js. Activating over it would let that page lazy-load the NEW
317
+ // CanvasKit, Drift worker and assets — one document running two builds —
318
+ // and the activate handler would delete the cache it is still reading
319
+ // from. Waiting is what makes the swap coherent: this worker stays in
320
+ // `waiting` until every client the old worker controls has gone, so the
321
+ // old build keeps its own complete cache for as long as it is on screen,
322
+ // and the next navigation gets the new build whole.
323
+ //
324
+ // N3b does not weaken this. It removes the requirement that the USER
325
+ // close every tab, not the requirement that every tab be gone: open tabs
326
+ // flush their durable state and move themselves to a page outside this
327
+ // scope, the old worker retires with no controllees exactly as it does
328
+ // today, and each tab returns to its route under the new build. There is
329
+ // still no moment at which one document sees two builds.
330
+ })(),
331
+ );
332
+ });
333
+
334
+ self.addEventListener('activate', (event) => {
335
+ event.waitUntil(
336
+ (async () => {
337
+ // ORDER IS THE SAFETY PROPERTY HERE.
338
+ //
339
+ // Activation is the only place that destroys the previous build, and the
340
+ // previous build is the device's fallback if this one turns out not to
341
+ // work. So: everything that can fail runs BEFORE anything is deleted, and
342
+ // the deleting itself is not allowed to fail activation. Any other order
343
+ // has a path where a rejected activation leaves the device with no
344
+ // complete build at all.
345
+
346
+ // 1. Mark this cache. Storage can refuse a write (quota, eviction,
347
+ // CacheStorage unavailable). Failing here leaves the previous build
348
+ // whole and this one unmarked, which the next install sweeps.
349
+ const cache = await caches.open(CACHE_NAME);
350
+ await cache.put(ACTIVATION_MARKER_URL, new Response(BUILD_VERSION));
351
+
352
+ // 2. Take over the clients. This can reject too, so it also happens while
353
+ // the previous cache is still intact. A build that could not claim its
354
+ // clients has not really taken over, so it must not look durable
355
+ // either — unmark it, best-effort, and rethrow.
356
+ try {
357
+ await self.clients.claim();
358
+ } catch (error) {
359
+ try {
360
+ await cache.delete(ACTIVATION_MARKER_URL);
361
+ } catch (unmarkError) {
362
+ console.warn('cosyncing: could not unmark a failed activation', unmarkError);
363
+ }
364
+ throw error;
365
+ }
366
+
367
+ // 3. Cleanup, and nothing else. Reached only once no client controlled by
368
+ // the previous worker remains, so deleting its cache cannot pull an
369
+ // asset out from under a live page.
370
+ //
371
+ // Settled, never awaited as a group: one storage error here means one
372
+ // leftover cache, and this activation has already succeeded. Rejecting
373
+ // it over a leftover would be the original hazard wearing a different
374
+ // hat — the previous cache is already gone by the time the sibling
375
+ // rejects. Unmarked leftovers are swept by the next install anyway.
376
+ try {
377
+ const names = await caches.keys();
378
+ const swept = await Promise.allSettled(
379
+ names.map((name) => {
380
+ // Scope-local: only caches this exact installation owns.
381
+ if (name.startsWith(CACHE_PREFIX)) {
382
+ return name === CACHE_NAME ? null : caches.delete(name);
383
+ }
384
+ if (LEGACY_CACHE_NAMES.indexOf(name) !== -1) return pruneLegacyCache(name);
385
+ return null;
386
+ }),
387
+ );
388
+ for (const result of swept) {
389
+ if (result.status === 'rejected') {
390
+ console.warn('cosyncing: could not remove an obsolete cache', result.reason);
391
+ }
392
+ }
393
+ } catch (error) {
394
+ console.warn('cosyncing: obsolete cache cleanup failed', error);
395
+ }
396
+ })(),
397
+ );
398
+ });
399
+
400
+ /**
401
+ * Deletes caches with this scope's prefix that no worker ever activated.
402
+ *
403
+ * Activation cleans up the caches of BUILDS THAT RAN. It cannot clean up builds
404
+ * that never ran: an install that fails, or one that is superseded while
405
+ * waiting, leaves a cache no activate handler will ever visit. Those are
406
+ * exactly the caches without the activation marker, and an install is the right
407
+ * moment to sweep them — the currently active build always carries its marker,
408
+ * so it is never a candidate.
409
+ */
410
+ async function deleteAbandonedCaches() {
411
+ const names = await caches.keys();
412
+ await Promise.all(
413
+ names.map(async (name) => {
414
+ if (!name.startsWith(CACHE_PREFIX) || name === CACHE_NAME) return;
415
+ const cache = await caches.open(name);
416
+ const activated = await cache.match(ACTIVATION_MARKER_URL);
417
+ if (!activated) await caches.delete(name);
418
+ }),
419
+ );
420
+ }
421
+
422
+ /**
423
+ * Removes this build's own entries from a generically-named legacy Flutter
424
+ * cache.
425
+ *
426
+ * Deliberately NOT "everything under this scope path": an app mounted deeper
427
+ * (`/cosy/other/`) is inside this scope's prefix but is not this app, and its
428
+ * entries are not ours to delete. Only exact manifest URLs and the shell — the
429
+ * URLs this installation demonstrably owns — are removed, and the cache itself
430
+ * is deleted only when that leaves it empty.
431
+ */
432
+ async function pruneLegacyCache(name) {
433
+ const cache = await caches.open(name);
434
+ const keys = await cache.keys();
435
+ let remaining = 0;
436
+ for (const request of keys) {
437
+ if (isCacheableAsset(request.url) || request.url === SHELL_URL) {
438
+ await cache.delete(request);
439
+ } else {
440
+ remaining += 1;
441
+ }
442
+ }
443
+ if (remaining === 0) await caches.delete(name);
444
+ }
445
+
446
+ /**
447
+ * Largest client census this worker will report (N3b).
448
+ *
449
+ * Above this the census is REFUSED (-1), not truncated. The handoff proceeds
450
+ * only when acknowledgements equal the census, so a truncated count is the one
451
+ * answer that could ever be actively wrong: 70 open tabs reported as 64, with
452
+ * 64 acknowledgements, is an equality that moves a subset and strands it while
453
+ * six unasked tabs keep the old worker alive. Refusing defers the round, which
454
+ * is always safe.
455
+ */
456
+ const MAX_CENSUS_WINDOWS = 64;
457
+
458
+ /**
459
+ * Two read-only questions, and nothing else (N3b).
460
+ *
461
+ * `cosyncing-build-identity` — which build is this worker? Asked of the WAITING
462
+ * worker to prove a replacement is real and different before any tab is moved,
463
+ * and of the ACTIVE worker afterwards to prove the swap actually happened. It is
464
+ * the only way a page can tell one build from another: Flutter content-hashes no
465
+ * URL, so identity lives in the manifest this worker was stamped with.
466
+ *
467
+ * `cosyncing-client-census` — how many window clients does this worker control?
468
+ * A page cannot enumerate its origin's tabs; a worker can enumerate its own
469
+ * controllees exactly. That census is what makes "every tab acknowledged" a
470
+ * fact rather than a hope, and it is what a frozen tab fails: it is counted but
471
+ * cannot answer, so the handoff defers instead of stranding it.
472
+ *
473
+ * DELIBERATELY NOT A CONTROL CHANNEL. There is no `skipWaiting` message, no
474
+ * cache mutation, no unregister, no navigation. Every branch below only reads.
475
+ * A message that could activate this worker over a live page would reintroduce
476
+ * exactly the mixed-build hazard the waiting model exists to prevent, and it
477
+ * would be reachable by any same-origin script.
478
+ */
479
+ self.addEventListener('message', (event) => {
480
+ const data = event.data;
481
+ if (!data || typeof data !== 'object') return;
482
+ const type = data.type;
483
+ if (type !== 'cosyncing-build-identity' && type !== 'cosyncing-client-census') return;
484
+
485
+ // Reply down the caller's own port when it supplied one, so an answer cannot
486
+ // be observed by an unrelated listener on the page.
487
+ const port = (event.ports && event.ports[0]) || null;
488
+ const reply = (payload) => {
489
+ try {
490
+ if (port) port.postMessage(payload);
491
+ else if (event.source && event.source.postMessage) event.source.postMessage(payload);
492
+ } catch (error) {
493
+ // The asker navigated away mid-question. Nothing to do and nothing lost.
494
+ }
495
+ };
496
+
497
+ if (type === 'cosyncing-build-identity') {
498
+ reply({
499
+ type,
500
+ version: BUILD_VERSION,
501
+ cacheName: CACHE_NAME,
502
+ scope: SCOPE_URL.href,
503
+ });
504
+ return;
505
+ }
506
+
507
+ // `waitUntil` so the worker is not terminated between the query and the reply.
508
+ event.waitUntil(
509
+ (async () => {
510
+ let windows = -1; // -1 = could not be established; the caller must defer.
511
+ try {
512
+ const clients = await self.clients.matchAll({
513
+ type: 'window',
514
+ includeUncontrolled: false,
515
+ });
516
+ windows = clients.length > MAX_CENSUS_WINDOWS ? -1 : clients.length;
517
+ } catch (error) {
518
+ console.warn('cosyncing: client census failed', error);
519
+ }
520
+ reply({ type, version: BUILD_VERSION, windows });
521
+ })(),
522
+ );
523
+ });
524
+
525
+ self.addEventListener('fetch', (event) => {
526
+ const request = event.request;
527
+ if (!isStaticCandidate(request)) return; // network owns it; we never see the body
528
+
529
+ if (isShellNavigation(request)) {
530
+ event.respondWith(respondWithShell(request));
531
+ return;
532
+ }
533
+
534
+ if (!isCacheableAsset(request.url)) return; // not a build artefact: pass through
535
+
536
+ event.respondWith(respondWithAsset(request));
537
+ });
538
+
539
+ /** The response an old build gets when the network has moved on without it. */
540
+ function buildMismatchResponse() {
541
+ return new Response(
542
+ 'This asset does not belong to the build that requested it. ' +
543
+ 'Reload to pick up the current version.',
544
+ {
545
+ status: 503,
546
+ statusText: 'Build version mismatch',
547
+ headers: { 'Content-Type': 'text/plain' },
548
+ },
549
+ );
550
+ }
551
+
552
+ /**
553
+ * Serves the precached shell for in-scope navigations.
554
+ *
555
+ * Cache-first, because the whole point is that a repeat or offline start paints
556
+ * without waiting for the network. The shell carries no user data — it is the
557
+ * same bytes for every visitor — and a new build replaces it wholesale via a
558
+ * new cache, so it cannot go stale past one update cycle.
559
+ *
560
+ * The miss path is verified like every other asset. A cache entry can go
561
+ * missing on its own (storage pressure evicts individual entries), and an
562
+ * unverified network fallback here is the worst possible mix: the HTML is the
563
+ * document that chooses which bundle to load, so serving the NEW shell to a
564
+ * worker that still holds the OLD main.dart.js is a mixed build by
565
+ * construction. Refusing is recoverable; that is not.
566
+ */
567
+ async function respondWithShell(request) {
568
+ const cache = await caches.open(CACHE_NAME);
569
+ const cached = await cache.match(SHELL_URL);
570
+ if (cached) return cached;
571
+ // A network error (offline, no cached shell) propagates so the browser shows
572
+ // its own error rather than one this worker invented.
573
+ const verified = await fetchVerified(SHELL_URL);
574
+ if (!verified) return buildMismatchResponse();
575
+ await cache.put(SHELL_URL, responseFor(verified));
576
+ return responseFor(verified);
577
+ }
578
+
579
+ /**
580
+ * Serves one allowlisted build artefact, filling the runtime set on first use.
581
+ *
582
+ * The response must hash to what this build's manifest says that URL contains.
583
+ * A mismatch means the server has moved on to a newer build, and the page
584
+ * asking is running the older one — so the request fails explicitly instead of
585
+ * handing an old engine a new engine's binary. An explicit failure is
586
+ * recoverable (reload, and the waiting worker takes over); a silently mixed
587
+ * runtime is not.
588
+ */
589
+ async function respondWithAsset(request) {
590
+ const cache = await caches.open(CACHE_NAME);
591
+ const cached = await cache.match(request.url);
592
+ if (cached) return cached;
593
+ const verified = await fetchVerified(request.url);
594
+ if (!verified) return buildMismatchResponse();
595
+ await cache.put(request.url, responseFor(verified));
596
+ return responseFor(verified);
597
+ }
@@ -0,0 +1 @@
1
+ {"app_name":"cosyncing_client","version":"0.2.0","build_number":"2","package_name":"cosyncing_client"}