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.
@@ -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.
@@ -150,6 +163,10 @@ module Samagotchi
150
163
  []
151
164
  end
152
165
 
166
+ # As #subscriptions, but a corrupt file raises (chi web push says so).
167
+ # @raise [Corrupt]
168
+ def subscriptions! = read
169
+
153
170
  # The subscriptions for +origin+ (the ones chi web sends to now).
154
171
  def for_origin(origin) = subscriptions.select { |sub| sub.origin == origin }
155
172
 
@@ -193,6 +210,31 @@ module Samagotchi
193
210
  end
194
211
  end
195
212
 
213
+ # Removes the device with this short id, only one subscribed on
214
+ # +origin+ (nil: any; chi web push rm).
215
+ # @return [Sub, nil] the one removed
216
+ def remove_device(id, origin: nil)
217
+ update do |subs|
218
+ gone = subs.find { |sub| sub.id == id && (origin.nil? || sub.origin == origin) }
219
+ [subs - [gone], gone]
220
+ end
221
+ end
222
+
223
+ # Sets the device's prefs (+done+: a push for a long turn done).
224
+ # @param origin [String, nil] only a device subscribed there
225
+ # @return [Sub, nil] the device as it is now, nil when there is none
226
+ def set_prefs(id, done:, origin: nil)
227
+ update do |subs|
228
+ found = nil
229
+ subs = subs.map do |sub|
230
+ next sub unless sub.id == id && (origin.nil? || sub.origin == origin)
231
+
232
+ found = sub.with(prefs: sub.prefs.merge("done" => done == true))
233
+ end
234
+ [subs, found]
235
+ end
236
+ end
237
+
196
238
  # Records a delivery: +ok+ true stamps last_ok_at (and clears the
197
239
  # error), else +error+ (a short word: "410", "timeout") is kept.
198
240
  def mark(id, ok: false, error: nil)
@@ -206,10 +248,50 @@ module Samagotchi
206
248
  end
207
249
  end
208
250
 
251
+ # The attention keys already handled (pushed to a device, or
252
+ # skipped while the owner looked, or over the cap): key → iso8601
253
+ # time. None when sent.json is missing; one that doesn't read is
254
+ # logged once and read as none (the next #mark_sent rewrites it:
255
+ # it only spares a restart's repeat).
256
+ # @return [Hash{String => String}]
257
+ def sent_keys = read_sent
258
+
259
+ # Records +key+ as handled now; keys older than SENT_KEEP go.
260
+ def mark_sent(key)
261
+ locked do
262
+ at = now
263
+ keys = read_sent.reject { |_, time| stale_sent?(time, at) }
264
+ keys[key.to_s] = stamp(at)
265
+ body = JSON.pretty_generate({ "version" => 1, "keys" => keys })
266
+ AtomicFile.write(sent_path, "#{body}\n", perm: 0o600)
267
+ end
268
+ nil
269
+ end
270
+
209
271
  private
210
272
 
211
273
  def now = @clock.call
212
274
 
275
+ def read_sent
276
+ data = JSON.parse(File.read(sent_path))
277
+ keys = data["keys"] if data.is_a?(Hash)
278
+ raise JSON::ParserError, "no keys" unless keys.is_a?(Hash) && keys.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
279
+
280
+ keys
281
+ rescue Errno::ENOENT
282
+ {}
283
+ rescue JSON::ParserError
284
+ Log.warn(:web, "push_sent_corrupt", path: sent_path) unless @sent_corrupt_logged
285
+ @sent_corrupt_logged = true
286
+ {}
287
+ end
288
+
289
+ def stale_sent?(time, at)
290
+ at - Time.iso8601(time) > SENT_KEEP
291
+ rescue ArgumentError
292
+ true
293
+ end
294
+
213
295
  def stamp(time) = time.utc.iso8601
214
296
 
215
297
  # @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,17 @@ 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
31
+
32
+ # The test push (POST /api/push/test, chi web push test): no badge
33
+ # (it would clear the app's) and no open list (no cleanup).
34
+ TEST_PAYLOAD = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }.freeze
35
+ TEST_TTL = 600
27
36
 
28
37
  module_function
29
38
 
@@ -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,37 @@ 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 unknown_device(id) 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
+
76
+ # DELETE …/subscriptions/ID → 204: the devices list's Remove for
77
+ # another device (this device's row turns the bell off instead, so
78
+ # the page and the store agree).
79
+ def handle_push_remove(_req, id)
80
+ return push_off unless @push
81
+
82
+ sub = Push::Store.short_id?(id) && @push.store.remove_device(id, origin: @push.origin)
83
+ return unknown_device(id) unless sub
84
+
85
+ Log.info(:web, "push_removed", device: sub.id)
86
+ [204, { "Cache-Control" => "no-store" }, []]
87
+ end
88
+
51
89
  # {endpoint} → 204: this device's bell turned off.
