samagotchi 0.111.0 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/configuration.md +24 -14
  5. data/docs/internals/remote-sessions.md +5 -1
  6. data/docs/internals/web-push.md +122 -46
  7. data/docs/sessions.md +3 -1
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/reply_wait/poll.rb +11 -10
  10. data/lib/samagotchi/reply_wait.rb +30 -3
  11. data/lib/samagotchi/session.rb +4 -2
  12. data/lib/samagotchi/session_access/remote.rb +5 -1
  13. data/lib/samagotchi/version.rb +1 -1
  14. data/lib/samagotchi/web/app.rb +3 -0
  15. data/lib/samagotchi/web/participant_access.rb +1 -1
  16. data/lib/samagotchi/web/public/chains.js +9 -6
  17. data/lib/samagotchi/web/public/format.js +20 -5
  18. data/lib/samagotchi/web/public/index.html +16 -5
  19. data/lib/samagotchi/web/public/lists_view.js +3 -2
  20. data/lib/samagotchi/web/public/notifications.js +115 -13
  21. data/lib/samagotchi/web/public/notify.js +3 -3
  22. data/lib/samagotchi/web/public/participant.html +4 -0
  23. data/lib/samagotchi/web/public/push.js +16 -17
  24. data/lib/samagotchi/web/public/session_state.js +4 -3
  25. data/lib/samagotchi/web/public/sw.js +6 -5
  26. data/lib/samagotchi/web/push/attention.rb +80 -35
  27. data/lib/samagotchi/web/push/client.rb +23 -6
  28. data/lib/samagotchi/web/push/notifier.rb +152 -20
  29. data/lib/samagotchi/web/push/presence.rb +60 -0
  30. data/lib/samagotchi/web/push/store.rb +84 -6
  31. data/lib/samagotchi/web/push.rb +8 -4
  32. data/lib/samagotchi/web/push_routes.rb +49 -8
  33. data/lib/samagotchi/web/server.rb +2 -1
  34. data/lib/samagotchi/web/session_access_routes.rb +19 -8
  35. metadata +2 -1
@@ -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.
@@ -158,20 +171,30 @@ module Samagotchi
158
171
  # Adds the subscription a page's pushManager.subscribe gave (its
159
172
  # JSON: endpoint, keys {p256dh, auth}); the same endpoint again
160
173
  # replaces the old entry (a device subscribing again).
174
+ # +replaces+ is the short id this device had before (the page keeps
175
+ # it): that entry on +origin+ goes too, before the cap is counted,
176
+ # since the device has a new endpoint and the old one is dead (iOS
177
+ # revokes silently: its push service keeps answering 201). Yields
178
+ # the entry dropped that way, if any; an unknown id is ignored.
161
179
  # @return [Sub]
162
180
  # @raise [Invalid] one message per refusal
163
- def add(subscription, origin:, label:)
181
+ def add(subscription, origin:, label:, replaces: nil)
164
182
  endpoint, p256dh, auth = validate(subscription)
165
183
  at = stamp(now)
166
184
  sub = Sub.new(id: Store.short_id(endpoint), endpoint: endpoint, p256dh: p256dh, auth: auth, origin: origin,
167
185
  label: label.to_s[0, LABEL_MAX], created_at: at, last_ok_at: nil, last_error: nil,
168
186
  prefs: { "done" => false })
169
- update do |subs|
187
+ replaced = nil
188
+ added = update do |subs|
170
189
  kept = subs.reject { |old| old.endpoint == endpoint }
190
+ replaced = kept.find { |old| old.id == replaces && old.origin == origin } if replaces.is_a?(String)
191
+ kept -= [replaced] if replaced
171
192
  raise Invalid, "chi web already pushes to #{MAX_SUBS} devices: turn the bell off on one first" if kept.size >= MAX_SUBS
172
193
 
173
194
  [kept + [sub], sub]
174
195
  end
196
+ yield replaced if replaced && block_given?
197
+ added
175
198
  end
176
199
 
177
200
  # Removes the subscription with this short id or endpoint.
@@ -183,6 +206,21 @@ module Samagotchi
183
206
  end
184
207
  end
185
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
+
186
224
  # Records a delivery: +ok+ true stamps last_ok_at (and clears the
187
225
  # error), else +error+ (a short word: "410", "timeout") is kept.
188
226
  def mark(id, ok: false, error: nil)
@@ -196,10 +234,50 @@ module Samagotchi
196
234
  end
197
235
  end
198
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
+
199
257
  private
200
258
 
