coldwire-rails 0.1.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE +21 -0
  4. data/README.md +69 -0
  5. data/VERSION +1 -0
  6. data/app/assets/javascripts/coldwire/archives.js +52 -0
  7. data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
  8. data/app/assets/javascripts/coldwire/entries.js +55 -0
  9. data/app/assets/javascripts/coldwire/format.js +60 -0
  10. data/app/assets/javascripts/coldwire/worker.js +35 -0
  11. data/app/controllers/coldwire/application_controller.rb +21 -0
  12. data/app/controllers/coldwire/caches_controller.rb +33 -0
  13. data/app/controllers/coldwire/service_worker_controller.rb +26 -0
  14. data/app/helpers/coldwire/service_worker_helper.rb +59 -0
  15. data/app/views/coldwire/caches/show.html.erb +304 -0
  16. data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
  17. data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
  18. data/app/views/coldwire/service_worker/show.js.erb +66 -0
  19. data/config/importmap.rb +7 -0
  20. data/config/routes.rb +8 -0
  21. data/docs/README.md +19 -0
  22. data/docs/configuration.md +539 -0
  23. data/docs/how-it-works.md +64 -0
  24. data/docs/images/offline-fallback.png +0 -0
  25. data/docs/images/offline-settings-cached.png +0 -0
  26. data/docs/images/offline-settings.png +0 -0
  27. data/docs/setup.md +218 -0
  28. data/lib/coldwire/client/api.js +44 -0
  29. data/lib/coldwire/client/cookie.js +13 -0
  30. data/lib/coldwire/client/forced.js +17 -0
  31. data/lib/coldwire/client/identity.js +28 -0
  32. data/lib/coldwire/client/marker.js +35 -0
  33. data/lib/coldwire/client/register.js +19 -0
  34. data/lib/coldwire/client/store.js +69 -0
  35. data/lib/coldwire/client/sync.js +122 -0
  36. data/lib/coldwire/client_user_agent.rb +48 -0
  37. data/lib/coldwire/configuration.rb +319 -0
  38. data/lib/coldwire/debug.css +197 -0
  39. data/lib/coldwire/engine.rb +34 -0
  40. data/lib/coldwire/source.rb +50 -0
  41. data/lib/coldwire/version.rb +11 -0
  42. data/lib/coldwire/worker/archives.js +158 -0
  43. data/lib/coldwire/worker/events.js +79 -0
  44. data/lib/coldwire/worker/inspect.js +81 -0
  45. data/lib/coldwire/worker/ranges.js +136 -0
  46. data/lib/coldwire/worker/rules.js +100 -0
  47. data/lib/coldwire/worker/serve.js +226 -0
  48. data/lib/coldwire/worker/sync.js +234 -0
  49. data/lib/coldwire-rails.rb +5 -0
  50. data/lib/coldwire.rb +55 -0
  51. data/lib/generators/coldwire/install/install_generator.rb +110 -0
  52. data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
  53. data/lib/tasks/coldwire.rake +8 -0
  54. metadata +113 -0
