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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -1
@@ -5,6 +5,14 @@
5
5
  // several tabs show one, and none while another chi tab is in front with it
6
6
  // (notify.js, Several tabs).
7
7
  //
8
+ // In the Home Screen app (push.js pushSupported) the bell means "push to
9
+ // this device" instead (Web Push): chi web pushes approvals and questions to
10
+ // it while the app is closed. chi web without push (no https public URL)
11
+ // leaves the bell as it is in a tab. With the bell on, the app also tidies
12
+ // the device (syncDevice): it closes the pushed notifications whose
13
+ // question isn't open any more and sets the app's badge, as chi web sends
14
+ // no close push (WebKit's silent-push quota).
15
+ //
8
16
  // The browser parts come in as deps (globals by default) so the specs can
9
17
  // drive it without a DOM.
10
18
 
@@ -12,6 +20,9 @@ import { sessionHash } from "./route.js";
12
20
  import {
13
21
  NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention,
14
22
  } from "./notify.js";
23
+ import {
24
+ isQuestionTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
25
+ } from "./push.js";
15
26
 
16
27
  export const NOTIFY_KEY = "chi_notify";
17
28
 
@@ -21,6 +32,24 @@ const NOTIFY_TITLES = {
21
32
  denied: "Notifications are blocked for this site in the browser's settings",
22
33
  };
23
34
 
