samagotchi 0.111.0 → 0.111.1
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 +16 -1
- data/docs/configuration.md +2 -1
- data/docs/internals/remote-sessions.md +5 -1
- data/docs/internals/web-push.md +16 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +11 -10
- data/lib/samagotchi/reply_wait.rb +30 -3
- data/lib/samagotchi/session_access/remote.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/participant_access.rb +1 -1
- data/lib/samagotchi/web/public/notifications.js +44 -3
- data/lib/samagotchi/web/push/store.rb +12 -2
- data/lib/samagotchi/web/push_routes.rb +7 -2
- data/lib/samagotchi/web/session_access_routes.rb +19 -8
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2153d4c9c7afa58d9d06d330d1839a021032df3502f515d4a093bad7f9dde347
|
|
4
|
+
data.tar.gz: 9ac11fddf172afa0eb4c5ea520d3e64471c0b2502753539fafcce28aa4e99294
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc73ef6cdd220a1dfb83b4e1b6f51ba4e42c80eff05ae661363b06d4d53b9bf4c0c2c954173997475571518ed98e7f589afe0eb9251787e3a894eab8dd1fec00
|
|
7
|
+
data.tar.gz: 9105631a20fcfe1e6889dbc904e146c4fcc68c75e5879111e589efe515ecaaecd4e03b6e7a3f389eb674bd1b0c0bb6a1420dca439b01cdefe04ed508d0830c9a
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,20 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.111.1] - 2026-10-25
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `chi --remote NAME send --wait` with no message (and one whose message merged into the running turn) ends
|
|
16
|
+
`canceled` (`worker_stopped`) when that turn's worker dies and the next worker settles it between two of chi web's
|
|
17
|
+
30 s wait slices, as it does on this machine, instead of waiting until `--timeout`.
|
|
18
|
+
- chi web: turning the phone's bell off and on again now replaces a subscription iOS dropped silently, instead of
|
|
19
|
+
keeping the dead one in `subscriptions.json`.
|
|
20
|
+
- chi web: a Home Screen app left open for days picks up a newer service worker (after `chi update`) when it comes
|
|
21
|
+
back to the front.
|
|
22
|
+
|
|
23
|
+
Update with `chi update` (the system bundle 0.111.1), then restart `chi web`.
|
|
24
|
+
|
|
11
25
|
## [0.111.0] - 2026-10-24
|
|
12
26
|
|
|
13
27
|
### Added
|
|
@@ -4250,7 +4264,8 @@ and long-lived sessions.
|
|
|
4250
4264
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4251
4265
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4252
4266
|
|
|
4253
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.111.
|
|
4267
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.111.1...HEAD
|
|
4268
|
+
[0.111.1]: https://github.com/dm1try/samagotchi/compare/v0.111.0...v0.111.1
|
|
4254
4269
|
[0.111.0]: https://github.com/dm1try/samagotchi/compare/v0.110.0...v0.111.0
|
|
4255
4270
|
[0.110.0]: https://github.com/dm1try/samagotchi/compare/v0.109.0...v0.110.0
|
|
4256
4271
|
[0.109.0]: https://github.com/dm1try/samagotchi/compare/v0.108.0...v0.109.0
|
data/docs/configuration.md
CHANGED
|
@@ -1800,7 +1800,8 @@ is in the background), as before.
|
|
|
1800
1800
|
- **The files**: `$XDG_STATE_HOME/samagotchi/web-push/` (next to `web-token`): `vapid.pem` (chi web's key) and
|
|
1801
1801
|
`subscriptions.json` (the devices), 0600 in a 0700 folder; chi web refuses to start when others can read them.
|
|
1802
1802
|
Subscriptions belong to the public URL they were made on: after renaming the machine or the tailnet, turn the bell
|
|
1803
|
-
on again on the phone (the old ones are never sent to).
|
|
1803
|
+
on again on the phone (the old ones are never sent to). iOS can drop a subscription without telling chi web; if
|
|
1804
|
+
notifications stop arriving, turn the bell off and on again: the new subscription replaces the dead one.
|
|
1804
1805
|
- `web.push.enabled: false` turns it off; `web.push.contact` (a `mailto:` address or an `https://` URL; default:
|
|
1805
1806
|
the public URL) is the contact the push services see in chi web's key. A value that is neither stops `chi web`
|
|
1806
1807
|
(Apple refuses it); a `[chi web] the push service refused chi's VAPID token` line means the contact or the key was
|
|
@@ -98,7 +98,11 @@ registry flag (`--recap-base-url` …) is this machine's config: `LOCAL_ONLY` wi
|
|
|
98
98
|
5. **The wait.** `ParentWait#wait_for_reply` → `Remote#wait_reply` loops on `POST …/wait` with
|
|
99
99
|
`timeout: min(time left, WAIT_SLICE)` (30 s). The server runs `ReplyWait.call` for at most `WAIT_MAX_SECONDS` (30 s)
|
|
100
100
|
and returns the `ReplyWait::Result` fields plus `parent_strategy`. On a `timeout` status the client asks again until
|
|
101
|
-
its own `--timeout` runs out, at which point it builds the `timeout` result itself.
|
|
101
|
+
its own `--timeout` runs out, at which point it builds the `timeout` result itself. Each slice is a fresh `ReplyWait`
|
|
102
|
+
there, so a `timeout` answer carries the slice's `running_look` (`ReplyWait::RunningLook`: the running turn it saw
|
|
103
|
+
with its checkpoint mark, `{last_end, claimed}`, else null) and the client sends it back on the next slice, as is:
|
|
104
|
+
a dead worker's turn settled between two slices ends the wait `canceled` (`worker_stopped`) as it does locally,
|
|
105
|
+
not at the timeout. A 503 `server_stopping` (chi web
|
|
102
106
|
shutting down mid-wait: `ReplyWait`'s `cancelled:` returns `:canceled`) is retried after `RETRY_AFTER` (1 s).
|
|
103
107
|
`HTTPTransport::READ_TIMEOUT` (75 s) is set above the longest answer, a wait slice or a create's wait for its worker.
|
|
104
108
|
6. **The report.** `ParentReport.report` prints it as it does locally. It uses `Remote#parent_strategy`, which
|
data/docs/internals/web-push.md
CHANGED
|
@@ -57,7 +57,11 @@ files, one per machine, never in config.yml:
|
|
|
57
57
|
`invalid_subscription`): an endpoint and both keys; https on port 443 without userinfo, at most 2048 bytes; a host in
|
|
58
58
|
`Store::PUSH_SERVICES` (Apple, Google's FCM, Mozilla, `*.notify.windows.com`; a `services:` seam for specs, not
|
|
59
59
|
config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point,
|
|
60
|
-
`auth` 16 bytes. The same endpoint again replaces the old entry.
|
|
60
|
+
`auth` 16 bytes. The same endpoint again replaces the old entry. `replaces:` (the device's id from its last
|
|
61
|
+
subscribe, which the page keeps) drops that entry on this origin too, before the `MAX_SUBS` cap is counted, and
|
|
62
|
+
yields it (the route logs `push_replaced`); its own endpoint is never dropped that way, and an unknown id is ignored.
|
|
63
|
+
That is how a subscription iOS revoked silently (Apple keeps answering 201, so `:gone` never comes) leaves the store:
|
|
64
|
+
the user turns the bell off and on, the page has no live subscription to delete, and the new one replaces it. Every write is a read-change-write under
|
|
61
65
|
`store.lock` (chi web's request threads and the sender thread both write). A `subscriptions.json` that doesn't read
|
|
62
66
|
lists no device and is never written over. `Store#check_permissions!` uses `Web::PrivateFiles.check!`, the check the
|
|
63
67
|
grants use, worded with what the folder holds; `Server.launch` runs it (`Push.preflight!`, with the contact and the
|
|
@@ -93,7 +97,10 @@ show iOS refuses is shown once more without its icon and data. A show calls `sho
|
|
|
93
97
|
icon, data: {url}})` first; then, when `open` is an array, closes every `getNotifications()` whose tag is a
|
|
94
98
|
question's `"<sid>:<qid>"` (never `chi:*`), isn't the new one and isn't in `open` (after the show: closing before it
|
|
95
99
|
fails on iOS; errors are swallowed); then `setAppBadge(badge)` when `badge` is a number. A tap focuses the app's first window and navigates it to the url, else opens one (only a path of chi web's); `install`
|
|
96
|
-
skips waiting, `activate` claims the clients.
|
|
100
|
+
skips waiting, `activate` claims the clients. A Home Screen app can stay open for days, and the browser checks for
|
|
101
|
+
a newer `sw.js` only on `register()` and on navigations: so when the page comes back in front (`backInFront` in
|
|
102
|
+
`notifications.js`) it calls `reg.update()` (errors swallowed), at most once per `SW_UPDATE_MS` (an hour; counted
|
|
103
|
+
from the register, which has just checked), with the bell on or off, never without push. No fetch handler and no caching: chi web has no offline mode, and a
|
|
97
104
|
fetch handler would change how the page loads. `spec/web/public/sw.test.js` runs it in a `vm` with a fake worker
|
|
98
105
|
scope.
|
|
99
106
|
|
|
@@ -145,8 +152,9 @@ public URL, or `web.push.enabled: false`):
|
|
|
145
152
|
|
|
146
153
|
- `GET /api/push[?endpoint=E]` → `{enabled, public_key, origin, this: {id, label}|null}`; off: `enabled: false` and
|
|
147
154
|
no key.
|
|
148
|
-
- `POST /api/push/subscriptions {subscription: {endpoint, keys}}` → 201 `{id, label}`; the label is
|
|
149
|
-
("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser)
|
|
155
|
+
- `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
|
|
156
|
+
chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
|
|
157
|
+
drops the device's earlier entry (`Store#add`).
|
|
150
158
|
- `DELETE /api/push/subscriptions {endpoint}` → 204.
|
|
151
159
|
- `POST /api/push/test {endpoint?}` → a test push to that device or every one on this origin, answered per device
|
|
152
160
|
(`{devices: [{id, label, status, code, ms}]}`); the bell sends one after subscribing.
|
|
@@ -160,8 +168,10 @@ standalone app: `matchMedia("(display-mode: standalone)")` or iOS's `navigator.s
|
|
|
160
168
|
`subscribe({reg, key, post})` (drops a subscription made with another key), `unsubscribe({reg, del})`. In the Home
|
|
161
169
|
Screen app the bell (`notifications.js`) registers `/sw.js`, asks `GET /api/push` with its subscription's endpoint,
|
|
162
170
|
and is "push to this phone" when chi web pushes: on while chi web has this device. A tap asks permission from the tap
|
|
163
|
-
(iOS asks only from a user gesture), subscribes, posts it
|
|
164
|
-
|
|
171
|
+
(iOS asks only from a user gesture), subscribes, posts it (with `replaces`: the id chi web answered last time,
|
|
172
|
+
kept in localStorage as `chi_push_device`, `PUSH_DEVICE_KEY`; a storage that throws only loses the replace) and sends
|
|
173
|
+
a test push; a tap when on unsubscribes and deletes, and forgets the id, unless there was no subscription left to
|
|
174
|
+
delete (revoked meanwhile): then the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
|
|
165
175
|
chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
|
|
166
176
|
|
|
167
177
|
**The device's tidy-up** (`syncDevice`, the Home Screen app with the bell on): closes the shown notifications whose
|
|
@@ -15,7 +15,7 @@ module Samagotchi
|
|
|
15
15
|
# them. ReplyWait.call's parameters say what each means.
|
|
16
16
|
class Poll
|
|
17
17
|
def initialize(id, state_dir:, cursor:, timeout:, poll_interval:, cancelled:, baseline:, owner_grace:, interject:,
|
|
18
|
-
turn:, queued:, ended_before:)
|
|
18
|
+
turn:, queued:, ended_before:, running_look: nil)
|
|
19
19
|
@id = id
|
|
20
20
|
@state_dir = state_dir
|
|
21
21
|
@cursor = cursor
|
|
@@ -41,10 +41,10 @@ module Samagotchi
|
|
|
41
41
|
@own_base = nil
|
|
42
42
|
@seen_running = false
|
|
43
43
|
# A turn seen running with its checkpoint mark (a wait with no
|
|
44
|
-
# turn
|
|
45
|
-
#
|
|
44
|
+
# turn:; RunningLook), by this call or an earlier one of the same
|
|
45
|
+
# wait, so its settle by a next worker (#settled_running) tells
|
|
46
46
|
# from a turn that ended or an input that runs next.
|
|
47
|
-
@running_look =
|
|
47
|
+
@running_look = running_look
|
|
48
48
|
@gone_since = nil
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -264,15 +264,14 @@ module Samagotchi
|
|
|
264
264
|
nil
|
|
265
265
|
end
|
|
266
266
|
|
|
267
|
-
RunningLook = Data.define(:last_end, :claimed)
|
|
268
|
-
|
|
269
267
|
def note_running(session)
|
|
270
268
|
return unless session.interrupted_turn
|
|
271
269
|
|
|
272
|
-
@running_look = RunningLook.new(last_end: session.last_turn&.ended_at,
|
|
273
|
-
claimed: SessionInbox.claimed_input_files(@session_dir))
|
|
270
|
+
@running_look = RunningLook.new(last_end: session.last_turn&.ended_at, claimed: claimed_names)
|
|
274
271
|
end
|
|
275
272
|
|
|
273
|
+
def claimed_names = SessionInbox.claimed_input_files(@session_dir).map { |path| File.basename(path) }.sort.freeze
|
|
274
|
+
|
|
276
275
|
# The turn seen running is gone with no turn's end: its worker died
|
|
277
276
|
# in it and the next one settled it (TurnCheckpoint.settle: idle, the
|
|
278
277
|
# mark gone, last_turn left alone), which ends a wait for it (chi
|
|
@@ -287,7 +286,7 @@ module Samagotchi
|
|
|
287
286
|
return nil unless look && session.status == Session::STATUS_IDLE && session.interrupted_turn.nil? &&
|
|
288
287
|
session.last_turn&.ended_at == look.last_end
|
|
289
288
|
return nil unless SessionInbox.find_new_input_files(@session_dir).empty?
|
|
290
|
-
return nil unless (
|
|
289
|
+
return nil unless (claimed_names - look.claimed).empty?
|
|
291
290
|
|
|
292
291
|
Result.new(status: :no_reply, outcome: "canceled", cancel_reason: TurnCheckpoint::WORKER_STOPPED, session: session)
|
|
293
292
|
end
|
|
@@ -303,7 +302,9 @@ module Samagotchi
|
|
|
303
302
|
nil
|
|
304
303
|
end
|
|
305
304
|
|
|
306
|
-
def timed_out
|
|
305
|
+
def timed_out
|
|
306
|
+
@deadline && ReplyWait.monotonic > @deadline ? Result.new(status: :timeout, running_look: @running_look) : nil
|
|
307
|
+
end
|
|
307
308
|
|
|
308
309
|
# Whether the session's last turn is the one that had ended at the
|
|
309
310
|
# baseline (its ended_at set and unchanged).
|
|
@@ -63,8 +63,31 @@ module Samagotchi
|
|
|
63
63
|
# @!attribute origin [String, nil] "command" for a :no_reply that a
|
|
64
64
|
# file-delivered session command settled (no turn ran:
|
|
65
65
|
# TurnCheckpoint.record_command_outcome); nil otherwise
|
|
66
|
+
# @!attribute running_look [RunningLook, nil] on a :timeout, the turn
|
|
67
|
+
# this wait saw running (call's running_look:, for a wait that goes
|
|
68
|
+
# on in a next call: chi web's 30 s slices of a remote wait)
|
|
66
69
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
67
|
-
:stopped_by, :kept_steps, :session, :turn_id, :origin, keyword_init: true)
|
|
70
|
+
:stopped_by, :kept_steps, :session, :turn_id, :origin, :running_look, keyword_init: true)
|
|
71
|
+
|
|
72
|
+
# A turn a wait with no turn: saw running with its checkpoint mark:
|
|
73
|
+
# last_turn's ended_at then and the names of the input files claimed
|
|
74
|
+
# then (*.json.processing), so its settle by a next worker tells from
|
|
75
|
+
# a turn that ended or an input that runs next (Poll#settled_running).
|
|
76
|
+
# Plain strings, so a remote wait carries it from one slice to the next.
|
|
77
|
+
RunningLook = Data.define(:last_end, :claimed) do
|
|
78
|
+
# @return [RunningLook, nil] nil unless +raw+ is {last_end: String or
|
|
79
|
+
# nil, claimed: [file names]} (string keys, as JSON parsed it)
|
|
80
|
+
def self.from_h(raw)
|
|
81
|
+
return nil unless raw.is_a?(Hash) && raw.keys.sort == %w[claimed last_end]
|
|
82
|
+
|
|
83
|
+
last_end = raw["last_end"]
|
|
84
|
+
claimed = raw["claimed"]
|
|
85
|
+
return nil unless (last_end.nil? || last_end.is_a?(String)) && claimed.is_a?(Array) &&
|
|
86
|
+
claimed.all? { |name| name.is_a?(String) && File.basename(name) == name }
|
|
87
|
+
|
|
88
|
+
new(last_end: last_end, claimed: claimed.sort.freeze)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
68
91
|
|
|
69
92
|
# The session as it was before a message went in (call's baseline:,
|
|
70
93
|
# #baseline_of): its message count, the question pending then, and when
|
|
@@ -133,13 +156,17 @@ module Samagotchi
|
|
|
133
156
|
# sorts at the cursor, not past it, and its end precedes the
|
|
134
157
|
# baseline, so without it the wait would time out "running" again.
|
|
135
158
|
# Not for a turn: wait (chi answer) whose own end it waits for.
|
|
159
|
+
# @param running_look [RunningLook, nil] the running turn an earlier
|
|
160
|
+
# call of the same wait saw (its :timeout result's running_look): its
|
|
161
|
+
# settle ends this call too, as if this call had seen it running
|
|
136
162
|
# @return [Result]
|
|
137
163
|
# @raise [ArgumentError] no such session
|
|
138
164
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
139
|
-
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false
|
|
165
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false,
|
|
166
|
+
running_look: nil)
|
|
140
167
|
Poll.new(id, state_dir: state_dir, cursor: cursor, timeout: timeout, poll_interval: poll_interval,
|
|
141
168
|
cancelled: cancelled, baseline: baseline, owner_grace: owner_grace, interject: interject, turn: turn,
|
|
142
|
-
queued: queued, ended_before: ended_before).run
|
|
169
|
+
queued: queued, ended_before: ended_before, running_look: running_look).run
|
|
143
170
|
end
|
|
144
171
|
|
|
145
172
|
# The session as it was before a message went in (call's baseline:):
|
|
@@ -333,7 +333,9 @@ module Samagotchi
|
|
|
333
333
|
end
|
|
334
334
|
|
|
335
335
|
# ReplyWait on the server, asked again each WAIT_SLICE until it ends
|
|
336
|
-
# or +timeout+ passes.
|
|
336
|
+
# or +timeout+ passes. Each slice is a fresh ReplyWait there: the
|
|
337
|
+
# running turn a slice saw (its timeout's running_look) goes into the
|
|
338
|
+
# next, so a settle between two slices ends the wait as it would here.
|
|
337
339
|
# @return [ReplyWait::Result]
|
|
338
340
|
# @raise [ArgumentError] the session is gone
|
|
339
341
|
def wait_reply(id, cursor:, timeout:, baseline:, owner_grace: nil, turn: nil, queued: false, ended_before: false,
|
|
@@ -356,6 +358,8 @@ module Samagotchi
|
|
|
356
358
|
@strategies[id] = strategy_of(reply.json["parent_strategy"])
|
|
357
359
|
result = result_of(reply.json)
|
|
358
360
|
return result unless result.status == :timeout
|
|
361
|
+
|
|
362
|
+
body[:running_look] = reply.json["running_look"]
|
|
359
363
|
end
|
|
360
364
|
end
|
|
361
365
|
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -341,7 +341,7 @@ module Samagotchi
|
|
|
341
341
|
grant = principal(req).grant
|
|
342
342
|
return not_permitted_for(req, principal(req)) unless own_turn?(grant, id, options[:turn])
|
|
343
343
|
|
|
344
|
-
options = options.merge(queued: true, cursor: nil)
|
|
344
|
+
options = options.merge(queued: true, cursor: nil, running_look: nil)
|
|
345
345
|
session = session_access.show(id)
|
|
346
346
|
result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
|
|
347
347
|
cancelled: -> { !@server_running.call || !@grants.active?(grant.id) })
|
|
@@ -25,6 +25,13 @@ import {
|
|
|
25
25
|
} from "./push.js";
|
|
26
26
|
|
|
27
27
|
export const NOTIFY_KEY = "chi_notify";
|
|
28
|
+
// The device id chi web gave this app's subscription (POST subscribe's
|
|
29
|
+
// answer): the next subscribe sends it as replaces, so a subscription iOS
|
|
30
|
+
// revoked silently (its push service keeps answering 201) leaves the store.
|
|
31
|
+
export const PUSH_DEVICE_KEY = "chi_push_device";
|
|
32
|
+
// A long-lived Home Screen app checks for a new sw.js at most this often
|
|
33
|
+
// when it comes back in front (reg.update()).
|
|
34
|
+
export const SW_UPDATE_MS = 60 * 60 * 1000;
|
|
28
35
|
|
|
29
36
|
const NOTIFY_TITLES = {
|
|
30
37
|
on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
|
|
@@ -68,6 +75,7 @@ function defaultChannel() {
|
|
|
68
75
|
// @param api (method, path, body) => Promise of chi web's JSON
|
|
69
76
|
// @param scoped the page shows one project's sessions (?dir=): it never
|
|
70
77
|
// tidies the device, as it doesn't see the others'
|
|
78
|
+
// @param now () => ms (the service worker update's spacing; specs)
|
|
71
79
|
// @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
|
|
72
80
|
export function createNotifications({
|
|
73
81
|
button,
|
|
@@ -80,6 +88,7 @@ export function createNotifications({
|
|
|
80
88
|
nav = globalThis.navigator,
|
|
81
89
|
api = defaultApi,
|
|
82
90
|
scoped = false,
|
|
91
|
+
now = () => Date.now(),
|
|
83
92
|
}) {
|
|
84
93
|
let attention = initialAttentionState();
|
|
85
94
|
// Web Push in the Home Screen app: null until chi web says it pushes
|
|
@@ -88,6 +97,8 @@ export function createNotifications({
|
|
|
88
97
|
let pushBusy = false;
|
|
89
98
|
let syncing = null;
|
|
90
99
|
let syncAgain = false;
|
|
100
|
+
// When this page last had the browser check for a newer sw.js.
|
|
101
|
+
let swCheckedAt = null;
|
|
91
102
|
const badgeKeys = new Set();
|
|
92
103
|
let baseTitle = "Chi";
|
|
93
104
|
|
|
@@ -101,6 +112,17 @@ export function createNotifications({
|
|
|
101
112
|
return doc.visibilityState === "visible" && doc.hasFocus();
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
function stored(key) {
|
|
116
|
+
try { return storage().getItem(key); } catch (_) { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function store(key, value) {
|
|
120
|
+
try {
|
|
121
|
+
if (value == null) storage().removeItem(key);
|
|
122
|
+
else storage().setItem(key, value);
|
|
123
|
+
} catch (_) {}
|
|
124
|
+
}
|
|
125
|
+
|
|
104
126
|
function notifyWanted() {
|
|
105
127
|
try { return storage().getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
106
128
|
}
|
|
@@ -148,13 +170,19 @@ export function createNotifications({
|
|
|
148
170
|
let failed = null;
|
|
149
171
|
try {
|
|
150
172
|
if (push.on) {
|
|
151
|
-
await pushUnsubscribe({ reg: push.reg, del: (endpoint) => api("DELETE", "/api/push/subscriptions", { endpoint }) });
|
|
173
|
+
const had = await pushUnsubscribe({ reg: push.reg, del: (endpoint) => api("DELETE", "/api/push/subscriptions", { endpoint }) });
|
|
174
|
+
// Without a subscription to delete (revoked meanwhile) the id stays
|
|
175
|
+
// for the next subscribe to replace.
|
|
176
|
+
if (had) store(PUSH_DEVICE_KEY, null);
|
|
152
177
|
push.on = false;
|
|
153
178
|
setDeviceBadge(0);
|
|
154
179
|
} else if (NotificationApi.permission === "granted" || (await NotificationApi.requestPermission()) === "granted") {
|
|
155
|
-
const
|
|
156
|
-
|
|
180
|
+
const replaces = stored(PUSH_DEVICE_KEY);
|
|
181
|
+
const { subscription, answer } = await pushSubscribe({
|
|
182
|
+
reg: push.reg, key: push.key,
|
|
183
|
+
post: (json) => api("POST", "/api/push/subscriptions", replaces ? { subscription: json, replaces } : { subscription: json }),
|
|
157
184
|
});
|
|
185
|
+
if (answer?.id) store(PUSH_DEVICE_KEY, answer.id);
|
|
158
186
|
push.on = true;
|
|
159
187
|
api("POST", "/api/push/test", { endpoint: subscription.endpoint }).catch(() => {});
|
|
160
188
|
syncDevice();
|
|
@@ -180,11 +208,23 @@ export function createNotifications({
|
|
|
180
208
|
const info = await api("GET", `/api/push${query}`);
|
|
181
209
|
if (!info?.enabled || !info.public_key) return;
|
|
182
210
|
push = { reg, key: info.public_key, on: !!(sub && info.this) };
|
|
211
|
+
// register() has just checked for a newer sw.js.
|
|
212
|
+
swCheckedAt = now();
|
|
183
213
|
renderNotifyBtn();
|
|
184
214
|
} catch (_) {}
|
|
185
215
|
syncDevice();
|
|
186
216
|
}
|
|
187
217
|
|
|
218
|
+
// A Home Screen app can stay open for days: when it comes back in front
|
|
219
|
+
// it asks for a newer sw.js, at most once per SW_UPDATE_MS.
|
|
220
|
+
function updateWorker() {
|
|
221
|
+
if (!push || typeof push.reg?.update !== "function") return;
|
|
222
|
+
const at = now();
|
|
223
|
+
if (swCheckedAt !== null && at - swCheckedAt < SW_UPDATE_MS) return;
|
|
224
|
+
swCheckedAt = at;
|
|
225
|
+
try { push.reg.update()?.catch?.(() => {}); } catch (_) {}
|
|
226
|
+
}
|
|
227
|
+
|
|
188
228
|
function setDeviceBadge(count) {
|
|
189
229
|
try {
|
|
190
230
|
const done = count > 0 && typeof nav?.setAppBadge === "function" ? nav.setAppBadge(count)
|
|
@@ -279,6 +319,7 @@ export function createNotifications({
|
|
|
279
319
|
if (doc.visibilityState !== "visible") return;
|
|
280
320
|
clearBadge();
|
|
281
321
|
syncDevice();
|
|
322
|
+
updateWorker();
|
|
282
323
|
}
|
|
283
324
|
doc.addEventListener("visibilitychange", backInFront);
|
|
284
325
|
win.addEventListener("focus", backInFront);
|
|
@@ -158,20 +158,30 @@ module Samagotchi
|
|
|
158
158
|
# Adds the subscription a page's pushManager.subscribe gave (its
|
|
159
159
|
# JSON: endpoint, keys {p256dh, auth}); the same endpoint again
|
|
160
160
|
# replaces the old entry (a device subscribing again).
|
|
161
|
+
# +replaces+ is the short id this device had before (the page keeps
|
|
162
|
+
# it): that entry on +origin+ goes too, before the cap is counted,
|
|
163
|
+
# since the device has a new endpoint and the old one is dead (iOS
|
|
164
|
+
# revokes silently: its push service keeps answering 201). Yields
|
|
165
|
+
# the entry dropped that way, if any; an unknown id is ignored.
|
|
161
166
|
# @return [Sub]
|
|
162
167
|
# @raise [Invalid] one message per refusal
|
|
163
|
-
def add(subscription, origin:, label:)
|
|
168
|
+
def add(subscription, origin:, label:, replaces: nil)
|
|
164
169
|
endpoint, p256dh, auth = validate(subscription)
|
|
165
170
|
at = stamp(now)
|
|
166
171
|
sub = Sub.new(id: Store.short_id(endpoint), endpoint: endpoint, p256dh: p256dh, auth: auth, origin: origin,
|
|
167
172
|
label: label.to_s[0, LABEL_MAX], created_at: at, last_ok_at: nil, last_error: nil,
|
|
168
173
|
prefs: { "done" => false })
|
|
169
|
-
|
|
174
|
+
replaced = nil
|
|
175
|
+
added = update do |subs|
|
|
170
176
|
kept = subs.reject { |old| old.endpoint == endpoint }
|
|
177
|
+
replaced = kept.find { |old| old.id == replaces && old.origin == origin } if replaces.is_a?(String)
|
|
178
|
+
kept -= [replaced] if replaced
|
|
171
179
|
raise Invalid, "chi web already pushes to #{MAX_SUBS} devices: turn the bell off on one first" if kept.size >= MAX_SUBS
|
|
172
180
|
|
|
173
181
|
[kept + [sub], sub]
|
|
174
182
|
end
|
|
183
|
+
yield replaced if replaced && block_given?
|
|
184
|
+
added
|
|
175
185
|
end
|
|
176
186
|
|
|
177
187
|
# Removes the subscription with this short id or endpoint.
|
|
@@ -29,14 +29,19 @@ module Samagotchi
|
|
|
29
29
|
origin: @push.origin, this: this && { id: this.id, label: this.label } })
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# {subscription: {endpoint, keys: {p256dh, auth}}
|
|
32
|
+
# {subscription: {endpoint, keys: {p256dh, auth}}, replaces?: id} →
|
|
33
|
+
# 201 {id, label}. +replaces+ is the id this device was given before
|
|
34
|
+
# (the page keeps it): that entry goes (Store#add).
|
|
33
35
|
def handle_push_subscribe(req)
|
|
34
36
|
return push_off unless @push
|
|
35
37
|
|
|
36
38
|
body = push_body(req)
|
|
37
39
|
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
38
40
|
|
|
39
|
-
sub = @push.store.add(body["subscription"], origin: @push.origin, label: device_label(req)
|
|
41
|
+
sub = @push.store.add(body["subscription"], origin: @push.origin, label: device_label(req),
|
|
42
|
+
replaces: body["replaces"]) do |old|
|
|
43
|
+
Log.info(:web, "push_replaced", device: old.id)
|
|
44
|
+
end
|
|
40
45
|
Log.info(:web, "push_subscribed", device: sub.id)
|
|
41
46
|
json_response(201, { id: sub.id, label: sub.label })
|
|
42
47
|
rescue Push::Store::Invalid => e
|
|
@@ -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)
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# question, outcome, …; never the
|
|
178
|
-
# a parent may pick on the
|
|
179
|
-
# (ParentReport.strategy_for). A timeout
|
|
180
|
-
#
|
|
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
|
|
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
|