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
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
|