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
data/docs/setup.md ADDED
@@ -0,0 +1,218 @@
1
+ # Setup
2
+
3
+ The gem is `coldwire-rails`; everything in it lives under `Coldwire`, the way `turbo-rails`
4
+ provides `Turbo`. Add the gem, then let the installer wire the rest.
5
+
6
+ ## Requirements
7
+
8
+ - Rails 7.1+
9
+ - Turbo — a plain Hotwire app, a PWA, or Hotwire Native
10
+ - Service workers, and HTTPS (or localhost). They are same-origin, so the engine has to be
11
+ mounted on the app's own domain
12
+ - Hotwire Native is optional. Nothing here requires it
13
+
14
+ On iOS, service workers only run in `WKWebView` when navigation is limited to app-bound
15
+ domains. See [Hotwire Native on iOS](#hotwire-native-on-ios).
16
+
17
+ ## 1. Add the gem
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "coldwire-rails"
22
+ ```
23
+
24
+ Then `bundle install` and:
25
+
26
+ ```bash
27
+ bin/rails coldwire:install
28
+ ```
29
+
30
+ That does four things, and skips any it finds already done:
31
+
32
+ 1. Mounts the engine at `/offline` in `config/routes.rb`
33
+ 2. Writes `config/initializers/coldwire.rb` with every option and its default
34
+ 3. Registers the Stimulus controller in `app/javascript/controllers/index.js`
35
+ 4. Adds `<%= coldwire_service_worker_tag %>` inside `<head>` in your application layout
36
+
37
+ Visit `/offline` to see what's cached. What to set after that is below.
38
+
39
+ ## By hand
40
+
41
+ The installer does the next four steps. Do them yourself if you would rather.
42
+
43
+ ## 2. Mount the engine
44
+
45
+ ```ruby
46
+ # config/routes.rb
47
+ mount Coldwire::Engine => "/offline"
48
+ ```
49
+
50
+ The worker is served from the mount point, but sends `Service-Worker-Allowed: /` and
51
+ registers at `/`, so it controls the whole origin wherever you mount it. Narrow that with
52
+ [`config.worker_scope`](configuration.md#worker_scope) if you need to.
53
+
54
+ The mount also exposes:
55
+
56
+ | Path | What |
57
+ |---|---|
58
+ | `/offline` | The [offline settings page](#the-offline-settings-page) |
59
+ | `/offline/service-worker.js` | The worker script |
60
+ | `/offline/pack` | The precache manifest JSON |
61
+
62
+ The worker script and the manifest are never intercepted — caching either would strand the
63
+ app on a stale copy of the thing meant to refresh it. The offline settings page is ordinary HTML; list
64
+ it in `cache_as_you_go` if you want it reachable offline.
65
+
66
+ ## 3. Register the Stimulus controller
67
+
68
+ ```js
69
+ // app/javascript/controllers/index.js
70
+ import ColdwireCacheController from "coldwire"
71
+ application.register("coldwire-cache", ColdwireCacheController)
72
+ ```
73
+
74
+ Coldwire pins `"coldwire"` into your importmap itself, so there is nothing to add to
75
+ `config/importmap.rb`.
76
+
77
+ ## 4. Add the tag to your layout
78
+
79
+ ```erb
80
+ <%# app/views/layouts/application.html.erb, inside <head> %>
81
+ <%= coldwire_service_worker_tag %>
82
+ ```
83
+
84
+ This is what registers the worker. A page without the tag does not cache or sync. The helper
85
+ honours [`config.register_if`](configuration.md#register_if), so you can keep the tag in the
86
+ layout and still skip registration for some requests.
87
+
88
+ ## 5. Create an initializer
89
+
90
+ ```ruby
91
+ # config/initializers/coldwire.rb
92
+ Coldwire.configure do |config|
93
+ config.auto_sync do |sync|
94
+ sync.enabled = false
95
+ sync.precache_urls = -> { [] }
96
+ end
97
+ end
98
+ ```
99
+
100
+ Every option has a working default. The full list, and what each one does, is in
101
+ [Configuration](configuration.md).
102
+
103
+ ## What to set first
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.
108
+
109
+ ```ruby
110
+ config.cache_identity = -> { current_user&.id }
111
+ ```
112
+
113
+ **`auto_sync`**, if there are pages worth having before anyone visits them. Off by default,
114
+ because background fetching is somebody's data plan.
115
+
116
+ ```ruby
117
+ config.auto_sync do |sync|
118
+ sync.enabled = true
119
+ sync.precache_urls = -> { Article.published.map { |a| article_path(a) } }
120
+ end
121
+ ```
122
+
123
+ **`never_cache`**, for auth and admin. Put those paths here, not in `never_intercept` — they
124
+ are not the same setting, and they fail very differently offline. See
125
+ [`never_cache`](configuration.md#never_cache) and
126
+ [`never_intercept`](configuration.md#never_intercept).
127
+
128
+ **Your cold-boot URL must be cacheable.** Whatever URL the app loads at launch has to be
129
+ something the cache can hold. A login path usually is not: signed in, it is a `302` to the
130
+ app root, and a redirect is never cached. Boot into a real page instead; signed out it still
131
+ redirects to login, so nothing about the online flow changes.
132
+
133
+ ## The offline settings page
134
+
135
+ Mounted at the engine root — `/offline` with the mount above. It inherits your
136
+ `ApplicationController`, so it picks up your layout, authentication, and helpers.
137
+
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.
140
+ Turning offline support off asks first, then deletes what is stored and hides the rest of the
141
+ page. It sets `content_for :title` to `"Offline settings"` — yield that in your layout's
142
+ `<title>` (and any native title bar that reads it) rather than expecting an on-page heading.
143
+ Put it behind whatever authentication you use by wrapping the route, or override
144
+ `app/views/coldwire/caches/show.html.erb`.
145
+
146
+ <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-cached.png" alt="Offline settings: every cached entry, with search, sort, and delete" width="280">
149
+ </p>
150
+
151
+ To reach it offline, list it in `cache_as_you_go` like any other page. **Sync now** talks to
152
+ 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
+ under Inspect cache, closed until you open it.
155
+
156
+ ## Hotwire Native on iOS
157
+
158
+ Service workers only run in `WKWebView` when navigation is limited to app-bound domains:
159
+
160
+ ```swift
161
+ Hotwire.config.makeCustomWebView = { config in
162
+ config.limitsNavigationsToAppBoundDomains = true
163
+ return WKWebView(frame: .zero, configuration: config)
164
+ }
165
+ ```
166
+
167
+ with every domain you navigate to listed under `WKAppBoundDomains` in `Info.plist`. **Apple
168
+ caps that list at 10 entries**, and an eleventh is silently dropped — which disables
169
+ app-bound mode and takes service workers with it.
170
+
171
+ ## Optional next steps
172
+
173
+ - 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)
175
+ and [`cache_ranges`](configuration.md#cache_ranges)
176
+ - Offer large files for download with [`cache_archives`](configuration.md#cache_archives)
177
+ - Override the offline fallback by creating
178
+ `app/views/coldwire/service_worker/offline_page.html.erb` (and
179
+ `offline_frame.html.erb` for frames) in your app. See
180
+ [The offline page](#the-offline-page) for what those templates have to keep.
181
+
182
+ ## The offline page
183
+
184
+ When the network is down and there is no cached copy of the page, Coldwire serves this
185
+ fallback instead of letting the request fail. It is a `200` that boots Turbo, which is
186
+ what lets Hotwire Native render it at all — a `503` or a plain-HTML page would show the
187
+ SDK's error screen instead.
188
+
189
+ <p align="center">
190
+ <img src="images/offline-fallback.png" alt="The offline fallback: You're offline. This page isn't available offline. Reconnect and try again." width="280">
191
+ </p>
192
+
193
+ It carries its own styles and needs no configuration. It deliberately does not pull in
194
+ your stylesheet: a fallback that depends on the cache being healthy is a fallback that
195
+ fails when it is needed. Cached pages still look like your app; this is only for URLs
196
+ nobody has, or that `never_cache` refused to store.
197
+
198
+ **Try again** retries the URL this page stood in for. The template is baked when the
199
+ worker is built, so it cannot know that URL — the page uses `href=""` plus
200
+ `data-turbo="false"` so the browser navigates to wherever it is being shown.
201
+
202
+ Override either template by creating it in your own app:
203
+
204
+ | Path | Renders |
205
+ |---|---|
206
+ | `app/views/coldwire/service_worker/offline_page.html.erb` | The full-page fallback |
207
+ | `app/views/coldwire/service_worker/offline_frame.html.erb` | The inside of the fallback `<turbo-frame>` |
208
+
209
+ Both are rendered at worker-build time and embedded in the script, so they are plain markup —
210
+ no request context, no helpers that need a current user. Three things to keep in the page:
211
+
212
+ - **CSS in the body, scoped.** Turbo's head merge copies new `<style>` elements into the app
213
+ and never removes them, so a `<style>` in the head outlives the offline page and restyles
214
+ everything after it.
215
+ - **The Turbo import**, alone rather than your app entry point — offline, every module in that
216
+ graph would have to be cached for it to evaluate, and one miss means no Turbo.
217
+ - **`<meta name="turbo-cache-control" content="no-cache">`**, or Turbo snapshots the offline
218
+ page and can restore it after you are back online.
@@ -0,0 +1,44 @@
1
+ (function () {
2
+ if (window.Coldwire) return
3
+
4
+ var root = document.documentElement
5
+
6
+ window.Coldwire = {
7
+ // True when the page you are looking at did not come from the network: either it
8
+ // was served out of the cache, or force offline is on. Not `navigator.onLine`,
9
+ // which a web view reports unreliably in both directions.
10
+ isOffline: function () {
11
+ return root.hasAttribute("data-coldwire-offline") || this.isForcedOffline()
12
+ },
13
+
14
+ // The switch on the offline settings page. Separate because it is a choice rather than a
15
+ // condition: worth telling a user apart from having no signal.
16
+ isForcedOffline: function () {
17
+ return window.coldwireStore.on(window.coldwireStore.keys.forced)
18
+ },
19
+
20
+ // The Offline support switch. Separate from register_if, which is the app's decision;
21
+ // this is the person's, remembered on the device.
22
+ isCachingEnabled: function () {
23
+ return window.coldwireStore.cachingOn()
24
+ },
25
+
26
+ // When the page in front of you was cached, or null if it came from the network.
27
+ cachedAt: function () {
28
+ var at = root.getAttribute("data-coldwire-cached-at")
29
+ var seconds = at ? Number(at) : NaN
30
+
31
+ return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : null
32
+ },
33
+
34
+ // Fires on every Turbo visit and whenever force offline is toggled, so a map can
35
+ // put its remote sources back without the page being reloaded. Returns the
36
+ // unsubscribe, because a Stimulus controller that disconnects needs one.
37
+ onChange: function (handler) {
38
+ var listener = function (event) { handler(event.detail) }
39
+ document.addEventListener("coldwire:change", listener)
40
+
41
+ return function () { document.removeEventListener("coldwire:change", listener) }
42
+ }
43
+ }
44
+ })();
@@ -0,0 +1,13 @@
1
+ (function () {
2
+ try {
3
+ var name = COLDWIRE.userAgentCookie
4
+ var value = encodeURIComponent(navigator.userAgent)
5
+ // Rewritten only when it has changed — an app update, or a first run.
6
+ if (document.cookie.indexOf(name + "=" + value) !== -1) return
7
+
8
+ document.cookie = name + "=" + value + "; path=/; max-age=31536000; samesite=lax"
9
+ } catch (error) {
10
+ // Cookies refused. The worker's requests will look like a browser's, which is
11
+ // where this started.
12
+ }
13
+ })();
@@ -0,0 +1,17 @@
1
+ (function () {
2
+ if (!("serviceWorker" in navigator)) return
3
+
4
+ function apply() {
5
+ if (!window.coldwireStore.cachingOn()) return
6
+ if (!window.coldwireStore.on(window.coldwireStore.keys.forced)) return
7
+
8
+ navigator.serviceWorker.ready.then(function (registration) {
9
+ if (registration.active) {
10
+ registration.active.postMessage({ type: "setForcedOffline", value: true })
11
+ }
12
+ })
13
+ }
14
+
15
+ document.addEventListener("turbo:load", apply)
16
+ apply()
17
+ })();
@@ -0,0 +1,28 @@
1
+ (function () {
2
+ var identity = COLDWIRE.identity
3
+ var store = window.coldwireStore
4
+ try {
5
+ var key = store.keys.identity
6
+ var previous = store.get(key)
7
+ if (previous === identity) return
8
+
9
+ // No stored identity is not a change of identity — it is a browser that has not
10
+ // been told yet. localStorage and the cache store are evicted independently, so
11
+ // treating null as "somebody else" would destroy a perfectly good cache the first
12
+ // time localStorage came back empty.
13
+ if (previous === null) {
14
+ store.set(key, identity)
15
+ return
16
+ }
17
+
18
+ // A real change, but never discard the cache while it is the only thing holding
19
+ // the app up. Leave the stored identity alone too, so the mismatch is still there
20
+ // to act on once there is a connection to refill from.
21
+ if (!navigator.onLine) return
22
+
23
+ store.set(key, identity)
24
+ if ("caches" in window) caches.delete(COLDWIRE.cacheName)
25
+ } catch (error) {
26
+ console.warn("[coldwire] could not reconcile cache identity", error)
27
+ }
28
+ })();
@@ -0,0 +1,35 @@
1
+ (function () {
2
+ // Anything watching Coldwire.onChange hears about it here, after the attributes are
3
+ // in step with the page that was just rendered.
4
+ function announce() {
5
+ document.dispatchEvent(new CustomEvent("coldwire:change", {
6
+ detail: {
7
+ offline: window.Coldwire ? window.Coldwire.isOffline() : false,
8
+ forced: window.Coldwire ? window.Coldwire.isForcedOffline() : false,
9
+ cachedAt: window.Coldwire ? window.Coldwire.cachedAt() : null
10
+ }
11
+ }))
12
+ }
13
+
14
+ function sync() {
15
+ var meta = document.querySelector('meta[name="coldwire-offline"]')
16
+ var root = document.documentElement
17
+ if (meta) {
18
+ root.setAttribute("data-coldwire-offline", "")
19
+ var at = meta.getAttribute("content")
20
+ if (at) {
21
+ root.setAttribute("data-coldwire-cached-at", at)
22
+ } else {
23
+ root.removeAttribute("data-coldwire-cached-at")
24
+ }
25
+ } else {
26
+ root.removeAttribute("data-coldwire-offline")
27
+ root.removeAttribute("data-coldwire-cached-at")
28
+ }
29
+
30
+ announce()
31
+ }
32
+
33
+ document.addEventListener("turbo:load", sync)
34
+ announce()
35
+ })();
@@ -0,0 +1,19 @@
1
+ if ("serviceWorker" in navigator) {
2
+ window.coldwireRegister = function () {
3
+ return navigator.serviceWorker
4
+ .register(COLDWIRE.workerPath, { scope: COLDWIRE.workerScope })
5
+ .catch(function (error) { console.warn("[coldwire] registration failed", error) })
6
+ }
7
+
8
+ window.coldwireUnregister = function () {
9
+ return navigator.serviceWorker.getRegistration(COLDWIRE.workerScope).then(function (registration) {
10
+ if (registration) return registration.unregister()
11
+ }).catch(function (error) { console.warn("[coldwire] unregister failed", error) })
12
+ }
13
+
14
+ if (window.coldwireStore.cachingOn()) {
15
+ window.coldwireRegister()
16
+ } else {
17
+ window.coldwireUnregister()
18
+ }
19
+ }
@@ -0,0 +1,69 @@
1
+ (function () {
2
+ if (window.coldwireStore) return
3
+
4
+ var memory = {}
5
+
6
+ window.coldwireStore = {
7
+ keys: {
8
+ identity: "coldwire-identity",
9
+ forced: "coldwire-forced",
10
+ syncedAt: "coldwire-synced-at",
11
+ claim: "coldwire-sync-claim",
12
+ // Set only when somebody turns automatic syncing off, so an unset store — a
13
+ // fresh device, a cleared one — means on, which is what the app configured.
14
+ syncOff: "coldwire-sync-off",
15
+ // "1" or "0" once somebody has used the Offline support switch. Unset follows
16
+ // COLDWIRE.cachingEnabledByDefault, so a fresh device gets the app's default.
17
+ caching: "coldwire-caching",
18
+ // The URL list on the settings page. Closed unless they have opened it.
19
+ inspect: "coldwire-inspect"
20
+ },
21
+
22
+ get: function (key) {
23
+ try {
24
+ var value = window.localStorage.getItem(key)
25
+ if (value !== null) return value
26
+ } catch (error) {
27
+ // Private mode and the like. Fall through to what this page remembers.
28
+ }
29
+
30
+ return key in memory ? memory[key] : null
31
+ },
32
+
33
+ set: function (key, value) {
34
+ memory[key] = String(value)
35
+
36
+ try {
37
+ window.localStorage.setItem(key, String(value))
38
+ } catch (error) {
39
+ // Nothing to do. The value is still in memory for as long as this page lives.
40
+ }
41
+ },
42
+
43
+ // A timestamp or a counter, and zero for anything missing or nonsensical — which
44
+ // for a deadline means "in the past", and that is the right answer for one that
45
+ // was never recorded.
46
+ number: function (key) {
47
+ var value = Number(this.get(key))
48
+
49
+ return Number.isFinite(value) && value > 0 ? value : 0
50
+ },
51
+
52
+ on: function (key) {
53
+ return this.get(key) === "1"
54
+ },
55
+
56
+ toggle: function (key, value) {
57
+ this.set(key, value ? "1" : "0")
58
+ },
59
+
60
+ // The Offline support switch: an explicit choice beats the configured default, and no
61
+ // choice yet means the default.
62
+ cachingOn: function () {
63
+ var stored = this.get(this.keys.caching)
64
+ if (stored === null) return window.COLDWIRE.cachingEnabledByDefault !== false
65
+
66
+ return stored === "1"
67
+ }
68
+ }
69
+ })();
@@ -0,0 +1,122 @@
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 leaves another timer behind.
4
+ if (window.__coldwireAutoSync) return
5
+ window.__coldwireAutoSync = true
6
+
7
+ var store = window.coldwireStore
8
+ var keys = store.keys
9
+ var fallbackInterval = COLDWIRE.syncInterval
10
+
11
+ // setTimeout holds its delay in a signed 32-bit integer and fires immediately on anything
12
+ // larger — about 24.8 days. A longer wait wakes early and schedules the remainder.
13
+ var maxDelay = 2147483647
14
+ // How long a started run may go quiet before it is presumed dead.
15
+ var silence = 30000
16
+ // A native app is several web views at once, all reading the same deadline. Only the
17
+ // visible one holds a timer, and whoever gets there first claims the run for this long.
18
+ var claimLife = 10000
19
+ var timer = null
20
+
21
+ function interval() {
22
+ // The last meta, not the first: Turbo appends what a visit brought and clears the old
23
+ // head elements after, so mid-merge querySelector hands back the previous page's value.
24
+ var metas = document.querySelectorAll('meta[name="coldwire-sync-interval"]')
25
+ var meta = metas.length ? metas[metas.length - 1] : null
26
+ var seconds = meta ? Number(meta.getAttribute("content")) : NaN
27
+
28
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : fallbackInterval
29
+ }
30
+
31
+ // Zero — never synced — is in the past, which is the right answer.
32
+ function dueAt() {
33
+ var last = store.number(keys.syncedAt)
34
+
35
+ return last ? last + interval() : 0
36
+ }
37
+
38
+ function schedule(delay) {
39
+ window.clearTimeout(timer)
40
+ timer = null
41
+ if (document.hidden) return
42
+ if (store.on(keys.syncOff)) return
43
+ if (!store.cachingOn()) return
44
+ if (typeof delay !== "number") delay = Math.max(0, dueAt() - Date.now())
45
+ timer = window.setTimeout(fire, Math.min(delay, maxDelay))
46
+ }
47
+
48
+ // Expiry rather than release: a page closed mid-sync must not lock the others out.
49
+ function claim() {
50
+ var held = store.number(keys.claim)
51
+ if (held && Date.now() - held < claimLife) return false
52
+
53
+ store.set(keys.claim, Date.now())
54
+
55
+ return true
56
+ }
57
+
58
+ function fire() {
59
+ // The offline settings page draws a countdown and drives its own sync. Two clocks on one page
60
+ // cannot be kept honest, so stand down.
61
+ if (window.__coldwireSyncOwnedByPage) return schedule(interval())
62
+ if (document.hidden) return
63
+ if (store.on(keys.syncOff)) return
64
+ if (!store.cachingOn()) return
65
+ if (store.on(keys.forced)) return schedule(interval())
66
+ // Another page may have synced while this one slept, or the wait may have been clamped.
67
+ if (Date.now() < dueAt()) return schedule()
68
+ if (!("serviceWorker" in navigator)) return
69
+ if (!claim()) return schedule(interval())
70
+
71
+ // A run that never reports back must not park this page forever.
72
+ schedule(silence)
73
+
74
+ navigator.serviceWorker.ready.then(function (registration) {
75
+ var worker = registration.active || navigator.serviceWorker.controller
76
+ if (!worker) return
77
+
78
+ // On a port this page owns, so the outcome cannot be missed the way a broadcast can.
79
+ // A page that navigates away mid-run records nothing, which is honest: the stamp stays
80
+ // old and the next page picks the work up from what is actually in the cache.
81
+ var channel = new MessageChannel()
82
+ channel.port1.onmessage = function (event) { settle(event.data) }
83
+ worker.postMessage({ type: "sync" }, [ channel.port2 ])
84
+ })
85
+ }
86
+
87
+ function settle(result) {
88
+ // Nothing was attempted — no connection, or force offline. Leave the clock alone so it
89
+ // keeps saying when the cache was last actually brought up to date.
90
+ if (result && result.offline) return schedule(interval())
91
+
92
+ // Record the pass even if some of the manifest would not fetch. Requiring every URL to
93
+ // succeed let one bad entry stop the clock for good: the app said "never synced" and
94
+ // retried as fast as it could, because a deadline in the past is always due.
95
+ store.set(keys.syncedAt, (result && result.finishedAt) || Date.now())
96
+ schedule()
97
+ }
98
+
99
+ if ("serviceWorker" in navigator) {
100
+ // A ServiceWorkerContainer starts with its message queue disabled, and addEventListener
101
+ // alone does not enable it. Without this a page never hears a word the worker says.
102
+ if (navigator.serviceWorker.startMessages) navigator.serviceWorker.startMessages()
103
+
104
+ // Keeps the retry at bay while a run is visibly working; the clock is settled by the
105
+ // reply above.
106
+ navigator.serviceWorker.addEventListener("message", function (event) {
107
+ var data = event.data
108
+ if (!data || data.type !== "coldwire:sync") return
109
+ if (data.state !== "finished") schedule(silence)
110
+ })
111
+ }
112
+
113
+ // Deliberately not gated on navigator.onLine: a web view reports it unreliably, and a
114
+ // false negative there would mean syncing never happens at all.
115
+ document.addEventListener("turbo:load", function () { schedule() })
116
+ window.addEventListener("online", function () { schedule() })
117
+ // A backgrounded web view freezes its timers, so recompute on the way back — and cancel on
118
+ // the way out, so a hidden tab holds nothing.
119
+ document.addEventListener("visibilitychange", function () { schedule() })
120
+
121
+ schedule()
122
+ })();
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coldwire
4
+ # Putting the app's user agent back on the requests its own service worker makes.
5
+ #
6
+ # A request carries the agent of whoever makes it, and a worker is not the page. On Android
7
+ # that is the difference between the app and a browser: Hotwire Native sets the agent on the
8
+ # web view, and `android.webkit.ServiceWorkerWebSettings` has no equivalent, so nothing the
9
+ # app configures reaches a worker's fetches — and `fetch` cannot set one, because Chromium
10
+ # drops a User-Agent given to it. Left alone, everything the cache holds comes back rendered
11
+ # for a browser: navigation chrome a native app hides, and none of the markup that depends on
12
+ # knowing it is the app.
13
+ #
14
+ # A cookie is the one thing the browser attaches by itself to every same-origin request,
15
+ # whoever makes it. The page writes this web view's own agent into one, and this reads it
16
+ # back before anything else in the stack runs.
17
+ #
18
+ # No new trust: the user agent was always the client's to state, and a cookie is as much the
19
+ # client's as the header is. It is written by a page in the same browser profile, which is
20
+ # the same client the request comes from.
21
+ class ClientUserAgent
22
+ COOKIE = "coldwire-user-agent"
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ claimed = claimed_user_agent(env)
30
+ env["HTTP_USER_AGENT"] = claimed if claimed.present?
31
+
32
+ @app.call(env)
33
+ end
34
+
35
+ private
36
+
37
+ def claimed_user_agent(env)
38
+ cookie = env["HTTP_COOKIE"]
39
+ return if cookie.blank?
40
+
41
+ value = Rack::Utils.parse_cookies_header(cookie)[COOKIE]
42
+ return if value.blank?
43
+
44
+ # A user agent is a header value: anything that cannot be one is not one.
45
+ value.match?(/\A[[:print:]]{1,512}\z/) ? value : nil
46
+ end
47
+ end
48
+ end