coldwire-rails 0.1.0 → 0.3.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.
@@ -9,12 +9,25 @@ Coldwire.configure do |config|
9
9
  config.auto_sync do |sync|
10
10
  sync.enabled = false
11
11
  sync.precache_urls = -> { [] }
12
- sync.interval = 6.hours
13
- sync.max_age = 7.days
12
+ sync.interval = 1.day
13
+ sync.max_age = 30.days
14
14
  sync.concurrency = 4
15
15
  end
16
16
 
17
- config.cache_identity = -> { nil }
17
+ config.garbage_collection do |gc|
18
+ gc.enabled = true
19
+ gc.max_age = 60.days
20
+ gc.max_size = 250.megabytes
21
+ gc.interval = 1.day
22
+ end
23
+
24
+ config.cache_identity = -> {
25
+ if respond_to?(:current_user)
26
+ current_user&.id
27
+ elsif defined?(Current) && Current.respond_to?(:user)
28
+ Current.user&.id
29
+ end
30
+ }
18
31
  config.register_if = -> { true }
19
32
  config.caching_enabled_by_default = true
20
33
  config.offline_import = "@hotwired/turbo-rails"
@@ -23,7 +36,7 @@ Coldwire.configure do |config|
23
36
  config.never_cache = []
24
37
  config.never_intercept = [ "/up" ]
25
38
 
26
- config.cache_origins = []
39
+ config.cacheable_hosts = []
27
40
  config.cache_ranges = []
28
41
  config.cache_archives = []
29
42
 
@@ -44,17 +57,21 @@ down with it.
44
57
  |---|---|---|
