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
|
@@ -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
|
-
|
|
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
|
|
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
|