samagotchi 0.110.0 → 0.111.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -1
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "openssl"
7
+ require "time"
8
+ require "uri"
9
+ require_relative "crypto"
10
+ require_relative "../private_files"
11
+ require_relative "../../atomic_file"
12
+ require_relative "../../session"
13
+
14
+ module Samagotchi
15
+ module Web
16
+ module Push
17
+ # This machine's Web Push state, in $XDG_STATE_HOME/samagotchi/web-push/
18
+ # (next to web-token and web-grants/; a 0700 folder, 0600 files, never
19
+ # in config.yml): vapid.pem, chi web's one VAPID key, and
20
+ # subscriptions.json, the devices that turned the bell on. A
21
+ # subscription holds no personal data (an opaque endpoint and two
22
+ # keys); the endpoint and keys never leave this store but for the push
23
+ # itself: what the page and the log show is Sub#public_h (the short
24
+ # id, the label, the dates). See docs/internals/web-push.md.
25
+ #
26
+ # chi web's request threads and the Notifier's sender thread both
27
+ # write: every write is a read-change-write under a lock file.
28
+ class Store
29
+ DIR = "web-push"
30
+ KEY_FILE = "vapid.pem"
31
+ SUBS_FILE = "subscriptions.json"
32
+ LOCK = "store.lock"
33
+
34
+ # The push services a subscription's endpoint may name: the
35
+ # browsers' (Apple, Google, Mozilla, Microsoft). chi web POSTs to
36
+ # nothing else (a subscription is the owner's, but an endpoint is a
37
+ # URL chi web fetches). A ".suffix" entry takes any host under it.
38
+ PUSH_SERVICES = ["web.push.apple.com", "fcm.googleapis.com", "updates.push.services.mozilla.com",
39
+ ".notify.windows.com"].freeze
40
+ ENDPOINT_MAX = 2048
41
+ LABEL_MAX = 40
42
+ # Devices a store keeps (every subscribe adds one until it is turned
43
+ # off): a bound on what one push fans out to.
44
+ MAX_SUBS = 50
45
+
46
+ # The store's folder or a file in it can be read by others: chi web
47
+ # refuses to start (Server.launch).
48
+ class Insecure < StandardError; end
49
+
50
+ # A subscription the store won't take: the message is the page's.
51
+ class Invalid < StandardError; end
52
+
53
+ # subscriptions.json is there but doesn't read: no device is pushed
54
+ # to, and nothing writes over it (that would forget every device).
55
+ class Corrupt < StandardError
56
+ def initialize(path, reason)
57
+ super("#{path} can't be read (#{reason}): chi won't write over it; fix it, or move it aside and turn " \
58
+ "the bell on again on each device")
59
+ end
60
+ end
61
+
62
+ # One device's subscription. +id+ is the short id (the first 8 hex
63
+ # of the endpoint's SHA-256), the only name of it outside this
64
+ # store. +p256dh+ and +auth+ as the browser gave them (base64url).
65
+ # +origin+ the public origin it subscribed on: it is sent to only
66
+ # while chi web runs there (a renamed machine's are stale).
67
+ Sub = Data.define(:id, :endpoint, :p256dh, :auth, :origin, :label, :created_at, :last_ok_at, :last_error,
68
+ :prefs) do
69
+ def self.from_store(hash)
70
+ keys = hash["keys"].is_a?(Hash) ? hash["keys"] : {}
71
+ new(id: hash["id"], endpoint: hash["endpoint"], p256dh: keys["p256dh"], auth: keys["auth"],
72
+ origin: hash["origin"], label: hash["label"], created_at: hash["created_at"],
73
+ last_ok_at: hash["last_ok_at"], last_error: hash["last_error"],
74
+ prefs: hash["prefs"].is_a?(Hash) ? hash["prefs"] : { "done" => false })
75
+ end
76
+
77
+ def to_store
78
+ { "id" => id, "endpoint" => endpoint, "keys" => { "p256dh" => p256dh, "auth" => auth }, "origin" => origin,
79
+ "label" => label, "created_at" => created_at, "last_ok_at" => last_ok_at, "last_error" => last_error,
80
+ "prefs" => prefs }
81
+ end
82
+
83
+ # The device's keys as bytes (Crypto.encrypt's).
84
+ def p256dh_bytes = Crypto.unb64u(p256dh)
85
+ def auth_bytes = Crypto.unb64u(auth)
86
+
87
+ # Never the endpoint or the keys: what the page, the CLI and the log show.
88
+ def public_h
89
+ { id: id, label: label, origin: origin, created_at: created_at, last_ok_at: last_ok_at,
90
+ last_error: last_error }
91
+ end
92
+
93
+ def inspect = "#<Sub #{id} #{label.inspect}>"
94
+ alias_method :to_s, :inspect
95
+ end
96
+
97
+ # $XDG_STATE_HOME/samagotchi/web-push, next to the grants;
98
+ # +state_dir+ is the sessions dir (Session.default_state_dir).
99
+ def self.dir(state_dir: nil, env: ENV)
100
+ File.join(File.dirname(state_dir || Session.default_state_dir(env: env)), DIR)
101
+ end
102
+
103
+ def self.short_id(endpoint) = Digest::SHA256.hexdigest(endpoint.to_s)[0, 8]
104
+
105
+ attr_reader :dir
106
+
107
+ # @param services [Array<String>] the push services endpoints may
108
+ # name (PUSH_SERVICES; a spec seam, not config)
109
+ # @param clock [#call] the Time now
110
+ def initialize(dir = Store.dir, services: PUSH_SERVICES, clock: -> { Time.now })
111
+ @dir = dir
112
+ @services = services
113
+ @clock = clock
114
+ @key_mutex = Mutex.new
115
+ @key = nil
116
+ end
117
+
118
+ # Never the key.
119
+ def inspect = "#<#{self.class.name} #{@dir}>"
120
+ alias to_s inspect
121
+
122
+ def key_path = File.join(@dir, KEY_FILE)
123
+ def subs_path = File.join(@dir, SUBS_FILE)
124
+
125
+ # Refuses a store others could read (PrivateFiles).
126
+ # @raise [Insecure]
127
+ def check_permissions!
128
+ PrivateFiles.check!(@dir, [key_path, subs_path], holds: "chi's push key and devices", error: Insecure)
129
+ end
130
+
131
+ # chi web's VAPID key: vapid.pem, made (0600) the first time.
132
+ # @return [OpenSSL::PKey::EC]
133
+ def vapid_key
134
+ @key_mutex.synchronize do
135
+ @key ||= locked do
136
+ if File.file?(key_path)
137
+ OpenSSL::PKey::EC.new(File.read(key_path))
138
+ else
139
+ Crypto.generate_key.tap { |key| AtomicFile.write(key_path, key.private_to_pem, perm: 0o600) }
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ # @return [Array<Sub>] none when the file is missing or corrupt (a
146
+ # corrupt one is refused on write: #update)
147
+ def subscriptions
148
+ read
149
+ rescue Corrupt
150
+ []
151
+ end
152
+
153
+ # The subscriptions for +origin+ (the ones chi web sends to now).
154
+ def for_origin(origin) = subscriptions.select { |sub| sub.origin == origin }
155
+
156
+ def find(id) = subscriptions.find { |sub| sub.id == id }
157
+
158
+ # Adds the subscription a page's pushManager.subscribe gave (its
159
+ # JSON: endpoint, keys {p256dh, auth}); the same endpoint again
160
+ # replaces the old entry (a device subscribing again).
161
+ # @return [Sub]
162
+ # @raise [Invalid] one message per refusal
163
+ def add(subscription, origin:, label:)
164
+ endpoint, p256dh, auth = validate(subscription)
165
+ at = stamp(now)
166
+ sub = Sub.new(id: Store.short_id(endpoint), endpoint: endpoint, p256dh: p256dh, auth: auth, origin: origin,
167
+ label: label.to_s[0, LABEL_MAX], created_at: at, last_ok_at: nil, last_error: nil,
168
+ prefs: { "done" => false })
169
+ update do |subs|
170
+ kept = subs.reject { |old| old.endpoint == endpoint }
171
+ raise Invalid, "chi web already pushes to #{MAX_SUBS} devices: turn the bell off on one first" if kept.size >= MAX_SUBS
172
+
173
+ [kept + [sub], sub]
174
+ end
175
+ end
176
+
177
+ # Removes the subscription with this short id or endpoint.
178
+ # @return [Sub, nil] the one removed
179
+ def remove(id_or_endpoint)
180
+ update do |subs|
181
+ gone = subs.find { |sub| sub.id == id_or_endpoint || sub.endpoint == id_or_endpoint }
182
+ [subs - [gone], gone]
183
+ end
184
+ end
185
+
186
+ # Records a delivery: +ok+ true stamps last_ok_at (and clears the
187
+ # error), else +error+ (a short word: "410", "timeout") is kept.
188
+ def mark(id, ok: false, error: nil)
189
+ update do |subs|
190
+ subs = subs.map do |sub|
191
+ next sub unless sub.id == id
192
+
193
+ ok ? sub.with(last_ok_at: stamp(now), last_error: nil) : sub.with(last_error: error.to_s[0, 80])
194
+ end
195
+ [subs, nil]
196
+ end
197
+ end
198
+
199
+ private
200
+
201
+ def now = @clock.call
202
+
203
+ def stamp(time) = time.utc.iso8601
204
+
205
+ # @return [Array(String, String, String)] endpoint, p256dh, auth
206
+ def validate(subscription)
207
+ keys = subscription["keys"] if subscription.is_a?(Hash)
208
+ endpoint = subscription["endpoint"] if subscription.is_a?(Hash)
209
+ unless endpoint.is_a?(String) && keys.is_a?(Hash) && keys["p256dh"].is_a?(String) && keys["auth"].is_a?(String)
210
+ raise Invalid, "a push subscription needs an endpoint and keys (p256dh, auth)"
211
+ end
212
+
213
+ check_endpoint(endpoint)
214
+ p256dh = decoded(keys["p256dh"])
215
+ unless p256dh&.bytesize == 65 && p256dh.getbyte(0) == 4
216
+ raise Invalid, "a push subscription's p256dh must be a P-256 public key (65 bytes, base64url)"
217
+ end
218
+ raise Invalid, "a push subscription's auth must be 16 bytes (base64url)" unless decoded(keys["auth"])&.bytesize == 16
219
+
220
+ [endpoint, keys["p256dh"], keys["auth"]]
221
+ end
222
+
223
+ def check_endpoint(endpoint)
224
+ uri = begin
225
+ URI.parse(endpoint)
226
+ rescue URI::InvalidURIError
227
+ nil
228
+ end
229
+ if endpoint.bytesize > ENDPOINT_MAX || uri.nil? || uri.scheme != "https" || uri.host.to_s.empty? ||
230
+ uri.userinfo || uri.port != 443
231
+ raise Invalid, "a push endpoint must be an https URL on port 443"
232
+ end
233
+ return if service?(uri.host.downcase)
234
+
235
+ raise Invalid, "chi web only pushes to the browsers' push services, not #{uri.host[0, 80]}"
236
+ end
237
+
238
+ def service?(host)
239
+ @services.any? { |entry| entry.start_with?(".") ? host.end_with?(entry) && host.length > entry.length : host == entry }
240
+ end
241
+
242
+ def decoded(text)
243
+ Crypto.unb64u(text)
244
+ rescue ArgumentError
245
+ nil
246
+ end
247
+
248
+ # @raise [Corrupt]
249
+ def read
250
+ data = begin
251
+ JSON.parse(File.read(subs_path))
252
+ rescue Errno::ENOENT
253
+ return []
254
+ rescue JSON::ParserError
255
+ raise Corrupt.new(subs_path, "not JSON")
256
+ end
257
+ rows = data["subscriptions"] if data.is_a?(Hash)
258
+ raise Corrupt.new(subs_path, "no subscriptions list") unless rows.is_a?(Array) && rows.all?(Hash)
259
+
260
+ rows.map { |row| Sub.from_store(row) }
261
+ end
262
+
263
+ # Read, change and write subscriptions.json under the lock. The block
264
+ # takes the subscriptions and returns [new subscriptions, result].
265
+ def update
266
+ locked do
267
+ subs, result = yield(read)
268
+ body = JSON.pretty_generate({ "version" => 1, "subscriptions" => subs.map(&:to_store) })
269
+ AtomicFile.write(subs_path, "#{body}\n", perm: 0o600)
270
+ result
271
+ end
272
+ end
273
+
274
+ def locked
275
+ FileUtils.mkdir_p(@dir, mode: 0o700)
276
+ File.chmod(0o700, @dir)
277
+ File.open(File.join(@dir, LOCK), File::RDWR | File::CREAT, 0o600) do |lock|
278
+ lock.flock(File::LOCK_EX)
279
+ yield
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "push/crypto"
4
+ require_relative "push/store"
5
+ require_relative "push/client"
6
+ require_relative "push/attention"
7
+ require_relative "push/notifier"
8
+ require_relative "../config"
9
+
10
+ module Samagotchi
11
+ module Web
12
+ # Web Push: chi web reaches the owner's phone (a Home Screen app that
13
+ # turned the bell on) when a session waits on an approval or a question.
14
+ # It needs an https public origin (web.public_url or chi web --tunnel
15
+ # tailscale) and web.push.enabled (on by default); without a subscribed
16
+ # device it does nothing. See docs/configuration.md (Phone
17
+ # notifications) and docs/internals/web-push.md.
18
+ module Push
19
+ # chi web won't start with push as configured: a web.push.contact
20
+ # Apple would refuse, or a key it can't read.
21
+ class Refused < StandardError; end
22
+
23
+ # What chi web's push routes and its Notifier share for one run: the
24
+ # store, the client that signs with its key, and the public origin
25
+ # the devices subscribe on.
26
+ Runtime = Data.define(:store, :client, :origin)
27
+
28
+ module_function
29
+
30
+ # Push runs for +public_url+ (nil: none) when web.push.enabled.
31
+ def active?(public_url) = !public_url.nil? && Config.get("web.push.enabled") != false
32
+
33
+ # The VAPID sub: web.push.contact, else the public origin.
34
+ # @raise [Refused] a contact that isn't a mailto: or https URL
35
+ def contact(origin)
36
+ given = Config.get("web.push.contact").to_s.strip
37
+ return origin if given.empty?
38
+ return given if Crypto.valid_sub?(given)
39
+
40
+ raise Refused, "web.push.contact must be a mailto: address or an https URL (the push services refuse " \
41
+ "anything else), not #{given[0, 80]}"
42
+ end
43
+
44
+ # Server.launch's check, before a tunnel goes up: the contact, the
45
+ # store's permissions and its key (made the first time).
46
+ # @raise [Refused, Store::Insecure]
47
+ def preflight!(public_url, store: Store.new)
48
+ return unless active?(public_url)
49
+
50
+ contact(public_url.origin)
51
+ store.check_permissions!
52
+ load_key(store)
53
+ end
54
+
55
+ # @raise [Refused] vapid.pem is there but isn't a key
56
+ def load_key(store)
57
+ store.vapid_key
58
+ rescue OpenSSL::PKey::PKeyError => e
59
+ raise Refused, "#{store.key_path} isn't a key chi can read (#{e.message[0, 80]}): move it aside and turn " \
60
+ "the bell on again on each device"
61
+ end
62
+
63
+ # This run's Runtime, nil when push is off.
64
+ def runtime(public_url, state_dir: nil)
65
+ return nil unless active?(public_url)
66
+
67
+ store = Store.new(Store.dir(state_dir: state_dir))
68
+ Runtime.new(store: store, client: Client.new(key: load_key(store), contact: contact(public_url.origin)),
69
+ origin: public_url.origin)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "push"
5
+
6
+ module Samagotchi
7
+ module Web
8
+ # The owner's Web Push routes (App::ROUTES, all :owner rows: a
9
+ # participant gets no pushes): what the page's bell asks (GET /api/push),
10
+ # subscribing this device and unsubscribing it, and a test push.
11
+ # Included in App: its helpers (json_response, error_response,
12
+ # parse_json, request_body) and @push (a Push::Runtime, nil when push is
13
+ # off: no https public_url, or web.push.enabled false) answer. See
14
+ # docs/internals/web-push.md.
15
+ module PushRoutes
16
+ PUSH_BODY_LIMIT = 8192
17
+
18
+ private
19
+
20
+ # {enabled, public_key, origin, this}: +this+ is the device whose
21
+ # endpoint the page sends as ?endpoint= (its pushManager's), nil when
22
+ # this store doesn't have it for this origin.
23
+ def handle_push_info(req)
24
+ return json_response(200, { enabled: false, public_key: nil, origin: nil, this: nil }) unless @push
25
+
26
+ endpoint = req.params["endpoint"].to_s
27
+ this = endpoint.empty? ? nil : @push.store.for_origin(@push.origin).find { |sub| sub.endpoint == endpoint }
28
+ json_response(200, { enabled: true, public_key: Push::Crypto.public_key_b64(@push.store.vapid_key),
29
+ origin: @push.origin, this: this && { id: this.id, label: this.label } })
30
+ end
31
+
32
+ # {subscription: {endpoint, keys: {p256dh, auth}}} → 201 {id, label}.
33
+ def handle_push_subscribe(req)
34
+ return push_off unless @push
35
+
36
+ body = push_body(req)
37
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
38
+
39
+ sub = @push.store.add(body["subscription"], origin: @push.origin, label: device_label(req))
40
+ Log.info(:web, "push_subscribed", device: sub.id)
41
+ json_response(201, { id: sub.id, label: sub.label })
42
+ rescue Push::Store::Invalid => e
43
+ error_response(400, "invalid_subscription", e.message)
44
+ end
45
+
46
+ # {endpoint} → 204: this device's bell turned off.
47
+ def handle_push_unsubscribe(req)
48
+ return push_off unless @push
49
+
50
+ body = push_body(req)
51
+ endpoint = body["endpoint"] if body.is_a?(Hash)
52
+ return error_response(400, "missing_fields", "endpoint is required") unless endpoint.is_a?(String) && !endpoint.empty?
53
+
54
+ gone = @push.store.remove(endpoint)
55
+ Log.info(:web, "push_unsubscribed", device: gone.id) if gone
56
+ [204, { "Cache-Control" => "no-store" }, []]
57
+ end
58
+
59
+ # {endpoint?} → a test push to that device (or every one on this
60
+ # origin), answered with each device's result.
61
+ def handle_push_test(req)
62
+ return push_off unless @push
63
+
64
+ body = push_body(req)
65
+ endpoint = body["endpoint"] if body.is_a?(Hash)
66
+ subs = @push.store.for_origin(@push.origin)
67
+ subs = subs.select { |sub| sub.endpoint == endpoint } if endpoint.is_a?(String) && !endpoint.empty?
68
+ payload = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }
69
+ results = subs.map do |sub|
70
+ result = @push.client.deliver(sub, payload, ttl: 600, urgency: "high")
71
+ Log.info(:web, "push_test", device: sub.id, code: result.code, ms: result.ms)
72
+ @push.store.remove(sub.id) if result.status == :gone
73
+ { id: sub.id, label: sub.label, status: result.status.to_s, code: result.code, ms: result.ms }
74
+ end
75
+ json_response(200, { devices: results })
76
+ end
77
+
78
+ def push_off
79
+ error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
80
+ "and web.push.enabled")
81
+ end
82
+
83
+ def push_body(req) = parse_json(request_body(req, PUSH_BODY_LIMIT))
84
+
85
+ # chi's own name for the device (a subscription carries none): its
86
+ # class from the User-Agent (iOS's is frozen, so it is generic) and
87
+ # the date.
88
+ def device_label(req)
89
+ agent = req.get_header("HTTP_USER_AGENT").to_s
90
+ kind = if agent.include?("iPhone") then "iPhone"
91
+ elsif agent.include?("iPad") then "iPad"
92
+ elsif agent.include?("Macintosh") then "Mac"
93
+ else "Browser"
94
+ end
95
+ "#{kind} · #{Time.now.strftime("%Y-%m-%d")}"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -12,6 +12,7 @@ require_relative "lan"
12
12
  require_relative "link_codes"