45
58
  | [`auto_sync.enabled`](#autosyncenabled) | `false` | Keep the precache manifest current on an interval |
46
59
  | [`auto_sync.precache_urls`](#autosyncprecache_urls) | `-> { [] }` | The pages to fetch, evaluated against your URL helpers |
47
- | [`auto_sync.interval`](#autosyncinterval) | `6.hours` | How long to leave between syncs |
48
- | [`auto_sync.max_age`](#autosyncmax_age) | `7.days` | Refetch a cached manifest page once it is older than this |
60
+ | [`auto_sync.interval`](#autosyncinterval) | `1.day` | How long to leave between syncs |
61
+ | [`auto_sync.max_age`](#autosyncmax_age) | `30.days` | Refetch a cached manifest page once it is older than this |
49
62
  | [`auto_sync.concurrency`](#autosyncconcurrency) | `4` | Fetches in flight at once during a sync |
50
- | [`cache_identity`](#cache_identity) | `-> { nil }` | Who the cache belongs to; changing it drops the cache |
63
+ | [`garbage_collection.enabled`](#garbage_collectionenabled) | `true` | Sweep entries nothing has used in a long time |
64
+ | [`garbage_collection.max_age`](#garbage_collectionmax_age) | `60.days` | How long an entry may go untouched before it is collected |
65
+ | [`garbage_collection.max_size`](#garbage_collectionmax_size) | `250.megabytes` | How much the cache may hold; over it the least recently read go first |
66
+ | [`garbage_collection.interval`](#garbage_collectioninterval) | `1.day` | How long to leave between sweeps |
67
+ | [`cache_identity`](#cache_identity) | `current_user` / `Current.user` | Who the cache belongs to; changing it drops the cache |
51
68
  | [`register_if`](#register_if) | `-> { true }` | Whether a page registers the worker at all |
52
69
  | [`caching_enabled_by_default`](#caching_enabled_by_default) | `true` | Starting position of the Offline support switch. Not a master on/off |
53
70
  | [`offline_import`](#offline_import) | `"@hotwired/turbo-rails"` | Importmap module the offline page loads to boot Turbo |
54
71
  | [`cache_as_you_go`](#cache_as_you_go) | `["/*"]` | Pages stored as somebody browses. `/*` is everything |
55
72
  | [`never_cache`](#never_cache) | `[]` | Never stored, by any route in. The one veto |
56
73
  | [`never_intercept`](#never_intercept) | `["/up"]` | Paths the worker does not touch at all |
57
- | [`cache_origins`](#cache_origins) | `[]` | Other origins the worker may cache |
74
+ | [`cacheable_hosts`](#cacheable_hosts) | `[]` | Other hosts the worker may cache |
58
75
  | [`cache_ranges`](#cache_ranges) | `[]` | URLs whose `Range` requests are cached piece by piece |
59
76
  | [`cache_archives`](#cache_archives) | `[]` | Large files somebody can choose to download |
60
77
  | [`ignore_query_params`](#ignore_query_params) | `true` | Treat `/map` and `/map?zoom=9` as one page |
@@ -74,8 +91,8 @@ fetched, an interval with no manifest has nothing to fetch.
74
91
  config.auto_sync do |sync|
75
92
  sync.enabled = true
76
93
  sync.precache_urls = -> { Site.published.map { |site| site_path(site) } }
77
- sync.interval = 6.hours
78
- sync.max_age = 7.days
94
+ sync.interval = 1.day
95
+ sync.max_age = 30.days
79
96
  sync.concurrency = 4
80
97
  end
81
98
  ```
@@ -105,7 +122,7 @@ The offline settings page has a per-device switch that turns automatic syncing o
105
122
  `localStorage`. Switched off, no page holds a sync timer; **Sync now** still runs a pass.
106
123
 
107
124
  <p align="center">
108
- <img src="images/offline-settings.png" alt="Offline settings: status, force offline, auto sync, and downloads" width="280">
125
+ <img src="images/offline-settings.png" alt="Offline settings: status, force offline, the storage limit, auto sync, and downloads" width="280">
109
126
  <img src="images/offline-settings-cached.png" alt="Offline settings: every cached entry, with search, sort, and delete" width="280">
110
127
  </p>
111
128
 
@@ -139,7 +156,7 @@ A stored page's stylesheets, scripts, and images are fetched with it, whatever t
139
156
 
140
157
  ### `auto_sync.interval`
141
158
 
142
- **Default:** `6.hours`
159
+ **Default:** `1.day`
143
160
 
144
161
  How long to leave between syncs. An ActiveSupport duration works; the worker receives
145
162
  seconds. Leave this long — a sync is a burst of fetches, not something to run on every
@@ -150,7 +167,7 @@ config change follows the new value rather than the one it was born with.
150
167
 
151
168
  ### `auto_sync.max_age`
152
169
 
153
- **Default:** `7.days`
170
+ **Default:** `30.days`
154
171
 
155
172
  Refetch a manifest page once its cached copy is older than this. `nil` fetches only what is
156
173
  missing, so pages already cached are never noticed to have changed.
@@ -164,20 +181,141 @@ once would stall the app's own requests behind hundreds of connections.
164
181
 
165
182
  ---
166
183
 
184
+ ## `garbage_collection`
185
+
186
+ A cache that fills as people browse fills forever. Collection takes back what nothing has
187
+ asked for in a long time.
188
+
189
+ ```ruby
190
+ config.garbage_collection do |gc|
191
+ gc.enabled = true
192
+ gc.max_age = 60.days
193
+ gc.max_size = 250.megabytes
194
+ gc.interval = 1.day
195
+ end
196
+ ```
197
+
198
+ Two limits, and a sweep applies both: `max_age` takes what has gone unused, then `max_size`
199
+ takes the least recently read of what is left until the cache fits. The second is the one that
200
+ binds on a device that browses far more than it revisits, where nothing is ever old enough to
201
+ collect and the cache grows until the browser evicts the lot.
202
+
203
+ **Only ever with a connection.** Deleting is the one cache operation with no way back:
204
+ whatever goes is gone until the network can be reached again. So a sweep pings
205
+ [`probe_path`](#probe_path) first and stands down if it cannot be reached, and stands down
206
+ under force offline. `navigator.onLine` is not consulted — a web view answers it wrongly often
207
+ enough to be worthless for a decision this expensive to get wrong.
208
+
209
+ **Untouched, not old.** Age is measured from when an entry was last *used*, not when it was
210
+ first fetched. Storing a page renews everything it names, so the stylesheet every page in your
211
+ app loads keeps a fresh date even though nothing ever refetches it. Without that, an asset
212
+ would carry the date of the very first page that pulled it in and be collected while the whole
213
+ app was still using it.
214
+
215
+ Renewal rewrites from the cache — it is never a network request — and only once an entry has
216
+ aged past a quarter of `max_age`. Under that it costs a lookup, so an ordinary navigation is
217
+ not rewriting every asset the page names.
218
+
219
+ Two things are never collected, whatever their age or how full the cache is — and neither is
220
+ counted against `max_size`, since the ceiling has to measure what a sweep can actually act on:
221
+
222
+ | | |
223
+ |---|---|
224
+ | **What the offline page needs** | browsing never touches it, and it is wanted precisely when there is no network |
225
+ | **Downloaded archives** | somebody chose to spend a data plan on those; disuse does not make them safe to throw away |
226
+
227
+ A sweep is paced by an open page the same way a sync is, for the same reason: nothing can wake
228
+ a worker in a WebKit web view. It is not recorded unless it actually ran, so a device that has
229
+ been offline for a week sweeps on its next page load with a connection.
230
+
231
+ ### `garbage_collection.enabled`
232
+
233
+ ```ruby
234
+ config.garbage_collection { |gc| gc.enabled = false }
235
+ ```
236
+
237
+ On by default, unlike [`auto_sync`](#auto_sync). Syncing spends somebody's data plan, which is
238
+ theirs to opt into; a sweep spends nothing and the alternative is a cache that grows on their
239
+ phone until the browser evicts the whole thing. Off, nothing is collected and nothing is
240
+ renewed.
241
+
242
+ ### `garbage_collection.max_age`
243
+
244
+ ```ruby
245
+ config.garbage_collection { |gc| gc.max_age = 60.days }
246
+ ```
247
+
248
+ How long an entry may go untouched before a sweep takes it. `nil` collects nothing, which is
249
+ the same as `enabled = false`.
250
+
251
+ Keep it comfortably longer than [`auto_sync.max_age`](#autosyncmax_age). A manifest page is
252
+ refetched once its copy is older than that, so as long as collection outlives refetching, a
253
+ sync brings a page back up to date well before a sweep would consider it. The defaults leave
254
+ 60 days against 30.
255
+
256
+ ### `garbage_collection.max_size`
257
+
258
+ ```ruby
259
+ config.garbage_collection { |gc| gc.max_size = 500.megabytes }
260
+ ```
261
+
262
+ How much the cache may hold, in bytes. Past it a sweep deletes the least recently read entry,
263
+ then the next, until what is left fits — the same clock `max_age` works from, so an entry
264
+ renewed because a page still loads it is among the last to go rather than the first. `nil` is
265
+ no ceiling.
266
+
267
+ Measured over what a sweep is allowed to take, which is everything but the offline page's own
268
+ assets and downloaded archives. Counting a 300 MB download somebody deliberately kept would
269
+ empty the rest of the cache to make room for a file no sweep may touch.
270
+
271
+ The ceiling is applied when a sweep runs, so [`interval`](#garbage_collectioninterval) is also
272
+ how long the cache may sit over it. Lower the interval if a tighter bound matters more than
273
+ the work.
274
+
275
+ **People can change it.** The offline settings page offers a ladder of sizes — the configured
276
+ default always among them — and the choice is remembered in `localStorage` for that device,
277
+ the way Force offline and the Auto Sync switch are. It travels to the worker with each sweep,
278
+ since a worker cannot read `localStorage`. Changing it there sweeps immediately rather than
279
+ waiting out the interval.
280
+
281
+ ### `garbage_collection.interval`
282
+
283
+ ```ruby
284
+ config.garbage_collection { |gc| gc.interval = 12.hours }
285
+ ```
286
+
287
+ How long to leave between sweeps. A sweep reads the cache index and deletes; there is nothing
288
+ to pace against a network, so this is about not doing pointless work on every page load rather
289
+ than about cost.
290
+
291
+ It is also how long the cache may exceed [`max_size`](#garbage_collectionmax_size), since that
292
+ is when the ceiling is applied. A sweep with a ceiling set costs a `match` per entry to measure
293
+ what is there, which is why it is worth pacing at all.
294
+
295
+ ---
296
+
167
297
  ## `cache_identity`
168
298
 
169
- **Default:** `-> { nil }`
299
+ **Default:** `current_user&.id` or `Current.user&.id` when either is in scope
170
300
 
171
301
  Who the cache belongs to, usually the signed-in user's id. Evaluated in the view, so
172
- `current_user` is in scope. Recorded in `localStorage`; when it changes between page loads
173
- the cache is dropped which is what makes signing out, and switching accounts, safe.
302
+ `current_user` is in scope and `Current.user` if you keep the user there instead.
303
+ Recorded in `localStorage`; when it changes between page loads the cache is dropped
304
+ which is what makes signing out, and switching accounts, safe.
174
305
 
175
306
  ```ruby
176
- config.cache_identity = -> { current_user&.id }
307
+ config.cache_identity = -> {
308
+ if respond_to?(:current_user)
309
+ current_user&.id
310
+ elsif defined?(Current) && Current.respond_to?(:user)
311
+ Current.user&.id
312
+ end
313
+ }
177
314
  ```
178
315
 
179
- Leave it unset and the cache persists across sessions: fine for a single-user or fully
180
- public app, wrong for anything else.
316
+ That is what the installer writes. If neither helper exists, the identity is empty and the
317
+ cache persists across sessions — fine for a single-user or fully public app. Override it
318
+ if your signed-in user lives somewhere else.
181
319
 
182
320
  A few edges the setting already handles:
183
321
 
@@ -359,22 +497,28 @@ config.never_cache = [ %r{^/admin(/|$)}, %r{^/users/[^/]+/edit$} ]
359
497
 
360
498
  ---
361
499
 
362
- ## `cache_origins`
500
+ ## `cacheable_hosts`
363
501
 
364
502
  **Default:** `[]`
365
503
 
366
- Origins besides your own that the worker may cache. Each has to send CORS headers naming
367
- your app, or the response arrives opaque — status 0, no headers, no readable body — and
368
- there is nothing worth storing. Ranged sources must also expose `Content-Range`.
504
+ The hosts besides your own that the worker may cache. Each has to send CORS headers naming
505
+ your app, or the response arrives opaque — status 0, no headers, no readable body — and there
506
+ is nothing worth storing. Ranged sources must also expose `Content-Range`.
369
507
 
370
508
  ```ruby
371
- config.cache_origins = [ "https://tiles.example.com" ]
509
+ config.cacheable_hosts = [ "tiles.example.com", "localhost:3001" ]
372
510
  ```
373
511
 
374
- Bare origins only: a scheme and a host, no path, no trailing slash. Anything else raises at
375
- boot, because a malformed origin silently fails to match a request's origin.
512
+ Just the host. No scheme: a worker runs only on a secure page, and a secure page cannot fetch
513
+ `http`, so there was never a second scheme for one to tell apart. Writing one raises at boot
514
+ rather than being stripped — a scheme quietly accepted is a config that looks migrated and is
515
+ not, and the error names what to write instead.
516
+
517
+ A port only where it is not the default. A request is matched on its URL's `host`, against
518
+ exactly what you wrote: `localhost:3001` matches that port and no other, and a subdomain is a
519
+ different host.
376
520
 
377
- Cross-origin requests are passed through unless the origin is listed here.
521
+ Requests to anywhere else are passed straight through not intercepted, not stored.
378
522
 
379
523
  ---
380
524
 
@@ -392,7 +536,7 @@ config.cache_ranges = [ "/tiles/*", %r{\.pmtiles$} ]
392
536
 
393
537
  Patterns match the URL path, same as the other lists — not the full URL. A cross-origin
394
538
  tile at `https://tiles.example.com/basemap.pmtiles` is allowed only when that origin is in
395
- [`cache_origins`](#cache_origins) *and* its path matches a rule here.
539
+ [`cacheable_hosts`](#cacheable_hosts) *and* its path matches a rule here.
396
540
 
397
541
  This pairs with [`cache_archives`](#cache_archives): `cache_ranges` caches the slices
398
542
  actually read, so the places you have already opened work offline, and downloading the
@@ -423,7 +567,7 @@ Files arrive in 8 MB chunks, which is what makes a dropped connection cost secon
423
567
  of the whole download. A `Range` request against a downloaded archive is answered by
424
568
  slicing the chunks.
425
569
 
426
- If the file lives on another origin, list that origin in [`cache_origins`](#cache_origins).
570
+ If the file lives on another host, list it in [`cacheable_hosts`](#cacheable_hosts).
427
571
 
428
572
  ---
429
573
 
@@ -478,14 +622,24 @@ for the first paint of a cold boot, before any JS runs, and a `<meta name="coldw
478
622
  for Turbo visits, since Turbo merges the head but never copies `<html>` attributes.
479
623
  `coldwire_service_worker_tag` mirrors the meta onto `<html>` on each `turbo:load`.
480
624
 
481
- Any CSS can key off the attribute. With Tailwind v4, two custom variants give you
482
- `offline:` and `online:`:
625
+ Any CSS can key off the attribute.
626
+
627
+ ### Tailwind variants
628
+
629
+ With Tailwind v4, two custom variants give you `offline:` and `online:`:
483
630
 
484
631
  ```css
485
632
  @custom-variant offline (html[data-coldwire-offline] &);
486
633
  @custom-variant online (html:not([data-coldwire-offline]) &);
487
634
  ```
488
635
 
636
+ Then show or hide content from the markup:
637
+
638
+ ```html
639
+ <p class="offline:hidden">You're online.</p>
640
+ <p class="online:hidden">You're looking at a cached page.</p>
641
+ ```
642
+
489
643
  From JavaScript, `window.Coldwire`:
490
644
 
491
645
  ```js
data/docs/how-it-works.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Six things break a naive offline cache in a Hotwire app. Four bite you in any browser; two
4
4
  are Hotwire Native holding you to a stricter standard. Coldwire handles all six, which is
5
- what lets one cache serve a plain Hotwire app, a PWA, and Hotwire Native.
5
+ what lets one cache serve a plain Hotwire web app, a PWA, and Hotwire Native.
6
6
 
7
7
  When a visit has no cached copy and no network, people see this — a `200` that boots
8
8
  Turbo — rather than a native error screen:
@@ -0,0 +1,57 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 626 190" width="626" height="190" role="img" aria-label="Coldwire — Offline">
2
+ <g stroke-linejoin="round" stroke-linecap="round">
3
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
4
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
5
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
6
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
7
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#F0F9FF" stroke="none"/>
8
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#F0F9FF" stroke="none"/>
9
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#F0F9FF" stroke="none"/>
10
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#F0F9FF" stroke="none"/>
11
+ <g fill="#0B1220" stroke="#0B1220" stroke-width="8">
12
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
13
+ <circle cx="54" cy="92" r="26"/>
14
+ <circle cx="88" cy="66" r="36"/>
15
+ <circle cx="124" cy="78" r="28"/>
16
+ <circle cx="150" cy="94" r="22"/>
17
+ </g>
18
+ <g fill="#38BDF8" stroke="none">
19
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
20
+ <circle cx="54" cy="92" r="26"/>
21
+ <circle cx="88" cy="66" r="36"/>
22
+ <circle cx="124" cy="78" r="28"/>
23
+ <circle cx="150" cy="94" r="22"/>
24
+ </g>
25
+ <g fill="none" stroke="#E6EDF3" stroke-width="9" stroke-linecap="round">
26
+ <path d="M 228.62 69.98 A 22 22 0 1 0 228.62 106.02"/>
27
+ <path d="M 254.00 88 A 22 22 0 1 0 298.00 88 A 22 22 0 1 0 254.00 88"/>
28
+ <path d="M 320 34 L 320 110"/>
29
+ <path d="M 346.00 88 A 22 22 0 1 0 390.00 88 A 22 22 0 1 0 346.00 88"/>
30
+ <path d="M 390 34 L 390 110"/>
31
+ <path d="M 410 66 L 421 110 L 432 74 L 443 110 L 454 66"/>
32
+ <path d="M 476 66 L 476 110"/>
33
+ <path d="M 476 42 L 476 42"/>
34
+ <path d="M 506 66 L 506 110"/>
35
+ <path d="M 506 82 A 20 20 0 0 1 530 66"/>
36
+ <path d="M 590.00 88.00 L 546.00 88"/>
37
+ <path d="M 590.00 88.00 A 22 22 0 1 0 579.00 107.05"/>
38
+ </g>
39
+ <g fill="none" stroke="#94A3B8" stroke-width="9" stroke-linecap="round" transform="translate(445.12 107.40) scale(0.46)">
40
+ <path d="M 0.00 88 A 22 22 0 1 0 44.00 88 A 22 22 0 1 0 0.00 88"/>
41
+ <path d="M 75 110 L 75 48"/>
42
+ <path d="M 75 48 A 17 17 0 0 1 92 34"/>
43
+ <path d="M 62 66 L 91 66"/>
44
+ <path d="M 121 110 L 121 48"/>
45
+ <path d="M 121 48 A 17 17 0 0 1 138 34"/>
46
+ <path d="M 108 66 L 137 66"/>
47
+ <path d="M 158 34 L 158 110"/>
48
+ <path d="M 188 66 L 188 110"/>
49
+ <path d="M 188 42 L 188 42"/>
50
+ <path d="M 218 66 L 218 110"/>
51
+ <path d="M 218 86 A 20 20 0 0 1 258 86"/>
52
+ <path d="M 258 86 L 258 110"/>
53
+ <path d="M 322.00 88.00 L 278.00 88"/>
54
+ <path d="M 322.00 88.00 A 22 22 0 1 0 311.00 107.05"/>
55
+ </g>
56
+ </g>
57
+ </svg>
@@ -0,0 +1,57 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 626 190" width="626" height="190" role="img" aria-label="Coldwire — Offline">
2
+ <g stroke-linejoin="round" stroke-linecap="round">
3
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
4
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
5
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
6
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
7
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#E0F2FE" stroke="none"/>
8
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#E0F2FE" stroke="none"/>
9
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#E0F2FE" stroke="none"/>
10
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#E0F2FE" stroke="none"/>
11
+ <g fill="#0F172A" stroke="#0F172A" stroke-width="8">
12
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
13
+ <circle cx="54" cy="92" r="26"/>
14
+ <circle cx="88" cy="66" r="36"/>
15
+ <circle cx="124" cy="78" r="28"/>
16
+ <circle cx="150" cy="94" r="22"/>
17
+ </g>
18
+ <g fill="#0EA5E9" stroke="none">
19
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
20
+ <circle cx="54" cy="92" r="26"/>
21
+ <circle cx="88" cy="66" r="36"/>
22
+ <circle cx="124" cy="78" r="28"/>
23
+ <circle cx="150" cy="94" r="22"/>
24
+ </g>
25
+ <g fill="none" stroke="#0F172A" stroke-width="9" stroke-linecap="round">
26
+ <path d="M 228.62 69.98 A 22 22 0 1 0 228.62 106.02"/>
27
+ <path d="M 254.00 88 A 22 22 0 1 0 298.00 88 A 22 22 0 1 0 254.00 88"/>
28
+ <path d="M 320 34 L 320 110"/>
29
+ <path d="M 346.00 88 A 22 22 0 1 0 390.00 88 A 22 22 0 1 0 346.00 88"/>
30
+ <path d="M 390 34 L 390 110"/>
31
+ <path d="M 410 66 L 421 110 L 432 74 L 443 110 L 454 66"/>
32
+ <path d="M 476 66 L 476 110"/>
33
+ <path d="M 476 42 L 476 42"/>
34
+ <path d="M 506 66 L 506 110"/>
35
+ <path d="M 506 82 A 20 20 0 0 1 530 66"/>
36
+ <path d="M 590.00 88.00 L 546.00 88"/>
37
+ <path d="M 590.00 88.00 A 22 22 0 1 0 579.00 107.05"/>
38
+ </g>
39
+ <g fill="none" stroke="#64748B" stroke-width="9" stroke-linecap="round" transform="translate(445.12 107.40) scale(0.46)">
40
+ <path d="M 0.00 88 A 22 22 0 1 0 44.00 88 A 22 22 0 1 0 0.00 88"/>
41
+ <path d="M 75 110 L 75 48"/>
42
+ <path d="M 75 48 A 17 17 0 0 1 92 34"/>
43
+ <path d="M 62 66 L 91 66"/>
44
+ <path d="M 121 110 L 121 48"/>
45
+ <path d="M 121 48 A 17 17 0 0 1 138 34"/>
46
+ <path d="M 108 66 L 137 66"/>
47
+ <path d="M 158 34 L 158 110"/>
48
+ <path d="M 188 66 L 188 110"/>
49
+ <path d="M 188 42 L 188 42"/>
50
+ <path d="M 218 66 L 218 110"/>
51
+ <path d="M 218 86 A 20 20 0 0 1 258 86"/>
52
+ <path d="M 258 86 L 258 110"/>
53
+ <path d="M 322.00 88.00 L 278.00 88"/>
54
+ <path d="M 322.00 88.00 A 22 22 0 1 0 311.00 107.05"/>
55
+ </g>
56
+ </g>
57
+ </svg>
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="22 22 158 142" width="158" height="142" role="img" aria-label="Coldwire">
2
+ <g stroke-linejoin="round" stroke-linecap="round">
3
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
4
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
5
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
6
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#0B1220" stroke="#0B1220" stroke-width="3.6"/>
7
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#F0F9FF" stroke="none"/>
8
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#F0F9FF" stroke="none"/>
9
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#F0F9FF" stroke="none"/>
10
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#F0F9FF" stroke="none"/>
11
+ <g fill="#0B1220" stroke="#0B1220" stroke-width="8">
12
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
13
+ <circle cx="54" cy="92" r="26"/>
14
+ <circle cx="88" cy="66" r="36"/>
15
+ <circle cx="124" cy="78" r="28"/>
16
+ <circle cx="150" cy="94" r="22"/>
17
+ </g>
18
+ <g fill="#38BDF8" stroke="none">
19
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
20
+ <circle cx="54" cy="92" r="26"/>
21
+ <circle cx="88" cy="66" r="36"/>
22
+ <circle cx="124" cy="78" r="28"/>
23
+ <circle cx="150" cy="94" r="22"/>
24
+ </g>
25
+ </g>
26
+ </svg>
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="22 22 158 142" width="158" height="142" role="img" aria-label="Coldwire">
2
+ <g stroke-linejoin="round" stroke-linecap="round">
3
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
4
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
5
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
6
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#0F172A" stroke="#0F172A" stroke-width="3.6"/>
7
+ <path d="M 57.24 107.00 C 57.44 116.36 62.24 125.20 64.00 133.00 C 65.76 125.20 70.56 116.36 70.76 107.00 Z" fill="#E0F2FE" stroke="none"/>
8
+ <path d="M 77.60 107.00 C 77.91 121.40 85.30 135.00 88.00 147.00 C 90.70 135.00 98.09 121.40 98.40 107.00 Z" fill="#E0F2FE" stroke="none"/>
9
+ <path d="M 104.20 107.00 C 104.43 117.80 109.97 128.00 112.00 137.00 C 114.03 128.00 119.57 117.80 119.80 107.00 Z" fill="#E0F2FE" stroke="none"/>
10
+ <path d="M 129.32 107.00 C 129.46 113.48 132.78 119.60 134.00 125.00 C 135.22 119.60 138.54 113.48 138.68 107.00 Z" fill="#E0F2FE" stroke="none"/>
11
+ <g fill="#0F172A" stroke="#0F172A" stroke-width="8">
12
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
13
+ <circle cx="54" cy="92" r="26"/>
14
+ <circle cx="88" cy="66" r="36"/>
15
+ <circle cx="124" cy="78" r="28"/>
16
+ <circle cx="150" cy="94" r="22"/>
17
+ </g>
18
+ <g fill="#0EA5E9" stroke="none">
19
+ <rect x="54" y="58.80" width="96" height="57.20" rx="28.60" ry="28.60"/>
20
+ <circle cx="54" cy="92" r="26"/>
21
+ <circle cx="88" cy="66" r="36"/>
22
+ <circle cx="124" cy="78" r="28"/>
23
+ <circle cx="150" cy="94" r="22"/>
24
+ </g>
25
+ </g>
26
+ </svg>
Binary file
Binary file
data/docs/setup.md CHANGED
@@ -6,7 +6,7 @@ provides `Turbo`. Add the gem, then let the installer wire the rest.
6
6
  ## Requirements
7
7
 
8
8
  - Rails 7.1+
9
- - Turbo — a plain Hotwire app, a PWA, or Hotwire Native
9
+ - Turbo — a plain Hotwire web app, a PWA, or Hotwire Native
10
10
  - Service workers, and HTTPS (or localhost). They are same-origin, so the engine has to be
11
11
  mounted on the app's own domain
12
12
  - Hotwire Native is optional. Nothing here requires it
@@ -102,9 +102,9 @@ Every option has a working default. The full list, and what each one does, is in
102
102
 
103
103
  ## What to set first
104
104
 
105
- **`cache_identity`**, if anyone signs in. Cached pages hold whatever the session that
106
- fetched them could see. Leave this unset and the cache persists across sessions — fine for
107
- a single-user or fully public app, wrong for anything else.
105
+ **`cache_identity`**, if the signed-in user is not `current_user` or `Current.user`. Cached
106
+ pages hold whatever the session that fetched them could see. The installer already uses
107
+ those two when they are in scope; override it if yours lives somewhere else.
108
108
 
109
109
  ```ruby
110
110
  config.cache_identity = -> { current_user&.id }
@@ -136,7 +136,8 @@ Mounted at the engine root — `/offline` with the mount above. It inherits your
136
136
  `ApplicationController`, so it picks up your layout, authentication, and helpers.
137
137
 
138
138
  This is the page people use to turn offline support on or off, see connection status, download
139
- archives, turn auto-sync off for this device, force offline, and manage what is cached.
139
+ archives, turn auto-sync off for this device, set how much storage the cache may use, force
140
+ offline, and manage what is cached.
140
141
  Turning offline support off asks first, then deletes what is stored and hides the rest of the
141
142
  page. It sets `content_for :title` to `"Offline settings"` — yield that in your layout's
142
143
  `<title>` (and any native title bar that reads it) rather than expecting an on-page heading.
@@ -144,13 +145,17 @@ Put it behind whatever authentication you use by wrapping the route, or override
144
145
  `app/views/coldwire/caches/show.html.erb`.
145
146
 
146
147
  <p align="center">
147
- <img src="images/offline-settings.png" alt="Offline settings: status, force offline, auto sync, and downloads" width="280">
148
+ <img src="images/offline-settings.png" alt="Offline settings: status, force offline, the storage limit, auto sync, and downloads" width="280">
148
149
  <img src="images/offline-settings-cached.png" alt="Offline settings: every cached entry, with search, sort, and delete" width="280">
149
150
  </p>
150
151
 
151
152
  To reach it offline, list it in `cache_as_you_go` like any other page. **Sync now** talks to
152
153
  the manifest, which is never intercepted, so that button fails while offline; **Inspect cache**,
153
- **Clear cache**, and **Force offline** are client-side and keep working. The URL list lives
154
+ **Clear cache**, and **Force offline** are client-side and keep working. **Keep at most**
155
+ the storage ceiling, from
156
+ [`garbage_collection.max_size`](configuration.md#garbage_collectionmax_size) — is remembered
157
+ straight away, but the sweep it triggers needs a connection like any other, so a lowered
158
+ ceiling applies once there is one. The URL list lives
154
159
  under Inspect cache, closed until you open it.
155
160
 
156
161
  ## Hotwire Native on iOS
@@ -171,7 +176,7 @@ app-bound mode and takes service workers with it.
171
176
  ## Optional next steps
172
177
 
173
178
  - Restrict what browsing stores with [`cache_as_you_go`](configuration.md#cache_as_you_go)
174
- - Nominate other origins or `Range` URLs with [`cache_origins`](configuration.md#cache_origins)
179
+ - Nominate other hosts or `Range` URLs with [`cacheable_hosts`](configuration.md#cacheable_hosts)
175
180
  and [`cache_ranges`](configuration.md#cache_ranges)
176
181
  - Offer large files for download with [`cache_archives`](configuration.md#cache_archives)
177
182
  - Override the offline fallback by creating
@@ -0,0 +1,49 @@
1
+ (function () {
2
+ // Turbo copies head scripts it does not recognise, and a per-request CSP nonce makes this
3
+ // one look new on every visit — without the guard each visit wires another listener.
4
+ if (window.__coldwireCollect) return
5
+ window.__coldwireCollect = true
6
+
7
+ var store = window.coldwireStore
8
+ var keys = store.keys
9
+ var interval = COLDWIRE.collectInterval
10
+
11
+ // Nothing like the sync scheduler here on purpose. A sweep is quick, takes no network worth
12
+ // pacing, and two of them at once is harmless — so there is no claim to hold, no countdown
13
+ // to draw, and no progress to report. A stamp and a check are the whole clock.
14
+ function due() {
15
+ var last = store.number(keys.collectedAt)
16
+
17
+ return !last || Date.now() - last >= interval
18
+ }
19
+
20
+ function run() {
21
+ if (!due()) return
22
+ if (store.on(keys.forced)) return
23
+ if (!store.cachingOn()) return
24
+ if (!("serviceWorker" in navigator)) return
25
+
26
+ navigator.serviceWorker.ready.then(function (registration) {
27
+ var worker = registration.active || navigator.serviceWorker.controller
28
+ if (!worker) return
29
+
30
+ var channel = new MessageChannel()
31
+ channel.port1.onmessage = function (event) {
32
+ var result = event.data
33
+ // No connection, or force offline: nothing was swept, so leave the clock alone and
34
+ // let the next page find it still due.
35
+ if (!result || result.offline) return
36
+
37
+ store.set(keys.collectedAt, Date.now())
38
+ }
39
+ // The ceiling travels with the request: it lives in localStorage, which a worker
40
+ // cannot read, and it is a choice this device made rather than one the app baked in.
41
+ worker.postMessage({ type: "collect", maxSize: store.maxSize() }, [ channel.port2 ])
42
+ })
43
+ }
44
+
45
+ document.addEventListener("turbo:load", run)
46
+ window.addEventListener("online", run)
47
+
48
+ run()
49
+ })();