52
90
  def handle_push_unsubscribe(req)
53
91
  return push_off unless @push
@@ -70,9 +108,8 @@ module Samagotchi
70
108
  endpoint = body["endpoint"] if body.is_a?(Hash)
71
109
  subs = @push.store.for_origin(@push.origin)
72
110
  subs = subs.select { |sub| sub.endpoint == endpoint } if endpoint.is_a?(String) && !endpoint.empty?
73
- payload = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }
74
111
  results = subs.map do |sub|
75
- result = @push.client.deliver(sub, payload, ttl: 600, urgency: "high")
112
+ result = @push.client.deliver(sub, Push::TEST_PAYLOAD, ttl: Push::TEST_TTL, urgency: "high")
76
113
  Log.info(:web, "push_test", device: sub.id, code: result.code, ms: result.ms)
77
114
  @push.store.remove(sub.id) if result.status == :gone
78
115
  { id: sub.id, label: sub.label, status: result.status.to_s, code: result.code, ms: result.ms }
@@ -80,6 +117,19 @@ module Samagotchi
80
117
  json_response(200, { devices: results })
81
118
  end
82
119
 
120
+ # {page, front} → 204: whether the user is at this page (visible,
121
+ # focused, touched lately; notifications.js reports it). Also with
122
+ # push off: a desk tab has nothing to be told.
123
+ def handle_push_presence(req)
124
+ page, front = Push::Presence.parse(push_body(req))
125
+ return error_response(400, "invalid_presence", "give {page: id, front: true|false}") unless page
126
+
127
+ @push&.presence&.report(page, front)
128
+ [204, { "Cache-Control" => "no-store" }, []]
129
+ end
130
+
131
+ def unknown_device(id) = error_response(404, "unknown_device", "no device #{id[0, 16]} pushes from this origin")
132
+
83
133
  def push_off
84
134
  error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
85
135
  "and web.push.enabled")
@@ -130,7 +130,9 @@ module Samagotchi
130
130
  annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
131
131
  public_url: public_url }
132
132
  started = if plan
133
- with_tunnel(provider, plan) { start(**options, public_notes: provider.start_lines(plan)) }
133
+ with_tunnel(provider, plan) do |up|
134
+ start(**options, public_notes: provider.start_lines(plan), on_bound: up)
135
+ end
134
136
  else
135
137
  start(**options)
136
138
  end
@@ -147,25 +149,43 @@ module Samagotchi
147
149
  end
148
150
 
149
151
  # The tunnel on while the block runs (the server), off after it
150
- # whatever ends it (Ctrl-C, SIGTERM, SIGHUP, a port taken), and only
151
- # once #up succeeded: a refusal before it, or a failed #up, turns off
152
- # nothing (a live tunnel of another chi web stays).
152
+ # whatever ends it (Ctrl-C, SIGTERM, SIGHUP), once #up may have taken
153
+ # effect: a refusal before it turns off nothing (a live tunnel of
154
+ # another chi web stays). The block gets #up to call once the port is
155
+ # bound (start's on_bound), so Serve never forwards the tailnet to a
156
+ # listener that took the port first. A signal during #up waits for it
157
+ # (its command runs in its own process group, and Serve keeps what it
158
+ # set even if chi stopped waiting), so it can't land between the serve
159
+ # going on and the teardown being due; #up is bounded by its timeout.
153
160
  def self.with_tunnel(provider, plan)
154
- provider.up(plan)
155
- upped = true
156
- yield
161
+ attempted = false
162
+ yield(lambda do
163
+ Thread.handle_interrupt(SignalException => :never) do
164
+ attempted = true
165
+ provider.up(plan)
166
+ end
167
+ end)
157
168
  ensure
158
- tunnel_down(provider, plan) if upped
169
+ tunnel_down(provider, plan) if attempted
159
170
  end
160
171
 
161
172
  # The tunnel's teardown, before any line (a hung-up terminal can't
162
173
  # write): a failure is one warning line, never a crash or another exit
163
- # status, and a second Ctrl-C during it only cuts it short.
174
+ # status. A second Ctrl-C (SIGTERM, SIGHUP) during it waits for it
175
+ # (bounded by its commands' timeouts) and is then dropped: the run
176
+ # already ends the way the first signal ends it.
164
177
  def self.tunnel_down(provider, plan)
