samagotchi 0.111.1 → 0.113.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.
@@ -4,12 +4,12 @@ module Samagotchi
4
4
  module Web
5
5
  module Push
6
6
  # When a session needs the user, decided from two of the hub's session
7
- # summaries (SessionSummary.build: symbol keys): the server's port of
8
- # the page's rules (web/public/notify.js attentionFor, closedKeys,
9
- # attentionText), so a push and the tab's notification ring for the
10
- # same thing under the same key. P1 pushes approvals and questions
11
- # only (the plan's D1): a step-limit question, a card, a failed or
12
- # done turn come later. See docs/internals/web-push.md.
7
+ # summaries (SessionSummary.build: symbol keys, but +last_turn+'s are
8
+ # strings): the server's port of the page's rules (web/public/notify.js
9
+ # attentionFor, closedKeys, attentionText), so a push and the tab's
10
+ # notification ring for the same thing under the same key.
11
+ # spec/fixtures/attention_cases.json holds the cases both sides answer.
12
+ # See docs/internals/web-push.md.
13
13
  module Attention
14
14
  module_function
15
15
 
@@ -23,11 +23,20 @@ module Samagotchi
23
23
  # (or hook question) waits, as its parent may relay it.
24
24
  RELAY_HOLD = 3.5
25
25
  RELAYED_KINDS = %w[approval hook].freeze
26
- # The reasons P1 pushes.
27
- PUSHED = %w[approval question].freeze
26
+ # The reasons that wait on the user: open until answered, counted in
27
+ # the badge, cleaned up once answered. A failed or done turn is
28
+ # neither (nothing to answer).
29
+ WAITING = %w[approval question continue card].freeze
30
+ # notify.js DONE_MIN_SECONDS, UNWATCHED_ORIGINS: a finished turn
31
+ # shorter than this was watched (no "done"), unless nobody started
32
+ # it (a delegate child's report, a context change, a finished task).
33
+ DONE_MIN_SECONDS = 10
34
+ UNWATCHED_ORIGINS = %w[delegate_report context task].freeze
28
35
  TITLE_MAX = 61
29
36
  BODIES = { "question" => "needs an answer", "approval" => "needs approval", "continue" => "hit its step limit",
30
- "card" => "needs you" }.freeze
37
+ "card" => "needs you", "failed" => "turn failed" }.freeze
38
+
39
+ def waiting?(reason) = WAITING.include?(reason)
31
40
 
32
41
  # notify.js questionReason: a hook's question is a question.
33
42
  def reason_of(question)
@@ -39,52 +48,81 @@ module Samagotchi
39
48
  end
40
49
 
41
50
  # What +nxt+ newly needs the user for, compared with +prev+ (nil for
42
- # a session not seen yet).
51
+ # a session not seen yet): a new question first, then a new card,
52
+ # then a finished turn (notify.js attentionFor's order).
43
53
  # @return [Event, nil]
44
54
  def for(prev, nxt)
45
55
  return nil unless nxt && nxt[:id]
46
56
 
47
57
  question = nxt[:pending_question]
48
- return nil unless question && question[:id] && question[:id] != prev&.dig(:pending_question, :id)
49
- # Relayed to the parent already: the parent's notifies.
50
- return nil if question[:relayed_to]
58
+ if question.is_a?(Hash) && question[:id] && question[:id] != prev&.dig(:pending_question, :id)
59
+ # Relayed to the parent already: the parent's notifies.
60
+ return nil if question[:relayed_to]
51
61
 
52
- reason = reason_of(question)
53
- return nil unless PUSHED.include?(reason)
62
+ hold = RELAY_HOLD if nxt[:parent_id] && RELAYED_KINDS.include?(question[:kind])
63
+ return Event.new(reason: reason_of(question), session_id: nxt[:id], key: key(nxt[:id], question[:id]), hold: hold)
64
+ end
65
+ card = nxt[:pending_card]
66
+ if card.is_a?(Hash) && card[:id] && card[:id] != prev&.dig(:pending_card, :id)
67
+ return Event.new(reason: "card", session_id: nxt[:id], key: card_key(nxt[:id], card[:id]), hold: nil)
68
+ end
69
+
70
+ turn_event(prev, nxt)
71
+ end
54
72
 
