samagotchi 0.111.1 → 0.112.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 +4 -4
- data/CHANGELOG.md +29 -1
- data/docs/architecture.md +2 -2
- data/docs/configuration.md +22 -13
- data/docs/internals/web-push.md +106 -40
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/chains.js +9 -6
- data/lib/samagotchi/web/public/format.js +20 -5
- data/lib/samagotchi/web/public/index.html +16 -5
- data/lib/samagotchi/web/public/lists_view.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +72 -11
- data/lib/samagotchi/web/public/notify.js +3 -3
- data/lib/samagotchi/web/public/participant.html +4 -0
- data/lib/samagotchi/web/public/push.js +16 -17
- data/lib/samagotchi/web/public/session_state.js +4 -3
- data/lib/samagotchi/web/public/sw.js +6 -5
- data/lib/samagotchi/web/push/attention.rb +80 -35
- data/lib/samagotchi/web/push/client.rb +23 -6
- data/lib/samagotchi/web/push/notifier.rb +152 -20
- data/lib/samagotchi/web/push/presence.rb +60 -0
- data/lib/samagotchi/web/push/store.rb +72 -4
- data/lib/samagotchi/web/push.rb +8 -4
- data/lib/samagotchi/web/push_routes.rb +42 -6
- data/lib/samagotchi/web/server.rb +2 -1
- metadata +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require_relative "attention"
|
|
5
6
|
require_relative "../../log"
|
|
6
7
|
|
|
@@ -10,9 +11,11 @@ module Samagotchi
|
|
|
10
11
|
# chi web's one sender of pushes: a SessionHub subscriber that keeps
|
|
11
12
|
# the summaries the page's trackAttention keeps (seeded by the
|
|
12
13
|
# subscribe's snapshot, so what was open before never pushes), turns
|
|
13
|
-
# each new
|
|
14
|
-
#
|
|
15
|
-
#
|
|
14
|
+
# each new attention (Attention.for: a question, an approval, a
|
|
15
|
+
# step-limit continue, a card with actions, a failed or done turn)
|
|
16
|
+
# into a show, and hands it to its own sender thread through a Queue;
|
|
17
|
+
# one answered anywhere (Attention.closed_keys) only cancels its
|
|
18
|
+
# show's hold.
|
|
16
19
|
#
|
|
17
20
|
# Every push it sends shows a notification: WebKit removes a
|
|
18
21
|
# subscription after a few pushes that show none (its silent-push
|
|
@@ -25,33 +28,57 @@ module Samagotchi
|
|
|
25
28
|
# tick thread and on chi web's request threads (SessionHub#touch), so
|
|
26
29
|
# it only enqueues; a push is a 0.5 s https call, made on the sender
|
|
27
30
|
# thread. There a show waits its hold (HOLD, or the event's own) and
|
|
28
|
-
# is pushed only while still open, to
|
|
29
|
-
# run's public origin
|
|
31
|
+
# is pushed, a waiting one only while still open, to the devices
|
|
32
|
+
# subscribed on this run's public origin (a done turn only to those
|
|
33
|
+
# that asked for it), unless a chi web page is in front (Presence):
|
|
34
|
+
# then the owner is looking, and the key is never pushed. At most CAP
|
|
35
|
+
# shows an hour; a 429 or 5xx is retried once (Client::Result
|
|
36
|
+
# #retry_after) without the sender ever sleeping. Each key handled
|
|
37
|
+
# (pushed, skipped or capped) goes in the store's sent.json, so after
|
|
38
|
+
# a restart the seed pushes an approval asked meanwhile (open, under
|
|
39
|
+
# CATCH_UP old) and nothing pushed already. See
|
|
40
|
+
# docs/internals/web-push.md.
|
|
30
41
|
class Notifier
|
|
31
42
|
HOLD = 3.0
|
|
32
43
|
# Waiting kinds: kept 12 h for an offline phone, delivered at once.
|
|
33
44
|
SHOW_TTL = 12 * 3600
|
|
45
|
+
# A failed or done turn: stale soon, no need to wake the phone.
|
|
46
|
+
EVENT_TTL = 600
|
|
34
47
|
# A show carries +open+ only while its JSON stays this small: one
|
|
35
48
|
# aes128gcm record leaves 3993 bytes of plaintext under Web Push's
|
|
36
49
|
# 4096 (86 header, 1 delimiter, 16 tag). Longer, it goes without
|
|
37
50
|
# (and the worker cleans up nothing).
|
|
38
51
|
OPEN_LIMIT = 3000
|
|
39
52
|
STOP_JOIN = 2.0
|
|
53
|
+
# Shows (one event, however many devices) per rolling CAP_WINDOW.
|
|
54
|
+
CAP = 30
|
|
55
|
+
CAP_WINDOW = 3600
|
|
56
|
+
# A question open at a restart is pushed if it was asked this
|
|
57
|
+
# recently and isn't in sent.json.
|
|
58
|
+
CATCH_UP = 3600
|
|
40
59
|
|
|
41
60
|
Show = Data.define(:attention, :title, :body, :at)
|
|
42
61
|
Close = Data.define(:keys)
|
|
62
|
+
# One device's show, again at +at+ (the push service said when).
|
|
63
|
+
Retry = Data.define(:show, :sub_id, :at)
|
|
43
64
|
|
|
44
65
|
# @param store [Store] the devices (and the key the client signs with)
|
|
45
66
|
# @param client [#deliver] Client
|
|
46
67
|
# @param origin [String] this run's public origin: only the
|
|
47
68
|
# subscriptions made on it are sent to
|
|
69
|
+
# @param presence [Presence, nil] the pages' reports (nil: nobody is
|
|
70
|
+
# ever in front)
|
|
48
71
|
# @param now [#call] monotonic seconds (specs move it)
|
|
49
|
-
|
|
72
|
+
# @param clock [#call] the Time now (a question's created_at)
|
|
73
|
+
def initialize(store:, client:, origin:, presence: nil, hold: HOLD,
|
|
74
|
+
now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, clock: -> { Time.now })
|
|
50
75
|
@store = store
|
|
51
76
|
@client = client
|
|
52
77
|
@origin = origin
|
|
78
|
+
@presence = presence
|
|
53
79
|
@hold = hold
|
|
54
80
|
@now = now
|
|
81
|
+
@clock = clock
|
|
55
82
|
@queue = Queue.new
|
|
56
83
|
# The sink's side (under @mutex): the last summary per session.
|
|
57
84
|
@mutex = Mutex.new
|
|
@@ -60,6 +87,9 @@ module Samagotchi
|
|
|
60
87
|
@early = []
|
|
61
88
|
# The sender's side (its thread only, or #pump in a spec).
|
|
62
89
|
@pending = {} # key → Show waiting its hold
|
|
90
|
+
@retries = {} # [key, device id] → Retry
|
|
91
|
+
@shown = [] # when each counted show went, oldest first
|
|
92
|
+
@capped_warned_at = nil
|
|
63
93
|
@refused = false
|
|
64
94
|
@thread = nil
|
|
65
95
|
@handle = nil
|
|
@@ -97,10 +127,13 @@ module Samagotchi
|
|
|
97
127
|
end
|
|
98
128
|
|
|
99
129
|
# The seed: the summaries as the subscribe saw them. Events that came
|
|
100
|
-
# before it (newer than the snapshot) are applied after it.
|
|
130
|
+
# before it (newer than the snapshot) are applied after it. An open
|
|
131
|
+
# question asked under CATCH_UP ago that no earlier run handled
|
|
132
|
+
# (sent.json) is shown: it came while chi web was down.
|
|
101
133
|
def seed(summaries)
|
|
102
134
|
@mutex.synchronize do
|
|
103
135
|
summaries.each { |summary| @by_id[summary[:id]] = summary if summary[:id] }
|
|
136
|
+
catch_up(summaries)
|
|
104
137
|
@seeded = true
|
|
105
138
|
@early.each { |event| notice(event) }
|
|
106
139
|
@early.clear
|
|
@@ -142,6 +175,32 @@ module Samagotchi
|
|
|
142
175
|
end
|
|
143
176
|
end
|
|
144
177
|
|
|
178
|
+
# Cards carry no time: they seed as they are.
|
|
179
|
+
def catch_up(summaries)
|
|
180
|
+
sent = nil
|
|
181
|
+
at = @clock.call
|
|
182
|
+
summaries.each do |summary|
|
|
183
|
+
question = summary[:pending_question]
|
|
184
|
+
next unless summary[:id] && question.is_a?(Hash) && question[:id] && !question[:relayed_to]
|
|
185
|
+
|
|
186
|
+
asked = asked_at(question[:created_at])
|
|
187
|
+
next unless asked && at - asked < CATCH_UP
|
|
188
|
+
|
|
189
|
+
sent ||= @store.sent_keys
|
|
190
|
+
attention = Attention.for(nil, summary)
|
|
191
|
+
next if attention.nil? || sent.key?(attention.key)
|
|
192
|
+
|
|
193
|
+
Log.info(:web, "push_catch_up", kind: attention.reason)
|
|
194
|
+
enqueue_show(attention, summary)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def asked_at(text)
|
|
199
|
+
text.is_a?(String) ? Time.iso8601(text) : nil
|
|
200
|
+
rescue ArgumentError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
145
204
|
def enqueue_show(attention, summary)
|
|
146
205
|
title, body = Attention.text(summary, attention.reason)
|
|
147
206
|
@queue << Show.new(attention: attention, title: title, body: body, at: @now.call)
|
|
@@ -149,7 +208,7 @@ module Samagotchi
|
|
|
149
208
|
|
|
150
209
|
def run
|
|
151
210
|
loop do
|
|
152
|
-
due = @pending.values.map { |show| due_at(show) }.min
|
|
211
|
+
due = (@pending.values.map { |show| due_at(show) } + @retries.values.map(&:at)).min
|
|
153
212
|
wait = due && [due - @now.call, 0].max
|
|
154
213
|
message = @queue.pop(timeout: wait)
|
|
155
214
|
break if message == :stop
|
|
@@ -175,16 +234,70 @@ module Samagotchi
|
|
|
175
234
|
def flush
|
|
176
235
|
now = @now.call
|
|
177
236
|
due = @pending.values.select { |show| due_at(show) <= now }
|
|
178
|
-
|
|
237
|
+
retries = @retries.values.select { |again| again.at <= now }
|
|
238
|
+
return if due.empty? && retries.empty?
|
|
179
239
|
|
|
180
240
|
due.each { |show| @pending.delete(show.attention.key) }
|
|
241
|
+
retries.each { |again| @retries.delete([again.show.attention.key, again.sub_id]) }
|
|
181
242
|
open = @mutex.synchronize { Attention.open_keys(@by_id) }
|
|
182
|
-
due.each
|
|
183
|
-
|
|
184
|
-
|
|
243
|
+
due.each { |show| send_show(show, open) if still_due?(show, open) }
|
|
244
|
+
retries.each { |again| send_retry(again, open) if still_due?(again.show, open) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A waiting show while its key is open; a failed or done turn's as
|
|
248
|
+
# is (nothing to answer).
|
|
249
|
+
def still_due?(show, open) = !Attention.waiting?(show.attention.reason) || open.include?(show.attention.key)
|
|
250
|
+
|
|
251
|
+
# A due show to its devices, unless a page is in front (the in-tab
|
|
252
|
+
# notification and badge have it) or the hour's cap is reached:
|
|
253
|
+
# either way the key is handled, never pushed later.
|
|
254
|
+
def send_show(show, open)
|
|
255
|
+
return if @refused
|
|
185
256
|
|
|
186
|
-
|
|
257
|
+
subs = targets(show.attention.reason)
|
|
258
|
+
return if subs.empty?
|
|
259
|
+
|
|
260
|
+
key = show.attention.key
|
|
261
|
+
if @presence&.present?
|
|
262
|
+
Log.info(:web, "push_skipped", reason: "present", kind: show.attention.reason)
|
|
263
|
+
return handled(key)
|
|
187
264
|
end
|
|
265
|
+
return handled(key) if capped?(show.attention.reason)
|
|
266
|
+
|
|
267
|
+
@shown << @now.call
|
|
268
|
+
handled(key) if deliver(subs, show_payload(show, open), show)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The retry of one device's show: to that device, if it is still
|
|
272
|
+
# there; not counted against the cap again.
|
|
273
|
+
def send_retry(again, open)
|
|
274
|
+
return if @refused
|
|
275
|
+
|
|
276
|
+
sub = @store.for_origin(@origin).find { |candidate| candidate.id == again.sub_id }
|
|
277
|
+
return unless sub
|
|
278
|
+
|
|
279
|
+
handled(again.show.attention.key) if deliver([sub], show_payload(again.show, open), again.show, final: true)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Over CAP shows in the last CAP_WINDOW: one warning per window.
|
|
283
|
+
def capped?(kind)
|
|
284
|
+
at = @now.call
|
|
285
|
+
@shown.shift while @shown.any? && at - @shown.first >= CAP_WINDOW
|
|
286
|
+
return false if @shown.size < CAP
|
|
287
|
+
|
|
288
|
+
if @capped_warned_at.nil? || at - @capped_warned_at >= CAP_WINDOW
|
|
289
|
+
@capped_warned_at = at
|
|
290
|
+
Log.warn(:web, "push_capped", cap: CAP, kind: kind,
|
|
291
|
+
echo: "[chi web] #{CAP} pushes in the last hour: holding the rest back")
|
|
292
|
+
end
|
|
293
|
+
true
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# In sent.json: a restart's catch-up skips it.
|
|
297
|
+
def handled(key)
|
|
298
|
+
@store.mark_sent(key)
|
|
299
|
+
rescue SystemCallError => e
|
|
300
|
+
Log.warn(:web, "push_sent_unwritten", error: e.class.name)
|
|
188
301
|
end
|
|
189
302
|
|
|
190
303
|
def show_payload(show, open)
|
|
@@ -194,18 +307,37 @@ module Samagotchi
|
|
|
194
307
|
JSON.generate(with_open).bytesize <= OPEN_LIMIT ? with_open : payload
|
|
195
308
|
end
|
|
196
309
|
|
|
197
|
-
# @param
|
|
310
|
+
# @param show [Show] its reason: the TTL and urgency, the log
|
|
311
|
+
# line's kind
|
|
312
|
+
# @param final [Boolean] this is the one retry: a failure is final
|
|
198
313
|
# @return [Boolean] whether any device took it
|
|
199
|
-
def deliver(payload,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
result = @client.deliver(sub, payload, ttl: SHOW_TTL, urgency: "high")
|
|
204
|
-
|
|
314
|
+
def deliver(subs, payload, show, final: false)
|
|
315
|
+
reason = show.attention.reason
|
|
316
|
+
waiting = Attention.waiting?(reason)
|
|
317
|
+
subs.map do |sub|
|
|
318
|
+
result = @client.deliver(sub, payload, ttl: waiting ? SHOW_TTL : EVENT_TTL, urgency: waiting ? "high" : "normal")
|
|
319
|
+
if result.retry_after && !final
|
|
320
|
+
retry_later(show, sub, result)
|
|
321
|
+
next false
|
|
322
|
+
end
|
|
323
|
+
record(sub, result, reason)
|
|
205
324
|
result.ok?
|
|
206
325
|
end.any?
|
|
207
326
|
end
|
|
208
327
|
|
|
328
|
+
def retry_later(show, sub, result)
|
|
329
|
+
Log.info(:web, "push_retry", device: sub.id, code: result.code, ms: result.ms, kind: show.attention.reason,
|
|
330
|
+
after: result.retry_after)
|
|
331
|
+
@retries[[show.attention.key, sub.id]] = Retry.new(show: show, sub_id: sub.id, at: @now.call + result.retry_after)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# This origin's devices; a done turn only to those whose prefs ask
|
|
335
|
+
# for it (off by default).
|
|
336
|
+
def targets(reason)
|
|
337
|
+
subs = @store.for_origin(@origin)
|
|
338
|
+
reason == "done" ? subs.select(&:done?) : subs
|
|
339
|
+
end
|
|
340
|
+
|
|
209
341
|
def record(sub, result, kind)
|
|
210
342
|
fields = { device: sub.id, code: result.code, ms: result.ms, kind: kind }
|
|
211
343
|
case result.status
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Web
|
|
5
|
+
module Push
|
|
6
|
+
# Whether the owner is at a chi web page now (the plan's D3: no push
|
|
7
|
+
# while they look): each page reports {page, front} (POST
|
|
8
|
+
# /api/push/presence; front = visible, focused and touched in the last
|
|
9
|
+
# 2 min) when that changes and every 30 s while front. The iPhone's
|
|
10
|
+
# service worker can't see the app in front, so this is decided here.
|
|
11
|
+
# In memory only (a restart forgets it: a page reports again within
|
|
12
|
+
# 30 s), shared by the route and the Notifier through Push::Runtime.
|
|
13
|
+
class Presence
|
|
14
|
+
# A front report counts this long: a page that stopped reporting (a
|
|
15
|
+
# closed laptop, a lost network) quiets the phone no longer.
|
|
16
|
+
TTL = 45.0
|
|
17
|
+
# Pages remembered (one per page load): the oldest report goes.
|
|
18
|
+
MAX_PAGES = 64
|
|
19
|
+
PAGE_MAX = 64
|
|
20
|
+
|
|
21
|
+
Report = Data.define(:front, :at)
|
|
22
|
+
|
|
23
|
+
# @param now [#call] monotonic seconds (specs move it)
|
|
24
|
+
def initialize(now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
25
|
+
@now = now
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@pages = {} # page id → Report, oldest first
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A page's report: +page+ its id for this load, +front+ whether the
|
|
31
|
+
# user is at it.
|
|
32
|
+
def report(page, front)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@pages.delete(page)
|
|
35
|
+
@pages[page] = Report.new(front: front == true, at: @now.call)
|
|
36
|
+
@pages.shift while @pages.size > MAX_PAGES
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Some page is in front, reported in the last TTL seconds.
|
|
42
|
+
def present?
|
|
43
|
+
at = @now.call
|
|
44
|
+
@mutex.synchronize { @pages.each_value.any? { |r| r.front && at - r.at < TTL } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A report's body as the page sends it: {page: id, front: bool}.
|
|
48
|
+
# @return [Array(String, Boolean), nil] nil when it isn't one
|
|
49
|
+
def self.parse(body)
|
|
50
|
+
return nil unless body.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
page, front = body.values_at("page", "front")
|
|
53
|
+
return nil unless page.is_a?(String) && !page.empty? && page.bytesize <= PAGE_MAX && [true, false].include?(front)
|
|
54
|
+
|
|
55
|
+
[page, front]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -8,6 +8,7 @@ require "time"
|
|
|
8
8
|
require "uri"
|
|
9
9
|
require_relative "crypto"
|
|
10
10
|
require_relative "../private_files"
|
|
11
|
+
require_relative "../../log"
|
|
11
12
|
require_relative "../../atomic_file"
|
|
12
13
|
require_relative "../../session"
|
|
13
14
|
|
|
@@ -16,8 +17,10 @@ module Samagotchi
|
|
|
16
17
|
module Push
|
|
17
18
|
# This machine's Web Push state, in $XDG_STATE_HOME/samagotchi/web-push/
|
|
18
19
|
# (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,
|
|
20
|
-
# subscriptions.json, the devices that turned the bell on
|
|
20
|
+
# in config.yml): vapid.pem, chi web's one VAPID key,
|
|
21
|
+
# subscriptions.json, the devices that turned the bell on, and
|
|
22
|
+
# sent.json, the attention keys already handled (a restart pushes
|
|
23
|
+
# none of them again). A
|
|
21
24
|
# subscription holds no personal data (an opaque endpoint and two
|
|
22
25
|
# keys); the endpoint and keys never leave this store but for the push
|
|
23
26
|
# itself: what the page and the log show is Sub#public_h (the short
|
|
@@ -29,7 +32,11 @@ module Samagotchi
|
|
|
29
32
|
DIR = "web-push"
|
|
30
33
|
KEY_FILE = "vapid.pem"
|
|
31
34
|
SUBS_FILE = "subscriptions.json"
|
|
35
|
+
SENT_FILE = "sent.json"
|
|
32
36
|
LOCK = "store.lock"
|
|
37
|
+
# A handled key is kept this long (a restart catches up questions
|
|
38
|
+
# under an hour old).
|
|
39
|
+
SENT_KEEP = 24 * 3600
|
|
33
40
|
|
|
34
41
|
# The push services a subscription's endpoint may name: the
|
|
35
42
|
# browsers' (Apple, Google, Mozilla, Microsoft). chi web POSTs to
|
|
@@ -87,9 +94,12 @@ module Samagotchi
|
|
|
87
94
|
# Never the endpoint or the keys: what the page, the CLI and the log show.
|
|
88
95
|
def public_h
|
|
89
96
|
{ id: id, label: label, origin: origin, created_at: created_at, last_ok_at: last_ok_at,
|
|
90
|
-
last_error: last_error }
|
|
97
|
+
last_error: last_error, prefs: { done: done? } }
|
|
91
98
|
end
|
|
92
99
|
|
|
100
|
+
# The device wants a push for a long turn done (off by default).
|
|
101
|
+
def done? = prefs["done"] == true
|
|
102
|
+
|
|
93
103
|
def inspect = "#<Sub #{id} #{label.inspect}>"
|
|
94
104
|
alias_method :to_s, :inspect
|
|
95
105
|
end
|
|
@@ -101,6 +111,7 @@ module Samagotchi
|
|
|
101
111
|
end
|
|
102
112
|
|
|
103
113
|
def self.short_id(endpoint) = Digest::SHA256.hexdigest(endpoint.to_s)[0, 8]
|
|
114
|
+
def self.short_id?(text) = text.is_a?(String) && text.match?(/\A[0-9a-f]{8}\z/)
|
|
104
115
|
|
|
105
116
|
attr_reader :dir
|
|
106
117
|
|
|
@@ -113,6 +124,7 @@ module Samagotchi
|
|
|
113
124
|
@clock = clock
|
|
114
125
|
@key_mutex = Mutex.new
|
|
115
126
|
@key = nil
|
|
127
|
+
@sent_corrupt_logged = false
|
|
116
128
|
end
|
|
117
129
|
|
|
118
130
|
# Never the key.
|
|
@@ -121,11 +133,12 @@ module Samagotchi
|
|
|
121
133
|
|
|
122
134
|
def key_path = File.join(@dir, KEY_FILE)
|
|
123
135
|
def subs_path = File.join(@dir, SUBS_FILE)
|
|
136
|
+
def sent_path = File.join(@dir, SENT_FILE)
|
|
124
137
|
|
|
125
138
|
# Refuses a store others could read (PrivateFiles).
|
|
126
139
|
# @raise [Insecure]
|
|
127
140
|
def check_permissions!
|
|
128
|
-
PrivateFiles.check!(@dir, [key_path, subs_path], holds: "chi's push key and devices", error: Insecure)
|
|
141
|
+
PrivateFiles.check!(@dir, [key_path, subs_path, sent_path], holds: "chi's push key and devices", error: Insecure)
|
|
129
142
|
end
|
|
130
143
|
|
|
131
144
|
# chi web's VAPID key: vapid.pem, made (0600) the first time.
|
|
@@ -193,6 +206,21 @@ module Samagotchi
|
|
|
193
206
|
end
|
|
194
207
|
end
|
|
195
208
|
|
|
209
|
+
# Sets the device's prefs (+done+: a push for a long turn done).
|
|
210
|
+
# @param origin [String, nil] only a device subscribed there
|
|
211
|
+
# @return [Sub, nil] the device as it is now, nil when there is none
|
|
212
|
+
def set_prefs(id, done:, origin: nil)
|
|
213
|
+
update do |subs|
|
|
214
|
+
found = nil
|
|
215
|
+
subs = subs.map do |sub|
|
|
216
|
+
next sub unless sub.id == id && (origin.nil? || sub.origin == origin)
|
|
217
|
+
|
|
218
|
+
found = sub.with(prefs: sub.prefs.merge("done" => done == true))
|
|
219
|
+
end
|
|
220
|
+
[subs, found]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
196
224
|
# Records a delivery: +ok+ true stamps last_ok_at (and clears the
|
|
197
225
|
# error), else +error+ (a short word: "410", "timeout") is kept.
|
|
198
226
|
def mark(id, ok: false, error: nil)
|
|
@@ -206,10 +234,50 @@ module Samagotchi
|
|
|
206
234
|
end
|
|
207
235
|
end
|
|
208
236
|
|
|
237
|
+
# The attention keys already handled (pushed to a device, or
|
|
238
|
+
# skipped while the owner looked, or over the cap): key → iso8601
|
|
239
|
+
# time. None when sent.json is missing; one that doesn't read is
|
|
240
|
+
# logged once and read as none (the next #mark_sent rewrites it:
|
|
241
|
+
# it only spares a restart's repeat).
|
|
242
|
+
# @return [Hash{String => String}]
|
|
243
|
+
def sent_keys = read_sent
|
|
244
|
+
|
|
245
|
+
# Records +key+ as handled now; keys older than SENT_KEEP go.
|
|
246
|
+
def mark_sent(key)
|
|
247
|
+
locked do
|
|
248
|
+
at = now
|
|
249
|
+
keys = read_sent.reject { |_, time| stale_sent?(time, at) }
|
|
250
|
+
keys[key.to_s] = stamp(at)
|
|
251
|
+
body = JSON.pretty_generate({ "version" => 1, "keys" => keys })
|
|
252
|
+
AtomicFile.write(sent_path, "#{body}\n", perm: 0o600)
|
|
253
|
+
end
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
209
257
|
private
|
|
210
258
|
|
|
211
259
|
def now = @clock.call
|
|
212
260
|
|
|
261
|
+
def read_sent
|
|
262
|
+
data = JSON.parse(File.read(sent_path))
|
|
263
|
+
keys = data["keys"] if data.is_a?(Hash)
|
|
264
|
+
raise JSON::ParserError, "no keys" unless keys.is_a?(Hash) && keys.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
|
|
265
|
+
|
|
266
|
+
keys
|
|
267
|
+
rescue Errno::ENOENT
|
|
268
|
+
{}
|
|
269
|
+
rescue JSON::ParserError
|
|
270
|
+
Log.warn(:web, "push_sent_corrupt", path: sent_path) unless @sent_corrupt_logged
|
|
271
|
+
@sent_corrupt_logged = true
|
|
272
|
+
{}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def stale_sent?(time, at)
|
|
276
|
+
at - Time.iso8601(time) > SENT_KEEP
|
|
277
|
+
rescue ArgumentError
|
|
278
|
+
true
|
|
279
|
+
end
|
|
280
|
+
|
|
213
281
|
def stamp(time) = time.utc.iso8601
|
|
214
282
|
|
|
215
283
|
# @return [Array(String, String, String)] endpoint, p256dh, auth
|
data/lib/samagotchi/web/push.rb
CHANGED
|
@@ -4,13 +4,14 @@ require_relative "push/crypto"
|
|
|
4
4
|
require_relative "push/store"
|
|
5
5
|
require_relative "push/client"
|
|
6
6
|
require_relative "push/attention"
|
|
7
|
+
require_relative "push/presence"
|
|
7
8
|
require_relative "push/notifier"
|
|
8
9
|
require_relative "../config"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
module Web
|
|
12
13
|
# Web Push: chi web reaches the owner's phone (a Home Screen app that
|
|
13
|
-
# turned the bell on) when a session
|
|
14
|
+
# turned the bell on) when a session needs the user or a turn failed.
|
|
14
15
|
# It needs an https public origin (web.public_url or chi web --tunnel
|
|
15
16
|
# tailscale) and web.push.enabled (on by default); without a subscribed
|
|
16
17
|
# device it does nothing. See docs/configuration.md (Phone
|
|
@@ -21,9 +22,12 @@ module Samagotchi
|
|
|
21
22
|
class Refused < StandardError; end
|
|
22
23
|
|
|
23
24
|
# What chi web's push routes and its Notifier share for one run: the
|
|
24
|
-
# store, the client that signs with its key,
|
|
25
|
-
#
|
|
26
|
-
|
|
25
|
+
# store, the client that signs with its key, the public origin the
|
|
26
|
+
# devices subscribe on, and the pages' presence (the route writes it,
|
|
27
|
+
# the Notifier reads it).
|
|
28
|
+
Runtime = Data.define(:store, :client, :origin, :presence) do
|
|
29
|
+
def initialize(presence: Presence.new, **) = super
|
|
30
|
+
end
|
|
27
31
|
|
|
28
32
|
module_function
|
|
29
33
|
|
|
@@ -7,7 +7,8 @@ module Samagotchi
|
|
|
7
7
|
module Web
|
|
8
8
|
# The owner's Web Push routes (App::ROUTES, all :owner rows: a
|
|
9
9
|
# participant gets no pushes): what the page's bell asks (GET /api/push),
|
|
10
|
-
# subscribing this device and unsubscribing it,
|
|
10
|
+
# subscribing this device and unsubscribing it, a test push, and the
|
|
11
|
+
# pages' presence (no push while the owner looks at one).
|
|
11
12
|
# Included in App: its helpers (json_response, error_response,
|
|
12
13
|
# parse_json, request_body) and @push (a Push::Runtime, nil when push is
|
|
13
14
|
# off: no https public_url, or web.push.enabled false) answer. See
|
|
@@ -17,16 +18,22 @@ module Samagotchi
|
|
|
17
18
|
|
|
18
19
|
private
|
|
19
20
|
|
|
20
|
-
# {enabled, public_key, origin, this}: +this+ is the
|
|
21
|
-
# endpoint the page sends as ?endpoint= (its
|
|
22
|
-
# this store doesn't have it for this
|
|
21
|
+
# {enabled, public_key, origin, this, devices, stale}: +this+ is the
|
|
22
|
+
# device whose endpoint the page sends as ?endpoint= (its
|
|
23
|
+
# pushManager's), nil when this store doesn't have it for this
|
|
24
|
+
# origin; +devices+ this origin's (Sub#public_h, +this+ marked),
|
|
25
|
+
# +stale+ how many were made on another origin (never sent to).
|
|
23
26
|
def handle_push_info(req)
|
|
24
27
|
return json_response(200, { enabled: false, public_key: nil, origin: nil, this: nil }) unless @push
|
|
25
28
|
|
|
26
29
|
endpoint = req.params["endpoint"].to_s
|
|
27
|
-
|
|
30
|
+
subs = @push.store.subscriptions
|
|
31
|
+
mine = subs.select { |sub| sub.origin == @push.origin }
|
|
32
|
+
this = endpoint.empty? ? nil : mine.find { |sub| sub.endpoint == endpoint }
|
|
28
33
|
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 }
|
|
34
|
+
origin: @push.origin, this: this && { id: this.id, label: this.label },
|
|
35
|
+
devices: mine.map { |sub| sub.public_h.merge(this: sub.equal?(this)) },
|
|
36
|
+
stale: subs.size - mine.size })
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
# {subscription: {endpoint, keys: {p256dh, auth}}, replaces?: id} →
|
|
@@ -48,6 +55,24 @@ module Samagotchi
|
|
|
48
55
|
error_response(400, "invalid_subscription", e.message)
|
|
49
56
|
end
|
|
50
57
|
|
|
58
|
+
# PATCH …/subscriptions/ID {prefs: {done: true|false}} → 200 the
|
|
59
|
+
# device (Sub#public_h): whether it gets a push for a long turn done.
|
|
60
|
+
def handle_push_prefs(req, id)
|
|
61
|
+
return push_off unless @push
|
|
62
|
+
|
|
63
|
+
body = push_body(req)
|
|
64
|
+
prefs = body["prefs"] if body.is_a?(Hash)
|
|
65
|
+
unless prefs.is_a?(Hash) && prefs.keys == ["done"] && [true, false].include?(prefs["done"])
|
|
66
|
+
return error_response(400, "invalid_prefs", "give {prefs: {done: true|false}}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sub = Push::Store.short_id?(id) && @push.store.set_prefs(id, done: prefs["done"], origin: @push.origin)
|
|
70
|
+
return error_response(404, "unknown_device", "no device #{id[0, 16]} pushes from this origin") unless sub
|
|
71
|
+
|
|
72
|
+
Log.info(:web, "push_prefs", device: sub.id, done: sub.done?)
|
|
73
|
+
json_response(200, sub.public_h)
|
|
74
|
+
end
|
|
75
|
+
|
|
51
76
|
# {endpoint} → 204: this device's bell turned off.
|
|
52
77
|
def handle_push_unsubscribe(req)
|
|
53
78
|
return push_off unless @push
|
|
@@ -80,6 +105,17 @@ module Samagotchi
|
|
|
80
105
|
json_response(200, { devices: results })
|
|
81
106
|
end
|
|
82
107
|
|
|
108
|
+
# {page, front} → 204: whether the user is at this page (visible,
|
|
109
|
+
# focused, touched lately; notifications.js reports it). Also with
|
|
110
|
+
# push off: a desk tab has nothing to be told.
|
|
111
|
+
def handle_push_presence(req)
|
|
112
|
+
page, front = Push::Presence.parse(push_body(req))
|
|
113
|
+
return error_response(400, "invalid_presence", "give {page: id, front: true|false}") unless page
|
|
114
|
+
|
|
115
|
+
@push&.presence&.report(page, front)
|
|
116
|
+
[204, { "Cache-Control" => "no-store" }, []]
|
|
117
|
+
end
|
|
118
|
+
|
|
83
119
|
def push_off
|
|
84
120
|
error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
|
|
85
121
|
"and web.push.enabled")
|
|
@@ -219,7 +219,8 @@ module Samagotchi
|
|
|
219
219
|
# The Notifier seeds from the hub's snapshot, which is empty before
|
|
220
220
|
# a scan: one now, so what is already open never pushes.
|
|
221
221
|
hub.scan
|
|
222
|
-
notifier = Push::Notifier.new(store: push.store, client: push.client, origin: push.origin
|
|
222
|
+
notifier = Push::Notifier.new(store: push.store, client: push.client, origin: push.origin,
|
|
223
|
+
presence: push.presence).start(hub)
|
|
223
224
|
Samagotchi::Log.info(:web, "push", devices: push.store.for_origin(push.origin).size)
|
|
224
225
|
end
|
|
225
226
|
hub.start
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.112.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -843,6 +843,7 @@ files:
|
|
|
843
843
|
- lib/samagotchi/web/push/client.rb
|
|
844
844
|
- lib/samagotchi/web/push/crypto.rb
|
|
845
845
|
- lib/samagotchi/web/push/notifier.rb
|
|
846
|
+
- lib/samagotchi/web/push/presence.rb
|
|
846
847
|
- lib/samagotchi/web/push/store.rb
|
|
847
848
|
- lib/samagotchi/web/push_routes.rb
|
|
848
849
|
- lib/samagotchi/web/qr.rb
|