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.
@@ -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 approval or question (Attention.for) into a show, and
14
- # hands it to its own sender thread through a Queue; one answered
15
- # anywhere (Attention.closed_keys) only cancels its show's hold.
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 every device subscribed on this
29
- # run's public origin. See docs/internals/web-push.md.
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
- def initialize(store:, client:, origin:, hold: HOLD, now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
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
- return if due.empty?
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 do |show|
183
- key = show.attention.key
184
- next unless open.include?(key)
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
- deliver(show_payload(show, open), kind: show.attention.reason)
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 kind [String] the reason, for the log line
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, kind:)
200
- return false if @refused
201
-
202
- @store.for_origin(@origin).map do |sub|
203
- result = @client.deliver(sub, payload, ttl: SHOW_TTL, urgency: "high")
204
- record(sub, result, kind)
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, and
20
- # subscriptions.json, the devices that turned the bell on. A
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
@@ -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 waits on an approval or a question.
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, and the public origin
25
- # the devices subscribe on.
26
- Runtime = Data.define(:store, :client, :origin)
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, and a test push.
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 device whose
21
- # endpoint the page sends as ?endpoint= (its pushManager's), nil when
22
- # this store doesn't have it for this origin.
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
- this = endpoint.empty? ? nil : @push.store.for_origin(@push.origin).find { |sub| sub.endpoint == endpoint }
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).start(hub)
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.111.1
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