35
+ const PUSH_TITLES = {
36
+ on: "Notifications to this phone: on (approvals and questions, also while the app is closed). Tap to turn off",
37
+ off: "Notify this phone when a session needs approval or an answer, also while the app is closed",
38
+ denied: "Notifications are blocked for this app in the phone's settings",
39
+ };
40
+
41
+ // fetch's JSON, or an Error with chi web's detail.
42
+ async function defaultApi(method, path, body) {
43
+ const res = await fetch(path, {
44
+ method,
45
+ headers: body === undefined ? {} : { "Content-Type": "application/json" },
46
+ body: body === undefined ? undefined : JSON.stringify(body),
47
+ });
48
+ const data = res.status === 204 ? null : await res.json().catch(() => null);
49
+ if (!res.ok) throw new Error(data?.detail || `HTTP ${res.status}`);
50
+ return data;
51
+ }
52
+
24
53
  function defaultChannel() {
25
54
  try {
26
55
  if (typeof globalThis.BroadcastChannel === "function") return new BroadcastChannel(NOTIFY_CHANNEL);
@@ -35,6 +64,10 @@ function defaultChannel() {
35
64
  // @param storage () => localStorage (it can throw)
36
65
  // @param channel the BroadcastChannel to the other chi tabs, or null
37
66
  // @param gateOptions passed to createNotifyGate (specs: schedule, now)
67
+ // @param nav the navigator (serviceWorker; push.js pushSupported)
68
+ // @param api (method, path, body) => Promise of chi web's JSON
69
+ // @param scoped the page shows one project's sessions (?dir=): it never
70
+ // tidies the device, as it doesn't see the others'
38
71
  // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
39
72
  export function createNotifications({
40
73
  button,
@@ -44,8 +77,17 @@ export function createNotifications({
44
77
  storage = () => globalThis.localStorage,
45
78
  channel = defaultChannel(),
46
79
  gateOptions = {},
80
+ nav = globalThis.navigator,
81
+ api = defaultApi,
82
+ scoped = false,
47
83
  }) {
48
84
  let attention = initialAttentionState();
85
+ // Web Push in the Home Screen app: null until chi web says it pushes
86
+ // ({reg, key, on}), and for good in a tab or without push.
87
+ let push = null;
88
+ let pushBusy = false;
89
+ let syncing = null;
90
+ let syncAgain = false;
49
91
  const badgeKeys = new Set();
50
92
  let baseTitle = "Chi";
51
93
 
@@ -65,6 +107,10 @@ export function createNotifications({
65
107
 
66
108
  // "on" | "off" | "denied" | "unsupported" (notify.js)
67
109
  function notifyState() {
110
+ if (push) {
111
+ if (typeof NotificationApi === "function" && NotificationApi.permission === "denied") return "denied";
112
+ return push.on ? "on" : "off";
113
+ }
68
114
  const hasNotification = typeof NotificationApi === "function";
69
115
  return bellState({
70
116
  hasNotification,
@@ -78,11 +124,12 @@ export function createNotifications({
78
124
  const state = notifyState();
79
125
  button.hidden = state === "unsupported";
80
126
  button.dataset.state = state;
81
- button.title = NOTIFY_TITLES[state] || "";
127
+ button.title = (push ? PUSH_TITLES : NOTIFY_TITLES)[state] || "";
82
128
  button.setAttribute("aria-pressed", state === "on" ? "true" : "false");
83
129
  }
84
130
 
85
131
  async function toggleNotify() {
132
+ if (push) return togglePush();
86
133
  const state = notifyState();
87
134
  if (state === "unsupported" || state === "denied") return;
88
135
  let wanted = state !== "on";
@@ -93,8 +140,87 @@ export function createNotifications({
93
140
  renderNotifyBtn();
94
141
  }
95
142
 
143
+ // This device's subscription on (permission from the tap: iOS asks only
144
+ // from one), a test push to show it works; or off.
145
+ async function togglePush() {
146
+ if (pushBusy || notifyState() === "denied") return;
147
+ pushBusy = true;
148
+ let failed = null;
149
+ try {
150
+ if (push.on) {
151
+ await pushUnsubscribe({ reg: push.reg, del: (endpoint) => api("DELETE", "/api/push/subscriptions", { endpoint }) });
152
+ push.on = false;
153
+ setDeviceBadge(0);
154
+ } 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 }),
157
+ });
158
+ push.on = true;
159
+ api("POST", "/api/push/test", { endpoint: subscription.endpoint }).catch(() => {});
160
+ syncDevice();
161
+ }
162
+ } catch (e) {
163
+ failed = e?.message || String(e);
164
+ } finally {
165
+ pushBusy = false;
166
+ }
167
+ renderNotifyBtn();
168
+ // Said on the bell (its tooltip); the next tap tries again.
169
+ if (failed) button.title = `Notifications to this phone failed: ${failed}`;
170
+ }
171
+
172
+ // The Home Screen app: its service worker, chi web's key, and whether
173
+ // chi web has this device. A tab, or chi web without push: the bell as is.
174
+ async function startPush() {
175
+ if (typeof NotificationApi !== "function" || !pushSupported({ win, nav })) return;
176
+ try {
177
+ const reg = await nav.serviceWorker.register("/sw.js", { scope: "/" });
178
+ const sub = await reg.pushManager.getSubscription();
179
+ const query = sub ? `?endpoint=${encodeURIComponent(sub.endpoint)}` : "";
180
+ const info = await api("GET", `/api/push${query}`);
181
+ if (!info?.enabled || !info.public_key) return;
182
+ push = { reg, key: info.public_key, on: !!(sub && info.this) };
183
+ renderNotifyBtn();
184
+ } catch (_) {}
185
+ syncDevice();
186
+ }
187
+
188
+ function setDeviceBadge(count) {
189
+ try {
190
+ const done = count > 0 && typeof nav?.setAppBadge === "function" ? nav.setAppBadge(count)
191
+ : typeof nav?.clearAppBadge === "function" ? nav.clearAppBadge() : null;
192
+ done?.catch?.(() => {});
193
+ } catch (_) {}
194
+ }
195
+
196
+ // The Home Screen app with the bell on: closes the pushed notifications
197
+ // whose question isn't open (chi's own, chi:test, stay) and sets the
198
+ // app's badge. Never before the first snapshot (an empty byId would close
199
+ // them all) nor on a project's page (its byId lacks the others). One at a
200
+ // time; asked again meanwhile, it runs once more after.
201
+ function syncDevice() {
202
+ if (!push || !push.on || scoped || !attention.seeded) return syncing;
203
+ if (syncing) {
204
+ syncAgain = true;
205
+ return syncing;
206
+ }
207
+ syncing = (async () => {
208
+ do {
209
+ syncAgain = false;
210
+ const open = new Set(openPushKeys(attention.byId));
211
+ try {
212
+ const shown = await push.reg.getNotifications();
213
+ for (const n of shown || []) if (isQuestionTag(n.tag) && !open.has(n.tag)) n.close();
214
+ } catch (_) {}
215
+ if (push?.on) setDeviceBadge(open.size);
216
+ } while (syncAgain);
217
+ })().finally(() => { syncing = null; });
218
+ return syncing;
219
+ }
220
+
96
221
  function showNotification(session, a) {
97
- if (notifyState() !== "on") return;
222
+ // The app gets chi web's pushes instead.
223
+ if (push || notifyState() !== "on") return;
98
224
  const { title, body } = attentionText(session, a);
99
225
  try {
100
226
  const n = new NotificationApi(title, { body, tag: a.key });
@@ -135,6 +261,7 @@ export function createNotifications({
135
261
  }
136
262
  }
137
263
  if (badgeKeys.size !== size) setTitle();
264
+ if (doc.visibilityState === "visible") syncDevice();
138
265
  }
139
266
 
140
267
  // The user is here: the badge goes, and the other tabs drop what it had.
@@ -149,7 +276,9 @@ export function createNotifications({
149
276
  // before it has focus, and a tab switch inside a focused window sends no
150
277
  // focus event), the window got focus, or they touched the page.
151
278
  function backInFront() {
152
- if (doc.visibilityState === "visible") clearBadge();
279
+ if (doc.visibilityState !== "visible") return;
280
+ clearBadge();
281
+ syncDevice();
153
282
  }
154
283
  doc.addEventListener("visibilitychange", backInFront);
155
284
  win.addEventListener("focus", backInFront);
@@ -157,6 +286,7 @@ export function createNotifications({
157
286
  doc.addEventListener("keydown", clearBadge, true);
158
287
  button.addEventListener("click", toggleNotify);
159
288
  renderNotifyBtn();
289
+ const pushReady = startPush();
160
290
 
161
291
  return {
162
292
  noticeSessions,
@@ -170,5 +300,9 @@ export function createNotifications({
170
300
  setTitle();
171
301
  },
172
302
  clearBadge,
303
+ // Settles once the Home Screen app's push state is known (specs).
304
+ pushReady,
305
+ // The device's tidy-up in flight, or null (specs await it).
306
+ syncing: () => syncing,
173
307
  };
174
308
  }
@@ -23,7 +23,7 @@ const UNWATCHED_ORIGINS = ["delegate_report", "context", "task"];
23
23
 
24
24
  // The step-limit question (kind "continue": a turn ran out of iterations
25
25
  // and asks whether to go on) has its own reason: "out of steps".
26
- const questionReason = (q) => (q.kind === "approval" ? "approval" : q.kind === "continue" ? "continue" : "question");
26
+ export const questionReason = (q) => (q.kind === "approval" ? "approval" : q.kind === "continue" ? "continue" : "question");
27
27
 
28
28
  // What a session waits on the user for right now, from one summary: an open
29
29
  // question or approval, or a running turn's card with actions (check-in's
@@ -0,0 +1,95 @@
1
+ // Web Push, the page's pure parts (notifications.js drives them): whether
2
+ // this page can get pushes, subscribing this device with chi web's key
3
+ // and telling chi web (POST /api/push/subscriptions) or the reverse, and
4
+ // which pushed notifications are still open (the page's cleanup). Only
5
+ // the Home Screen app subscribes (iOS gives push to a standalone app only;
6
+ // a browser tab keeps the in-tab notifications). See
7
+ // docs/internals/web-push.md.
8
+
9
+ import { questionReason } from "./notify.js";
10
+
11
+ // The reasons chi web pushes (Web::Push::Attention::PUSHED).
12
+ const PUSHED = new Set(["approval", "question"]);
13
+
14
+ // The keys still open in a byId (notify.js trackAttention's: session id →
15
+ // summary): a pending question with an id, not relayed, of a reason chi
16
+ // web pushes. Attention.open_keys's rule, so the page, the worker's open
17
+ // list and the badge agree.
18
+ // @return [String] "<session id>:<question id>" keys
19
+ export function openPushKeys(byId) {
20
+ const keys = [];
21
+ for (const summary of Object.values(byId || {})) {
22
+ const q = summary?.pending_question;
23
+ if (!q || !q.id || q.relayed_to || !PUSHED.has(questionReason(q))) continue;
24
+ keys.push(`${summary.id}:${q.id}`);
25
+ }
26
+ return keys;
27
+ }
28
+
29
+ // A pushed question's tag ("<session id>:<question id>"), not one of chi's
30
+ // own (chi:test, chi:other): only these are ever cleaned up. sw.js keeps
31
+ // the same pattern (a classic script imports nothing).
32
+ export const QUESTION_TAG = /^(?!chi:)[^:]+:[^:]+$/;
33
+ export const isQuestionTag = (tag) => typeof tag === "string" && QUESTION_TAG.test(tag);
34
+
35
+ // A standalone app with a service worker and PushManager, on a secure
36
+ // context (chi web's https public URL; the LAN's plain http has none).
37
+ // @param win the window (isSecureContext, PushManager, matchMedia)
38
+ // @param nav the navigator (serviceWorker, standalone on iOS)
39
+ export function pushSupported({ win, nav }) {
40
+ if (!win || !nav || win.isSecureContext !== true) return false;
41
+ if (!nav.serviceWorker || typeof win.PushManager !== "function") return false;
42
+ let standalone = nav.standalone === true;
43
+ try {
44
+ if (!standalone && typeof win.matchMedia === "function") standalone = win.matchMedia("(display-mode: standalone)").matches === true;
45
+ } catch (_) {}
46
+ return standalone;
47
+ }
48
+
49
+ // base64url (padded or not) → bytes: applicationServerKey's form.
50
+ export function urlBase64ToBytes(text) {
51
+ const b64 = String(text).replace(/-/g, "+").replace(/_/g, "/");
52
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
53
+ const raw = atob(padded);
54
+ const bytes = new Uint8Array(raw.length);
55
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
56
+ return bytes;
57
+ }
58
+
59
+ function sameBytes(a, b) {
60
+ if (!a || !b) return false;
61
+ const x = new Uint8Array(a);
62
+ const y = new Uint8Array(b);
63
+ return x.length === y.length && x.every((v, i) => v === y[i]);
64
+ }
65
+
66
+ // Subscribes this device with chi web's key (a subscription made with
67
+ // another key, an older vapid.pem's, is dropped first: the push service
68
+ // would refuse chi web's token for it) and posts it.
69
+ // @param reg the service worker's registration
70
+ // @param key chi web's public key (GET /api/push's public_key)
71
+ // @param post (subscriptionJson) => Promise of chi web's answer ({id, label})
72
+ // @return {subscription, answer}
73
+ export async function subscribe({ reg, key, post }) {
74
+ const want = urlBase64ToBytes(key);
75
+ let sub = await reg.pushManager.getSubscription();
76
+ if (sub && !sameBytes(sub.options?.applicationServerKey, want)) {
77
+ await sub.unsubscribe();
78
+ sub = null;
79
+ }
80
+ if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: want });
81
+ const answer = await post(typeof sub.toJSON === "function" ? sub.toJSON() : sub);
82
+ return { subscription: sub, answer };
83
+ }
84
+
85
+ // Unsubscribes this device and tells chi web to forget it.
86
+ // @param del (endpoint) => Promise
87
+ // @return whether there was a subscription
88
+ export async function unsubscribe({ reg, del }) {
89
+ const sub = await reg.pushManager.getSubscription();
90
+ if (!sub) return false;
91
+ const endpoint = sub.endpoint;
92
+ await sub.unsubscribe();
93
+ await del(endpoint);
94
+ return true;
95
+ }
@@ -565,6 +565,14 @@ export function createStageView({
565
565
 
566
566
  function emptySlots() {
567
567
  for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
568
+ // The turn left the stage: the status row and the chip say nothing until
569
+ // the next one. Kept as they were, a hidden stage still reads "thinking ·
570
+ // step 1" — the stale stage of a turn the page stopped following.
571
+ mark.textContent = "";
572
+ phaseEl.textContent = "";
573
+ stepEl.textContent = "";
574
+ miniTool.textContent = "";
575
+ chipLabel.textContent = "";
568
576
  slotsView.reset();
569
577
  rail?.reset();
570
578
  promptSlot.classList.remove("full");
@@ -0,0 +1,101 @@
1
+ // chi web's service worker (Web Push, docs/internals/web-push.md): shows
2
+ // what chi web pushes and opens the session on a tap. A classic script
3
+ // (module service workers on iOS are unverified), registered at scope "/"
4
+ // by notifications.js in the Home Screen app only. No fetch handler and no
5
+ // caching: chi web has no offline mode, and a fetch handler would change
6
+ // how the page loads.
7
+ //
8
+ // A push's payload (JSON, end-to-end encrypted by chi web):
9
+ // {v: 1, kind: "show", tag, title, body, url, badge?, open?: [tag…]}
10
+ // Every push shows a notification: WebKit removes a subscription after a
11
+ // few pushes that show none (its silent-push quota), so anything else (an
12
+ // unreadable payload, another version or kind, a show iOS refuses) shows a
13
+ // fallback "open chi". There is no close push: a show's open list (the
14
+ // keys chi web still has open) lets it close the answered ones after it
15
+ // shows itself (closing before showNotification fails on iOS), and the
16
+ // page does the same when the app opens.
17
+
18
+ const ICON = "/icons/chi-192.png";
19
+ const FALLBACK = { title: "chi", body: "open chi", tag: "chi:other", url: "/" };
20
+ // push.js QUESTION_TAG: a pushed question's "<session id>:<question id>",
21
+ // never chi's own (chi:test, chi:other).
22
+ const QUESTION_TAG = /^(?!chi:)[^:]+:[^:]+$/;
23
+
24
+ function payloadOf(event) {
25
+ try {
26
+ return event.data ? event.data.json() : null;
27
+ } catch (_) {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ async function setBadge(count) {
33
+ const nav = self.navigator;
34
+ try {
35
+ if (count > 0 && nav && typeof nav.setAppBadge === "function") await nav.setAppBadge(count);
36
+ else if (nav && typeof nav.clearAppBadge === "function") await nav.clearAppBadge();
37
+ else if (nav && typeof nav.setAppBadge === "function") await nav.setAppBadge(0);
38
+ } catch (_) {}
39
+ }
40
+
41
+ // Only a same-origin path opens: a payload names where in chi web to go.
42
+ function pagePath(url) {
43
+ // Not "//host" nor "/\host" (a browser reads a backslash as a slash).
44
+ return typeof url === "string" && /^\/(?![/\\])/.test(url) && !url.includes("\\") ? url : "/";
45
+ }
46
+
47
+ // Shows it, or once more without the icon and data if that is refused.
48
+ async function show({ title, body, tag, url }) {
49
+ try {
50
+ await self.registration.showNotification(title, { body, tag, icon: ICON, data: { url: pagePath(url) } });
51
+ } catch (_) {
52
+ await self.registration.showNotification(title, { body, tag });
53
+ }
54
+ }
55
+
56
+ // Closes the shown question notifications chi web no longer has open
57
+ // (never the new one nor chi's own). The unfiltered list only names the
58
+ // tags: on the iPhone (iOS 26, 2026-10-24) close() on a notification from
59
+ // it did nothing, while one looked up by its tag closes (the probe in
60
+ // docs/internals/web-push.md, iOS facts).
61
+ async function closeAnswered(open, keep) {
62
+ try {
63
+ const still = new Set(open.map(String));
64
+ const tags = new Set();
65
+ for (const n of await self.registration.getNotifications()) {
66
+ if (n.tag !== keep && QUESTION_TAG.test(n.tag || "") && !still.has(n.tag)) tags.add(n.tag);
67
+ }
68
+ for (const tag of tags) {
69
+ for (const n of await self.registration.getNotifications({ tag })) n.close();
70
+ }
71
+ } catch (_) {}
72
+ }
73
+
74
+ async function onPush(data) {
75
+ if (!data || data.v !== 1 || data.kind !== "show") {
76
+ await show(FALLBACK);
77
+ return;
78
+ }
79
+ const tag = String(data.tag || "");
80
+ await show({ title: String(data.title || "chi"), body: String(data.body || ""), tag, url: data.url });
81
+ if (Array.isArray(data.open)) await closeAnswered(data.open, tag);
82
+ if (typeof data.badge === "number") await setBadge(data.badge);
83
+ }
84
+
85
+ async function onClick(notification) {
86
+ notification.close();
87
+ const url = pagePath(notification.data && notification.data.url);
88
+ const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
89
+ const win = windows[0];
90
+ if (win) {
91
+ await win.focus();
92
+ if (typeof win.navigate === "function") await win.navigate(url);
93
+ return;
94
+ }
95
+ await clients.openWindow(url);
96
+ }
97
+
98
+ self.addEventListener("install", () => self.skipWaiting());
99
+ self.addEventListener("activate", (event) => event.waitUntil(clients.claim()));
100
+ self.addEventListener("push", (event) => event.waitUntil(onPush(payloadOf(event))));
101
+ self.addEventListener("notificationclick", (event) => event.waitUntil(onClick(event.notification)));
@@ -21,6 +21,28 @@ export function workerGoneText({ turnRunning, stopped = false }) {
21
21
  return stopped ? "\u25A0 canceled (session stopped)" : "\u2715 canceled (worker exited)";
22
22
  }
23
23
 
24
+ // Whether the turn view ends now, or waits for the session re-read: a stream
25
+ // that was open and dropped with no stop (a phone briefly offline) says
26
+ // nothing about the worker, and `resync` re-renders the turn and adds the end
27
+ // line only when no worker took it — ending the view first is a wrong flash.
28
+ // A stop, or a stream that never opened at all, is the worker leaving: end
29
+ // now, and the re-read repeats it only when nothing took the turn.
30
+ // @return {{endNow: Boolean, endText: String|null}}
31
+ export function streamDropEnd({ turnRunning, stopped = false, opened = true }) {
32
+ const endText = workerGoneText({ turnRunning, stopped });
33
+ return { endNow: Boolean(endText) && (stopped || !opened), endText };
34
+ }
35
+
36
+ // A session re-read that failed (the phone still offline, chi web restarting):
37
+ // try again, backing off (3 s, 6 s, 9 s), and stop after +max+ tries — the
38
+ // hub's next event for the session re-reads it then. Nothing else changes in
39
+ // the meantime: a turn a worker still owns keeps showing as running.
40
+ // @return {{attempt: number, delayMs: number|null}} (null: stop trying)
41
+ export function resyncRetry({ attempts = 0, max = 3, baseMs = 3000 }) {
42
+ const next = attempts + 1;
43
+ return next <= max ? { attempt: next, delayMs: next * baseMs } : { attempt: attempts, delayMs: null };
44
+ }
45
+
24
46
  // How long a turn's answer waits for the after_turn hooks' answer_display
25
47
  // before the page stops waiting and pops what it has (4.14).
26
48
  const DISPLAY_WAIT_MS = 20000;
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Web
5
+ module Push
6
+ # When a session needs the user, decided from two of the hub's session
7
+ # summaries (SessionSummary.build: symbol keys): the server's port of
8
+ # the page's rules (web/public/notify.js attentionFor, closedKeys,
9
+ # attentionText), so a push and the tab's notification ring for the
10
+ # same thing under the same key. P1 pushes approvals and questions
11
+ # only (the plan's D1): a step-limit question, a card, a failed or
12
+ # done turn come later. See docs/internals/web-push.md.
13
+ module Attention
14
+ module_function
15
+
16
+ # One thing that needs the user. +key+ names it everywhere (the
17
+ # notification's tag, notify.js's key); +hold+ the seconds it waits
18
+ # before it pushes, nil for the Notifier's own (a delegated child's
19
+ # approval waits longer: its parent may relay it).
20
+ Event = Data.define(:reason, :session_id, :key, :hold)
21
+
22
+ # notify.js RELAY_HOLD_MS: how long a delegated child's new approval
23
+ # (or hook question) waits, as its parent may relay it.
24
+ RELAY_HOLD = 3.5
25
+ RELAYED_KINDS = %w[approval hook].freeze
26
+ # The reasons P1 pushes.
27
+ PUSHED = %w[approval question].freeze
28
+ TITLE_MAX = 61
29
+ BODIES = { "question" => "needs an answer", "approval" => "needs approval", "continue" => "hit its step limit",
30
+ "card" => "needs you" }.freeze
31
+
32
+ # notify.js questionReason: a hook's question is a question.
33
+ def reason_of(question)
34
+ case question[:kind]
35
+ when "approval" then "approval"
36
+ when "continue" then "continue"
37
+ else "question"
38
+ end
39
+ end
40
+
41
+ # What +nxt+ newly needs the user for, compared with +prev+ (nil for
42
+ # a session not seen yet).
43
+ # @return [Event, nil]
44
+ def for(prev, nxt)
45
+ return nil unless nxt && nxt[:id]
46
+
47
+ question = nxt[:pending_question]
48
+ return nil unless question && question[:id] && question[:id] != prev&.dig(:pending_question, :id)
49
+ # Relayed to the parent already: the parent's notifies.
50
+ return nil if question[:relayed_to]
51
+
52
+ reason = reason_of(question)
53
+ return nil unless PUSHED.include?(reason)
54
+
55
+ hold = RELAY_HOLD if nxt[:parent_id] && RELAYED_KINDS.include?(question[:kind])
56
+ Event.new(reason: reason, session_id: nxt[:id], key: key(nxt[:id], question[:id]), hold: hold)
57
+ end
58
+
59
+ # The keys of what +prev+ had open and +nxt+ no longer has: its
60
+ # question answered (anywhere), gone or newly relayed; everything
61
+ # when the session is gone (+nxt+ nil).
62
+ # @return [Array<String>]
63
+ def closed_keys(prev, nxt)
64
+ question = prev&.dig(:pending_question)
65
+ return [] unless question && question[:id]
66
+
67
+ now = nxt&.dig(:pending_question)
68
+ gone = now.nil? || now[:id] != question[:id] || (now[:relayed_to] && !question[:relayed_to])
69
+ gone ? [key(prev[:id], question[:id])] : []
70
+ end
71
+
72
+ # Every key still open in +by_id+ (session id → summary): a
73
+ # pending question with an id, not relayed, of a reason P1 pushes.
74
+ # The rule push.js openPushKeys keeps, so the show's +open+ list,
75
+ # the page's cleanup and the badge agree.
76
+ # @return [Array<String>]
77
+ def open_keys(by_id)
78
+ by_id.each_value.filter_map do |summary|
79
+ question = summary[:pending_question]
80
+ next unless question.is_a?(Hash) && question[:id] && !question[:relayed_to]
81
+ next unless PUSHED.include?(reason_of(question))
82
+
83
+ key(summary[:id], question[:id])
84
+ end
85
+ end
86
+
87
+ def key(session_id, question_id) = "#{session_id}:#{question_id}"
88
+
89
+ # notify.js attentionText: the session's name (clipped) and the
90
+ # reason, never the question or the command (a lock screen shows it).
91
+ # @return [Array(String, String)] title, body
92
+ def text(summary, reason)
93
+ name = (summary && [summary[:title], summary[:first_preview], summary[:last_prompt]].find { |v| present?(v) }) ||
94
+ "chi session"
95
+ [clip(name.to_s.strip, TITLE_MAX), BODIES.fetch(reason, "")]
96
+ end
97
+
98
+ def present?(value) = !value.nil? && !value.to_s.strip.empty?
99
+
100
+ # format.js clip.
101
+ def clip(text, max) = text.length > max ? "#{text[0, max - 1].rstrip}…" : text
102
+ end
103
+ end
104
+ end
105
+ end