55
- hold = RELAY_HOLD if nxt[:parent_id] && RELAYED_KINDS.include?(question[:kind])
56
- Event.new(reason: reason, session_id: nxt[:id], key: key(nxt[:id], question[:id]), hold: hold)
73
+ # A top-level turn that ended since +prev+: failed, or done when it
74
+ # ran long or nobody watched it. A delegated child's turn is
75
+ # reported by its parent's.
76
+ def turn_event(prev, nxt)
77
+ turn = nxt[:last_turn]
78
+ return nil unless turn.is_a?(Hash) && turn["ended_at"]
79
+ return nil if turn["ended_at"] == prev&.dig(:last_turn, "ended_at") || nxt[:parent_id]
80
+
81
+ reason = case turn["outcome"]
82
+ when "failed" then "failed"
83
+ when "completed"
84
+ "done" if seconds(turn) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.include?(turn["origin"])
85
+ end
86
+ reason && Event.new(reason: reason, session_id: nxt[:id], key: key(nxt[:id], turn["ended_at"]), hold: nil)
57
87
  end
58
88
 
59
89
  # The keys of what +prev+ had open and +nxt+ no longer has: its
60
- # question answered (anywhere), gone or newly relayed; everything
61
- # when the session is gone (+nxt+ nil).
90
+ # question answered (anywhere), gone or newly relayed, its card
91
+ # resolved or replaced; everything when the session is gone (+nxt+
92
+ # nil).
62
93
  # @return [Array<String>]
63
94
  def closed_keys(prev, nxt)
95
+ keys = []
64
96
  question = prev&.dig(:pending_question)
65
- return [] unless question && question[:id]
66
-
67
- now = nxt&.dig(:pending_question)
68
- gone = now.nil? || now[:id] != question[:id] || (now[:relayed_to] && !question[:relayed_to])
69
- gone ? [key(prev[:id], question[:id])] : []
97
+ if question.is_a?(Hash) && question[:id]
98
+ now = nxt&.dig(:pending_question)
99
+ gone = !now.is_a?(Hash) || now[:id] != question[:id] || (now[:relayed_to] && !question[:relayed_to])
100
+ keys << key(prev[:id], question[:id]) if gone
101
+ end
102
+ card = prev&.dig(:pending_card)
103
+ keys << card_key(prev[:id], card[:id]) if card.is_a?(Hash) && card[:id] && nxt&.dig(:pending_card, :id) != card[:id]
104
+ keys
70
105
  end
71
106
 
72
- # Every key still open in +by_id+ (session id → summary): a
73
- # pending question with an id, not relayed, of a reason P1 pushes.
74
- # The rule push.js openPushKeys keeps, so the show's +open+ list,
75
- # the page's cleanup and the badge agree.
107
+ # Every waiting key still open in +by_id+ (session id → summary): a
108
+ # pending question with an id, not relayed (any reason: a step-limit
109
+ # continue too), and a card with actions. The rule push.js
110
+ # openPushKeys keeps, so the show's +open+ list, the page's cleanup
111
+ # and the badge agree.
76
112
  # @return [Array<String>]
77
113
  def open_keys(by_id)
78
- by_id.each_value.filter_map do |summary|
114
+ by_id.each_value.flat_map do |summary|
79
115
  question = summary[:pending_question]
80
- next unless question.is_a?(Hash) && question[:id] && !question[:relayed_to]
81
- next unless PUSHED.include?(reason_of(question))
82
-
83
- key(summary[:id], question[:id])
116
+ card = summary[:pending_card]
117
+ keys = []
118
+ keys << key(summary[:id], question[:id]) if question.is_a?(Hash) && question[:id] && !question[:relayed_to]
119
+ keys << card_key(summary[:id], card[:id]) if card.is_a?(Hash) && card[:id]
120
+ keys
84
121
  end
85
122
  end