201
259
  def now = @clock.call
202
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
+
203
281
  def stamp(time) = time.utc.iso8601
204
282
 
205
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,32 +18,61 @@ 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
- # {subscription: {endpoint, keys: {p256dh, auth}}} → 201 {id, label}.
39
+ # {subscription: {endpoint, keys: {p256dh, auth}}, replaces?: id} →
40
+ # 201 {id, label}. +replaces+ is the id this device was given before
41
+ # (the page keeps it): that entry goes (Store#add).
33
42
  def handle_push_subscribe(req)
34
43
  return push_off unless @push
35
44
 
36
45
  body = push_body(req)
37
46
  return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
38
47
 
39
- sub = @push.store.add(body["subscription"], origin: @push.origin, label: device_label(req))
48
+ sub = @push.store.add(body["subscription"], origin: @push.origin, label: device_label(req),
49
+ replaces: body["replaces"]) do |old|
50
+ Log.info(:web, "push_replaced", device: old.id)
51
+ end
40
52
  Log.info(:web, "push_subscribed", device: sub.id)
41
53
  json_response(201, { id: sub.id, label: sub.label })
42
54
  rescue Push::Store::Invalid => e
43
55
  error_response(400, "invalid_subscription", e.message)
44
56
  end
45
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
+
46
76
  # {endpoint} → 204: this device's bell turned off.
47
77
  def handle_push_unsubscribe(req)
48
78
  return push_off unless @push
@@ -75,6 +105,17 @@ module Samagotchi
75
105
  json_response(200, { devices: results })
76
106
  end
77
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
+
78
119
  def push_off
79
120
  error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
80
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
@@ -171,13 +171,16 @@ module Samagotchi
171
171
  # (SessionAccess::Local#wait_reply). Body: cursor, baseline
172
172
  # ({messages, question_id, last_turn}, as the prepare route gave it),
173
173
  # turn, queued, ended_before, owner_grace (seconds, null: wait for
174
- # whatever wakes a worker), timeout (seconds, capped). 200: the
175
- # ReplyWait::Result's fields (status: done, waiting_for_answer,
176
- # no_reply, error, stopped, timeout or worker_gone; text, file,
177
- # question, outcome, …; never the Session) and parent_strategy, what
178
- # a parent may pick on the session's questions here
179
- # (ParentReport.strategy_for). A timeout status: ask again. 503
180
- # server_stopping when chi web stops mid-wait.
174
+ # whatever wakes a worker), timeout (seconds, capped), running_look
175
+ # (the last timeout's, as it came). 200: the ReplyWait::Result's
176
+ # fields (status: done, waiting_for_answer, no_reply, error, stopped,
177
+ # timeout or worker_gone; text, file, question, outcome, …; never the
178
+ # Session) and parent_strategy, what a parent may pick on the
179
+ # session's questions here (ParentReport.strategy_for). A timeout
180
+ # status: ask again, with its running_look ({last_end, claimed}, or
181
+ # null: the running turn this slice saw, so a settle between two
182
+ # slices still ends the wait). 503 server_stopping when chi web stops
183
+ # mid-wait.
181
184
  def handle_wait(req, id)
182
185
  body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
183
186
  options, error = wait_options(body)
@@ -209,8 +212,15 @@ module Samagotchi
209
212
  end
210
213
 
211
214
  timeout = [numbers["timeout"] || WAIT_MAX_SECONDS, WAIT_MAX_SECONDS].min
215
+ look = body["running_look"]
216
+ running_look = look.nil? ? nil : ReplyWait::RunningLook.from_h(look)
217
+ if !look.nil? && running_look.nil?
218
+ return [nil, error_response(400, "invalid_wait", "running_look is {last_end, claimed: [file names]}")]
219
+ end
220
+
212
221
  [{ cursor: strings["cursor"], turn: strings["turn"], baseline: baseline, timeout: timeout,
213
- owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true }, nil]
222
+ owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true,
223
+ running_look: running_look }, nil]
214
224
  end
215
225
 
216
226
  def wait_baseline(raw)
@@ -231,6 +241,7 @@ module Samagotchi
231
241
  RESULT_FIELDS.each { |key| json[key] = result[key] }
232
242
  json[:text] = json[:text]&.dup&.force_encoding(Encoding::UTF_8)&.scrub
233
243
  json[:question] = result.question&.to_file
244
+ json[:running_look] = result.running_look&.to_h
234
245
  json[:parent_strategy] = strategy_json(strategy)
235
246
  json
236
247
  end
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.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