coldwire-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/VERSION +1 -0
- data/app/assets/javascripts/coldwire/archives.js +52 -0
- data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
- data/app/assets/javascripts/coldwire/entries.js +55 -0
- data/app/assets/javascripts/coldwire/format.js +60 -0
- data/app/assets/javascripts/coldwire/worker.js +35 -0
- data/app/controllers/coldwire/application_controller.rb +21 -0
- data/app/controllers/coldwire/caches_controller.rb +33 -0
- data/app/controllers/coldwire/service_worker_controller.rb +26 -0
- data/app/helpers/coldwire/service_worker_helper.rb +59 -0
- data/app/views/coldwire/caches/show.html.erb +304 -0
- data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
- data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
- data/app/views/coldwire/service_worker/show.js.erb +66 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +8 -0
- data/docs/README.md +19 -0
- data/docs/configuration.md +539 -0
- data/docs/how-it-works.md +64 -0
- data/docs/images/offline-fallback.png +0 -0
- data/docs/images/offline-settings-cached.png +0 -0
- data/docs/images/offline-settings.png +0 -0
- data/docs/setup.md +218 -0
- data/lib/coldwire/client/api.js +44 -0
- data/lib/coldwire/client/cookie.js +13 -0
- data/lib/coldwire/client/forced.js +17 -0
- data/lib/coldwire/client/identity.js +28 -0
- data/lib/coldwire/client/marker.js +35 -0
- data/lib/coldwire/client/register.js +19 -0
- data/lib/coldwire/client/store.js +69 -0
- data/lib/coldwire/client/sync.js +122 -0
- data/lib/coldwire/client_user_agent.rb +48 -0
- data/lib/coldwire/configuration.rb +319 -0
- data/lib/coldwire/debug.css +197 -0
- data/lib/coldwire/engine.rb +34 -0
- data/lib/coldwire/source.rb +50 -0
- data/lib/coldwire/version.rb +11 -0
- data/lib/coldwire/worker/archives.js +158 -0
- data/lib/coldwire/worker/events.js +79 -0
- data/lib/coldwire/worker/inspect.js +81 -0
- data/lib/coldwire/worker/ranges.js +136 -0
- data/lib/coldwire/worker/rules.js +100 -0
- data/lib/coldwire/worker/serve.js +226 -0
- data/lib/coldwire/worker/sync.js +234 -0
- data/lib/coldwire-rails.rb +5 -0
- data/lib/coldwire.rb +55 -0
- data/lib/generators/coldwire/install/install_generator.rb +110 -0
- data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
- data/lib/tasks/coldwire.rake +8 -0
- metadata +113 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Coldwire
|
|
6
|
+
# Host apps tune Coldwire through `Coldwire.configure`. Everything here has a working
|
|
7
|
+
# default except `precache_urls`, which only the host app can know.
|
|
8
|
+
class Configuration
|
|
9
|
+
# Name of the Cache API cache. Bump it to invalidate everything at once.
|
|
10
|
+
attr_accessor :cache_name
|
|
11
|
+
|
|
12
|
+
# Scope the worker claims. The worker is served from the engine mount point, so the
|
|
13
|
+
# response also sends `Service-Worker-Allowed` to widen it past that directory.
|
|
14
|
+
attr_accessor :worker_scope
|
|
15
|
+
|
|
16
|
+
# The importmap module the offline page loads. Hotwire Native reports "Turbo is not
|
|
17
|
+
# present" for any page where `window.Turbo` never appears, so the fallback has to boot
|
|
18
|
+
# Turbo — and Turbo alone, since one uncached module would fail the whole graph. nil if
|
|
19
|
+
# you are not on importmap-rails; load it yourself in the template instead.
|
|
20
|
+
attr_accessor :offline_import
|
|
21
|
+
|
|
22
|
+
# Stamp HTML served from cache with `data-coldwire-offline` and `data-coldwire-cached-at`,
|
|
23
|
+
# plus a <meta> that survives Turbo's head merge, so a page can say it is stale.
|
|
24
|
+
attr_accessor :mark_cached_pages
|
|
25
|
+
|
|
26
|
+
# Treat "/map" and "/map?zoom=9" as one cached page, matching and storing. Blunt: it also
|
|
27
|
+
# collapses `/search?q=`, so a cached result set can answer a different query.
|
|
28
|
+
attr_accessor :ignore_query_params
|
|
29
|
+
|
|
30
|
+
# What the offline settings page pings to tell online from offline, because navigator.onLine only
|
|
31
|
+
# reports whether an interface is up. Never intercepted — a probe answered from the cache
|
|
32
|
+
# would resolve with the network down, which is precisely backwards.
|
|
33
|
+
attr_accessor :probe_path
|
|
34
|
+
|
|
35
|
+
# Paths the worker does not touch at all, as prefix strings; the engine's own are added
|
|
36
|
+
# for you. The request goes straight to the network and so fails outright offline, showing
|
|
37
|
+
# the SDK's error screen rather than your offline page — which is what you want for a
|
|
38
|
+
# health check, and almost never what you want for a page.
|
|
39
|
+
#
|
|
40
|
+
# Not the same as `never_cache`, which is about storing. Compare:
|
|
41
|
+
#
|
|
42
|
+
# never_intercept the worker stands aside. No cache, no offline page, no fallback.
|
|
43
|
+
# never_cache the worker still answers, and can still show your offline page.
|
|
44
|
+
# It just never stores the response.
|
|
45
|
+
#
|
|
46
|
+
# So auth pages, admin, anything sensitive: `never_cache`. A probe the worker must
|
|
47
|
+
# never be able to answer from a cache: `never_intercept`.
|
|
48
|
+
attr_accessor :never_intercept
|
|
49
|
+
|
|
50
|
+
# What browsing stores. Strings are route patterns — "/sites/:id" matches that shape and
|
|
51
|
+
# nothing beneath it, "/assets/*" takes everything under it — and Regexps are tested
|
|
52
|
+
# against the path by JavaScript's RegExp, so write `^`/`$` rather than `\A`/`\z`.
|
|
53
|
+
#
|
|
54
|
+
# These are the pages worth keeping as somebody moves through the app. What one of them
|
|
55
|
+
# needs in order to render — its stylesheets, its scripts, its images — is stored with it
|
|
56
|
+
# whether or not those match anything here, because a page held without them is the
|
|
57
|
+
# offline equivalent of not holding it at all.
|
|
58
|
+
#
|
|
59
|
+
# Defaults to "/*", which is every path. An empty list stores nothing by browsing. It
|
|
60
|
+
# does not govern the precache manifest: listing a URL there is an explicit instruction.
|
|
61
|
+
attr_reader :cache_as_you_go
|
|
62
|
+
|
|
63
|
+
def cache_as_you_go=(patterns)
|
|
64
|
+
@cache_as_you_go = validate_patterns(patterns, :cache_as_you_go)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Never stored, by any route in: not by browsing, not as a subresource of a page that
|
|
68
|
+
# references it, not by the precache manifest. The one veto.
|
|
69
|
+
attr_reader :never_cache
|
|
70
|
+
|
|
71
|
+
def never_cache=(patterns)
|
|
72
|
+
@never_cache = validate_patterns(patterns, :never_cache)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether a page registers the worker at all — and so whether it caches or syncs anything.
|
|
76
|
+
# Evaluated in the view, so `request` and `current_user` are both in scope:
|
|
77
|
+
#
|
|
78
|
+
# config.register_if = -> { hotwire_native_app? && current_user.present? }
|
|
79
|
+
attr_writer :register_if
|
|
80
|
+
|
|
81
|
+
# Default for the Offline support switch on the offline settings page. People can turn
|
|
82
|
+
# it off there, which deletes what is stored and stops anything new being saved. Unset
|
|
83
|
+
# on a device follows this; a choice they have already made is remembered.
|
|
84
|
+
attr_accessor :caching_enabled_by_default
|
|
85
|
+
|
|
86
|
+
# Who the cache belongs to, usually the signed-in user's id. When it changes between page
|
|
87
|
+
# loads the cache is dropped, which is what makes signing out and switching accounts safe:
|
|
88
|
+
# cached pages hold whatever the previous session could see.
|
|
89
|
+
attr_writer :cache_identity
|
|
90
|
+
|
|
91
|
+
# Origins besides your own that the worker may cache. Each has to send CORS headers naming
|
|
92
|
+
# your app, or the response arrives opaque — status 0, no headers, no readable body — and
|
|
93
|
+
# there is nothing worth storing. Ranged sources must also expose Content-Range.
|
|
94
|
+
attr_reader :cache_origins
|
|
95
|
+
|
|
96
|
+
def cache_origins=(origins)
|
|
97
|
+
@cache_origins = Array(origins).map { |origin| validate_origin(origin) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# URLs whose Range requests are cached piece by piece, keyed by the range — for a large
|
|
101
|
+
# immutable archive read a slice at a time, the slices you actually read are a rounding
|
|
102
|
+
# error next to the file. Same patterns as the lists above.
|
|
103
|
+
attr_reader :cache_ranges
|
|
104
|
+
|
|
105
|
+
def cache_ranges=(patterns)
|
|
106
|
+
@cache_ranges = validate_patterns(patterns, :cache_ranges) || Array(patterns)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Large files somebody can choose to keep, each described well enough for the offline settings page
|
|
110
|
+
# to offer it without knowing what it is:
|
|
111
|
+
#
|
|
112
|
+
# config.cache_archives = [
|
|
113
|
+
# { url: "https://tiles.example.com/basemap.pmtiles", title: "Offline map",
|
|
114
|
+
# description: "The whole coast, rather than only the places you have opened." }
|
|
115
|
+
# ]
|
|
116
|
+
#
|
|
117
|
+
# A bare URL works too, with the filename as the title. Nothing downloads on its own:
|
|
118
|
+
# hundreds of megabytes over somebody's connection is their decision.
|
|
119
|
+
attr_reader :cache_archives
|
|
120
|
+
|
|
121
|
+
def cache_archives=(archives)
|
|
122
|
+
@cache_archives = Array(archives).map { |archive| normalize_archive(archive) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Just the URLs, for the worker — it downloads and serves; the words are the page's job.
|
|
126
|
+
def cache_archive_urls
|
|
127
|
+
cache_archives.map { |archive| archive[:url] }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Keeping the cache current on its own. Grouped because these only mean anything together:
|
|
131
|
+
# a manifest with no interval is never fetched, an interval with no manifest has nothing
|
|
132
|
+
# to fetch.
|
|
133
|
+
#
|
|
134
|
+
# config.auto_sync do |sync|
|
|
135
|
+
# sync.enabled = true
|
|
136
|
+
# sync.precache_urls = -> { Site.published.map { |site| site_path(site) } }
|
|
137
|
+
# end
|
|
138
|
+
def auto_sync
|
|
139
|
+
@auto_sync ||= AutoSync.new
|
|
140
|
+
yield(@auto_sync) if block_given?
|
|
141
|
+
|
|
142
|
+
@auto_sync
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# WebKit has no Background Sync, Periodic Background Sync or Background Fetch, so nothing
|
|
146
|
+
# can wake a worker. What a page load can do is hand work to one, which then runs on
|
|
147
|
+
# without it — so syncing is triggered by an open page and paced, not scheduled.
|
|
148
|
+
class AutoSync
|
|
149
|
+
# Off unless asked for. Background fetching is a decision about somebody's data plan.
|
|
150
|
+
attr_accessor :enabled
|
|
151
|
+
|
|
152
|
+
# The pages to keep cached, evaluated in the controller so route helpers and the current
|
|
153
|
+
# user are both available.
|
|
154
|
+
attr_accessor :precache_urls
|
|
155
|
+
|
|
156
|
+
# How long to leave between syncs.
|
|
157
|
+
attr_accessor :interval
|
|
158
|
+
|
|
159
|
+
# Refetch a manifest page once its copy is older than this. nil fetches only what is
|
|
160
|
+
# missing, so pages already cached are never noticed to have changed.
|
|
161
|
+
attr_accessor :max_age
|
|
162
|
+
|
|
163
|
+
# Lanes sharing a queue: sequential would take a round trip per URL, and all at once
|
|
164
|
+
# would stall the app's own requests behind hundreds of connections.
|
|
165
|
+
attr_accessor :concurrency
|
|
166
|
+
|
|
167
|
+
def initialize
|
|
168
|
+
@enabled = false
|
|
169
|
+
@precache_urls = -> { [] }
|
|
170
|
+
@interval = 6 * 60 * 60
|
|
171
|
+
@max_age = 7 * 24 * 60 * 60
|
|
172
|
+
@concurrency = 4
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def initialize
|
|
177
|
+
@cache_name = "coldwire"
|
|
178
|
+
@worker_scope = "/"
|
|
179
|
+
@probe_path = "/up"
|
|
180
|
+
@never_intercept = [ "/up" ]
|
|
181
|
+
@cache_as_you_go = [ "/*" ]
|
|
182
|
+
@never_cache = []
|
|
183
|
+
# Propshaft and Sprockets, Webpacker, Vite, and Active Storage's signed blob URLs —
|
|
184
|
+
# every one of them addressed by something that changes when the bytes do.
|
|
185
|
+
@mark_cached_pages = true
|
|
186
|
+
@offline_import = "@hotwired/turbo-rails"
|
|
187
|
+
@ignore_query_params = true
|
|
188
|
+
@register_if = -> { true }
|
|
189
|
+
@caching_enabled_by_default = true
|
|
190
|
+
@cache_identity = -> { nil }
|
|
191
|
+
@cache_origins = []
|
|
192
|
+
@cache_ranges = []
|
|
193
|
+
@cache_archives = []
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Evaluated in the view, so `request` and `current_user` are both in scope. A block that
|
|
197
|
+
# declares a parameter is handed the request as well, which keeps `->(request) { ... }`
|
|
198
|
+
# working for anyone who only cares about the headers.
|
|
199
|
+
def register?(view)
|
|
200
|
+
result = if @register_if.arity.zero?
|
|
201
|
+
view.instance_exec(&@register_if)
|
|
202
|
+
else
|
|
203
|
+
view.instance_exec(view.request, &@register_if)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
result ? true : false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# `view` is the view context, so a host can write `-> { current_user&.id }`.
|
|
210
|
+
def cache_identity(view)
|
|
211
|
+
view.instance_exec(&@cache_identity).to_s
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Fail here rather than when the worker script is rendered, so a bad pattern surfaces at
|
|
217
|
+
# boot instead of as a 500 that quietly takes caching down with it.
|
|
218
|
+
def validate_patterns(patterns, setting)
|
|
219
|
+
Array(patterns).each do |pattern|
|
|
220
|
+
next validate_path_pattern(pattern, setting) unless pattern.is_a?(Regexp)
|
|
221
|
+
|
|
222
|
+
# `\A` and friends are reflex for a Ruby developer, and JavaScript reads them as
|
|
223
|
+
# identity escapes — `\A` quietly becomes a literal "A" and the rule never matches.
|
|
224
|
+
if pattern.source.match?(/(?<!\\)\\[AzZ]/)
|
|
225
|
+
raise ArgumentError,
|
|
226
|
+
"Coldwire evaluates #{setting} patterns with JavaScript's RegExp, which reads " \
|
|
227
|
+
"\\A, \\z and \\Z as literal letters. Use ^ and $ instead: #{pattern.inspect}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
unsupported = []
|
|
231
|
+
unsupported << "x (extended)" if pattern.options.anybits?(Regexp::EXTENDED)
|
|
232
|
+
unsupported << "m (multiline)" if pattern.options.anybits?(Regexp::MULTILINE)
|
|
233
|
+
next if unsupported.empty?
|
|
234
|
+
|
|
235
|
+
raise ArgumentError,
|
|
236
|
+
"Coldwire evaluates #{setting} patterns with JavaScript's RegExp, which has no " \
|
|
237
|
+
"equivalent for #{unsupported.join(' and ')}: #{pattern.inspect}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# An origin and nothing more: no path, no trailing slash. Anything else silently fails to
|
|
242
|
+
# match a request's origin, which is the same quiet failure as a malformed path pattern.
|
|
243
|
+
def validate_origin(origin)
|
|
244
|
+
value = origin.to_s
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
uri = URI.parse(value)
|
|
248
|
+
rescue URI::InvalidURIError
|
|
249
|
+
uri = nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
unless uri&.scheme && uri.host && uri.path.to_s.empty? && uri.query.nil?
|
|
253
|
+
raise ArgumentError,
|
|
254
|
+
"Coldwire cache_origins takes bare origins like " \
|
|
255
|
+
"\"https://tiles.example.com\": #{origin.inspect}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
value
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A Hash with a url, or a bare URL string. Title falls back to the filename, which is a
|
|
262
|
+
# poor title but a better one than a blank card.
|
|
263
|
+
def normalize_archive(archive)
|
|
264
|
+
archive = { url: archive } unless archive.is_a?(Hash)
|
|
265
|
+
archive = archive.transform_keys(&:to_sym)
|
|
266
|
+
url = archive[:url].to_s
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
uri = URI.parse(url)
|
|
270
|
+
rescue URI::InvalidURIError
|
|
271
|
+
uri = nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
unless uri&.scheme && uri.host
|
|
275
|
+
raise ArgumentError,
|
|
276
|
+
"Coldwire cache_archives needs an absolute url for each entry: #{archive.inspect}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
{
|
|
280
|
+
url: url,
|
|
281
|
+
title: archive[:title].presence || File.basename(uri.path.to_s).presence || url,
|
|
282
|
+
description: archive[:description].presence
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Path patterns are route-shaped: literal segments, ":name" for exactly one segment, and a
|
|
287
|
+
# trailing "*" for the rest. Checked here because every mistake in this shape fails the
|
|
288
|
+
# same silent way — the rule simply never matches, and you find out when something you
|
|
289
|
+
# expected to be there offline is not.
|
|
290
|
+
def validate_path_pattern(pattern, setting)
|
|
291
|
+
path = pattern.to_s
|
|
292
|
+
|
|
293
|
+
unless path.start_with?("/")
|
|
294
|
+
raise ArgumentError,
|
|
295
|
+
"Coldwire #{setting} paths are matched from the root, so they start with a " \
|
|
296
|
+
"slash: #{pattern.inspect}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
parts = path.split("/").reject(&:empty?)
|
|
300
|
+
|
|
301
|
+
parts.each_with_index do |part, index|
|
|
302
|
+
next if part == "*" && index == parts.length - 1
|
|
303
|
+
|
|
304
|
+
if part == "*"
|
|
305
|
+
raise ArgumentError,
|
|
306
|
+
"Coldwire #{setting} \"*\" matches everything remaining, so it can only be " \
|
|
307
|
+
"the last segment: #{pattern.inspect}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
next if part.match?(/\A:[A-Za-z_]\w*\z/)
|
|
311
|
+
next if part.match?(/\A[^:*]+\z/)
|
|
312
|
+
|
|
313
|
+
raise ArgumentError,
|
|
314
|
+
"Coldwire #{setting} segment #{part.inspect} is not a literal, a \":name\", or " \
|
|
315
|
+
"a trailing \"*\": #{pattern.inspect}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
.coldwire { --coldwire-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m19.5 8.25-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");
|
|
2
|
+
max-width: 42rem; margin: 0 auto; padding: 0.75rem;
|
|
3
|
+
font-family: system-ui, -apple-system, sans-serif; color: #1f2937; }
|
|
4
|
+
.coldwire h1 { font-size: 1.5rem; margin: 0 0 1rem; }
|
|
5
|
+
.coldwire-card { border: 1px solid #e5e7eb; border-radius: 1rem; background: #fff;
|
|
6
|
+
padding: 1rem; margin-bottom: 0.75rem; }
|
|
7
|
+
.coldwire-card h2 { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
|
|
8
|
+
text-transform: uppercase; color: #9ca3af; margin: 0 0 0.75rem; }
|
|
9
|
+
|
|
10
|
+
/* Status: the one thing worth reading at a glance, so it gets the largest type on the
|
|
11
|
+
page and the colour. */
|
|
12
|
+
.coldwire-headline { display: flex; align-items: center; gap: 0.6rem;
|
|
13
|
+
font-size: 1.125rem; font-weight: 600; }
|
|
14
|
+
.coldwire-light { flex-shrink: 0; width: 0.6rem; height: 0.6rem; border-radius: 9999px;
|
|
15
|
+
background: #9ca3af; box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.18); }
|
|
16
|
+
.coldwire-light[data-state="online"] { background: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18); }
|
|
17
|
+
.coldwire-light[data-state="forced"] { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.20); }
|
|
18
|
+
.coldwire-light[data-state="offline"] { background: #dc2626; box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18); }
|
|
19
|
+
/* Grey, not red: switched off is not a fault. */
|
|
20
|
+
.coldwire-light[data-state="disabled"] { background: #9ca3af; box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.18); }
|
|
21
|
+
.coldwire-facts code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8125rem; }
|
|
22
|
+
.coldwire-facts { margin: 0.5rem 0 0; font-size: 0.875rem; color: #6b7280; line-height: 1.5; }
|
|
23
|
+
|
|
24
|
+
.coldwire-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
|
|
25
|
+
/* Offline support lives in the status card. The rule only applies while the Online/offline
|
|
26
|
+
row is showing — with support off that row is hidden and the switch is the whole card. */
|
|
27
|
+
.coldwire-head:not([hidden]) + .coldwire-setting { margin-top: 0.9rem; padding-top: 0.9rem;
|
|
28
|
+
border-top: 1px solid #e5e7eb; }
|
|
29
|
+
.coldwire-card hr { border: 0; border-top: 1px solid #e5e7eb; margin: 0.9rem 0; }
|
|
30
|
+
.coldwire-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
31
|
+
.coldwire-actions--spaced { margin-top: 0.75rem; }
|
|
32
|
+
|
|
33
|
+
.coldwire-button { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
|
|
34
|
+
cursor: pointer; border: 1px solid #d1d5db; border-radius: 0.5rem; background: #fff; color: #111827;
|
|
35
|
+
padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 600; line-height: 1.5rem; white-space: nowrap; }
|
|
36
|
+
.coldwire-button[disabled] { opacity: 0.6; cursor: default; }
|
|
37
|
+
/* `hidden` is a UA rule, and any author `display` beats it — so the Delete button, which
|
|
38
|
+
sets its own, ignored the attribute and showed on an archive nobody had downloaded.
|
|
39
|
+
Stated once for the whole page rather than per component that grows a display. */
|
|
40
|
+
.coldwire [hidden] { display: none !important; }
|
|
41
|
+
.coldwire-button--danger { color: #b91c1c; border-color: #fecaca; }
|
|
42
|
+
.coldwire-icon-button { width: 2.25rem; height: 2.25rem; padding: 0; flex-shrink: 0; }
|
|
43
|
+
/* Heroicons outline, which are drawn for 1.5. Set here rather than on each svg: CSS beats
|
|
44
|
+
presentation attributes, so a value here silently wins anyway. */
|
|
45
|
+
.coldwire-button svg { width: 1rem; height: 1rem; flex-shrink: 0; fill: none;
|
|
46
|
+
stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
47
|
+
.coldwire-icon-button svg { width: 1.2rem; height: 1.2rem; fill: none; stroke: currentColor;
|
|
48
|
+
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
49
|
+
/* Spins while a refresh is in flight — without it the button re-reads values that usually
|
|
50
|
+
look identical and seems to do nothing. */
|
|
51
|
+
.coldwire-icon-button[data-busy] svg { animation: coldwire-spin 0.7s linear infinite; }
|
|
52
|
+
|
|
53
|
+
.coldwire-spinner { width: 1rem; height: 1rem; flex-shrink: 0; border-radius: 9999px;
|
|
54
|
+
border: 2px solid #d1d5db; border-top-color: #374151; animation: coldwire-spin 0.7s linear infinite; }
|
|
55
|
+
@keyframes coldwire-spin { to { transform: rotate(360deg); } }
|
|
56
|
+
|
|
57
|
+
/* A real checkbox underneath, so it keeps its keyboard behaviour and its label. */
|
|
58
|
+
.coldwire-switch { position: relative; display: inline-flex; flex-shrink: 0;
|
|
59
|
+
width: 2.6rem; height: 1.55rem; }
|
|
60
|
+
.coldwire-switch input { position: absolute; inset: 0; width: 100%; height: 100%;
|
|
61
|
+
margin: 0; opacity: 0; cursor: pointer; }
|
|
62
|
+
.coldwire-switch span { position: absolute; inset: 0; pointer-events: none; border-radius: 9999px;
|
|
63
|
+
background: #d1d5db; transition: background-color 160ms ease; }
|
|
64
|
+
.coldwire-switch span::after { content: ""; position: absolute; top: 0.2rem; left: 0.2rem;
|
|
65
|
+
width: 1.15rem; height: 1.15rem; border-radius: 9999px; background: #fff;
|
|
66
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 160ms ease; }
|
|
67
|
+
/* Amber, matching the forced-offline light — the two say the same thing. */
|
|
68
|
+
.coldwire-switch input:checked + span { background: #f59e0b; }
|
|
69
|
+
/* Except where being on is the ordinary state rather than a deliberate degradation; green,
|
|
70
|
+
matching the online light. Same switch otherwise. */
|
|
71
|
+
.coldwire-switch--on input:checked + span { background: #16a34a; }
|
|
72
|
+
.coldwire-switch input:checked + span::after { transform: translateX(1.05rem); }
|
|
73
|
+
.coldwire-switch input:focus-visible + span { outline: 2px solid #2563eb; outline-offset: 2px; }
|
|
74
|
+
|
|
75
|
+
.coldwire-setting { display: flex; align-items: flex-start; gap: 0.85rem; cursor: pointer; }
|
|
76
|
+
/* A setting that leads a card, with facts under it rather than standing alone. */
|
|
77
|
+
.coldwire-setting + .coldwire-facts { margin-top: 0.9rem; }
|
|
78
|
+
.coldwire-setting-title { display: block; font-weight: 600; }
|
|
79
|
+
.coldwire-setting-note { display: block; margin-top: 0.15rem; font-size: 0.875rem; color: #6b7280; }
|
|
80
|
+
|
|
81
|
+
.coldwire-track { height: 0.375rem; width: 100%; overflow: hidden; border-radius: 9999px; background: #e5e7eb; }
|
|
82
|
+
.coldwire-bar { height: 100%; width: 100%; border-radius: 9999px; background: #374151; transition: width 200ms; }
|
|
83
|
+
.coldwire-pulse { animation: coldwire-fade 1.4s ease-in-out infinite; }
|
|
84
|
+
@keyframes coldwire-fade { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
85
|
+
.coldwire-progress-label { margin-top: 0.375rem; font-size: 0.75rem; color: #6b7280; }
|
|
86
|
+
|
|
87
|
+
/* Search and sort sit above the list rather than beside the heading: on a phone the two
|
|
88
|
+
controls need the full width between them, and the heading is only a label. */
|
|
89
|
+
.coldwire-filters { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
|
90
|
+
.coldwire-input, .coldwire-select { border: 1px solid #d1d5db; border-radius: 0.5rem;
|
|
91
|
+
background-color: #fff; color: #111827; padding: 0.375rem 0.5rem; font-size: 0.875rem;
|
|
92
|
+
line-height: 1.5rem; font-family: inherit; }
|
|
93
|
+
/* The search takes the slack; the select asks only for its own content. 0 min-width lets it
|
|
94
|
+
shrink inside the flex row instead of forcing the card wider than the screen. */
|
|
95
|
+
.coldwire-input { flex: 1 1 auto; min-width: 0; }
|
|
96
|
+
|
|
97
|
+
/* Safari stops drawing the native arrow once a select is given a background and a border,
|
|
98
|
+
so opt out of the native look entirely and draw one. Heroicons chevron-down, to match the
|
|
99
|
+
other icons on the page. */
|
|
100
|
+
.coldwire-select { flex: 0 0 auto; cursor: pointer;
|
|
101
|
+
appearance: none; -webkit-appearance: none;
|
|
102
|
+
padding-right: 1.9rem;
|
|
103
|
+
background-image: var(--coldwire-caret);
|
|
104
|
+
background-repeat: no-repeat;
|
|
105
|
+
background-position: right 0.55rem center;
|
|
106
|
+
background-size: 0.85rem; }
|
|
107
|
+
.coldwire-input:focus-visible, .coldwire-select:focus-visible { outline: 2px solid #6b7280;
|
|
108
|
+
outline-offset: 1px; }
|
|
109
|
+
|
|
110
|
+
/* The rule separates one download from the next; the first has nothing above it. */
|
|
111
|
+
.coldwire-archive { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; }
|
|
112
|
+
.coldwire-archive:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
|
|
113
|
+
.coldwire-archive-title { font-size: 0.9375rem; font-weight: 600; }
|
|
114
|
+
/* The buttons used to sit under a status line that spaced them; they lead now. */
|
|
115
|
+
.coldwire-archive .coldwire-actions { margin-top: 0.75rem; }
|
|
116
|
+
/* What pressing them got you, reading under them rather than above. */
|
|
117
|
+
.coldwire-archive-meta { margin-top: 0.6rem; font-size: 0.8125rem; }
|
|
118
|
+
/* Under the filter, because it answers what the filter asks. */
|
|
119
|
+
.coldwire-count { margin: 0 0 0.6rem; }
|
|
120
|
+
|
|
121
|
+
.coldwire-entries { list-style: none; margin: 0; padding: 0; max-height: 32rem; overflow-y: auto; }
|
|
122
|
+
.coldwire-entries li + li { margin-top: 0.5rem; }
|
|
123
|
+
.coldwire-entry { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
|
|
124
|
+
/* A button, not a div: the whole point is that it is tappable, and this way it is reachable
|
|
125
|
+
from a keyboard and announced as a control without any of that being reinvented. It takes
|
|
126
|
+
the slack and is allowed to shrink, so a long path ellipsises rather than pushing the
|
|
127
|
+
trash off the row. */
|
|
128
|
+
.coldwire-entry-text { flex: 1 1 auto; min-width: 0; display: block; text-align: left;
|
|
129
|
+
padding: 0; border: 0; background: none; font: inherit; color: inherit; cursor: pointer; }
|
|
130
|
+
.coldwire-entry-text:focus-visible { outline: 2px solid #6b7280; outline-offset: 2px;
|
|
131
|
+
border-radius: 0.25rem; }
|
|
132
|
+
.coldwire-entry-forget { flex: 0 0 auto; display: inline-flex; align-items: center;
|
|
133
|
+
justify-content: center; width: 2rem; height: 2rem; padding: 0; cursor: pointer;
|
|
134
|
+
border: 0; border-radius: 0.5rem; background: none; color: #9ca3af; }
|
|
135
|
+
.coldwire-entry-forget svg { width: 1rem; height: 1rem; fill: none; stroke: currentColor;
|
|
136
|
+
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
137
|
+
/* Quiet until you go for it: one of these sits on every row, and a column of red would
|
|
138
|
+
read as a warning rather than a control. */
|
|
139
|
+
.coldwire-entry-forget:hover, .coldwire-entry-forget:focus-visible { color: #b91c1c;
|
|
140
|
+
background: #fef2f2; }
|
|
141
|
+
.coldwire-entry-forget[disabled] { opacity: 0.5; cursor: default; }
|
|
142
|
+
.coldwire-entry-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
143
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem; color: #1f2937; }
|
|
144
|
+
.coldwire-entry-meta { margin-top: 0.125rem; font-size: 0.75rem; color: #6b7280; }
|
|
145
|
+
.coldwire-empty { color: #9ca3af; font-size: 0.875rem; }
|
|
146
|
+
/* Centred here rather than left to the browser. A modal <dialog> is centred by the UA's
|
|
147
|
+
`margin: auto`, and a host app's CSS reset routinely zeroes margin on everything —
|
|
148
|
+
Tailwind's preflight does — which drops the dialog into the top left corner. A definite
|
|
149
|
+
width and height are what let `margin: auto` centre on both axes. */
|
|
150
|
+
.coldwire-dialog { position: fixed; inset: 0; margin: auto;
|
|
151
|
+
width: min(28rem, calc(100vw - 2rem)); height: fit-content;
|
|
152
|
+
max-height: calc(100vh - 2rem); overflow: auto;
|
|
153
|
+
border: 0; border-radius: 1rem; padding: 1rem; background: #fff; color: #1f2937;
|
|
154
|
+
font-family: inherit; }
|
|
155
|
+
.coldwire-dialog::backdrop { background: rgba(17, 24, 39, 0.45); }
|
|
156
|
+
.coldwire-dialog h2 { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
|
|
157
|
+
text-transform: uppercase; color: #9ca3af; margin: 0 0 0.75rem; }
|
|
158
|
+
/* The reason this exists: a URL too long for its row has to wrap here, anywhere it must,
|
|
159
|
+
rather than ellipsising again or pushing the dialog wider than the screen. */
|
|
160
|
+
.coldwire-dialog-url { margin: 0; font-size: 0.875rem; line-height: 1.5;
|
|
161
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
|
162
|
+
|
|
163
|
+
/* Quiet on purpose: a list of every cached URL is a debug surface, not a setting.
|
|
164
|
+
No card, no colour, just a disclosure at the bottom for whoever needs it. */
|
|
165
|
+
.coldwire-inspect { margin: 1.25rem 0 0; padding-top: 0.85rem; border-top: 1px solid #e5e7eb; }
|
|
166
|
+
.coldwire-inspect summary { cursor: pointer; list-style: none; }
|
|
167
|
+
.coldwire-inspect summary::-webkit-details-marker { display: none; }
|
|
168
|
+
.coldwire-inspect-label { display: flex; align-items: center; gap: 0.35rem;
|
|
169
|
+
font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
|
|
170
|
+
text-transform: uppercase; color: #9ca3af; }
|
|
171
|
+
.coldwire-inspect-label::after { content: ""; width: 0.8rem; height: 0.8rem; flex-shrink: 0;
|
|
172
|
+
background: var(--coldwire-caret) no-repeat center / contain;
|
|
173
|
+
opacity: 0.7; transition: transform 160ms ease; }
|
|
174
|
+
.coldwire-inspect[open] .coldwire-inspect-label::after { transform: rotate(180deg); }
|
|
175
|
+
.coldwire-inspect-hint { display: block; margin-top: 0.15rem;
|
|
176
|
+
font-size: 0.8125rem; color: #9ca3af; line-height: 1.4; }
|
|
177
|
+
.coldwire-inspect-body { margin-top: 0.85rem; }
|
|
178
|
+
.coldwire-inspect summary:focus-visible { outline: 2px solid #6b7280; outline-offset: 2px;
|
|
179
|
+
border-radius: 0.25rem; }
|
|
180
|
+
|
|
181
|
+
/* A phone gives up a lot of its width to two sets of gutters — the page's and each card's.
|
|
182
|
+
Tighten both, and let the cards run closer to the edges where the room is worth more
|
|
183
|
+
than the margin. */
|
|
184
|
+
@media (max-width: 30rem) {
|
|
185
|
+
.coldwire { padding: 0.5rem 0.25rem; }
|
|
186
|
+
.coldwire h1 { font-size: 1.375rem; margin-bottom: 0.75rem; }
|
|
187
|
+
.coldwire-card { padding: 0.75rem; border-radius: 0.75rem; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@media (prefers-reduced-motion: reduce) {
|
|
191
|
+
.coldwire-icon-button[data-busy] svg,
|
|
192
|
+
.coldwire-spinner,
|
|
193
|
+
.coldwire-pulse { animation: none; }
|
|
194
|
+
.coldwire-switch span,
|
|
195
|
+
.coldwire-switch span::after,
|
|
196
|
+
.coldwire-inspect-label::after { transition: none; }
|
|
197
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coldwire
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Coldwire
|
|
6
|
+
|
|
7
|
+
# Let host apps `pin "coldwire", to: "coldwire/cache_controller.js"` without knowing
|
|
8
|
+
# where the gem lives.
|
|
9
|
+
initializer "coldwire.importmap", before: "importmap" do |app|
|
|
10
|
+
if app.config.respond_to?(:importmap)
|
|
11
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
12
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "coldwire.assets" do |app|
|
|
17
|
+
if app.config.respond_to?(:assets)
|
|
18
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Ahead of everything, so whatever asks whether this is the native app — the layout, the
|
|
23
|
+
# helpers, `register_if` — sees the client the request actually came from.
|
|
24
|
+
initializer "coldwire.client_user_agent" do |app|
|
|
25
|
+
app.config.middleware.insert_before 0, Coldwire::ClientUserAgent
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
initializer "coldwire.helpers" do
|
|
29
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
30
|
+
helper Coldwire::ServiceWorkerHelper
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Coldwire
|
|
6
|
+
# JavaScript and CSS that ship as files and are assembled when they are rendered, so none of
|
|
7
|
+
# it has to be written inside a Ruby string.
|
|
8
|
+
module Source
|
|
9
|
+
ROOT = Pathname.new(__dir__)
|
|
10
|
+
|
|
11
|
+
# The worker is served as one script but written as several. Concatenated rather than
|
|
12
|
+
# imported, so the browser still fetches one file and function declarations hoist across
|
|
13
|
+
# the whole of it.
|
|
14
|
+
WORKER = %w[rules serve ranges archives inspect sync events].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def worker
|
|
18
|
+
assemble(WORKER.map { |part| read("worker/#{part}.js") })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The page-side script: the values it cannot know, then whichever parts this page needs.
|
|
22
|
+
#
|
|
23
|
+
# A property rather than a const. Turbo copies head scripts it does not recognise, and a
|
|
24
|
+
# per-request CSP nonce makes this one look new on every visit — a second `const COLDWIRE`
|
|
25
|
+
# in the same document is a SyntaxError, and the whole script dies with it.
|
|
26
|
+
def client(config, parts)
|
|
27
|
+
assemble([ "window.COLDWIRE = #{config.to_json};" ] + parts.map { |part| read("client/#{part}.js") })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def debug_css
|
|
31
|
+
read("debug.css")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Spelled out rather than left to the host's locale: these files have em dashes in them,
|
|
37
|
+
# and a process running under a C locale would otherwise read them as US-ASCII and raise
|
|
38
|
+
# on the first one.
|
|
39
|
+
def read(path)
|
|
40
|
+
ROOT.join(path).read(encoding: "UTF-8")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Each fragment is self-terminated. `})()` followed by `(function` reads as one call
|
|
44
|
+
# expression rather than two statements, and ASI does not save you.
|
|
45
|
+
def assemble(parts)
|
|
46
|
+
parts.join("\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coldwire
|
|
4
|
+
# Read from the VERSION file at the root, which is the single place the number is written:
|
|
5
|
+
# the gemspec takes it from here rather than from a second copy, and a release only has to
|
|
6
|
+
# touch one line.
|
|
7
|
+
#
|
|
8
|
+
# `.freeze` because the frozen_string_literal pragma above covers literals in this file, not
|
|
9
|
+
# a string that arrives from IO.
|
|
10
|
+
VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip.freeze
|
|
11
|
+
end
|