165
- line = begin
166
- provider.down_line(plan, provider.down(plan))
167
- rescue Tunnel::Refused, SignalException => e
168
- provider.down_warning(plan, e)
178
+ line = nil
179
+ begin
180
+ Thread.handle_interrupt(SignalException => :never) do
181
+ line = begin
182
+ provider.down_line(plan, provider.down(plan))
183
+ rescue Tunnel::Refused => e
184
+ provider.down_warning(plan, e)
185
+ end
186
+ end
187
+ rescue SignalException
188
+ nil
169
189
  end
170
190
  say(line) if line
171
191
  end
@@ -187,11 +207,14 @@ module Samagotchi
187
207
  # (web.public_url, or --tunnel's): every request needs the access token
188
208
  # @param public_notes [Array<String>] lines after the proxied link's
189
209
  # (the tunnel's)
210
+ # @param on_bound [#call, nil] run once the port is bound, before any
211
+ # line (the tunnel's #up); what it raises ends the run
190
212
  # @return [Boolean] false when the port was taken (said so on stderr)
191
213
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
192
214
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
193
215
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
194
- working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [])
216
+ working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [],
217
+ on_bound: nil)
195
218
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
196
219
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
197
220
  end
@@ -219,14 +242,19 @@ module Samagotchi
219
242
  # The Notifier seeds from the hub's snapshot, which is empty before
220
243
  # a scan: one now, so what is already open never pushes.
221
244
  hub.scan
222
- notifier = Push::Notifier.new(store: push.store, client: push.client, origin: push.origin).start(hub)
245
+ notifier = Push::Notifier.new(store: push.store, client: push.client, origin: push.origin,
246
+ presence: push.presence).start(hub)
223
247
  Samagotchi::Log.info(:web, "push", devices: push.store.for_origin(push.origin).size)
224
248
  end
225
249
  hub.start
226
250
  # Said once the port is bound: a second chi web racing for it gets
227
251
  # the in-use line instead.
228
252
  running_path = Running.path(state_dir: state_dir)
253
+ webrick = nil
254
+ callback_unfinished = false
229
255
  started = lambda do
256
+ callback_unfinished = true
257
+ on_bound&.call
230
258
  record_running(running_path, port, public_url)
231
259
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
232
260
  puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
@@ -243,12 +271,14 @@ module Samagotchi
243
271
  open_url(open_target)
244
272
  end
245
273
  end
274
+ callback_unfinished = false
246
275
  end
247
276
 
248
277
  # The event loops (GET /api/events) end once WEBrick leaves
249
278
  # :Running: it joins every request thread before run returns.
250
279
  Rackup::Handler::WEBrick.run(app, Host: host, Port: port, AccessLog: [], Logger: Log.new($stderr, WEBrick::Log::WARN),
251
280
  StartCallback: started) do |server|
281
+ webrick = server
252
282
  app.server_running = -> { server.status == :Running }
253
283
  listen_lan(server, lan, port) if lan
254
284
  end
@@ -260,6 +290,9 @@ module Samagotchi
260
290
  warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
261
291
  false
262
292
  ensure
293
+ # A start callback that raised (on_bound's refusal, a Ctrl-C) leaves
294
+ # WEBrick :Running with its sockets open.
295
+ webrick&.listeners&.each { |socket| socket.close unless socket.closed? } if callback_unfinished
263
296
  Running.clear(path: running_path) if running_path
264
297
  config_restart_unsubscribe&.call
265
298
  notifier&.stop
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.113.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -503,6 +503,7 @@ files:
503
503
  - lib/samagotchi/prompt_note.rb
504
504
  - lib/samagotchi/prompt_warmup.rb
505
505
  - lib/samagotchi/proxy_trace.rb
506
+ - lib/samagotchi/push_command.rb
506
507
  - lib/samagotchi/question_answer.rb
507
508
  - lib/samagotchi/question_asked.rb
508
509
  - lib/samagotchi/question_desk.rb
@@ -780,6 +781,7 @@ files:
780
781
  - lib/samagotchi/web/public/plain_line.js
781
782
  - lib/samagotchi/web/public/prompt_history.js
782
783
  - lib/samagotchi/web/public/push.js
784
+ - lib/samagotchi/web/public/push_devices.js
783
785
  - lib/samagotchi/web/public/question_card.js
784
786
  - lib/samagotchi/web/public/question_cards.js
785
787
  - lib/samagotchi/web/public/read_view.js
@@ -843,6 +845,7 @@ files:
843
845
  - lib/samagotchi/web/push/client.rb
844
846
  - lib/samagotchi/web/push/crypto.rb
845
847
  - lib/samagotchi/web/push/notifier.rb
848
+ - lib/samagotchi/web/push/presence.rb
846
849
  - lib/samagotchi/web/push/store.rb
847
850
  - lib/samagotchi/web/push_routes.rb
848
851
  - lib/samagotchi/web/qr.rb