@goodandready/dsh-plugin-notify 0.3.8 → 0.4.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.
- package/CHANGELOG.md +30 -5
- package/README.md +58 -4
- package/README.ru.md +48 -4
- package/README.zh.md +48 -4
- package/lib/client.js +240 -8
- package/lib/index.js +520 -39
- package/package.json +9 -3
package/lib/index.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
2
7
|
import Schema from '@deepseek-ai/schemastery';
|
|
3
8
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
4
9
|
|
|
5
10
|
export const name = '@goodandready/dsh-plugin-notify';
|
|
6
11
|
/** Settings namespace shared with the Web settings card. */
|
|
7
12
|
export const NS = '@goodandready/dsh-plugin-notify';
|
|
13
|
+
/** Canonical entry id matching cordis.patch.yml */
|
|
14
|
+
export const ENTRY_ID = 'plugin-notify';
|
|
8
15
|
|
|
9
16
|
// Session firehose + credentials (webhook URL refs) + settings scope for the card + webServer for SSE events + connection for authentication.
|
|
10
17
|
export const inject = ['sessions', 'credentials', 'settings', 'webServer', 'connection'];
|
|
11
18
|
|
|
12
19
|
const webhookRef = (label) => Schema.string()
|
|
13
20
|
.role('credential-ref')
|
|
14
|
-
.
|
|
15
|
-
|
|
16
|
-
// Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
|
|
17
|
-
// rather than its value, and the Loader mutates those boxes in place.
|
|
18
|
-
export function plainConfig(value) {
|
|
19
|
-
if (value === null || typeof value !== 'object') return value
|
|
20
|
-
if (Array.isArray(value)) return value.map(plainConfig)
|
|
21
|
-
if (typeof value.get === 'function') return plainConfig(value.get())
|
|
22
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
23
|
-
}
|
|
21
|
+
.volatile()
|
|
22
|
+
.description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`)
|
|
24
23
|
|
|
24
|
+
// Everything the settings card writes is marked volatile so dsh 0.2 accepts
|
|
25
|
+
// live edits through the settings controller (0.1 ignores the flag). Only
|
|
26
|
+
// leaves may be volatile: cordis 0.2 rejects volatile fields under a
|
|
27
|
+
// volatile enclosing object.
|
|
25
28
|
export const Config = Schema.object({
|
|
26
29
|
webhooks: Schema.object({
|
|
27
30
|
feishu: webhookRef('Feishu custom bot'),
|
|
@@ -31,23 +34,35 @@ export const Config = Schema.object({
|
|
|
31
34
|
discord: webhookRef('Discord webhook'),
|
|
32
35
|
custom: webhookRef('Custom generic webhook (POST JSON)'),
|
|
33
36
|
}).description('Per-channel credential refs for webhook URLs; leave empty to disable'),
|
|
34
|
-
events: Schema.array(Schema.string())
|
|
37
|
+
events: Schema.array(Schema.string())
|
|
38
|
+
.volatile()
|
|
35
39
|
.description('Events that trigger notifications: task_done / error / approval_requested'),
|
|
36
|
-
local: Schema.boolean().
|
|
37
|
-
enableSound: Schema.boolean().
|
|
38
|
-
enableToasts: Schema.boolean().
|
|
39
|
-
enableDesktopNotifications: Schema.boolean().
|
|
40
|
-
notifyBackgroundOnly: Schema.boolean().
|
|
41
|
-
timeoutMs: Schema.number().
|
|
40
|
+
local: Schema.boolean().default(true).volatile().description('Also emit a local system notification (macOS osascript)'),
|
|
41
|
+
enableSound: Schema.boolean().default(false).volatile().description('Play synthesized audio chime on completion, error, or approval'),
|
|
42
|
+
enableToasts: Schema.boolean().default(false).volatile().description('Show in-app on-screen toast notifications across sessions'),
|
|
43
|
+
enableDesktopNotifications: Schema.boolean().default(false).volatile().description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
|
|
44
|
+
notifyBackgroundOnly: Schema.boolean().default(false).volatile().description('Notify only if the event occurred in a background/inactive session'),
|
|
45
|
+
timeoutMs: Schema.number().default(5000).volatile().description('Per-webhook request timeout (ms)'),
|
|
42
46
|
dnd: Schema.object({
|
|
43
|
-
start: Schema.string().default('').description('Do-not-disturb start (HH:MM, empty disables)'),
|
|
44
|
-
end: Schema.string().default('').description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
|
|
47
|
+
start: Schema.string().default('').volatile().description('Do-not-disturb start (HH:MM, empty disables)'),
|
|
48
|
+
end: Schema.string().default('').volatile().description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
|
|
45
49
|
}).description('DND window: events are logged but no local/webhook emission'),
|
|
46
|
-
includeSession: Schema.boolean().
|
|
47
|
-
includeDuration: Schema.boolean().
|
|
48
|
-
excludeSessionPrefixes: Schema.array(Schema.string())
|
|
50
|
+
includeSession: Schema.boolean().default(true).volatile().description('Include the session line in notification text'),
|
|
51
|
+
includeDuration: Schema.boolean().default(true).volatile().description('Include the duration line in notification text'),
|
|
52
|
+
excludeSessionPrefixes: Schema.array(Schema.string())
|
|
49
53
|
.default([])
|
|
54
|
+
.volatile()
|
|
50
55
|
.description('Skip notifications when session id starts with any prefix (e.g. msgw- for messenger-gateway)'),
|
|
56
|
+
webPush: Schema.object({
|
|
57
|
+
enabled: Schema.boolean().default(false).volatile()
|
|
58
|
+
.description('Web Push (PWA) channel: send service-worker push notifications that reach devices with every dsh tab closed'),
|
|
59
|
+
onlyWhenAway: Schema.boolean().default(true).volatile()
|
|
60
|
+
.description('Skip web push while a browser client is connected to the SSE stream'),
|
|
61
|
+
subject: Schema.string().default('mailto:notify@localhost').volatile()
|
|
62
|
+
.description('VAPID subject (mailto: or https: contact URL) advertised to push services'),
|
|
63
|
+
stateFile: Schema.string().default('').volatile()
|
|
64
|
+
.description('State file for VAPID keys and push subscriptions (default: $DSH_HOME/plugin-notify-state.json)'),
|
|
65
|
+
}).description('Web Push (PWA) channel configuration'),
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
function isExcludedSession(sessionId, prefixes) {
|
|
@@ -59,6 +74,17 @@ function isExcludedSession(sessionId, prefixes) {
|
|
|
59
74
|
return false;
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
// Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
|
|
78
|
+
// rather than its value, and the Loader mutates those boxes in place.
|
|
79
|
+
export function plainConfig(value) {
|
|
80
|
+
if (value === null || typeof value !== 'object') return value
|
|
81
|
+
if (Array.isArray(value)) return value.map(plainConfig)
|
|
82
|
+
if (typeof value.get === 'function') return plainConfig(value.get())
|
|
83
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const unwrapConfig = plainConfig
|
|
87
|
+
|
|
62
88
|
const DEFAULT_EVENTS = ['task_done', 'error', 'approval_requested'];
|
|
63
89
|
/** Per-session last `turn/start` epoch ms, for turn-duration reporting. */
|
|
64
90
|
export const turnStarts = new Map();
|
|
@@ -252,13 +278,416 @@ export function closeAllSseClients() {
|
|
|
252
278
|
sseClients.clear();
|
|
253
279
|
}
|
|
254
280
|
|
|
255
|
-
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Web Push (PWA) channel: VAPID keys + subscription state, push routes, sender.
|
|
283
|
+
// Reaches devices with every dsh tab closed — the SSE/local channels cannot.
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** Service worker served at GET /dsh-plugin-notify/sw.js (no secrets inside). */
|
|
287
|
+
export const SW_SOURCE = [
|
|
288
|
+
'self.addEventListener("push", (event) => {',
|
|
289
|
+
' let data = {};',
|
|
290
|
+
' try { data = event.data ? event.data.json() : {}; } catch (_) { data = {}; }',
|
|
291
|
+
' const title = data.title || "DeepSeek Harness";',
|
|
292
|
+
' event.waitUntil(self.registration.showNotification(title, {',
|
|
293
|
+
' body: data.body || "",',
|
|
294
|
+
' tag: data.tag || "dsh-notify",',
|
|
295
|
+
' data: { sessionId: data.sessionId || "" },',
|
|
296
|
+
' }));',
|
|
297
|
+
'});',
|
|
298
|
+
'self.addEventListener("notificationclick", (event) => {',
|
|
299
|
+
' event.notification.close();',
|
|
300
|
+
' const sessionId = (event.notification.data && event.notification.data.sessionId) || "";',
|
|
301
|
+
' const target = sessionId ? "./?dshNotifySession=" + encodeURIComponent(sessionId) : "./";',
|
|
302
|
+
' event.waitUntil((async () => {',
|
|
303
|
+
' const clientList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });',
|
|
304
|
+
' for (const client of clientList) {',
|
|
305
|
+
' let pathname = "";',
|
|
306
|
+
' try { pathname = new URL(client.url).pathname; } catch (_) { continue; }',
|
|
307
|
+
' if (pathname.endsWith("/") || pathname.endsWith("/index.html")) {',
|
|
308
|
+
' await client.focus();',
|
|
309
|
+
' if (sessionId) client.postMessage({ type: "dsh-notify-jump", sessionId });',
|
|
310
|
+
' return;',
|
|
311
|
+
' }',
|
|
312
|
+
' }',
|
|
313
|
+
' await self.clients.openWindow(target);',
|
|
314
|
+
' })());',
|
|
315
|
+
'});',
|
|
316
|
+
].join('\n');
|
|
317
|
+
|
|
318
|
+
/** Env overrides so users can keep VAPID keys out of the state file entirely. */
|
|
319
|
+
export const VAPID_PUBLIC_ENV = 'DSH_NOTIFY_VAPID_PUBLIC_KEY';
|
|
320
|
+
export const VAPID_PRIVATE_ENV = 'DSH_NOTIFY_VAPID_PRIVATE_KEY';
|
|
321
|
+
|
|
322
|
+
/** P-256 VAPID keypair as base64url — raw 65-byte point / raw 32-byte scalar,
|
|
323
|
+
* the exact shapes web-push.generateVAPIDKeys() produces (applicationServerKey needs the raw point).
|
|
324
|
+
* ECDH, not DER slicing: the pkcs8 DER ends with the public key, so slice(-32) would grab point bytes. */
|
|
325
|
+
export function generateVapidKeys() {
|
|
326
|
+
const ecdh = crypto.createECDH('prime256v1');
|
|
327
|
+
ecdh.generateKeys();
|
|
328
|
+
return {
|
|
329
|
+
publicKey: ecdh.getPublicKey('base64url'),
|
|
330
|
+
privateKey: ecdh.getPrivateKey('base64url'),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function resolveStatePath(webPushConfig = {}) {
|
|
335
|
+
if (webPushConfig && typeof webPushConfig.stateFile === 'string' && webPushConfig.stateFile.trim())
|
|
336
|
+
return webPushConfig.stateFile.trim();
|
|
337
|
+
const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim())
|
|
338
|
+
|| path.join(os.homedir(), '.dsh');
|
|
339
|
+
return path.join(home, 'plugin-notify-state.json');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function loadState(statePath) {
|
|
343
|
+
try {
|
|
344
|
+
const raw = await fsp.readFile(statePath, 'utf8');
|
|
345
|
+
const parsed = JSON.parse(raw);
|
|
346
|
+
return {
|
|
347
|
+
vapid: (parsed && typeof parsed.vapid === 'object' && parsed.vapid) || null,
|
|
348
|
+
subscriptions: Array.isArray(parsed?.subscriptions) ? parsed.subscriptions : [],
|
|
349
|
+
};
|
|
350
|
+
} catch {
|
|
351
|
+
return { vapid: null, subscriptions: [] };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Atomic-ish persist: temp file + rename, serialized per path to avoid RMW races. */
|
|
356
|
+
const writeChains = new Map();
|
|
357
|
+
export function saveState(statePath, state) {
|
|
358
|
+
const prev = writeChains.get(statePath) || Promise.resolve();
|
|
359
|
+
const next = prev.catch(() => {}).then(async () => {
|
|
360
|
+
await fsp.mkdir(path.dirname(statePath), { recursive: true });
|
|
361
|
+
const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
|
|
362
|
+
await fsp.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
363
|
+
await fsp.rename(tmp, statePath);
|
|
364
|
+
});
|
|
365
|
+
writeChains.set(statePath, next);
|
|
366
|
+
return next;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Env keys win; otherwise state file; otherwise generate once and persist. */
|
|
370
|
+
export async function getVapidKeys(ctx, webPushConfig = {}) {
|
|
371
|
+
const envPub = process.env[VAPID_PUBLIC_ENV];
|
|
372
|
+
const envPriv = process.env[VAPID_PRIVATE_ENV];
|
|
373
|
+
if (envPub && envPriv) {
|
|
374
|
+
return { publicKey: envPub, privateKey: envPriv };
|
|
375
|
+
}
|
|
376
|
+
const statePath = resolveStatePath(webPushConfig);
|
|
377
|
+
const state = await loadState(statePath);
|
|
378
|
+
if (state.vapid && state.vapid.publicKey && state.vapid.privateKey)
|
|
379
|
+
return state.vapid;
|
|
380
|
+
const keys = generateVapidKeys();
|
|
381
|
+
state.vapid = keys;
|
|
382
|
+
await saveState(statePath, state);
|
|
383
|
+
ctx?.logger?.info?.(`[plugin-notify] generated VAPID keys → ${statePath}`);
|
|
384
|
+
return keys;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isValidSubscription(sub) {
|
|
388
|
+
return Boolean(
|
|
389
|
+
sub
|
|
390
|
+
&& typeof sub.endpoint === 'string'
|
|
391
|
+
&& /^https:\/\//i.test(sub.endpoint)
|
|
392
|
+
&& sub.keys
|
|
393
|
+
&& typeof sub.keys.p256dh === 'string' && sub.keys.p256dh.length > 0
|
|
394
|
+
&& typeof sub.keys.auth === 'string' && sub.keys.auth.length > 0,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function addSubscription(statePath, sub) {
|
|
399
|
+
if (!isValidSubscription(sub))
|
|
400
|
+
throw new Error('invalid push subscription (need https endpoint and keys.p256dh/auth)');
|
|
401
|
+
const state = await loadState(statePath);
|
|
402
|
+
const clean = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, addedAt: Date.now() };
|
|
403
|
+
const existing = state.subscriptions.findIndex((s) => s.endpoint === clean.endpoint);
|
|
404
|
+
if (existing >= 0) state.subscriptions[existing] = clean;
|
|
405
|
+
else state.subscriptions.push(clean);
|
|
406
|
+
await saveState(statePath, state);
|
|
407
|
+
return state.subscriptions.length;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function removeSubscription(statePath, endpoint) {
|
|
411
|
+
const state = await loadState(statePath);
|
|
412
|
+
const before = state.subscriptions.length;
|
|
413
|
+
state.subscriptions = state.subscriptions.filter((s) => s.endpoint !== endpoint);
|
|
414
|
+
if (state.subscriptions.length !== before)
|
|
415
|
+
await saveState(statePath, state);
|
|
416
|
+
return before - state.subscriptions.length;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function listSubscriptions(statePath) {
|
|
420
|
+
const state = await loadState(statePath);
|
|
421
|
+
return state.subscriptions;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let webPushLibrary = null;
|
|
425
|
+
/** Test seam: inject a stub with async sendNotification(sub, payload, options). */
|
|
426
|
+
export function setWebPushLibrary(lib) {
|
|
427
|
+
webPushLibrary = lib;
|
|
428
|
+
}
|
|
429
|
+
async function getWebPush() {
|
|
430
|
+
if (!webPushLibrary) {
|
|
431
|
+
const mod = await import('web-push');
|
|
432
|
+
webPushLibrary = mod.default ?? mod;
|
|
433
|
+
}
|
|
434
|
+
return webPushLibrary;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Compact payload for the 4 KB push budget: pointer + label, never secret material. */
|
|
438
|
+
export function webPushPayload(n, config = {}) {
|
|
439
|
+
const badge = n.kind === 'task_done' ? '✅' : n.kind === 'error' ? '⚠️' : '⏸️';
|
|
440
|
+
const kindLabel = n.kind === 'task_done' ? 'Task done' : n.kind === 'error' ? 'Error' : 'Approval needed';
|
|
441
|
+
const title = `${badge} ${kindLabel}: ${n.title || n.sessionId || ''}`.trim();
|
|
442
|
+
const bodyLines = [];
|
|
443
|
+
if (n.summary) bodyLines.push(n.summary);
|
|
444
|
+
if (n.reason) bodyLines.push(`Reason: ${n.reason}`);
|
|
445
|
+
if ((config.includeDuration ?? true) && n.durationMs !== undefined)
|
|
446
|
+
bodyLines.push(`Duration: ${formatDuration(n.durationMs)}`);
|
|
447
|
+
if ((config.includeSession ?? true) && n.sessionId)
|
|
448
|
+
bodyLines.push(`Session: ${n.sessionId}`);
|
|
449
|
+
return {
|
|
450
|
+
kind: n.kind,
|
|
451
|
+
title,
|
|
452
|
+
body: bodyLines.join('\n'),
|
|
453
|
+
sessionId: n.sessionId || '',
|
|
454
|
+
tag: `dsh-notify-${n.sessionId || 'any'}`,
|
|
455
|
+
ts: Date.now(),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const inFlightDeliveries = new Set();
|
|
460
|
+
/** Test seam: resolves once every in-flight deliverWebPush has finished. */
|
|
461
|
+
export async function settleWebPushDeliveries() {
|
|
462
|
+
while (inFlightDeliveries.size > 0) {
|
|
463
|
+
await Promise.allSettled([...inFlightDeliveries]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function deliverWebPush(ctx, n, config = {}) {
|
|
468
|
+
let tracked;
|
|
469
|
+
tracked = (async () => {
|
|
470
|
+
const wp = config.webPush || {};
|
|
471
|
+
const statePath = resolveStatePath(wp);
|
|
472
|
+
const subscriptions = await listSubscriptions(statePath);
|
|
473
|
+
if (subscriptions.length === 0) return 0;
|
|
474
|
+
const keys = await getVapidKeys(ctx, wp);
|
|
475
|
+
const webpush = await getWebPush();
|
|
476
|
+
const payload = JSON.stringify(webPushPayload(n, config));
|
|
477
|
+
let sent = 0;
|
|
478
|
+
for (const sub of subscriptions) {
|
|
479
|
+
try {
|
|
480
|
+
await webpush.sendNotification(sub, payload, {
|
|
481
|
+
vapidDetails: {
|
|
482
|
+
subject: wp.subject || 'mailto:notify@localhost',
|
|
483
|
+
publicKey: keys.publicKey,
|
|
484
|
+
privateKey: keys.privateKey,
|
|
485
|
+
},
|
|
486
|
+
timeout: Math.max(config.timeoutMs ?? 5000, 1000),
|
|
487
|
+
});
|
|
488
|
+
sent++;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (error?.statusCode === 404 || error?.statusCode === 410) {
|
|
491
|
+
await removeSubscription(statePath, sub.endpoint);
|
|
492
|
+
ctx?.logger?.info?.(`[plugin-notify] pruned dead push subscription (${error.statusCode})`);
|
|
493
|
+
} else {
|
|
494
|
+
ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${String(error)}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return sent;
|
|
499
|
+
})().finally(() => {
|
|
500
|
+
inFlightDeliveries.delete(tracked);
|
|
501
|
+
});
|
|
502
|
+
inFlightDeliveries.add(tracked);
|
|
503
|
+
return tracked;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function readJsonBody(req, limit = 64 * 1024) {
|
|
507
|
+
return new Promise((resolve, reject) => {
|
|
508
|
+
let size = 0;
|
|
509
|
+
const chunks = [];
|
|
510
|
+
req.on('data', (chunk) => {
|
|
511
|
+
size += chunk.length;
|
|
512
|
+
if (size > limit) {
|
|
513
|
+
reject(new Error('request body too large'));
|
|
514
|
+
req.destroy();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
chunks.push(chunk);
|
|
518
|
+
});
|
|
519
|
+
req.on('end', () => {
|
|
520
|
+
try {
|
|
521
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
|
522
|
+
} catch {
|
|
523
|
+
reject(new Error('invalid JSON body'));
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
req.on('error', reject);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function writeJson(res, status, body, headers = {}) {
|
|
531
|
+
if (typeof res.writeHead === 'function') {
|
|
532
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers });
|
|
533
|
+
} else {
|
|
534
|
+
res.statusCode = status;
|
|
535
|
+
}
|
|
536
|
+
if (typeof res.end === 'function') res.end(JSON.stringify(body));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function writeText(res, status, text, headers = {}) {
|
|
540
|
+
if (typeof res.writeHead === 'function') {
|
|
541
|
+
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers });
|
|
542
|
+
} else {
|
|
543
|
+
res.statusCode = status;
|
|
544
|
+
}
|
|
545
|
+
if (typeof res.end === 'function') res.end(text);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
|
|
549
|
+
export function authorizePushRequest(req, res, targetCtx) {
|
|
550
|
+
const conn = connectionOf(targetCtx);
|
|
551
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
552
|
+
const rejection = conn.requestRejection(req);
|
|
553
|
+
if (rejection !== void 0) {
|
|
554
|
+
writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
560
|
+
if (!remoteAddr || !isLoopback(remoteAddr)) {
|
|
561
|
+
writeText(res, 403, 'Forbidden');
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function handleServiceWorker(req, res) {
|
|
568
|
+
if (req.method !== 'GET') {
|
|
569
|
+
writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// Unauthenticated on purpose: static, no secrets; the header lets the SW
|
|
573
|
+
// control the app root even though the script lives under /dsh-plugin-notify/.
|
|
574
|
+
if (typeof res.writeHead === 'function') {
|
|
575
|
+
res.writeHead(200, {
|
|
576
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
577
|
+
'Service-Worker-Allowed': '/',
|
|
578
|
+
'Cache-Control': 'no-store',
|
|
579
|
+
});
|
|
580
|
+
} else {
|
|
581
|
+
res.statusCode = 200;
|
|
582
|
+
}
|
|
583
|
+
if (typeof res.end === 'function') res.end(SW_SOURCE);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export async function handlePushKey(req, res, targetCtx, getConfig) {
|
|
587
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
588
|
+
if (req.method !== 'GET') return writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
|
|
589
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
590
|
+
const cfg = getConfigU();
|
|
591
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
592
|
+
const keys = await getVapidKeys(targetCtx, cfg.webPush || {});
|
|
593
|
+
writeJson(res, 200, { publicKey: keys.publicKey, subject: cfg.webPush.subject || 'mailto:notify@localhost' });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function handlePushSubscribe(req, res, targetCtx, getConfig) {
|
|
597
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
598
|
+
if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
|
|
599
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
600
|
+
const cfg = getConfigU();
|
|
601
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
602
|
+
let sub;
|
|
603
|
+
try {
|
|
604
|
+
sub = await readJsonBody(req);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return writeText(res, 400, String(error.message || error));
|
|
607
|
+
}
|
|
608
|
+
const statePath = resolveStatePath(cfg.webPush || {});
|
|
609
|
+
try {
|
|
610
|
+
const count = await addSubscription(statePath, sub);
|
|
611
|
+
targetCtx?.logger?.info?.(`[plugin-notify] push subscribed (${count} total)`);
|
|
612
|
+
writeJson(res, 200, { ok: true, count });
|
|
613
|
+
} catch (error) {
|
|
614
|
+
writeText(res, 400, String(error.message || error));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export async function handlePushUnsubscribe(req, res, targetCtx, getConfig) {
|
|
619
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
620
|
+
if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
|
|
621
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
622
|
+
const cfg = getConfigU();
|
|
623
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
624
|
+
let body;
|
|
625
|
+
try {
|
|
626
|
+
body = await readJsonBody(req);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return writeText(res, 400, String(error.message || error));
|
|
629
|
+
}
|
|
630
|
+
if (!body || typeof body.endpoint !== 'string') return writeText(res, 400, 'endpoint required');
|
|
631
|
+
const removed = await removeSubscription(resolveStatePath(cfg.webPush || {}), body.endpoint);
|
|
632
|
+
writeJson(res, 200, { ok: true, removed });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export async function handlePushTest(req, res, targetCtx, getConfig) {
|
|
636
|
+
const getConfigU = () => unwrapConfig(getConfig());
|
|
637
|
+
if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
|
|
638
|
+
if (!authorizePushRequest(req, res, targetCtx)) return;
|
|
639
|
+
const cfg = getConfigU();
|
|
640
|
+
if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
|
|
641
|
+
const sent = await deliverWebPush(targetCtx, {
|
|
642
|
+
kind: 'task_done',
|
|
643
|
+
title: 'Test notification',
|
|
644
|
+
sessionId: 'test',
|
|
645
|
+
summary: 'If you can read this, the dsh-plugin-notify web push channel works.',
|
|
646
|
+
}, cfg);
|
|
647
|
+
writeJson(res, 200, { ok: true, sent });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function mountWebServer(targetCtx, hostCtx, getConfig) {
|
|
256
651
|
if (!targetCtx?.webServer?.register) return () => {};
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
handler
|
|
652
|
+
// Async route wrapper: an unexpected rejection becomes a 500 instead of an
|
|
653
|
+
// unhandled rejection; authorized handlers have already written their response.
|
|
654
|
+
const asyncRoute = (handler) => (req, res) => {
|
|
655
|
+
Promise.resolve(handler(req, res)).catch(() => {
|
|
656
|
+
try { writeText(res, 500, 'internal error'); } catch { /* ignore */ }
|
|
657
|
+
});
|
|
261
658
|
};
|
|
659
|
+
const routeDefs = [
|
|
660
|
+
{
|
|
661
|
+
kind: 'exact',
|
|
662
|
+
path: '/dsh-plugin-notify/events',
|
|
663
|
+
handler: (req, res) => handleSseConnection(req, res, targetCtx),
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
kind: 'exact',
|
|
667
|
+
path: '/dsh-plugin-notify/sw.js',
|
|
668
|
+
handler: (req, res) => handleServiceWorker(req, res),
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
kind: 'exact',
|
|
672
|
+
path: '/dsh-plugin-notify/push/key',
|
|
673
|
+
handler: asyncRoute((req, res) => handlePushKey(req, res, targetCtx, getConfig)),
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
kind: 'exact',
|
|
677
|
+
path: '/dsh-plugin-notify/push/subscribe',
|
|
678
|
+
handler: asyncRoute((req, res) => handlePushSubscribe(req, res, targetCtx, getConfig)),
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
kind: 'exact',
|
|
682
|
+
path: '/dsh-plugin-notify/push/unsubscribe',
|
|
683
|
+
handler: asyncRoute((req, res) => handlePushUnsubscribe(req, res, targetCtx, getConfig)),
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
kind: 'exact',
|
|
687
|
+
path: '/dsh-plugin-notify/push/test',
|
|
688
|
+
handler: asyncRoute((req, res) => handlePushTest(req, res, targetCtx, getConfig)),
|
|
689
|
+
},
|
|
690
|
+
];
|
|
262
691
|
|
|
263
692
|
let timer = null;
|
|
264
693
|
const startHeartbeat = () => {
|
|
@@ -281,8 +710,10 @@ function mountWebServer(targetCtx, hostCtx) {
|
|
|
281
710
|
clearInterval(timer);
|
|
282
711
|
timer = null;
|
|
283
712
|
}
|
|
284
|
-
if (
|
|
285
|
-
|
|
713
|
+
if (Array.isArray(unregister)) {
|
|
714
|
+
for (const fn of unregister) {
|
|
715
|
+
try { fn(); } catch { /* ignore */ }
|
|
716
|
+
}
|
|
286
717
|
unregister = null;
|
|
287
718
|
}
|
|
288
719
|
closeAllSseClients();
|
|
@@ -290,14 +721,14 @@ function mountWebServer(targetCtx, hostCtx) {
|
|
|
290
721
|
|
|
291
722
|
if (typeof targetCtx.effect === 'function') {
|
|
292
723
|
targetCtx.effect(() => {
|
|
293
|
-
unregister = targetCtx.webServer.register(routeDef);
|
|
724
|
+
unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
|
|
294
725
|
return () => cleanup();
|
|
295
726
|
}, 'dsh-plugin-notify: sse events');
|
|
296
727
|
targetCtx.effect(() => {
|
|
297
728
|
return startHeartbeat();
|
|
298
729
|
}, 'dsh-plugin-notify: sse heartbeat');
|
|
299
730
|
} else {
|
|
300
|
-
unregister = targetCtx.webServer.register(routeDef);
|
|
731
|
+
unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
|
|
301
732
|
startHeartbeat();
|
|
302
733
|
}
|
|
303
734
|
|
|
@@ -305,7 +736,7 @@ function mountWebServer(targetCtx, hostCtx) {
|
|
|
305
736
|
}
|
|
306
737
|
|
|
307
738
|
export function apply(ctx, config = {}) {
|
|
308
|
-
let getConfig = () => config ?? {};
|
|
739
|
+
let getConfig = () => unwrapConfig(config ?? {});
|
|
309
740
|
|
|
310
741
|
let webServerMounted = false;
|
|
311
742
|
let unmountWebServer = null;
|
|
@@ -315,7 +746,7 @@ export function apply(ctx, config = {}) {
|
|
|
315
746
|
if (target?.webServer?.register) {
|
|
316
747
|
webServerMounted = true;
|
|
317
748
|
const effectiveTarget = target?.connection ? target : (ctx?.connection ? ctx : target);
|
|
318
|
-
unmountWebServer = mountWebServer(effectiveTarget, ctx);
|
|
749
|
+
unmountWebServer = mountWebServer(effectiveTarget, ctx, () => getConfig());
|
|
319
750
|
}
|
|
320
751
|
};
|
|
321
752
|
|
|
@@ -338,21 +769,58 @@ export function apply(ctx, config = {}) {
|
|
|
338
769
|
|
|
339
770
|
if (typeof ctx.inject === 'function') {
|
|
340
771
|
ctx.inject(['settings'], (sctx) => {
|
|
341
|
-
|
|
772
|
+
if (typeof sctx.settings?.register === 'function') {
|
|
773
|
+
try {
|
|
774
|
+
const scope = sctx.settings.register(ENTRY_ID, Config, { base: config ?? {} });
|
|
775
|
+
getConfig = () => (scope ? unwrapConfig(scope.get() ?? config ?? {}) : unwrapConfig(config ?? {}));
|
|
776
|
+
return;
|
|
777
|
+
} catch (err) {
|
|
778
|
+
ctx?.logger?.warn?.(`[plugin-notify] settings.register('${ENTRY_ID}') failed, falling back to describe: ${String(err)}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
let currentRow = (() => { try { return sctx.settings?.describe?.().find((r) => r && (r.ns === ENTRY_ID || r.ns === NS)); } catch { return null; } })();
|
|
782
|
+
const refreshSettings = () => {
|
|
783
|
+
try {
|
|
784
|
+
const next = sctx.settings?.describe?.().find((r) => r && (r.ns === ENTRY_ID || r.ns === NS));
|
|
785
|
+
if (next) currentRow = next;
|
|
786
|
+
} catch {
|
|
787
|
+
// best effort
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
if (typeof ctx.on === 'function') {
|
|
791
|
+
ctx.on('settings/document-updated', refreshSettings);
|
|
792
|
+
ctx.on('loader/volatile-update', refreshSettings);
|
|
793
|
+
ctx.on('settings/updated', refreshSettings);
|
|
794
|
+
}
|
|
795
|
+
if (typeof sctx.settings?.on === 'function') {
|
|
796
|
+
try { sctx.settings.on('update', refreshSettings); } catch { /* ignore */ }
|
|
797
|
+
}
|
|
342
798
|
const scope = {
|
|
343
|
-
get: () =>
|
|
344
|
-
|
|
799
|
+
get: () => {
|
|
800
|
+
refreshSettings();
|
|
801
|
+
return currentRow?.value ?? config ?? {};
|
|
802
|
+
},
|
|
803
|
+
replace: async (next) => {
|
|
804
|
+
try {
|
|
805
|
+
const targetNs = sctx.settings?.describe?.().some((r) => r && r.ns === ENTRY_ID) ? ENTRY_ID : NS;
|
|
806
|
+
const res = await sctx.settings?.replace?.(targetNs, next, currentRow?.revision);
|
|
807
|
+
refreshSettings();
|
|
808
|
+
return res;
|
|
809
|
+
} catch (err) {
|
|
810
|
+
ctx?.logger?.warn?.(`[plugin-notify] settings replace failed: ${String(err)}`);
|
|
811
|
+
}
|
|
812
|
+
},
|
|
345
813
|
};
|
|
346
|
-
getConfig = () => scope.get() ?? config ?? {};
|
|
814
|
+
getConfig = () => (scope ? unwrapConfig(scope.get() ?? config ?? {}) : unwrapConfig(config ?? {}));
|
|
347
815
|
});
|
|
348
|
-
ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
|
|
816
|
+
ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
|
|
349
817
|
}
|
|
350
818
|
if (ctx.webServer) {
|
|
351
819
|
tryMountWebServer(ctx);
|
|
352
820
|
}
|
|
353
821
|
|
|
354
822
|
const dispatch = (n) => {
|
|
355
|
-
const cfg = getConfig();
|
|
823
|
+
const cfg = unwrapConfig(getConfig());
|
|
356
824
|
const dnd = cfg.dnd;
|
|
357
825
|
if (inDnd(dnd)) {
|
|
358
826
|
(ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
|
|
@@ -381,6 +849,19 @@ export function apply(ctx, config = {}) {
|
|
|
381
849
|
.catch((error) => {
|
|
382
850
|
ctx?.logger?.warn?.(`[plugin-notify] webhook resolve/send failed: ${String(error)}`);
|
|
383
851
|
});
|
|
852
|
+
|
|
853
|
+
// Web Push (PWA) channel: reaches devices with every dsh tab closed.
|
|
854
|
+
const wp = cfg.webPush;
|
|
855
|
+
if (wp && wp.enabled) {
|
|
856
|
+
const away = sseClients.size === 0;
|
|
857
|
+
if (away || wp.onlyWhenAway === false) {
|
|
858
|
+
deliverWebPush(ctx, n, cfg).catch((error) => {
|
|
859
|
+
ctx?.logger?.warn?.(`[plugin-notify] web push dispatch failed: ${String(error)}`);
|
|
860
|
+
});
|
|
861
|
+
} else {
|
|
862
|
+
(ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · session ${n.sessionId} · web push skipped (clients connected)`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
384
865
|
};
|
|
385
866
|
|
|
386
867
|
ctx.on('session/event', (session, event) => {
|