86
123
 
87
- def key(session_id, question_id) = "#{session_id}:#{question_id}"
124
+ def key(session_id, id) = "#{session_id}:#{id}"
125
+ def card_key(session_id, card_id) = "#{session_id}:card:#{card_id}"
88
126
 
89
127
  # notify.js attentionText: the session's name (clipped) and the
90
128
  # reason, never the question or the command (a lock screen shows it).
@@ -92,7 +130,14 @@ module Samagotchi
92
130
  def text(summary, reason)
93
131
  name = (summary && [summary[:title], summary[:first_preview], summary[:last_prompt]].find { |v| present?(v) }) ||
94
132
  "chi session"
95
- [clip(name.to_s.strip, TITLE_MAX), BODIES.fetch(reason, "")]
133
+ body = reason == "done" ? "done in #{seconds(summary&.dig(:last_turn) || {}).round} s" : BODIES.fetch(reason, "")
134
+ [clip(name.to_s.strip, TITLE_MAX), body]
135
+ end
136
+
137
+ # notify.js Number(turn.seconds): anything that isn't a number is 0.
138
+ def seconds(turn)
139
+ value = turn["seconds"]
140
+ value.is_a?(Numeric) ? value.to_f : Float(value.to_s, exception: false) || 0.0
96
141
  end
97
142
 
98
143
  def present?(value) = !value.nil? && !value.to_s.strip.empty?
@@ -11,18 +11,25 @@ module Samagotchi
11
11
  module Push
12
12
  # One push to one device: the payload encrypted for its keys
13
13
  # (Crypto.encrypt), POSTed to its endpoint with chi web's VAPID header,
14
- # and the push service's answer as a Result. No retry (P1). See
15
- # docs/internals/web-push.md.
14
+ # and the push service's answer as a Result. It never retries itself:
15
+ # a Result says when a retry may help (+retry_after+), and the
16
+ # Notifier keeps it. See docs/internals/web-push.md.
16
17
  class Client
17
18
  OPEN_TIMEOUT = 5
18
19
  READ_TIMEOUT = 10
20
+ # A 429 or 5xx's Retry-After (delta-seconds) is taken up to this;
21
+ # without one (or as an HTTP date) a retry waits RETRY_DEFAULT.
22
+ RETRY_MAX = 60
23
+ RETRY_DEFAULT = 10
19
24
 
20
25
  # +status+ :ok (201/200/202), :gone (404/410: the device dropped
21
26
  # it), :bad_jwt (403 BadJwtToken: the push service refused chi's
22
27
  # VAPID token) or :failed; +code+ the HTTP status (nil when none
23
- # came), +error+ a short word for a failure, +ms+ the call's time.
24
- Result = Data.define(:status, :code, :ms, :error) do
25
- def initialize(error: nil, **) = super
28
+ # came), +error+ a short word for a failure, +ms+ the call's time,
29
+ # +retry_after+ the seconds after which one retry may go (a 429 or
30
+ # 5xx; nil for anything else).
31
+ Result = Data.define(:status, :code, :ms, :error, :retry_after) do
32
+ def initialize(error: nil, retry_after: nil, **) = super
26
33
  def ok? = status == :ok
27
34
  end
28
35
 
@@ -93,7 +100,17 @@ module Samagotchi
93
100
  when 403 then response.body.to_s.include?("BadJwtToken") ? :bad_jwt : :failed
94
101
  else :failed
95
102
  end
96
- Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil)
103
+ Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil,
104
+ retry_after: retry_after(response, code))
105
+ end
106
+
107
+ # The push service is busy (429) or failing (5xx): when to try once
108
+ # more.
109
+ def retry_after(response, code)
110
+ return nil unless code == 429 || (500..599).cover?(code)
111
+
112
+ value = response["Retry-After"].to_s.strip
113
+ value.match?(/\A\d+\z/) ? [value.to_i, RETRY_MAX].min : RETRY_DEFAULT
97
114
  end
98
115
 
99
116
  def ms(started) = ((@monotonic.call - started) * 1000).round
@@ -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