@@ -0,0 +1,1058 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { formatBytes, formatCachedAt, formatDuration, formatInterval, displayUrl, plural } from "coldwire/format"
3
+ import { sendToWorker } from "coldwire/worker"
4
+ import { renderArchiveStatus, renderArchiveProgress, toggleArchiveBusy } from "coldwire/archives"
5
+ import { describeEntry, describeCached, describeFinishedSync } from "coldwire/entries"
6
+
7
+ // Storage goes through the one wrapper the head snippet defines, so the page and the snippet
8
+ // cannot disagree about where anything is kept. Without that snippet there is no service
9
+ // worker either, so an inert stand-in is the honest degradation rather than a second
10
+ // implementation that would only drift.
11
+ const INERT_STORE = {
12
+ keys: {},
13
+ get: () => null,
14
+ set: () => {},
15
+ number: () => 0,
16
+ on: () => false,
17
+ toggle: () => {},
18
+ cachingOn: () => true
19
+ }
20
+ const SYNC_MESSAGE = "coldwire:sync"
21
+
22
+ // Drives the offline settings page: inspect the cache, precache the manifest, force offline.
23
+ export default class extends Controller {
24
+ static values = { probeUrl: String, autoSync: Boolean, syncInterval: Number }
25
+ static targets = [
26
+ "connection",
27
+ "connectionLight",
28
+ "summary",
29
+ "total",
30
+ "entries",
31
+ "cachingToggle",
32
+ "whenOn",
33
+ "disableConfirm",
34
+ "forcedToggle",
35
+ "autoSyncToggle",
36
+ "spinner",
37
+ "progress",
38
+ "progressBar",
39
+ "progressLabel",
40
+ "clearButton",
41
+ "refreshButton",
42
+ "autoSync",
43
+ "syncedAt",
44
+ "syncStatus",
45
+ "syncButton",
46
+ "syncLabel",
47
+ "search",
48
+ "sort",
49
+ "inspect",
50
+ "forgetTemplate",
51
+ "detail",
52
+ "detailUrl",
53
+ "detailMeta",
54
+ "detailForget",
55
+ "archives"
56
+ ]
57
+
58
+ get store() {
59
+ return window.coldwireStore || INERT_STORE
60
+ }
61
+
62
+ connect() {
63
+ this.onOnline = () => this.renderConnection()
64
+ window.addEventListener("online", this.onOnline)
65
+ window.addEventListener("offline", this.onOnline)
66
+
67
+ // The worker broadcasts sync state to every open page, so this hears about syncs it did
68
+ // not start — including one already running when this page opened.
69
+ this.onWorkerMessage = (event) => this.handleSyncMessage(event)
70
+ if ("serviceWorker" in navigator) {
71
+ navigator.serviceWorker.addEventListener("message", this.onWorkerMessage)
72
+ // A ServiceWorkerContainer starts with its message queue disabled. Setting onmessage
73
+ // enables it; addEventListener alone does not. Without this the page can listen
74
+ // faithfully and never hear a thing — no progress, no counts, no outcome.
75
+ if (navigator.serviceWorker.startMessages) navigator.serviceWorker.startMessages()
76
+ }
77
+
78
+ this.restoreCaching()
79
+ this.restoreInspect()
80
+ this.restoreForced()
81
+ this.restoreAutoSync()
82
+ if (this.cachingOn()) {
83
+ this.renderArchives()
84
+ this.refresh()
85
+ }
86
+
87
+ // A download outlives the page that started it, and reports itself as it goes.
88
+ this.onArchiveMessage = (event) => this.handleArchiveMessage(event)
89
+ if ("serviceWorker" in navigator) {
90
+ navigator.serviceWorker.addEventListener("message", this.onArchiveMessage)
91
+ }
92
+
93
+ // Recomputed from the same clock the head snippet reads, rather than counted down from a
94
+ // number held here — a web view freezes timers when backgrounded, and a countdown that
95
+ // kept its own tally would come back wrong.
96
+ this.ticker = window.setInterval(() => this.tick(), 1000)
97
+
98
+ // This page draws a countdown, so it must be the thing that acts on it. The head snippet
99
+ // keeps its own timer for every other page; here it would be a second clock with its own
100
+ // idea of the interval, firing while the countdown still showed time remaining.
101
+ window.__coldwireSyncOwnedByPage = true
102
+ }
103
+
104
+ disconnect() {
105
+ window.cancelAnimationFrame(this.painting)
106
+ window.clearInterval(this.ticker)
107
+ if ("serviceWorker" in navigator && this.onArchiveMessage) {
108
+ navigator.serviceWorker.removeEventListener("message", this.onArchiveMessage)
109
+ }
110
+ delete window.__coldwireSyncOwnedByPage
111
+ window.removeEventListener("online", this.onOnline)
112
+ window.removeEventListener("offline", this.onOnline)
113
+ if ("serviceWorker" in navigator) {
114
+ navigator.serviceWorker.removeEventListener("message", this.onWorkerMessage)
115
+ }
116
+ }
117
+
118
+ // MARK: automatic sync
119
+
120
+ handleSyncMessage(event) {
121
+ const data = event.data
122
+ if (!data || data.type !== SYNC_MESSAGE) return
123
+
124
+ if (data.state === "started") {
125
+ this.syncRunning = true
126
+ const retired = data.retired ? `, retired ${data.retired}` : ""
127
+ this.setSyncStatus(data.pending
128
+ ? `Syncing ${data.pending} file${data.pending === 1 ? "" : "s"}${retired}…`
129
+ : `Already up to date${retired}.`)
130
+ // Counts arrive with the first progress tick; until then the bar just says "working".
131
+ if (data.pending) this.showProgress("Starting…")
132
+ // Spin for a run this page did not start, but leave the buttons alone: a worker that
133
+ // dies mid-sync sends no "finished", and a permanently disabled page would be worse
134
+ // than a button you can press twice. Pressing it joins the run in flight anyway.
135
+ this.toggleSyncing(Boolean(data.pending))
136
+ } else if (data.state === "progress") {
137
+ // A page that opened mid-run arrives here without ever having seen "started", so this
138
+ // branch has to be able to put the page into the running state on its own.
139
+ this.syncRunning = true
140
+ this.toggleSyncing(true)
141
+ if (this.hasProgressTarget) this.progressTarget.hidden = false
142
+ // The bar carries the live count; the line above stays on the high-level "what".
143
+ this.renderProgress(data)
144
+ } else if (data.state === "finished") {
145
+ this.syncRunning = false
146
+ this.syncSettled = true
147
+
148
+ this.settleSync(data)
149
+ this.setSyncStatus(describeFinishedSync(data))
150
+ this.hideProgress()
151
+ this.toggleBusy(false)
152
+ this.toggleSyncing(false)
153
+ // The stamp is written by the head snippet's listener, which is registered first and
154
+ // runs synchronously, so it is already up to date by the time this reads it.
155
+ this.renderSyncedAt()
156
+ this.renderCache()
157
+ }
158
+ }
159
+
160
+ // The head snippet fires a sync while the page is still parsing, so a run — a short one
161
+ // especially — can start and finish before this controller connects and starts listening.
162
+ // Asking the worker on arrival is the difference between showing the sync you just caused
163
+ // and sitting on "Idle" through it.
164
+ async catchUpOnSync() {
165
+ let state = null
166
+ try {
167
+ state = await sendToWorker("syncState", {}, 5000)
168
+ } catch {
169
+ // No worker yet, or an older one that does not answer. Nothing to catch up on.
170
+ return
171
+ }
172
+
173
+ const last = state?.last
174
+ if (!last) return
175
+
176
+ // A run that stopped without ever saying "finished" is a worker that was shut down
177
+ // mid-sync. Replaying it would leave a spinner up for a sync nobody is doing.
178
+ if (!state.running && last.state !== "finished") return
179
+
180
+ // Joining part way through, the count of what this run set out to do is already gone —
181
+ // only "started" carried it. The bar still says where it has got to.
182
+ if (last.state !== "finished") this.setSyncStatus("Syncing…")
183
+
184
+ this.handleSyncMessage({ data: last })
185
+ }
186
+
187
+ // Reached from this page's own reply as well as from the broadcast, because a broadcast can
188
+ // go missing and this page drives its own sync — miss the outcome and it reads "Never
189
+ // synced" and re-syncs on every tick. Writing the same finishedAt twice is harmless.
190
+ settleSync(data) {
191
+ if (!data) return
192
+
193
+ if (data.offline) {
194
+ // Nothing was attempted, so the clock keeps saying when the cache was last actually
195
+ // brought up to date — but do not ask again on the very next tick either.
196
+ this.retryAfter = Date.now() + this.syncIntervalValue * 1000
197
+ return
198
+ }
199
+
200
+ // A pass happened. Record it however it went: requiring every URL to succeed meant one
201
+ // bad entry among hundreds stopped the clock for good.
202
+ this.store.set(this.store.keys.syncedAt, data.finishedAt || Date.now())
203
+ this.retryAfter = 0
204
+ }
205
+
206
+ // The same pass a page load kicks off, minus the wait. The worker hands back the run already
207
+ // in flight rather than starting a second, so pressing this during a sync joins it.
208
+ async syncNow(event) {
209
+ event?.preventDefault()
210
+ this.syncSettled = false
211
+ // Reaching the worker takes a moment, and the ticker keeps ticking while it does.
212
+ this.syncStarting = true
213
+ this.toggleBusy(true)
214
+ this.toggleSyncing(true)
215
+ this.setSyncStatus("Starting…")
216
+ this.showProgress("Starting…")
217
+
218
+ try {
219
+ const result = await sendToWorker("sync", {}, 10 * 60 * 1000)
220
+
221
+ // The reply cannot be missed the way a broadcast can, so this is what the clock rests
222
+ // on. If the broadcast did arrive it has already recorded the same thing.
223
+ this.settleSync(result)
224
+ this.renderSyncedAt()
225
+ if (!this.syncSettled) this.setSyncStatus(describeFinishedSync(result))
226
+ } catch (error) {
227
+ // A sync that already reported itself finished has nothing to apologise for; the reply
228
+ // channel simply did not survive to say so.
229
+ if (!this.syncSettled) {
230
+ this.setSyncStatus(error.message || "Sync failed")
231
+ this.hideProgress()
232
+ }
233
+ } finally {
234
+ this.syncStarting = false
235
+ this.toggleBusy(false)
236
+ this.toggleSyncing(false)
237
+ }
238
+ }
239
+
240
+ setSyncStatus(text) {
241
+ if (!this.hasSyncStatusTarget) return
242
+
243
+ this.syncStatusTarget.textContent = text || ""
244
+ this.syncStatusTarget.hidden = !text
245
+ }
246
+
247
+ renderAutoSync() {
248
+ if (!this.hasAutoSyncTarget) return
249
+
250
+ // The switch beside this already says whether it is on, so this line carries the one
251
+ // thing the switch cannot: how often.
252
+ if (!this.autoSyncValue) {
253
+ this.autoSyncTarget.textContent = "Automatic syncing is off"
254
+ return
255
+ }
256
+
257
+ if (!this.autoSyncOn()) {
258
+ this.autoSyncTarget.textContent = "Off for this device"
259
+ return
260
+ }
261
+
262
+ this.autoSyncTarget.textContent = this.syncIntervalValue > 0
263
+ ? `Syncs every ${formatInterval(this.syncIntervalValue)}`
264
+ : "On"
265
+ }
266
+
267
+ renderSyncedAt() {
268
+ if (!this.hasSyncedAtTarget) return
269
+
270
+ const stamp = this.store.number(this.store.keys.syncedAt)
271
+ const synced = stamp
272
+ ? `Last synced ${formatCachedAt(Math.floor(stamp / 1000))}`
273
+ : "Never synced"
274
+ const next = this.describeNextSync()
275
+
276
+ this.syncedAtTarget.textContent = next ? `${synced} · ${next}` : synced
277
+ this.syncedAtTarget.title = stamp ? new Date(stamp).toLocaleString() : ""
278
+ }
279
+
280
+ // MARK: whole archives
281
+
282
+ archiveRows() {
283
+ if (!this.hasArchivesTarget) return []
284
+
285
+ return [ ...this.archivesTarget.querySelectorAll("[data-archive-url]") ]
286
+ }
287
+
288
+ async renderArchives() {
289
+ for (const row of this.archiveRows()) {
290
+ let status = null
291
+
292
+ try {
293
+ status = await sendToWorker("archiveStatus", { url: row.dataset.archiveUrl }, 15000)
294
+ } catch {
295
+ // No worker yet. The row still names the file and offers the button.
296
+ }
297
+
298
+ renderArchiveStatus(row, status)
299
+ }
300
+ }
301
+
302
+ archiveRow(url) {
303
+ return this.archiveRows().find((row) => row.dataset.archiveUrl === url)
304
+ }
305
+
306
+ async downloadArchive(event) {
307
+ event.preventDefault()
308
+
309
+ const url = event.currentTarget.dataset.url
310
+ const row = this.archiveRow(url)
311
+ toggleArchiveBusy(row, true)
312
+
313
+ try {
314
+ await sendToWorker("archiveDownload", { url }, 60 * 60 * 1000)
315
+ } catch {
316
+ // The row's own progress is the feedback.
317
+ } finally {
318
+ toggleArchiveBusy(row, false)
319
+ await this.renderArchives()
320
+ await this.renderCache()
321
+ }
322
+ }
323
+
324
+ async removeArchive(event) {
325
+ event.preventDefault()
326
+
327
+ const url = event.currentTarget.dataset.url
328
+ if (!window.confirm("Delete this download? It will have to be downloaded again to work offline.")) return
329
+
330
+ try {
331
+ await sendToWorker("archiveRemove", { url }, 60000)
332
+ } catch {
333
+ // The row refreshes either way.
334
+ } finally {
335
+ await this.renderArchives()
336
+ await this.renderCache()
337
+ }
338
+ }
339
+
340
+ handleArchiveMessage(event) {
341
+ const data = event.data
342
+ if (!data || data.type !== "coldwire:archive") return
343
+
344
+ const row = this.archiveRow(data.url)
345
+ if (!row) return
346
+
347
+ if (data.state === "progress") {
348
+ renderArchiveProgress(row, data.done, data.total)
349
+ } else if (data.state === "finished") {
350
+ toggleArchiveBusy(row, false)
351
+ this.renderArchives()
352
+ }
353
+ }
354
+
355
+ // A page that shows a countdown had better act on it. Two clocks disagreeing left the page
356
+ // saying "due now" with nothing scheduled to do anything about it, so the countdown reaching
357
+ // zero *is* what starts the sync here.
358
+ tick() {
359
+ this.renderSyncedAt()
360
+
361
+ if (!this.cachingOn()) return
362
+ if (!this.autoSyncOn() || this.syncIntervalValue <= 0) return
363
+ if (this.syncRunning || this.syncStarting) return
364
+ // Nothing to do out of sight, and nothing worth doing with no network.
365
+ if (document.hidden || this.syncPaused()) return
366
+ if (Date.now() < this.dueAt()) return
367
+
368
+ this.syncNow()
369
+ }
370
+
371
+ // When a sync is next owed. Reads the same keys the head snippet writes, so opening this
372
+ // page never resets a clock that was already running.
373
+ dueAt() {
374
+ const stamp = this.store.number(this.store.keys.syncedAt)
375
+
376
+ return Math.max(
377
+ stamp ? stamp + this.syncIntervalValue * 1000 : 0,
378
+ // Set only when a run was refused outright, which leaves the clock untouched and the
379
+ // deadline in the past. Held here rather than in storage: it is this page pacing itself,
380
+ // not a decision the rest of the app should inherit.
381
+ this.retryAfter || 0
382
+ )
383
+ }
384
+
385
+ // Syncing is nothing but network. Force offline is a request for none of it, and with no
386
+ // connection there is nothing to ask for — so neither counts down to anything.
387
+ syncPaused() {
388
+ if (this.isForced()) return "forced"
389
+ if (this.online === false) return "offline"
390
+
391
+ return null
392
+ }
393
+
394
+ isForced() {
395
+ if (this.hasForcedToggleTarget) return this.forcedToggleTarget.checked
396
+
397
+ return this.store.on(this.store.keys.forced)
398
+ }
399
+
400
+ describeNextSync() {
401
+ if (!this.autoSyncOn() || this.syncIntervalValue <= 0) return ""
402
+ if (this.syncRunning) return "syncing now"
403
+
404
+ const paused = this.syncPaused()
405
+ if (paused) {
406
+ return paused === "forced" ? "paused, force offline is on" : "paused, no connection"
407
+ }
408
+
409
+ const seconds = Math.ceil((this.dueAt() - Date.now()) / 1000)
410
+
411
+ return seconds > 0 ? `next in ${formatDuration(seconds)}` : "due now"
412
+ }
413
+
414
+ async refresh(event) {
415
+ event?.preventDefault()
416
+ this.setRefreshing(true)
417
+
418
+ try {
419
+ this.renderAutoSync()
420
+ this.renderSyncedAt()
421
+
422
+ // What this page can answer by itself comes first: the cache is read directly and the
423
+ // probe is one request. Behind the worker questions they waited out a registration that
424
+ // may never arrive, and the page sat on "Checking…" with an empty list.
425
+ await this.renderCache()
426
+ await this.renderConnection()
427
+
428
+ await this.syncForcedToWorker()
429
+ await this.catchUpOnSync()
430
+ } catch {
431
+ // One unreadable cache entry used to abandon the rest of the refresh. The spinner
432
+ // stopping is enough; a stray line at the bottom of the page was not.
433
+ } finally {
434
+ this.setRefreshing(false)
435
+ }
436
+ }
437
+
438
+ // Reloading beats re-rendering in place: it re-runs the worker registration, the identity
439
+ // check and the sync trigger too, so what you see afterwards is a genuine fresh start
440
+ // rather than a few values re-read.
441
+ reload(event) {
442
+ event?.preventDefault()
443
+ this.setRefreshing(true)
444
+ window.location.reload()
445
+ }
446
+
447
+ setRefreshing(busy) {
448
+ if (!this.hasRefreshButtonTarget) return
449
+
450
+ this.refreshButtonTarget.toggleAttribute("data-busy", busy)
451
+ this.refreshButtonTarget.disabled = busy
452
+ }
453
+
454
+ showProgress(label) {
455
+ if (this.hasProgressTarget) this.progressTarget.hidden = false
456
+ this.setProgressBar(null)
457
+ if (this.hasProgressLabelTarget) this.progressLabelTarget.textContent = label
458
+ }
459
+
460
+ hideProgress() {
461
+ if (!this.hasProgressTarget) return
462
+ this.progressTarget.hidden = true
463
+ this.progressTarget.removeAttribute("aria-busy")
464
+ }
465
+
466
+ renderProgress({ phase, done, total }) {
467
+ const noun = phase === "assets" ? "asset" : "page"
468
+ if (total === 0) {
469
+ this.setProgressBar(1)
470
+ if (this.hasProgressLabelTarget) this.progressLabelTarget.textContent = `No ${noun}s to cache.`
471
+ return
472
+ }
473
+
474
+ this.setProgressBar(done / total)
475
+ if (this.hasProgressLabelTarget) {
476
+ this.progressLabelTarget.textContent = `${noun === "page" ? "Pages" : "Assets"} ${done} of ${total}`
477
+ }
478
+ }
479
+
480
+ // A null fraction means "working, count unknown" — show a full-width pulse instead.
481
+ setProgressBar(fraction) {
482
+ if (!this.hasProgressBarTarget) return
483
+
484
+ const indeterminate = fraction === null
485
+ const percent = indeterminate ? 100 : Math.round(Math.min(Math.max(fraction, 0), 1) * 100)
486
+
487
+ this.progressBarTarget.style.width = `${percent}%`
488
+ this.progressBarTarget.classList.toggle("coldwire-pulse", indeterminate)
489
+
490
+ if (!this.hasProgressTarget) return
491
+ this.progressTarget.setAttribute("aria-busy", "true")
492
+ if (indeterminate) {
493
+ this.progressTarget.removeAttribute("aria-valuenow")
494
+ } else {
495
+ this.progressTarget.setAttribute("aria-valuenow", String(percent))
496
+ }
497
+ }
498
+
499
+ async clear(event) {
500
+ event.preventDefault()
501
+ // It is an unlabelled icon next to Reload and it throws away everything the app has to
502
+ // work with offline. Worth one question.
503
+ if (!window.confirm("Delete everything cached? The app will have nothing to show offline until it syncs again.")) return
504
+
505
+ this.toggleBusy(true)
506
+
507
+ try {
508
+ await this.clearCaches()
509
+ await this.renderCache()
510
+ } catch {
511
+ // The list re-renders; an empty cache is the confirmation.
512
+ } finally {
513
+ this.toggleBusy(false)
514
+ }
515
+ }
516
+
517
+ async toggleForced(event) {
518
+ const enabled = event.currentTarget.checked
519
+ this.store.toggle(this.store.keys.forced, enabled)
520
+ this.renderConnection()
521
+ // The countdown means something different the instant this changes; do not make the user
522
+ // wait a tick to see it.
523
+ this.renderSyncedAt()
524
+ // And anything watching Coldwire.onChange — a map holding remote tile sources, say —
525
+ // hears about it without waiting for a navigation.
526
+ document.dispatchEvent(new CustomEvent("coldwire:change", {
527
+ detail: { offline: enabled, forced: enabled, cachedAt: null }
528
+ }))
529
+
530
+ try {
531
+ await sendToWorker("setForcedOffline", { value: enabled }, 5000)
532
+ } catch {
533
+ // Worker may not be controlling yet; the checkbox still reflects local state.
534
+ }
535
+ }
536
+
537
+ restoreForced() {
538
+ if (!this.hasForcedToggleTarget) return
539
+ this.forcedToggleTarget.checked = this.store.on(this.store.keys.forced)
540
+ }
541
+
542
+ cachingOn() {
543
+ return this.store.cachingOn()
544
+ }
545
+
546
+ restoreCaching() {
547
+ if (this.hasCachingToggleTarget) this.cachingToggleTarget.checked = this.cachingOn()
548
+ this.applyCachingVisibility()
549
+ }
550
+
551
+ restoreInspect() {
552
+ if (!this.hasInspectTarget) return
553
+ this.inspectTarget.open = this.store.on(this.store.keys.inspect)
554
+ }
555
+
556
+ // Closed unless they have opened it. Remembered so a developer who needs the list
557
+ // does not have to expand it on every visit.
558
+ toggleInspect() {
559
+ if (!this.hasInspectTarget) return
560
+ this.store.toggle(this.store.keys.inspect, this.inspectTarget.open)
561
+ if (this.inspectTarget.open && this.entries) this.renderEntries()
562
+ }
563
+
564
+ applyCachingVisibility() {
565
+ const on = this.cachingOn()
566
+ this.whenOnTargets.forEach((el) => { el.hidden = !on })
567
+ }
568
+
569
+ // The switch itself flips before this runs. Turning off asks first: cancel puts it back.
570
+ toggleCaching(event) {
571
+ if (event.currentTarget.checked) {
572
+ this.enableCaching()
573
+ return
574
+ }
575
+
576
+ event.currentTarget.checked = true
577
+ this.openDisableConfirm()
578
+ }
579
+
580
+ openDisableConfirm() {
581
+ if (!this.hasDisableConfirmTarget) {
582
+ if (window.confirm("Turn off offline support? Everything saved for offline use will be deleted.")) {
583
+ this.confirmDisableCaching()
584
+ }
585
+ return
586
+ }
587
+
588
+ if (typeof this.disableConfirmTarget.showModal === "function") {
589
+ this.disableConfirmTarget.showModal()
590
+ } else {
591
+ this.disableConfirmTarget.setAttribute("open", "")
592
+ }
593
+ }
594
+
595
+ closeDisableConfirm() {
596
+ if (!this.hasDisableConfirmTarget) return
597
+
598
+ if (typeof this.disableConfirmTarget.close === "function") {
599
+ this.disableConfirmTarget.close()
600
+ } else {
601
+ this.disableConfirmTarget.removeAttribute("open")
602
+ }
603
+ }
604
+
605
+ cancelDisableCaching(event) {
606
+ event?.preventDefault()
607
+ this.closeDisableConfirm()
608
+ if (this.hasCachingToggleTarget) this.cachingToggleTarget.checked = true
609
+ }
610
+
611
+ closeDisableOnBackdrop(event) {
612
+ if (event.target === this.disableConfirmTarget) this.cancelDisableCaching(event)
613
+ }
614
+
615
+ async confirmDisableCaching(event) {
616
+ event?.preventDefault()
617
+ this.closeDisableConfirm()
618
+ if (this.hasCachingToggleTarget) this.cachingToggleTarget.checked = false
619
+ await this.disableCaching()
620
+ }
621
+
622
+ async enableCaching() {
623
+ this.store.toggle(this.store.keys.caching, true)
624
+ this.applyCachingVisibility()
625
+
626
+ if (window.coldwireRegister) {
627
+ try { await window.coldwireRegister() } catch { /* registration warns on its own */ }
628
+ }
629
+ try { await sendToWorker("setCachingEnabled", { value: true }, 5000) } catch { /* no worker yet */ }
630
+
631
+ this.restoreForced()
632
+ this.restoreAutoSync()
633
+ this.renderArchives()
634
+ this.refresh()
635
+ }
636
+
637
+ async disableCaching() {
638
+ this.store.toggle(this.store.keys.caching, false)
639
+ this.applyCachingVisibility()
640
+ this.toggleBusy(true)
641
+
642
+ try {
643
+ try { await sendToWorker("setCachingEnabled", { value: false }, 5000) } catch { /* worker may already be gone */ }
644
+ await this.clearCaches()
645
+ if (window.coldwireUnregister) await window.coldwireUnregister()
646
+ } catch {
647
+ // The switch is already off; the rest of the page has hidden.
648
+ } finally {
649
+ this.toggleBusy(false)
650
+ }
651
+ }
652
+
653
+ // Automatic syncing, as this device has it. The config decides whether it is on offer at
654
+ // all; this decides whether it happens, and is remembered per device rather than per page.
655
+ autoSyncOn() {
656
+ return this.autoSyncValue && !this.store.on(this.store.keys.syncOff)
657
+ }
658
+
659
+ restoreAutoSync() {
660
+ if (!this.hasAutoSyncToggleTarget) return
661
+ this.autoSyncToggleTarget.checked = this.autoSyncOn()
662
+ }
663
+
664
+ toggleAutoSync(event) {
665
+ this.store.toggle(this.store.keys.syncOff, !event.currentTarget.checked)
666
+
667
+ // Say so at once rather than on the next tick: the line above the switch and the
668
+ // countdown beside it both mean something different now.
669
+ this.renderAutoSync()
670
+ this.renderSyncedAt()
671
+
672
+ // Turning it back on with the clock already past due should sync, not wait out an
673
+ // interval that expired while it was off.
674
+ if (this.autoSyncOn()) this.tick()
675
+ }
676
+
677
+ async syncForcedToWorker() {
678
+ if (!this.hasForcedToggleTarget) return
679
+ try {
680
+ await sendToWorker("setForcedOffline", { value: this.forcedToggleTarget.checked }, 5000)
681
+ } catch {
682
+ // Worker may not be controlling yet; the checkbox still reflects local state.
683
+ }
684
+ }
685
+
686
+ // Ask the server, always. navigator.onLine answers "is an interface up", which in a web view
687
+ // is unreliable both ways — true with the server stopped, sometimes false while everything
688
+ // works. One HEAD to the health check is cheap and it is the truth.
689
+ async renderConnection() {
690
+ if (!this.hasConnectionTarget) return
691
+
692
+ if (this.hasForcedToggleTarget && this.forcedToggleTarget.checked) {
693
+ // Amber, not red: nothing is wrong, you asked for this.
694
+ this.setConnection("Forced offline", "forced")
695
+ return
696
+ }
697
+
698
+ this.setConnection("Checking…", "checking")
699
+
700
+ // A later check can finish after an earlier one; only the newest may write.
701
+ const token = (this.connectionToken = (this.connectionToken || 0) + 1)
702
+ const reachable = await this.serverReachable()
703
+ if (token !== this.connectionToken) return
704
+
705
+ if (reachable === null) {
706
+ this.setConnection("Unknown", "checking")
707
+ return
708
+ }
709
+
710
+ this.online = reachable
711
+ this.setConnection(reachable ? "Online" : "Offline", reachable ? "online" : "offline")
712
+ }
713
+
714
+ setConnection(text, state) {
715
+ this.connectionTarget.textContent = text
716
+ if (this.hasConnectionLightTarget) this.connectionLightTarget.dataset.state = state
717
+ }
718
+
719
+ async serverReachable() {
720
+ // With nothing to probe there is no honest answer, so say so rather than guess.
721
+ if (!this.hasProbeUrlValue) return null
722
+
723
+ const controller = new AbortController()
724
+ const timeout = window.setTimeout(() => controller.abort(), 4000)
725
+
726
+ try {
727
+ // Any answer at all means reachable — a 401 or a redirect is still the server talking.
728
+ await fetch(this.probeUrlValue, { method: "HEAD", cache: "no-store", signal: controller.signal })
729
+ return true
730
+ } catch {
731
+ return false
732
+ } finally {
733
+ window.clearTimeout(timeout)
734
+ }
735
+ }
736
+
737
+ async renderCache() {
738
+ const cachesInfo = await this.listCaches()
739
+
740
+ // Held so searching and sorting are pure display work. Reading the cache means asking
741
+ // every entry for its headers, which is slow enough to feel broken if it happened on each
742
+ // keystroke.
743
+ // The path and its lowercase form are derived once here. Both used to be recomputed for
744
+ // every row on every keystroke, and `new URL()` 485 times is 19ms of it.
745
+ this.entries = cachesInfo.flatMap((cache) =>
746
+ (cache.entries || []).map((entry) => {
747
+ const path = displayUrl(entry.url)
748
+
749
+ return { ...entry, cache: cache.name, path, search: path.toLowerCase() }
750
+ }))
751
+ this.cacheCount = cachesInfo.length
752
+
753
+ this.renderEntries()
754
+ }
755
+
756
+ filterEntries() {
757
+ this.renderEntries()
758
+ }
759
+
760
+ // Counts what is on screen, so filtering answers "how many match" rather than leaving the
761
+ // total sitting above a list of three.
762
+ // Two figures, because they answer different questions. The header says what the device is
763
+ // holding; the line under the filter says how much of it you are looking at.
764
+ renderSummary(matches, entries) {
765
+ // Every cache the origin has is listed, not only ours, and a URL held in two of them is
766
+ // two rows reading as one page cached twice. Bumping cache_name leaves the old one
767
+ // behind, so say when there is more than one rather than leaving that unexplained.
768
+ const spread = this.cacheCount > 1 ? ` · in ${this.cacheCount} caches` : ""
769
+
770
+ if (this.hasTotalTarget) {
771
+ this.totalTarget.textContent = entries.length === 0
772
+ ? "Nothing cached"
773
+ : `${plural(entries.length, "file")} cached · ${formatBytes(this.totalBytes(entries))}${spread}`
774
+ }
775
+
776
+ if (!this.hasSummaryTarget) return
777
+
778
+ if (entries.length === 0) {
779
+ this.summaryTarget.textContent = "Nothing cached"
780
+ return
781
+ }
782
+
783
+ const size = matches.length === 0 ? "" : ` · ${formatBytes(this.totalBytes(matches))}`
784
+ const count = matches.length === entries.length
785
+ ? `${plural(entries.length, "file")} cached`
786
+ : `${matches.length} of ${plural(entries.length, "file")}`
787
+
788
+ this.summaryTarget.textContent = `${count}${size}`
789
+ }
790
+
791
+ totalBytes(entries) {
792
+ return entries.reduce((sum, entry) => sum + (entry.size || 0), 0)
793
+ }
794
+
795
+ renderEntries() {
796
+ if (!this.hasEntriesTarget) return
797
+
798
+ const entries = this.entries || []
799
+ const query = this.hasSearchTarget ? this.searchTarget.value.trim().toLowerCase() : ""
800
+ const matches = query
801
+ ? entries.filter((entry) => entry.search.includes(query))
802
+ : entries
803
+
804
+ this.renderSummary(matches, entries)
805
+
806
+ // The list is hidden until they expand Inspect cache. Painting hundreds of rows for a
807
+ // panel nobody opened is wasted work; the header total is enough until then.
808
+ if (this.hasInspectTarget && !this.inspectTarget.open) {
809
+ window.cancelAnimationFrame(this.painting)
810
+ return
811
+ }
812
+
813
+ window.cancelAnimationFrame(this.painting)
814
+ this.entriesTarget.replaceChildren()
815
+
816
+ if (matches.length === 0) {
817
+ const empty = document.createElement("li")
818
+ empty.className = "coldwire-empty"
819
+ // Nothing cached and nothing matching are different problems, and the fix for each is
820
+ // different too.
821
+ empty.textContent = entries.length === 0 ? "None yet." : `No paths matching “${query}”.`
822
+ this.entriesTarget.append(empty)
823
+ return
824
+ }
825
+
826
+ this.paintRows(this.sortEntries(matches))
827
+ }
828
+
829
+ // A few rows at a time. Building all of them costs a third of a second for a real manifest,
830
+ // and doing that between keystrokes is what made typing feel stuck — the first chunk lands
831
+ // immediately, the rest arrive over the following frames, and the next keystroke cancels
832
+ // whatever is left rather than queueing behind it.
833
+ paintRows(rows) {
834
+ const CHUNK = 50
835
+ let index = 0
836
+
837
+ const paint = () => {
838
+ const fragment = document.createDocumentFragment()
839
+ for (const entry of rows.slice(index, index + CHUNK)) fragment.append(this.buildRow(entry))
840
+ this.entriesTarget.append(fragment)
841
+
842
+ index += CHUNK
843
+ this.painting = index < rows.length ? window.requestAnimationFrame(paint) : null
844
+ }
845
+
846
+ paint()
847
+ }
848
+
849
+ buildRow(entry) {
850
+ const item = document.createElement("li")
851
+ item.className = "coldwire-entry"
852
+
853
+ const text = document.createElement("button")
854
+ text.type = "button"
855
+ text.className = "coldwire-entry-text"
856
+ text.dataset.url = entry.url
857
+ if (entry.cache) text.dataset.cache = entry.cache
858
+ text.dataset.action = "click->coldwire-cache#showDetail"
859
+
860
+ const path = document.createElement("div")
861
+ path.className = "coldwire-entry-url"
862
+ path.textContent = entry.path
863
+ path.title = entry.url
864
+
865
+ const meta = document.createElement("div")
866
+ meta.className = "coldwire-entry-meta"
867
+ meta.textContent = entry.timestamp
868
+ ? `${formatBytes(entry.size)} · ${formatCachedAt(entry.timestamp)}`
869
+ : formatBytes(entry.size)
870
+ if (entry.timestamp) meta.title = new Date(entry.timestamp * 1000).toLocaleString()
871
+
872
+ text.append(path, meta)
873
+ item.append(text)
874
+
875
+ const forget = this.buildForgetButton(entry)
876
+ if (forget) item.append(forget)
877
+
878
+ return item
879
+ }
880
+
881
+ // A row can only ellipsise; the whole URL has to be readable somewhere, and this is it.
882
+ showDetail(event) {
883
+ event.preventDefault()
884
+
885
+ const { url, cache } = event.currentTarget.dataset
886
+ if (!url || !this.hasDetailTarget) return
887
+
888
+ const entry = (this.entries || []).find((candidate) => candidate.url === url)
889
+
890
+ if (this.hasDetailUrlTarget) this.detailUrlTarget.textContent = url
891
+ if (this.hasDetailMetaTarget) {
892
+ this.detailMetaTarget.textContent = entry ? describeEntry(entry) : ""
893
+ }
894
+ if (this.hasDetailForgetTarget) {
895
+ // The same handler the row button uses, so there is one way to remove an entry.
896
+ this.detailForgetTarget.dataset.url = url
897
+ if (cache) this.detailForgetTarget.dataset.cache = cache
898
+ this.detailForgetTarget.disabled = false
899
+ }
900
+
901
+ if (typeof this.detailTarget.showModal === "function") {
902
+ this.detailTarget.showModal()
903
+ } else {
904
+ // No <dialog> support: still show it, just without the backdrop and focus handling.
905
+ this.detailTarget.setAttribute("open", "")
906
+ }
907
+ }
908
+
909
+ closeDetail() {
910
+ if (!this.hasDetailTarget) return
911
+
912
+ if (typeof this.detailTarget.close === "function") {
913
+ this.detailTarget.close()
914
+ } else {
915
+ this.detailTarget.removeAttribute("open")
916
+ }
917
+ }
918
+
919
+ // A modal <dialog> is supposed to close itself on Escape, and mostly does — but it depends
920
+ // on the browser firing `cancel`, which is not something to rest a way out of a modal on.
921
+ // Checking the key here rather than with Stimulus's `keydown.esc` filter keeps this working
922
+ // on older Stimulus too.
923
+ closeDetailOnEscape(event) {
924
+ if (event.key !== "Escape") return
925
+
926
+ event.preventDefault()
927
+ this.closeDetail()
928
+ }
929
+
930
+ // A modal dialog fills the viewport with its backdrop, so a click outside the panel still
931
+ // lands on the dialog itself. Anything inside stops at the child that was clicked.
932
+ closeDetailOnBackdrop(event) {
933
+ if (event.target === this.detailTarget) this.closeDetail()
934
+ }
935
+
936
+ buildForgetButton(entry) {
937
+ if (!this.hasForgetTemplateTarget) return null
938
+
939
+ const button = this.forgetTemplateTarget.content.firstElementChild.cloneNode(true)
940
+ button.dataset.url = entry.url
941
+ if (entry.cache) button.dataset.cache = entry.cache
942
+ // Unlabelled but for its shape, and there is one per row — so the path goes in the label
943
+ // rather than a bare "Remove", which would read as a column of identical buttons.
944
+ button.setAttribute("aria-label", `Delete ${displayUrl(entry.url)} from the cache`)
945
+ button.title = "Delete from the cache"
946
+
947
+ return button
948
+ }
949
+
950
+ // No confirmation: one entry is a small, self-repairing loss, since anything the manifest
951
+ // lists comes back on the next sync. Reached from the row's icon and the dialog alike.
952
+ async forgetEntry(event) {
953
+ event.preventDefault()
954
+
955
+ const button = event.currentTarget
956
+ const { url, cache } = button.dataset
957
+ if (!url) return
958
+
959
+ button.disabled = true
960
+
961
+ try {
962
+ await this.forgetUrl(url, cache)
963
+ // Whether this came from the row or the dialog, the entry it was describing is gone.
964
+ this.closeDetail()
965
+ await this.renderCache()
966
+ } catch {
967
+ button.disabled = false
968
+ }
969
+ }
970
+
971
+ async forgetUrl(url, name) {
972
+ // The page can reach the cache directly, exactly as listing does — but only when the row
973
+ // knew which cache it came from. Guessing a name would silently delete nothing, so with
974
+ // no name the worker decides: it is the one that knows what it configured.
975
+ if (name && "caches" in window) {
976
+ const cache = await caches.open(name)
977
+ await cache.delete(new Request(url), { ignoreVary: true, ignoreSearch: true })
978
+ return
979
+ }
980
+
981
+ await sendToWorker("forget", { url, cache: name }, 5000)
982
+ }
983
+
984
+ // Sorted on a copy: `entries` is the cache as it was read, and re-sorting it in place would
985
+ // make the order depend on whatever was picked last.
986
+ sortEntries(entries) {
987
+ const byPath = (a, b) => a.path.localeCompare(b.path)
988
+ const order = this.hasSortTarget ? this.sortTarget.value : "recent"
989
+
990
+ if (order === "alphabetical") return entries.slice().sort(byPath)
991
+
992
+ if (order === "largest") {
993
+ return entries.slice().sort((a, b) => (b.size || 0) - (a.size || 0) || byPath(a, b))
994
+ }
995
+
996
+ // Newest first, and paths alphabetically within a second — a sync stamps everything it
997
+ // fetched at almost the same moment, so without the tiebreak the order looks arbitrary
998
+ // and shuffles between renders.
999
+ return entries.slice().sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0) || byPath(a, b))
1000
+ }
1001
+
1002
+ async listCaches() {
1003
+ if ("caches" in window) {
1004
+ const names = await caches.keys()
1005
+ const result = []
1006
+ for (const name of names) {
1007
+ const cache = await caches.open(name)
1008
+ const keys = await cache.keys()
1009
+
1010
+ // In lanes rather than one at a time. Every entry costs a `match`, and the ones with
1011
+ // no Content-Length — proxied images, anything streamed — cost a body read on top, so
1012
+ // a cache of a few hundred spent that serially and the list sat there empty.
1013
+ const entries = new Array(keys.length)
1014
+ const queue = keys.map((request, index) => ({ request, index }))
1015
+ const lanes = Math.min(8, Math.max(1, queue.length))
1016
+
1017
+ await Promise.all(Array.from({ length: lanes }, async () => {
1018
+ while (queue.length) {
1019
+ const { request, index } = queue.shift()
1020
+ const response = await cache.match(request, { ignoreVary: true })
1021
+ entries[index] = await describeCached(request, response)
1022
+ }
1023
+ }))
1024
+
1025
+ result.push({ name, entries })
1026
+ }
1027
+ return result
1028
+ }
1029
+
1030
+ const response = await sendToWorker("listCache", {}, 5000)
1031
+ return response.caches || []
1032
+ }
1033
+
1034
+ async clearCaches() {
1035
+ if ("caches" in window) {
1036
+ const names = await caches.keys()
1037
+ await Promise.all(names.map((name) => caches.delete(name)))
1038
+ sendToWorker("clearCache", {}, 5000).catch(() => {})
1039
+ return { ok: true, cleared: names.length }
1040
+ }
1041
+
1042
+ return sendToWorker("clearCache", {}, 5000)
1043
+ }
1044
+
1045
+ toggleBusy(disabled) {
1046
+ if (this.hasSyncButtonTarget) this.syncButtonTarget.disabled = disabled
1047
+ if (this.hasClearButtonTarget) this.clearButtonTarget.disabled = disabled
1048
+ if (this.hasRefreshButtonTarget) this.refreshButtonTarget.disabled = disabled
1049
+ }
1050
+
1051
+ // Only the sync button spins — toggleBusy also runs for Clear and Refresh.
1052
+ toggleSyncing(active) {
1053
+ if (this.hasSpinnerTarget) this.spinnerTarget.hidden = !active
1054
+ if (this.hasSyncLabelTarget) {
1055
+ this.syncLabelTarget.textContent = active ? "Syncing…" : "Sync now"
1056
+ }
1057
+ }
1058
+ }