13
13
  require_relative "proxy_gate"
14
14
  require_relative "public_url"
15
+ require_relative "push"
15
16
  require_relative "qr"
16
17
  require_relative "running"
17
18
  require_relative "session_hub"
@@ -121,6 +122,10 @@ module Samagotchi
121
122
  end
122
123
  # The participants' grants: never served from a store others can read.
123
124
  Grants.check_permissions!(Grants.path)
125
+ # Web Push (with an https public_url): a contact the push services
126
+ # would refuse, or a key store others can read, stops it here,
127
+ # before any tunnel goes up.
128
+ Push.preflight!(public_url)
124
129
  options = { port: port, host: host, url: url, open_browser: open_browser, markdown: markdown, view: view,
125
130
  annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
126
131
  public_url: public_url }
@@ -131,7 +136,7 @@ module Samagotchi
131
136
  end
132
137
  started ? 0 : 1
133
138
  end
134
- rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused => e
139
+ rescue Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused, Push::Refused, Push::Store::Insecure => e
135
140
  warn "Error: #{e.message}"
136
141
  1
137
142
  rescue Interrupt
@@ -198,9 +203,10 @@ module Samagotchi
198
203
  Token.load_or_create(token_path) if lan || public_url
199
204
  lan_option = { ip: lan.ip, token: Token::Source.new(token_path) } if lan
