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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3030ebe01072de864d730b27cfc709368d48948260f07b3e37fa83f8ba4b87a
4
- data.tar.gz: 38f33d3c633d5467c8200817c2a12fc5fd0e287cf87cbd5233f0486326280eed
3
+ metadata.gz: 2153d4c9c7afa58d9d06d330d1839a021032df3502f515d4a093bad7f9dde347
4
+ data.tar.gz: 9ac11fddf172afa0eb4c5ea520d3e64471c0b2502753539fafcce28aa4e99294
5
5
  SHA512:
6
- metadata.gz: af19fcb8e29f712f43098d68d466bb8e68bdfc3b87177d7a2f99e73a2b609a535322ab181cacae03d7cf00e774a2f28933ff5b3ba30446d3e4112724f213bf6f
7
- data.tar.gz: 72fb51ec5f3d216a36e27a040643517104fc8988f8010b6bdc70ac4f3b2bd2db281a4cc629a54b75e88dfbe46b78d02014bfc65142f39e40e6952ab13c919f7f
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.0...HEAD
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
@@ -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. A 503 `server_stopping` (chi web
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
@@ -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. Every write is a read-change-write under
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. No fetch handler and no caching: chi web has no offline mode, and a
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 chi's own
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 and sends a test push; a tap when on unsubscribes and
164
- deletes. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.111.0
3
+ version: 0.111.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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:): last_turn's ended_at then and the input files claimed
45
- # then, so its settle by a next worker (#settled_running) tells
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 = nil
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 (SessionInbox.claimed_input_files(@session_dir) - look.claimed).empty?
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 = @deadline && ReplyWait.monotonic > @deadline ? Result.new(status: :timeout) : nil
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
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.111.0"
4
+ VERSION = "0.111.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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 { subscription } = await pushSubscribe({
156
- reg: push.reg, key: push.key, post: (json) => api("POST", "/api/push/subscriptions", { subscription: json }),
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
- update do |subs|
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}}} → 201 {id, label}.
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). 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.111.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov