samagotchi 0.110.0 → 0.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +25 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "crypto"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Web
|
|
11
|
+
module Push
|
|
12
|
+
# One push to one device: the payload encrypted for its keys
|
|
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.
|
|
16
|
+
class Client
|
|
17
|
+
OPEN_TIMEOUT = 5
|
|
18
|
+
READ_TIMEOUT = 10
|
|
19
|
+
|
|
20
|
+
# +status+ :ok (201/200/202), :gone (404/410: the device dropped
|
|
21
|
+
# it), :bad_jwt (403 BadJwtToken: the push service refused chi's
|
|
22
|
+
# 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
|
|
26
|
+
def ok? = status == :ok
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Net::HTTP over TLS, the timeouts above; a transport takes the URI
|
|
30
|
+
# and the request and returns the response (specs inject one).
|
|
31
|
+
TLS = lambda do |uri, request|
|
|
32
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
|
|
33
|
+
http.request(request)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# What a failed connection says in the log: never its message (it
|
|
38
|
+
# may carry the endpoint).
|
|
39
|
+
NETWORK_ERRORS = [SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Timeout::Error,
|
|
40
|
+
Net::HTTPBadResponse, Net::ProtocolError].freeze
|
|
41
|
+
|
|
42
|
+
# @param key [OpenSSL::PKey::EC] chi web's VAPID key (Store#vapid_key)
|
|
43
|
+
# @param contact [String] the VAPID sub (Crypto.valid_sub?)
|
|
44
|
+
# @param transport [#call] (uri, request) → Net::HTTPResponse
|
|
45
|
+
# @param clock [#call] the Time now (the JWT's exp)
|
|
46
|
+
# @param monotonic [#call] seconds, for +ms+
|
|
47
|
+
def initialize(key:, contact:, transport: TLS, clock: -> { Time.now },
|
|
48
|
+
monotonic: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
49
|
+
@key = key
|
|
50
|
+
@contact = contact
|
|
51
|
+
@transport = transport
|
|
52
|
+
@clock = clock
|
|
53
|
+
@monotonic = monotonic
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Never the key.
|
|
57
|
+
def inspect = "#<#{self.class.name} #{@contact}>"
|
|
58
|
+
alias to_s inspect
|
|
59
|
+
|
|
60
|
+
# @param sub [Store::Sub]
|
|
61
|
+
# @param payload [Hash] the JSON the service worker reads
|
|
62
|
+
# @param ttl [Integer] seconds the push service keeps it for an
|
|
63
|
+
# offline device
|
|
64
|
+
# @param urgency ["high", "normal"]
|
|
65
|
+
# @return [Result]
|
|
66
|
+
def deliver(sub, payload, ttl:, urgency:)
|
|
67
|
+
started = @monotonic.call
|
|
68
|
+
response = @transport.call(URI(sub.endpoint), request(sub, payload, ttl: ttl, urgency: urgency))
|
|
69
|
+
result_for(response, ms(started))
|
|
70
|
+
rescue *NETWORK_ERRORS => e
|
|
71
|
+
Result.new(status: :failed, code: nil, ms: ms(started), error: e.is_a?(Timeout::Error) ? "timeout" : e.class.name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def request(sub, payload, ttl:, urgency:)
|
|
77
|
+
body = Crypto.encrypt(JSON.generate(payload), p256dh: sub.p256dh_bytes, auth: sub.auth_bytes)
|
|
78
|
+
Net::HTTP::Post.new(URI(sub.endpoint)).tap do |req|
|
|
79
|
+
req["Content-Encoding"] = "aes128gcm"
|
|
80
|
+
req["Content-Type"] = "application/octet-stream"
|
|
81
|
+
req["TTL"] = ttl.to_i.to_s
|
|
82
|
+
req["Urgency"] = urgency
|
|
83
|
+
req["Authorization"] = Crypto.vapid_authorization(sub.endpoint, key: @key, sub: @contact, now: @clock.call)
|
|
84
|
+
req.body = body
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def result_for(response, elapsed)
|
|
89
|
+
code = response.code.to_i
|
|
90
|
+
status = case code
|
|
91
|
+
when 200, 201, 202 then :ok
|
|
92
|
+
when 404, 410 then :gone
|
|
93
|
+
when 403 then response.body.to_s.include?("BadJwtToken") ? :bad_jwt : :failed
|
|
94
|
+
else :failed
|
|
95
|
+
end
|
|
96
|
+
Result.new(status: status, code: code, ms: elapsed, error: status == :failed ? code.to_s : nil)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ms(started) = ((@monotonic.call - started) * 1000).round
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Web
|
|
9
|
+
module Push
|
|
10
|
+
# Web Push's two pieces of crypto, on OpenSSL alone (no gem): the
|
|
11
|
+
# message body a push service carries to the device (RFC 8291's
|
|
12
|
+
# aes128gcm, RFC 8188: one record, no padding), and the VAPID
|
|
13
|
+
# Authorization header that says the push comes from chi web's key
|
|
14
|
+
# (RFC 8292: an ES256 JWT for the endpoint's origin). The RFC 8291
|
|
15
|
+
# Appendix A vector pins #encrypt byte for byte
|
|
16
|
+
# (spec/web/push/crypto_spec.rb). See docs/internals/web-push.md.
|
|
17
|
+
module Crypto
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
GROUP = "prime256v1"
|
|
21
|
+
# RFC 8188's record size: one record holds a push message (< 4 KB).
|
|
22
|
+
RECORD_SIZE = 4096
|
|
23
|
+
# A VAPID token lives this long (RFC 8292 allows up to 24 h).
|
|
24
|
+
JWT_TTL = 12 * 3600
|
|
25
|
+
|
|
26
|
+
def b64u(bytes) = [bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
27
|
+
|
|
28
|
+
# base64url with or without its padding; ArgumentError when it
|
|
29
|
+
# isn't base64url.
|
|
30
|
+
def unb64u(text)
|
|
31
|
+
text = text.to_s
|
|
32
|
+
raise ArgumentError, "not base64url" unless text.match?(/\A[A-Za-z0-9_-]*={0,2}\z/)
|
|
33
|
+
|
|
34
|
+
text = text.delete_suffix("=").delete_suffix("=")
|
|
35
|
+
raise ArgumentError, "not base64url" if text.length % 4 == 1
|
|
36
|
+
|
|
37
|
+
(text.tr("-_", "+/") + ("=" * ((4 - (text.length % 4)) % 4))).unpack1("m0")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A new P-256 key (chi web's VAPID key; a message's sender key).
|
|
41
|
+
def generate_key = OpenSSL::PKey::EC.generate(GROUP)
|
|
42
|
+
|
|
43
|
+
# The 65-byte uncompressed point of +key+'s public half.
|
|
44
|
+
def raw_public(key) = key.public_key.to_octet_string(:uncompressed)
|
|
45
|
+
|
|
46
|
+
# What the page hands pushManager.subscribe as applicationServerKey.
|
|
47
|
+
def public_key_b64(key) = b64u(raw_public(key))
|
|
48
|
+
|
|
49
|
+
# A P-256 public key from its 65-byte uncompressed point (OpenSSL 3
|
|
50
|
+
# keys are immutable: built through an SPKI DER).
|
|
51
|
+
def ec_public(raw)
|
|
52
|
+
spki = OpenSSL::ASN1::Sequence([
|
|
53
|
+
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
|
|
54
|
+
OpenSSL::ASN1::ObjectId(GROUP)]),
|
|
55
|
+
OpenSSL::ASN1::BitString(raw)
|
|
56
|
+
])
|
|
57
|
+
OpenSSL::PKey::EC.new(spki.to_der)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A P-256 key pair from its 32-byte private scalar and its public
|
|
61
|
+
# point (RFC 8291's fixed sender key, a spec's receiver).
|
|
62
|
+
def ec_private(priv, pub)
|
|
63
|
+
der = OpenSSL::ASN1::Sequence([
|
|
64
|
+
OpenSSL::ASN1::Integer(1),
|
|
65
|
+
OpenSSL::ASN1::OctetString(priv),
|
|
66
|
+
OpenSSL::ASN1::ObjectId(GROUP, 0, :EXPLICIT),
|
|
67
|
+
OpenSSL::ASN1::BitString(pub, 1, :EXPLICIT)
|
|
68
|
+
])
|
|
69
|
+
OpenSSL::PKey::EC.new(der.to_der)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def hkdf(salt, ikm, info, length) = OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: length, hash: "SHA256")
|
|
73
|
+
|
|
74
|
+
# RFC 8291 §3.4: +plaintext+ for the device whose subscription keys
|
|
75
|
+
# are +p256dh+ (its 65-byte public point) and +auth+ (16 bytes), as
|
|
76
|
+
# one aes128gcm record (RFC 8188 §2: salt, rs, idlen, keyid, then the
|
|
77
|
+
# record with its 0x02 delimiter and no padding). +salt+ and
|
|
78
|
+
# +sender_key+ are fresh per message; the RFC vector fixes them.
|
|
79
|
+
# @return [String] the request body (binary)
|
|
80
|
+
def encrypt(plaintext, p256dh:, auth:, salt: OpenSSL::Random.random_bytes(16), sender_key: generate_key)
|
|
81
|
+
sender_public = raw_public(sender_key)
|
|
82
|
+
shared = sender_key.derive(ec_public(p256dh))
|
|
83
|
+
ikm = hkdf(auth, shared, "WebPush: info\0".b + p256dh.b + sender_public, 32)
|
|
84
|
+
cek = hkdf(salt, ikm, "Content-Encoding: aes128gcm\0".b, 16)
|
|
85
|
+
nonce = hkdf(salt, ikm, "Content-Encoding: nonce\0".b, 12)
|
|
86
|
+
cipher = OpenSSL::Cipher.new("aes-128-gcm").encrypt
|
|
87
|
+
cipher.key = cek
|
|
88
|
+
cipher.iv = nonce
|
|
89
|
+
record = cipher.update("#{plaintext.b}\x02".b) + cipher.final + cipher.auth_tag
|
|
90
|
+
salt.b + [RECORD_SIZE].pack("N") + [sender_public.bytesize].pack("C") + sender_public + record
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# RFC 8292: the Authorization header for a push to +endpoint+, signed
|
|
94
|
+
# with +key+ (chi web's VAPID key): aud = the endpoint's origin, exp
|
|
95
|
+
# = +now+ + JWT_TTL, sub = +sub+ (a contact URL: valid_sub?).
|
|
96
|
+
# @param now [Time]
|
|
97
|
+
def vapid_authorization(endpoint, key:, sub:, now: Time.now)
|
|
98
|
+
claims = { aud: origin_of(endpoint), exp: now.to_i + JWT_TTL, sub: sub }
|
|
99
|
+
input = "#{b64u(JSON.generate({ typ: "JWT", alg: "ES256" }))}.#{b64u(JSON.generate(claims))}"
|
|
100
|
+
# ES256 wants r‖s, 32 bytes each; OpenSSL signs DER.
|
|
101
|
+
r, s = OpenSSL::ASN1.decode(key.sign("SHA256", input)).value.map { |int| int.value.to_s(2).rjust(32, "\0") }
|
|
102
|
+
"vapid t=#{input}.#{b64u(r + s)}, k=#{public_key_b64(key)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# scheme://host[:port] of a URL, the port only when it isn't the
|
|
106
|
+
# scheme's default.
|
|
107
|
+
def origin_of(url)
|
|
108
|
+
uri = URI(url)
|
|
109
|
+
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A VAPID sub Apple takes: a mailto: with an @, or an https URL with
|
|
113
|
+
# a host. A bare "chi@localhost" gets 403 BadJwtToken (the spike).
|
|
114
|
+
def valid_sub?(sub)
|
|
115
|
+
text = sub.to_s
|
|
116
|
+
return text.match?(/\Amailto:[^@\s]+@[^@\s]+\z/) if text.start_with?("mailto:")
|
|
117
|
+
|
|
118
|
+
uri = URI.parse(text)
|
|
119
|
+
uri.scheme == "https" && !uri.host.to_s.empty?
|
|
120
|
+
rescue URI::InvalidURIError
|
|
121
|
+
false
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "attention"
|
|
5
|
+
require_relative "../../log"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module Web
|
|
9
|
+
module Push
|
|
10
|
+
# chi web's one sender of pushes: a SessionHub subscriber that keeps
|
|
11
|
+
# the summaries the page's trackAttention keeps (seeded by the
|
|
12
|
+
# 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.
|
|
16
|
+
#
|
|
17
|
+
# Every push it sends shows a notification: WebKit removes a
|
|
18
|
+
# subscription after a few pushes that show none (its silent-push
|
|
19
|
+
# quota), so there is no close push. A show carries +open+, the keys
|
|
20
|
+
# still open, and the badge, both from the sink's summaries
|
|
21
|
+
# (Attention.open_keys): the service worker closes the answered ones
|
|
22
|
+
# when it shows the new one, and the page when it opens.
|
|
23
|
+
#
|
|
24
|
+
# #call is the hub's sink: it runs under the hub's Monitor, on the
|
|
25
|
+
# tick thread and on chi web's request threads (SessionHub#touch), so
|
|
26
|
+
# it only enqueues; a push is a 0.5 s https call, made on the sender
|
|
27
|
+
# 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.
|
|
30
|
+
class Notifier
|
|
31
|
+
HOLD = 3.0
|
|
32
|
+
# Waiting kinds: kept 12 h for an offline phone, delivered at once.
|
|
33
|
+
SHOW_TTL = 12 * 3600
|
|
34
|
+
# A show carries +open+ only while its JSON stays this small: one
|
|
35
|
+
# aes128gcm record leaves 3993 bytes of plaintext under Web Push's
|
|
36
|
+
# 4096 (86 header, 1 delimiter, 16 tag). Longer, it goes without
|
|
37
|
+
# (and the worker cleans up nothing).
|
|
38
|
+
OPEN_LIMIT = 3000
|
|
39
|
+
STOP_JOIN = 2.0
|
|
40
|
+
|
|
41
|
+
Show = Data.define(:attention, :title, :body, :at)
|
|
42
|
+
Close = Data.define(:keys)
|
|
43
|
+
|
|
44
|
+
# @param store [Store] the devices (and the key the client signs with)
|
|
45
|
+
# @param client [#deliver] Client
|
|
46
|
+
# @param origin [String] this run's public origin: only the
|
|
47
|
+
# subscriptions made on it are sent to
|
|
48
|
+
# @param now [#call] monotonic seconds (specs move it)
|
|
49
|
+
def initialize(store:, client:, origin:, hold: HOLD, now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
50
|
+
@store = store
|
|
51
|
+
@client = client
|
|
52
|
+
@origin = origin
|
|
53
|
+
@hold = hold
|
|
54
|
+
@now = now
|
|
55
|
+
@queue = Queue.new
|
|
56
|
+
# The sink's side (under @mutex): the last summary per session.
|
|
57
|
+
@mutex = Mutex.new
|
|
58
|
+
@by_id = {}
|
|
59
|
+
@seeded = false
|
|
60
|
+
@early = []
|
|
61
|
+
# The sender's side (its thread only, or #pump in a spec).
|
|
62
|
+
@pending = {} # key → Show waiting its hold
|
|
63
|
+
@refused = false
|
|
64
|
+
@thread = nil
|
|
65
|
+
@handle = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Subscribes to +hub+ (its snapshot is the seed) and starts the
|
|
69
|
+
# sender thread. Call it after a hub.scan, or the snapshot is empty
|
|
70
|
+
# and the first scan's events push every open approval.
|
|
71
|
+
# @param sender [Boolean] false: no thread, a spec drives #pump
|
|
72
|
+
def start(hub, sender: true)
|
|
73
|
+
@handle, summaries = hub.subscribe(self, snapshot: true)
|
|
74
|
+
seed(summaries)
|
|
75
|
+
@thread = Thread.new { run } if sender
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Unsubscribes and ends the sender thread; holds still waiting are
|
|
80
|
+
# dropped, a push in flight may finish (joined with a timeout).
|
|
81
|
+
def stop
|
|
82
|
+
@handle&.unsubscribe
|
|
83
|
+
@handle = nil
|
|
84
|
+
@queue << :stop
|
|
85
|
+
@thread&.join(STOP_JOIN)
|
|
86
|
+
@thread = nil
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The hub's sink (SessionHub::Event): never blocks.
|
|
91
|
+
def call(event)
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
next @early << event unless @seeded
|
|
94
|
+
|
|
95
|
+
notice(event)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The seed: the summaries as the subscribe saw them. Events that came
|
|
100
|
+
# before it (newer than the snapshot) are applied after it.
|
|
101
|
+
def seed(summaries)
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
summaries.each { |summary| @by_id[summary[:id]] = summary if summary[:id] }
|
|
104
|
+
@seeded = true
|
|
105
|
+
@early.each { |event| notice(event) }
|
|
106
|
+
@early.clear
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# What is queued, handled now, then the shows whose hold is up
|
|
111
|
+
# pushed; the sender thread's step (specs call it with no thread).
|
|
112
|
+
def pump
|
|
113
|
+
loop do
|
|
114
|
+
message = @queue.pop(true)
|
|
115
|
+
return if message == :stop
|
|
116
|
+
|
|
117
|
+
handle(message)
|
|
118
|
+
rescue ThreadError
|
|
119
|
+
break
|
|
120
|
+
end
|
|
121
|
+
flush
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def notice(event)
|
|
127
|
+
case event.type
|
|
128
|
+
when "session"
|
|
129
|
+
summary = event.data[:session]
|
|
130
|
+
return unless summary.is_a?(Hash) && summary[:id]
|
|
131
|
+
|
|
132
|
+
prev = @by_id[summary[:id]]
|
|
133
|
+
@by_id[summary[:id]] = summary
|
|
134
|
+
keys = Attention.closed_keys(prev, summary)
|
|
135
|
+
@queue << Close.new(keys: keys) unless keys.empty?
|
|
136
|
+
attention = Attention.for(prev, summary)
|
|
137
|
+
enqueue_show(attention, summary) if attention
|
|
138
|
+
when "session_gone"
|
|
139
|
+
prev = @by_id.delete(event.data[:id])
|
|
140
|
+
keys = Attention.closed_keys(prev, nil)
|
|
141
|
+
@queue << Close.new(keys: keys) unless keys.empty?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def enqueue_show(attention, summary)
|
|
146
|
+
title, body = Attention.text(summary, attention.reason)
|
|
147
|
+
@queue << Show.new(attention: attention, title: title, body: body, at: @now.call)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run
|
|
151
|
+
loop do
|
|
152
|
+
due = @pending.values.map { |show| due_at(show) }.min
|
|
153
|
+
wait = due && [due - @now.call, 0].max
|
|
154
|
+
message = @queue.pop(timeout: wait)
|
|
155
|
+
break if message == :stop
|
|
156
|
+
|
|
157
|
+
handle(message) if message
|
|
158
|
+
flush
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
Log.exception(:web, "push_sender_failed", e)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle(message)
|
|
165
|
+
case message
|
|
166
|
+
when Show then @pending[message.attention.key] = message
|
|
167
|
+
# Answered anywhere: a show still in its hold is dropped. Nothing
|
|
168
|
+
# is pushed (a push that shows nothing counts against the quota).
|
|
169
|
+
when Close then message.keys.each { |key| @pending.delete(key) }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def due_at(show) = show.at + (show.attention.hold || @hold)
|
|
174
|
+
|
|
175
|
+
def flush
|
|
176
|
+
now = @now.call
|
|
177
|
+
due = @pending.values.select { |show| due_at(show) <= now }
|
|
178
|
+
return if due.empty?
|
|
179
|
+
|
|
180
|
+
due.each { |show| @pending.delete(show.attention.key) }
|
|
181
|
+
open = @mutex.synchronize { Attention.open_keys(@by_id) }
|
|
182
|
+
due.each do |show|
|
|
183
|
+
key = show.attention.key
|
|
184
|
+
next unless open.include?(key)
|
|
185
|
+
|
|
186
|
+
deliver(show_payload(show, open), kind: show.attention.reason)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def show_payload(show, open)
|
|
191
|
+
payload = { v: 1, kind: "show", tag: show.attention.key, title: show.title, body: show.body,
|
|
192
|
+
url: "/#/s/#{show.attention.session_id}", badge: open.size }
|
|
193
|
+
with_open = payload.merge(open: open)
|
|
194
|
+
JSON.generate(with_open).bytesize <= OPEN_LIMIT ? with_open : payload
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param kind [String] the reason, for the log line
|
|
198
|
+
# @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)
|
|
205
|
+
result.ok?
|
|
206
|
+
end.any?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def record(sub, result, kind)
|
|
210
|
+
fields = { device: sub.id, code: result.code, ms: result.ms, kind: kind }
|
|
211
|
+
case result.status
|
|
212
|
+
when :ok
|
|
213
|
+
Log.info(:web, "push_sent", **fields)
|
|
214
|
+
@store.mark(sub.id, ok: true)
|
|
215
|
+
when :gone
|
|
216
|
+
Log.info(:web, "push_gone", **fields)
|
|
217
|
+
@store.remove(sub.id)
|
|
218
|
+
when :bad_jwt
|
|
219
|
+
@refused = true
|
|
220
|
+
Log.warn(:web, "push_bad_jwt", echo: "[chi web] the push service refused chi's VAPID token: check " \
|
|
221
|
+
"web.push.contact (no pushes until chi web restarts)", **fields)
|
|
222
|
+
else
|
|
223
|
+
Log.warn(:web, "push_failed", error: result.error, **fields)
|
|
224
|
+
@store.mark(sub.id, error: result.error)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|