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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/VERSION +1 -0
- data/app/assets/javascripts/coldwire/archives.js +52 -0
- data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
- data/app/assets/javascripts/coldwire/entries.js +55 -0
- data/app/assets/javascripts/coldwire/format.js +60 -0
- data/app/assets/javascripts/coldwire/worker.js +35 -0
- data/app/controllers/coldwire/application_controller.rb +21 -0
- data/app/controllers/coldwire/caches_controller.rb +33 -0
- data/app/controllers/coldwire/service_worker_controller.rb +26 -0
- data/app/helpers/coldwire/service_worker_helper.rb +59 -0
- data/app/views/coldwire/caches/show.html.erb +304 -0
- data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
- data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
- data/app/views/coldwire/service_worker/show.js.erb +66 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +8 -0
- data/docs/README.md +19 -0
- data/docs/configuration.md +539 -0
- data/docs/how-it-works.md +64 -0
- data/docs/images/offline-fallback.png +0 -0
- data/docs/images/offline-settings-cached.png +0 -0
- data/docs/images/offline-settings.png +0 -0
- data/docs/setup.md +218 -0
- data/lib/coldwire/client/api.js +44 -0
- data/lib/coldwire/client/cookie.js +13 -0
- data/lib/coldwire/client/forced.js +17 -0
- data/lib/coldwire/client/identity.js +28 -0
- data/lib/coldwire/client/marker.js +35 -0
- data/lib/coldwire/client/register.js +19 -0
- data/lib/coldwire/client/store.js +69 -0
- data/lib/coldwire/client/sync.js +122 -0
- data/lib/coldwire/client_user_agent.rb +48 -0
- data/lib/coldwire/configuration.rb +319 -0
- data/lib/coldwire/debug.css +197 -0
- data/lib/coldwire/engine.rb +34 -0
- data/lib/coldwire/source.rb +50 -0
- data/lib/coldwire/version.rb +11 -0
- data/lib/coldwire/worker/archives.js +158 -0
- data/lib/coldwire/worker/events.js +79 -0
- data/lib/coldwire/worker/inspect.js +81 -0
- data/lib/coldwire/worker/ranges.js +136 -0
- data/lib/coldwire/worker/rules.js +100 -0
- data/lib/coldwire/worker/serve.js +226 -0
- data/lib/coldwire/worker/sync.js +234 -0
- data/lib/coldwire-rails.rb +5 -0
- data/lib/coldwire.rb +55 -0
- data/lib/generators/coldwire/install/install_generator.rb +110 -0
- data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
- data/lib/tasks/coldwire.rake +8 -0
- 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
|
+
}
|