200
205
  public_token = Token::Source.new(token_path) if public_url
206
+ push = push_runtime(public_url, state_dir)
201
207
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
202
208
  annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
203
- public_url: public_url, public_token: public_token)
209
+ public_url: public_url, public_token: public_token, push: push)
204
210
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
205
211
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
206
212
  if public_url
@@ -209,6 +215,13 @@ module Samagotchi
209
215
  end
210
216
  # Subscribed before the first scan, which makes the first check.
211
217
  hub.subscribe(method(:on_hub_event))
218
+ if push
219
+ # The Notifier seeds from the hub's snapshot, which is empty before
220
+ # a scan: one now, so what is already open never pushes.
221
+ hub.scan
222
+ notifier = Push::Notifier.new(store: push.store, client: push.client, origin: push.origin).start(hub)
223
+ Samagotchi::Log.info(:web, "push", devices: push.store.for_origin(push.origin).size)
224
+ end
212
225
  hub.start
213
226
  # Said once the port is bound: a second chi web racing for it gets
214
227
  # the in-use line instead.
@@ -249,12 +262,23 @@ module Samagotchi
249
262
  ensure
250
263
  Running.clear(path: running_path) if running_path
251
264
  config_restart_unsubscribe&.call
265
+ notifier&.stop
252
266
  hub&.stop
253
267
  Samagotchi::Log.info(:web, "stop") if app
254
268
  end
255
269
 
256
270
  class LanListenError < StandardError; end
257
271
 
272
+ # This run's Web Push (Push.runtime), nil when off. launch checked it
273
+ # (Push.preflight!); a store that broke since leaves chi web running
274
+ # without pushes, said in one line.
275
+ def self.push_runtime(public_url, state_dir)
276
+ Push.runtime(public_url, state_dir: state_dir)
277
+ rescue Push::Refused, Push::Store::Insecure, SystemCallError => e
278
+ Samagotchi::Log.warn(:web, "push_off", echo: "Warning: #{e.message}; phone notifications are off")
279
+ nil
280
+ end
281
+
258
282
  # Where this chi web listens, and its public_url (config's or the
259
283
  # tunnel's), for chi share's link (Web::Running); a write that fails
260
284
  # leaves the server running and the readers on web.port and config.