@lelouchhe/webagent 0.2.6 → 0.4.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.
- package/README.md +58 -23
- package/bin/webagent.mjs +119 -8
- package/config.toml +102 -3
- package/dist/index.html +64 -41
- package/dist/js/app.GSAIYHML.js +4 -0
- package/dist/js/chunk.AJZBJBMO.js +1 -0
- package/dist/js/chunk.CGWFHJI2.js +76 -0
- package/dist/js/chunk.D4ZYHJAM.js +1 -0
- package/dist/js/chunk.VZXGXFNN.js +5 -0
- package/dist/js/login.PYIK52HN.js +1 -0
- package/dist/js/viewer.6DT53STL.js +1 -0
- package/dist/login.html +49 -0
- package/dist/share-viewer.00gubshk.css +114 -0
- package/dist/share-viewer.html +53 -0
- package/dist/styles.012p32dz.css +1443 -0
- package/dist/sw.js +79 -27
- package/dist/theme-init.js +6 -0
- package/lib/agent-detect.js +110 -0
- package/lib/atomic-write.js +50 -0
- package/lib/attachment-dispatch.js +86 -0
- package/lib/attachment-interceptor.js +130 -0
- package/lib/attachment-labels.js +139 -0
- package/lib/attachments.js +154 -0
- package/lib/auth-middleware.js +102 -0
- package/lib/auth-store.js +269 -0
- package/lib/auth.js +89 -0
- package/lib/bootstrap.js +70 -0
- package/lib/bridge.js +244 -93
- package/lib/client-registry.js +60 -0
- package/lib/config.js +127 -9
- package/lib/daemon.js +185 -40
- package/lib/event-handler.js +209 -90
- package/lib/log-fmt.js +67 -0
- package/lib/log.js +83 -0
- package/lib/message-cleanup.js +48 -0
- package/lib/mode-bucket.js +62 -0
- package/lib/preflight.js +195 -0
- package/lib/push-service.js +338 -45
- package/lib/routes.js +1218 -144
- package/lib/server.js +159 -32
- package/lib/session-manager.js +164 -18
- package/lib/session-state.js +160 -0
- package/lib/sessions-anchor.js +28 -0
- package/lib/share/cleanup.js +45 -0
- package/lib/share/routes.js +972 -0
- package/lib/share/sanitize.js +179 -0
- package/lib/sse-manager.js +94 -8
- package/lib/sse-ticket.js +45 -0
- package/lib/startup-checks.js +94 -0
- package/lib/store.js +654 -24
- package/lib/title-service.js +42 -9
- package/lib/tokens.js +50 -0
- package/lib/types.js +23 -0
- package/package.json +38 -4
- package/dist/js/app.4FZ67UW4.js +0 -10
- package/dist/styles.008ve1hx.css +0 -669
- package/lib/shared/constants.js +0 -17
package/lib/push-service.js
CHANGED
|
@@ -1,21 +1,78 @@
|
|
|
1
1
|
import webpush from "web-push";
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { log } from "./log.js";
|
|
6
|
+
const slog = log.scope("push");
|
|
7
|
+
const elog = log.scope("egress");
|
|
4
8
|
const VAPID_FILE = "vapid.json";
|
|
5
9
|
/** Remove a subscription after this many consecutive send failures. */
|
|
6
10
|
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
11
|
+
/** Derive the push tag for an ACP event. Kept module-level so it can be
|
|
12
|
+
* reused by the close-on-handle path without instantiating the service. */
|
|
13
|
+
export function pushTagForEvent(sessionId, event) {
|
|
14
|
+
switch (event.type) {
|
|
15
|
+
case "prompt_done":
|
|
16
|
+
return `sess-${sessionId}-done`;
|
|
17
|
+
case "permission_request":
|
|
18
|
+
return `sess-${sessionId}-perm-${event.eventId ?? "0"}`;
|
|
19
|
+
case "bash_done":
|
|
20
|
+
return `sess-${sessionId}-bash-${event.eventId ?? "0"}`;
|
|
21
|
+
default:
|
|
22
|
+
return `sess-${sessionId}-${event.type}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* True iff the push endpoint is an Apple (APNs / Web Push on Apple) host.
|
|
27
|
+
*
|
|
28
|
+
* We deliberately filter silent `kind:"close"` pushes to these endpoints in
|
|
29
|
+
* `sendToAll` — iOS Safari PWA enforces an undocumented silent-push budget
|
|
30
|
+
* that, once exhausted, causes WebKit to drop ALL subsequent pushes for the
|
|
31
|
+
* subscription (including user-visible `kind:"notify"`). APNs returns 201
|
|
32
|
+
* throughout, so the server has zero visibility.
|
|
33
|
+
*
|
|
34
|
+
* macOS Safari shares the same host but is not subject to that budget; the
|
|
35
|
+
* conflation here is accepted collateral damage until we add a `platform`
|
|
36
|
+
* column to `push_subscriptions`.
|
|
37
|
+
*/
|
|
38
|
+
export function isAppleEndpoint(endpoint) {
|
|
39
|
+
let host;
|
|
40
|
+
try {
|
|
41
|
+
host = new URL(endpoint).hostname.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
|
|
47
|
+
}
|
|
48
|
+
function emptyClientState() {
|
|
49
|
+
return { visible: false, sessionId: null, endpoint: null, visibleSince: 0 };
|
|
50
|
+
}
|
|
7
51
|
export class PushService {
|
|
8
52
|
store;
|
|
9
53
|
vapidKeys;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Consolidated per-client state. Previously 3 separate Maps
|
|
56
|
+
* (clientVisibility/clientEndpoints/clientSessions) which drifted under
|
|
57
|
+
* partial updates. v2 merges them; `visibleSince` stamps when the client
|
|
58
|
+
* transitioned to visible so the server can TTL-expire "ghost" visibility
|
|
59
|
+
* records left by iOS PWA process suspension (where the client never gets
|
|
60
|
+
* to POST visible:false).
|
|
61
|
+
*/
|
|
62
|
+
clients = new Map();
|
|
13
63
|
/** endpoint → consecutive failure count (absent or 0 = healthy) */
|
|
14
64
|
failureCounts = new Map();
|
|
15
|
-
|
|
65
|
+
globalVisibilitySuppression;
|
|
66
|
+
visibilityTtlMs;
|
|
67
|
+
now;
|
|
68
|
+
constructor(store, dataDir, vapidSubject, options = {}) {
|
|
16
69
|
this.store = store;
|
|
17
70
|
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
18
71
|
webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
|
|
72
|
+
this.globalVisibilitySuppression =
|
|
73
|
+
options.globalVisibilitySuppression ?? true;
|
|
74
|
+
this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
|
|
75
|
+
this.now = options.now ?? (() => Date.now());
|
|
19
76
|
}
|
|
20
77
|
// ---------------------------------------------------------------------------
|
|
21
78
|
// VAPID keys
|
|
@@ -25,12 +82,14 @@ export class PushService {
|
|
|
25
82
|
if (existsSync(filePath)) {
|
|
26
83
|
chmodSync(filePath, 0o600);
|
|
27
84
|
const keys = JSON.parse(readFileSync(filePath, "utf8"));
|
|
28
|
-
|
|
85
|
+
slog.info("loaded VAPID keys");
|
|
29
86
|
return keys;
|
|
30
87
|
}
|
|
31
88
|
const keys = webpush.generateVAPIDKeys();
|
|
32
|
-
writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", {
|
|
33
|
-
|
|
89
|
+
writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", {
|
|
90
|
+
mode: 0o600,
|
|
91
|
+
});
|
|
92
|
+
slog.info("generated new VAPID keys");
|
|
34
93
|
return keys;
|
|
35
94
|
}
|
|
36
95
|
getPublicKey() {
|
|
@@ -39,81 +98,153 @@ export class PushService {
|
|
|
39
98
|
// ---------------------------------------------------------------------------
|
|
40
99
|
// Notification formatting
|
|
41
100
|
// ---------------------------------------------------------------------------
|
|
42
|
-
formatNotification(sessionId, sessionTitle, eventType, eventData) {
|
|
43
|
-
const title = sessionTitle
|
|
101
|
+
formatNotification(sessionId, sessionTitle, eventType, eventData, tag) {
|
|
102
|
+
const title = sessionTitle ?? "WebAgent";
|
|
44
103
|
let body;
|
|
45
104
|
switch (eventType) {
|
|
46
105
|
case "permission_request":
|
|
47
|
-
body = `⚿ ${eventData.description
|
|
106
|
+
body = `⚿ ${typeof eventData.description === "string" ? eventData.description : "Permission requested"}`;
|
|
48
107
|
break;
|
|
49
108
|
case "prompt_done":
|
|
50
109
|
body = "✓ Task complete";
|
|
51
110
|
break;
|
|
52
111
|
case "bash_done": {
|
|
53
|
-
const cmd = eventData.command
|
|
54
|
-
const code = eventData.exitCode
|
|
112
|
+
const cmd = typeof eventData.command === "string" ? eventData.command : "command";
|
|
113
|
+
const code = typeof eventData.exitCode === "number" ||
|
|
114
|
+
typeof eventData.exitCode === "string"
|
|
115
|
+
? String(eventData.exitCode)
|
|
116
|
+
: "?";
|
|
55
117
|
body = `$ ${cmd} — exit ${code}`;
|
|
56
118
|
break;
|
|
57
119
|
}
|
|
58
120
|
default:
|
|
59
121
|
body = eventType;
|
|
60
122
|
}
|
|
61
|
-
return { title, body, data: { sessionId } };
|
|
123
|
+
return { kind: "notify", title, body, tag, data: { sessionId } };
|
|
62
124
|
}
|
|
63
125
|
// ---------------------------------------------------------------------------
|
|
64
126
|
// Client visibility tracking
|
|
65
127
|
// ---------------------------------------------------------------------------
|
|
128
|
+
/**
|
|
129
|
+
* Atomic consolidated setter. All visibility/session/endpoint updates
|
|
130
|
+
* should route through here. Callers distinguish "preserve" from "clear"
|
|
131
|
+
* by omitting the key vs passing `null`. Returns an edge flag so the
|
|
132
|
+
* caller can fire edge-triggered side effects (e.g. sendClose) without
|
|
133
|
+
* double-firing on every heartbeat refresh.
|
|
134
|
+
*/
|
|
135
|
+
updateClient(clientId, patch) {
|
|
136
|
+
const prev = this.clients.get(clientId) ?? emptyClientState();
|
|
137
|
+
const wasVisibleForSession = prev.visible && prev.sessionId != null ? prev.sessionId : null;
|
|
138
|
+
const next = { ...prev };
|
|
139
|
+
if (patch.visible !== undefined) {
|
|
140
|
+
next.visible = patch.visible;
|
|
141
|
+
next.visibleSince = patch.visible ? this.now() : 0;
|
|
142
|
+
}
|
|
143
|
+
if (patch.sessionId !== undefined)
|
|
144
|
+
next.sessionId = patch.sessionId;
|
|
145
|
+
if (patch.endpoint !== undefined)
|
|
146
|
+
next.endpoint = patch.endpoint;
|
|
147
|
+
const becameVisibleForSession = next.visible &&
|
|
148
|
+
next.sessionId != null &&
|
|
149
|
+
next.sessionId !== wasVisibleForSession
|
|
150
|
+
? next.sessionId
|
|
151
|
+
: null;
|
|
152
|
+
// Any transition into "visible + session X" restarts the TTL clock,
|
|
153
|
+
// including a session-switch that arrives without an explicit
|
|
154
|
+
// visible:true in the patch (e.g. a session_created POST that only
|
|
155
|
+
// carries sessionId). Otherwise the TTL would keep counting from the
|
|
156
|
+
// previous session's first-visible moment and could prematurely
|
|
157
|
+
// declare the newly-focused session "stale".
|
|
158
|
+
if (becameVisibleForSession) {
|
|
159
|
+
next.visibleSince = this.now();
|
|
160
|
+
}
|
|
161
|
+
this.clients.set(clientId, next);
|
|
162
|
+
return { becameVisibleForSession };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Read-only snapshot for tests and diagnostics. Do NOT mutate the
|
|
166
|
+
* returned object.
|
|
167
|
+
*/
|
|
168
|
+
getClientState(clientId) {
|
|
169
|
+
return this.clients.get(clientId) ?? null;
|
|
170
|
+
}
|
|
171
|
+
/** @deprecated Shim: prefer `updateClient({ visible })`. */
|
|
66
172
|
setClientVisibility(clientId, visible) {
|
|
67
|
-
this.
|
|
173
|
+
this.updateClient(clientId, { visible });
|
|
68
174
|
}
|
|
175
|
+
/** @deprecated Shim: prefer `updateClient({ sessionId })`. */
|
|
69
176
|
setClientSession(clientId, sessionId) {
|
|
70
|
-
this.
|
|
177
|
+
this.updateClient(clientId, { sessionId });
|
|
71
178
|
}
|
|
179
|
+
/** @deprecated Shim: prefer `updateClient({ endpoint })`. */
|
|
72
180
|
registerClient(clientId, endpoint) {
|
|
73
|
-
this.
|
|
181
|
+
this.updateClient(clientId, { endpoint });
|
|
74
182
|
}
|
|
75
183
|
removeClient(clientId) {
|
|
76
|
-
this.
|
|
77
|
-
this.clientEndpoints.delete(clientId);
|
|
78
|
-
this.clientSessions.delete(clientId);
|
|
184
|
+
this.clients.delete(clientId);
|
|
79
185
|
}
|
|
80
186
|
hasVisibleClient() {
|
|
81
|
-
|
|
82
|
-
|
|
187
|
+
const now = this.now();
|
|
188
|
+
for (const s of this.clients.values()) {
|
|
189
|
+
if (s.visible && now - s.visibleSince <= this.visibilityTtlMs)
|
|
83
190
|
return true;
|
|
84
191
|
}
|
|
85
192
|
return false;
|
|
86
193
|
}
|
|
87
|
-
/** Check if a specific endpoint has at least one visible client. */
|
|
194
|
+
/** Check if a specific endpoint has at least one visible (non-stale) client. */
|
|
88
195
|
isEndpointVisible(endpoint) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
196
|
+
const now = this.now();
|
|
197
|
+
for (const s of this.clients.values()) {
|
|
198
|
+
if (s.endpoint !== endpoint)
|
|
199
|
+
continue;
|
|
200
|
+
if (!s.visible)
|
|
201
|
+
continue;
|
|
202
|
+
if (now - s.visibleSince > this.visibilityTtlMs)
|
|
203
|
+
continue;
|
|
204
|
+
return true;
|
|
92
205
|
}
|
|
93
206
|
return false;
|
|
94
207
|
}
|
|
95
208
|
/**
|
|
96
|
-
* Check if any client (across all endpoints) is visible and viewing the
|
|
97
|
-
* A client with no session set does not suppress any
|
|
98
|
-
*
|
|
209
|
+
* Check if any client (across all endpoints) is visible and viewing the
|
|
210
|
+
* given session. A client with no session set does not suppress any
|
|
211
|
+
* session's push. Stale records (older than `visibilityTtlMs` since the
|
|
212
|
+
* last heartbeat refresh) are ignored — this is the server-side safety
|
|
213
|
+
* net for iOS PWA suspension, where the client's `visible:false` POST may
|
|
214
|
+
* never leave the device.
|
|
215
|
+
*
|
|
216
|
+
* Returns false unconditionally when global suppression is disabled via
|
|
217
|
+
* the `globalVisibilitySuppression` option (kill switch).
|
|
99
218
|
*/
|
|
100
219
|
isSessionVisibleToAnyClient(sessionId) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
220
|
+
if (!this.globalVisibilitySuppression)
|
|
221
|
+
return false;
|
|
222
|
+
const now = this.now();
|
|
223
|
+
for (const s of this.clients.values()) {
|
|
224
|
+
if (!s.visible)
|
|
225
|
+
continue;
|
|
226
|
+
if (s.sessionId !== sessionId)
|
|
227
|
+
continue;
|
|
228
|
+
if (now - s.visibleSince > this.visibilityTtlMs)
|
|
229
|
+
continue;
|
|
230
|
+
return true;
|
|
104
231
|
}
|
|
105
232
|
return false;
|
|
106
233
|
}
|
|
107
234
|
// ---------------------------------------------------------------------------
|
|
108
235
|
// High-level: decide whether to push, and if so, send
|
|
109
236
|
// ---------------------------------------------------------------------------
|
|
110
|
-
static NOTIFIABLE = new Set([
|
|
237
|
+
static NOTIFIABLE = new Set([
|
|
238
|
+
"permission_request",
|
|
239
|
+
"prompt_done",
|
|
240
|
+
"bash_done",
|
|
241
|
+
]);
|
|
111
242
|
/**
|
|
112
243
|
* Check if this event should trigger a push notification.
|
|
113
244
|
* Returns true if a notification should be sent (caller should then call sendToAll).
|
|
114
245
|
* Global session visibility suppression happens inside sendToAll.
|
|
115
246
|
*/
|
|
116
|
-
maybeNotify(sessionId, sessionTitle, eventType,
|
|
247
|
+
maybeNotify(sessionId, sessionTitle, eventType, _eventData) {
|
|
117
248
|
if (!PushService.NOTIFIABLE.has(eventType))
|
|
118
249
|
return false;
|
|
119
250
|
return true;
|
|
@@ -122,45 +253,207 @@ export class PushService {
|
|
|
122
253
|
// Send push to all subscriptions
|
|
123
254
|
// ---------------------------------------------------------------------------
|
|
124
255
|
async sendToAll(notification) {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
256
|
+
const allSubs = this.store.getAllSubscriptions();
|
|
257
|
+
if (allSubs.length === 0)
|
|
127
258
|
return;
|
|
259
|
+
// Global visibility: if any client is viewing this session, suppress notify pushes.
|
|
260
|
+
// Close pushes are never suppressed — they're silent and are the mechanism
|
|
261
|
+
// by which cross-device recall actually closes banners on the "losing" devices.
|
|
262
|
+
if (notification.kind === "notify") {
|
|
263
|
+
const targetSession = notification.data.sessionId;
|
|
264
|
+
if (targetSession && this.isSessionVisibleToAnyClient(targetSession))
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Skip Apple endpoints for silent close pushes to preserve iOS PWA's
|
|
268
|
+
// silent-push budget. See isAppleEndpoint() for rationale.
|
|
269
|
+
let subs = allSubs;
|
|
270
|
+
let filteredApple = 0;
|
|
271
|
+
if (notification.kind === "close") {
|
|
272
|
+
subs = allSubs.filter((s) => {
|
|
273
|
+
if (isAppleEndpoint(s.endpoint)) {
|
|
274
|
+
filteredApple++;
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
});
|
|
279
|
+
if (subs.length === 0) {
|
|
280
|
+
elog.info("sendClose", {
|
|
281
|
+
tag: notification.tag,
|
|
282
|
+
endpoints: 0,
|
|
283
|
+
ok: 0,
|
|
284
|
+
fail: 0,
|
|
285
|
+
fail_410: 0,
|
|
286
|
+
filtered_apple: filteredApple,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
128
291
|
const payload = JSON.stringify(notification);
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
292
|
+
// Derive an RFC 8030 Topic so push services can collapse undelivered
|
|
293
|
+
// pushes on the wire. FCM (Chrome/Firefox desktop + Android) honors
|
|
294
|
+
// this and collapses correctly. APNs (iOS Safari PWA) does NOT — we
|
|
295
|
+
// verified in dogfood that two same-Topic pushes still surface as
|
|
296
|
+
// two stacked banners on iOS 17, even combined with the SW-side
|
|
297
|
+
// close-before-show workaround (see public/sw.js). Keep the header
|
|
298
|
+
// anyway: it's spec-compliant, cheap, and benefits every non-Apple
|
|
299
|
+
// client. iOS banner stacking is a platform limitation we accept.
|
|
300
|
+
const topic = tagToTopic(notification.tag);
|
|
301
|
+
const results = await Promise.allSettled(subs.map((sub) => this.sendOne({
|
|
302
|
+
endpoint: sub.endpoint,
|
|
303
|
+
keys: { auth: sub.auth, p256dh: sub.p256dh },
|
|
304
|
+
}, payload, { topic })));
|
|
305
|
+
let ok = 0;
|
|
306
|
+
let fail = 0;
|
|
307
|
+
let fail410 = 0;
|
|
133
308
|
for (let i = 0; i < results.length; i++) {
|
|
134
309
|
const result = results[i];
|
|
135
310
|
const endpoint = subs[i].endpoint;
|
|
136
311
|
if (result.status === "fulfilled") {
|
|
137
312
|
this.failureCounts.delete(endpoint);
|
|
313
|
+
ok++;
|
|
138
314
|
}
|
|
139
315
|
else {
|
|
316
|
+
fail++;
|
|
140
317
|
const err = result.reason;
|
|
141
318
|
if (err.statusCode === 410) {
|
|
142
|
-
|
|
319
|
+
fail410++;
|
|
143
320
|
this.store.removeSubscription(endpoint);
|
|
144
321
|
this.failureCounts.delete(endpoint);
|
|
145
|
-
|
|
322
|
+
slog.info("removed expired subscription (410)", {
|
|
323
|
+
endpoint: endpoint.slice(0, 60) + "…",
|
|
324
|
+
});
|
|
146
325
|
}
|
|
147
326
|
else {
|
|
148
327
|
const count = (this.failureCounts.get(endpoint) ?? 0) + 1;
|
|
149
328
|
if (count >= MAX_CONSECUTIVE_FAILURES) {
|
|
150
329
|
this.store.removeSubscription(endpoint);
|
|
151
330
|
this.failureCounts.delete(endpoint);
|
|
152
|
-
|
|
331
|
+
slog.info("removed subscription after consecutive failures", {
|
|
332
|
+
count,
|
|
333
|
+
endpoint: endpoint.slice(0, 60) + "…",
|
|
334
|
+
});
|
|
153
335
|
}
|
|
154
336
|
else {
|
|
155
337
|
this.failureCounts.set(endpoint, count);
|
|
156
|
-
|
|
338
|
+
slog.error("send failed", {
|
|
339
|
+
count,
|
|
340
|
+
max: MAX_CONSECUTIVE_FAILURES,
|
|
341
|
+
endpoint: endpoint.slice(0, 60) + "…",
|
|
342
|
+
error: result.reason,
|
|
343
|
+
});
|
|
157
344
|
}
|
|
158
345
|
}
|
|
159
346
|
}
|
|
160
347
|
}
|
|
348
|
+
if (notification.kind === "close") {
|
|
349
|
+
// Observability signal #6 — aggregated per-call close outcome.
|
|
350
|
+
elog.info("sendClose", {
|
|
351
|
+
tag: notification.tag,
|
|
352
|
+
endpoints: subs.length,
|
|
353
|
+
ok,
|
|
354
|
+
fail,
|
|
355
|
+
fail_410: fail410,
|
|
356
|
+
filtered_apple: filteredApple,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** Send a silent close push for the given tag. Never visibility-suppressed. */
|
|
361
|
+
async sendClose(tag) {
|
|
362
|
+
await this.sendToAll({ kind: "close", tag });
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Send a push for an external message. Respects the message's `deliver`
|
|
366
|
+
* intent: `silent` sends nothing, `inapp` and `push` both send through
|
|
367
|
+
* web-push (the `inapp` vs `push` distinction is enforced by the SW /
|
|
368
|
+
* frontend rendering, not by the server).
|
|
369
|
+
*
|
|
370
|
+
* Tag = `msg-<id>` for unbound messages; bound messages get the
|
|
371
|
+
* `sess-<sid>-msg-<eid>` tag by taking a different code path in the
|
|
372
|
+
* consume handler.
|
|
373
|
+
*/
|
|
374
|
+
async sendForMessage(msg) {
|
|
375
|
+
const deliver = msg.deliver ?? "push";
|
|
376
|
+
if (deliver === "silent")
|
|
377
|
+
return false;
|
|
378
|
+
const subs = this.store.getAllSubscriptions();
|
|
379
|
+
const title = msg.from_label ?? msg.from_ref ?? "Message";
|
|
380
|
+
const body = msg.body.length > 140 ? msg.body.slice(0, 137) + "…" : msg.body;
|
|
381
|
+
const tag = msg.dedup_key ? `dedup-${msg.to}-${msg.dedup_key}` : msg.id;
|
|
382
|
+
// If this message targets a specific session, surface the sid in the
|
|
383
|
+
// push data so SW notificationclick can route the user there. Without
|
|
384
|
+
// this, clicks fall back to "/" and land on whatever session was last
|
|
385
|
+
// open — a confusing UX when multiple sessions get background pushes.
|
|
386
|
+
const sessionId = msg.to.startsWith("session:")
|
|
387
|
+
? msg.to.slice("session:".length)
|
|
388
|
+
: undefined;
|
|
389
|
+
// Observability signal #7 — sendForMessage entry.
|
|
390
|
+
elog.info("sendForMessage", {
|
|
391
|
+
msg_id: msg.id,
|
|
392
|
+
tag,
|
|
393
|
+
deliver,
|
|
394
|
+
endpoints: subs.length,
|
|
395
|
+
suppressed_by_visibility: false,
|
|
396
|
+
});
|
|
397
|
+
await this.sendToAll({
|
|
398
|
+
kind: "notify",
|
|
399
|
+
title,
|
|
400
|
+
body,
|
|
401
|
+
tag,
|
|
402
|
+
data: sessionId
|
|
403
|
+
? { messageId: msg.id, sessionId }
|
|
404
|
+
: { messageId: msg.id },
|
|
405
|
+
});
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Send a push for an ACP session event (permission_request / prompt_done /
|
|
410
|
+
* bash_done). Handles tag derivation, visibility suppression, and session-
|
|
411
|
+
* title lookup. Returns `true` if a push attempt was made.
|
|
412
|
+
*/
|
|
413
|
+
async sendForEvent(sessionId, event) {
|
|
414
|
+
if (!PushService.NOTIFIABLE.has(event.type))
|
|
415
|
+
return false;
|
|
416
|
+
const session = this.store.getSession(sessionId);
|
|
417
|
+
const sessionTitle = session?.title ?? null;
|
|
418
|
+
const tag = pushTagForEvent(sessionId, event);
|
|
419
|
+
const eventData = {};
|
|
420
|
+
if (event.type === "permission_request" && event.title !== undefined) {
|
|
421
|
+
eventData.description = event.title;
|
|
422
|
+
}
|
|
423
|
+
if (event.type === "bash_done") {
|
|
424
|
+
if (event.command !== undefined)
|
|
425
|
+
eventData.command = event.command;
|
|
426
|
+
if (event.exitCode !== undefined)
|
|
427
|
+
eventData.exitCode = event.exitCode;
|
|
428
|
+
}
|
|
429
|
+
const suppressed = this.isSessionVisibleToAnyClient(sessionId);
|
|
430
|
+
const subs = this.store.getAllSubscriptions();
|
|
431
|
+
elog.info("sendForEvent", {
|
|
432
|
+
sess_id: sessionId.slice(0, 8),
|
|
433
|
+
type: event.type,
|
|
434
|
+
tag,
|
|
435
|
+
endpoints: subs.length,
|
|
436
|
+
suppressed_by_visibility: suppressed,
|
|
437
|
+
});
|
|
438
|
+
const notification = this.formatNotification(sessionId, sessionTitle, event.type, eventData, tag);
|
|
439
|
+
await this.sendToAll(notification);
|
|
440
|
+
return true;
|
|
161
441
|
}
|
|
162
442
|
/** Send a single push notification. Extracted for testability. */
|
|
163
|
-
sendOne(sub, payload) {
|
|
164
|
-
return webpush.sendNotification(sub, payload);
|
|
443
|
+
sendOne(sub, payload, options) {
|
|
444
|
+
return webpush.sendNotification(sub, payload, options);
|
|
165
445
|
}
|
|
166
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Derive an RFC 8030 `Topic` header value from a notification tag. The
|
|
449
|
+
* spec limits topic to ≤32 chars of URL-safe Base64, so we hash and
|
|
450
|
+
* truncate. FCM maps this to its on-wire collapse key and works
|
|
451
|
+
* correctly. APNs is documented to map Topic → `apns-collapse-id`,
|
|
452
|
+
* but in practice iOS Safari PWA (≤17 at least) still surfaces
|
|
453
|
+
* stacked banners for same-Topic pushes — confirmed in dogfood.
|
|
454
|
+
* We keep the header for the platforms where it does work and for
|
|
455
|
+
* spec compliance; don't expect it to fix iOS.
|
|
456
|
+
*/
|
|
457
|
+
function tagToTopic(tag) {
|
|
458
|
+
return createHash("sha256").update(tag).digest("base64url").slice(0, 22);
|
|
459
|
+
}
|