fix browser notifications + make them work when browser is closed

This commit is contained in:
unknown
2026-03-23 20:24:35 +01:00
parent 17b88cb160
commit c33f6d4108
10 changed files with 480 additions and 36 deletions
+1
View File
@@ -20,6 +20,7 @@
"jose": "^6.1.3", "jose": "^6.1.3",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"web-push": "^3.6.7",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
@@ -18,9 +18,7 @@ self.addEventListener("notificationclick", (event) => {
try { try {
const clientUrl = new URL(client.url); const clientUrl = new URL(client.url);
if (clientUrl.origin === self.location.origin) { if (clientUrl.origin === self.location.origin) {
if (clientUrl.pathname !== "/notifications") { client.navigate(targetUrl);
client.navigate(targetUrl);
}
return client.focus(); return client.focus();
} }
} catch {} } catch {}
@@ -30,6 +28,29 @@ self.addEventListener("notificationclick", (event) => {
); );
}); });
self.addEventListener("push", (event) => {
if (!event.data) return;
let data;
try {
data = event.data.json();
} catch {
data = { title: "Overlord", body: event.data.text() };
}
const title = String(data.title || "Overlord Notification");
const options = {
body: String(data.body || ""),
icon: "/assets/overlord.png",
badge: "/assets/overlord.png",
tag: data.tag || `overlord-push-${Date.now()}`,
data: { url: data.url || "/notifications" },
requireInteraction: false,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
const data = event.data; const data = event.data;
if (!data || data.type !== "show_notification") return; if (!data || data.type !== "show_notification") return;
@@ -40,7 +61,7 @@ self.addEventListener("message", (event) => {
icon: data.icon || "/assets/overlord.png", icon: data.icon || "/assets/overlord.png",
badge: "/assets/overlord.png", badge: "/assets/overlord.png",
tag: data.tag || `overlord-${Date.now()}`, tag: data.tag || `overlord-${Date.now()}`,
data: { url: "/notifications" }, data: { url: data.url || "/notifications" },
requireInteraction: false, requireInteraction: false,
}; };
+127 -29
View File
@@ -3,6 +3,7 @@ import { decodeMsgpack } from "./msgpack-helpers.js";
const STORAGE_KEY = "overlord_notifications_enabled"; const STORAGE_KEY = "overlord_notifications_enabled";
const UNREAD_KEY = "overlord_notifications_unread"; const UNREAD_KEY = "overlord_notifications_unread";
const DESKTOP_NOTIF_KEY = "overlord_desktop_notifications_enabled"; const DESKTOP_NOTIF_KEY = "overlord_desktop_notifications_enabled";
const PUSH_SUBSCRIBED_KEY = "overlord_push_subscribed";
const CLIENT_EVENT_KEYS = { const CLIENT_EVENT_KEYS = {
client_online: "overlord_desktop_notify_client_online", client_online: "overlord_desktop_notify_client_online",
client_offline: "overlord_desktop_notify_client_offline", client_offline: "overlord_desktop_notify_client_offline",
@@ -27,6 +28,7 @@ if (
} }
let ws = null; let ws = null;
let started = false; let started = false;
let swRegistration = null;
let readyHandlers = new Set(); let readyHandlers = new Set();
let notificationHandlers = new Set(); let notificationHandlers = new Set();
let statusHandlers = new Set(); let statusHandlers = new Set();
@@ -99,6 +101,11 @@ export function getDesktopNotificationsEnabled() {
export function setDesktopNotificationsEnabled(value) { export function setDesktopNotificationsEnabled(value) {
localStorage.setItem(DESKTOP_NOTIF_KEY, value ? "1" : "0"); localStorage.setItem(DESKTOP_NOTIF_KEY, value ? "1" : "0");
if (value) {
subscribeToPush();
} else {
unsubscribeFromPush();
}
} }
export function getClientEventNotificationEnabled(eventType) { export function getClientEventNotificationEnabled(eventType) {
@@ -124,20 +131,105 @@ export async function requestDesktopNotificationPermission() {
} }
} }
function fireNotification(title, body, tag, clickUrl) { function urlBase64ToUint8Array(base64String) {
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
navigator.serviceWorker.ready const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
.then((reg) => const rawData = atob(base64);
reg.showNotification(title, { const outputArray = new Uint8Array(rawData.length);
body, for (let i = 0; i < rawData.length; ++i) {
icon: "/assets/overlord.png", outputArray[i] = rawData.charCodeAt(i);
tag, }
data: { url: clickUrl || "/notifications" }, return outputArray;
requireInteraction: false, }
}),
) async function getSwRegistration() {
.catch(() => fireNotificationFallback(title, body, tag, clickUrl)); if (swRegistration) return swRegistration;
return; if (!("serviceWorker" in navigator) || !window.isSecureContext) return null;
try {
swRegistration = await navigator.serviceWorker.ready;
return swRegistration;
} catch {
return null;
}
}
async function subscribeToPush() {
try {
const reg = await getSwRegistration();
if (!reg || !reg.pushManager) return;
const existing = await reg.pushManager.getSubscription();
if (existing) {
if (localStorage.getItem(PUSH_SUBSCRIBED_KEY) === "1") return;
await sendSubscriptionToServer(existing);
return;
}
const res = await fetch("/api/notifications/vapid-public-key");
if (!res.ok) return;
const { publicKey } = await res.json();
if (!publicKey) return;
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await sendSubscriptionToServer(subscription);
} catch (err) {
console.warn("[notifications] push subscribe failed", err);
}
}
async function sendSubscriptionToServer(subscription) {
try {
const res = await fetch("/api/notifications/push-subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
});
if (res.ok) {
localStorage.setItem(PUSH_SUBSCRIBED_KEY, "1");
}
} catch (err) {
console.warn("[notifications] failed to send push subscription to server", err);
}
}
async function unsubscribeFromPush() {
try {
const reg = await getSwRegistration();
if (!reg || !reg.pushManager) return;
const subscription = await reg.pushManager.getSubscription();
if (!subscription) return;
await fetch("/api/notifications/push-subscribe", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
}).catch(() => {});
await subscription.unsubscribe().catch(() => {});
localStorage.removeItem(PUSH_SUBSCRIBED_KEY);
} catch (err) {
console.warn("[notifications] push unsubscribe failed", err);
}
}
async function fireNotification(title, body, tag, clickUrl) {
const reg = await getSwRegistration();
if (reg) {
try {
await reg.showNotification(title, {
body,
icon: "/assets/overlord.png",
tag,
data: { url: clickUrl || "/notifications" },
requireInteraction: false,
});
return;
} catch {}
} }
fireNotificationFallback(title, body, tag, clickUrl); fireNotificationFallback(title, body, tag, clickUrl);
} }
@@ -299,6 +391,20 @@ function connect() {
}; };
} }
async function registerServiceWorker() {
if (!("serviceWorker" in navigator) || !window.isSecureContext) return null;
try {
const res = await fetch("/assets/notification-sw.js");
if (!res.ok) return null;
const reg = await navigator.serviceWorker.register("/assets/notification-sw.js", { scope: "/" });
swRegistration = reg;
await navigator.serviceWorker.ready;
return reg;
} catch {
return null;
}
}
export async function startNotificationClient() { export async function startNotificationClient() {
if (started) return; if (started) return;
started = true; started = true;
@@ -309,23 +415,15 @@ export async function startNotificationClient() {
console.warn("[notifications] failed to load msgpackr", err); console.warn("[notifications] failed to load msgpackr", err);
} }
if ("serviceWorker" in navigator && window.isSecureContext) { await registerServiceWorker();
fetch("/assets/notification-sw.js")
.then((res) => {
if (!res.ok) return;
navigator.serviceWorker
.register("/assets/notification-sw.js", { scope: "/" })
.catch(() => { });
})
.catch(() => {/* SW script unreachable (e.g. self-signed cert) — skip quietly */});
}
if (getDesktopNotificationsEnabled()) { if (getDesktopNotificationsEnabled()) {
requestDesktopNotificationPermission().then((perm) => { const perm = await requestDesktopNotificationPermission();
if (perm !== "granted") { if (perm !== "granted") {
setDesktopNotificationsEnabled(false); setDesktopNotificationsEnabled(false);
} } else {
}); subscribeToPush();
}
} }
connect(); connect();
+70
View File
@@ -142,6 +142,23 @@ db.run(
`CREATE INDEX IF NOT EXISTS idx_notification_screenshots_ts ON notification_screenshots(ts DESC);`, `CREATE INDEX IF NOT EXISTS idx_notification_screenshots_ts ON notification_screenshots(ts DESC);`,
); );
db.run(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`);
db.run(
`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);`,
);
db.run(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_push_subscriptions_endpoint ON push_subscriptions(endpoint);`,
);
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS auto_scripts ( CREATE TABLE IF NOT EXISTS auto_scripts (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -948,6 +965,59 @@ export function getEnrollmentStats(): {
return stats; return stats;
} }
export interface PushSubscriptionRecord {
id: number;
userId: number;
endpoint: string;
p256dh: string;
auth: string;
createdAt: number;
}
export function savePushSubscription(userId: number, endpoint: string, p256dh: string, auth: string): void {
db.run(
`INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?, ?)`,
userId, endpoint, p256dh, auth, Date.now(),
);
}
export function deletePushSubscription(endpoint: string): void {
db.run(`DELETE FROM push_subscriptions WHERE endpoint=?`, endpoint);
}
export function deletePushSubscriptionsByUser(userId: number): void {
db.run(`DELETE FROM push_subscriptions WHERE user_id=?`, userId);
}
export function getPushSubscriptionsByUser(userId: number): PushSubscriptionRecord[] {
return db
.query<any>(`SELECT * FROM push_subscriptions WHERE user_id=?`)
.all(userId)
.map((r: any) => ({
id: r.id,
userId: r.user_id,
endpoint: r.endpoint,
p256dh: r.p256dh,
auth: r.auth,
createdAt: Number(r.created_at) || 0,
}));
}
export function getAllPushSubscriptions(): PushSubscriptionRecord[] {
return db
.query<any>(`SELECT * FROM push_subscriptions`)
.all()
.map((r: any) => ({
id: r.id,
userId: r.user_id,
endpoint: r.endpoint,
p256dh: r.p256dh,
auth: r.auth,
createdAt: Number(r.created_at) || 0,
}));
}
export function getPendingClients(): { export function getPendingClients(): {
id: string; id: string;
host: string | null; host: string | null;
+3 -1
View File
@@ -115,6 +115,7 @@ import {
handleVoiceViewerOpen, handleVoiceViewerOpen,
} from "./server/ws-voice"; } from "./server/ws-voice";
import { createNotificationPluginHandlers } from "./server/ws-notifications-plugin"; import { createNotificationPluginHandlers } from "./server/ws-notifications-plugin";
import { loadOrGenerateVapidKeys } from "./server/web-push";
import * as clientManager from "./clientManager"; import * as clientManager from "./clientManager";
import * as sessionManager from "./sessions/sessionManager"; import * as sessionManager from "./sessions/sessionManager";
import type { SocketData } from "./sessions/types"; import type { SocketData } from "./sessions/types";
@@ -260,6 +261,7 @@ const notificationPluginHandlers = createNotificationPluginHandlers({
pluginState, pluginState,
getNotificationConfig, getNotificationConfig,
canUserAccessClient, canUserAccessClient,
getUserRole: (userId: number) => getUserById(userId)?.role,
storeNotificationScreenshot: storeNotificationScreenshotForPending, storeNotificationScreenshot: storeNotificationScreenshotForPending,
deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord, deliverNotificationWithScreenshot: deliverNotificationWithScreenshotForRecord,
savePluginState, savePluginState,
@@ -526,7 +528,7 @@ async function startServer() {
markAllClientsOffline(); markAllClientsOffline();
clearNotificationScreenshots(); clearNotificationScreenshots();
loadOrGenerateVapidKeys();
deleteExpiredBuilds(); deleteExpiredBuilds();
logger.info(`[db] Cleaned up expired builds`); logger.info(`[db] Cleaned up expired builds`);
@@ -2,9 +2,13 @@ import { v4 as uuidv4 } from "uuid";
import { import {
getNotificationScreenshot, getNotificationScreenshot,
saveNotificationScreenshot, saveNotificationScreenshot,
getAllPushSubscriptions,
deletePushSubscription,
type NotificationScreenshotRecord, type NotificationScreenshotRecord,
type PushSubscriptionRecord,
} from "../db"; } from "../db";
import { logger } from "../logger"; import { logger } from "../logger";
import { sendWebPush } from "./web-push";
export type NotificationRecord = { export type NotificationRecord = {
id: string; id: string;
@@ -17,7 +21,7 @@ export type NotificationRecord = {
processPath?: string; processPath?: string;
pid?: number; pid?: number;
keyword?: string; keyword?: string;
category: "active_window"; category: "active_window" | "clipboard";
ts: number; ts: number;
screenshotId?: string; screenshotId?: string;
}; };
@@ -271,12 +275,113 @@ async function deliverToUser(
]); ]);
} }
async function deliverWebPushToAll(
record: NotificationRecord,
getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[],
): Promise<void> {
const subs = getAllPushSubscriptions();
if (subs.length === 0) return;
const targets = getUserDeliveryTargets(record.clientId);
const allowedUserIds = new Set(targets.map((t) => t.userId));
const title = record.keyword
? `Overlord \u2014 ${record.keyword}`
: "Overlord \u2014 Notification";
const lines = [record.title];
if (record.user) lines.push(`User: ${record.user}`);
if (record.host) lines.push(`Host: ${record.host}`);
if (record.process) lines.push(`Process: ${record.process}`);
const payload = JSON.stringify({
type: "notification",
title,
body: lines.filter(Boolean).join("\n"),
tag: `overlord-${record.id || Date.now()}`,
url: "/notifications",
});
await Promise.allSettled(
subs
.filter((sub) => allowedUserIds.has(sub.userId))
.map(async (sub) => {
const result = await sendWebPush(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload,
);
if (result.gone) {
deletePushSubscription(sub.endpoint);
logger.info(`[web-push] removed gone subscription for user ${sub.userId}`);
} else if (!result.success) {
logger.warn(`[web-push] delivery failed for user ${sub.userId}: ${result.error}`);
}
}),
);
}
export async function deliverWebPushClientEvent(
event: string,
info: { id: string; host?: string; user?: string; os?: string },
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
getUserRole: (userId: number) => string | undefined,
): Promise<void> {
const subs = getAllPushSubscriptions();
if (subs.length === 0) return;
const labels: Record<string, string> = {
client_online: "\u{1F7E2} Client Online",
client_offline: "\u{1F534} Client Offline",
client_purgatory: "\u{1F7E1} Client Awaiting Approval",
};
const title = labels[event] || "Overlord \u2014 Client Event";
const lines: string[] = [];
if (info.host) lines.push(`Host: ${info.host}`);
if (info.user) lines.push(`User: ${info.user}`);
if (info.os) lines.push(`OS: ${info.os}`);
if (info.id) lines.push(`ID: ${info.id}`);
const dest = event === "client_purgatory" ? "/purgatory" : "/";
const payload = JSON.stringify({
type: "client_event",
event,
title,
body: lines.join("\n") || info.id || "",
tag: `overlord-client-${event}-${info.id || Date.now()}`,
url: dest,
});
await Promise.allSettled(
subs.map(async (sub) => {
const role = getUserRole(sub.userId);
if (!role) return;
if (event === "client_purgatory") {
if (role !== "admin" && role !== "operator") return;
} else if (role !== "admin") {
if (!canUserAccessClient(sub.userId, role, info.id)) return;
}
const result = await sendWebPush(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
payload,
);
if (result.gone) {
deletePushSubscription(sub.endpoint);
}
}),
);
}
export async function deliverNotificationWithScreenshot( export async function deliverNotificationWithScreenshot(
record: NotificationRecord, record: NotificationRecord,
getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[], getUserDeliveryTargets: (clientId: string) => UserDeliveryTarget[],
): Promise<void> { ): Promise<void> {
const screenshot = await waitForNotificationScreenshot(record.id); const screenshot = await waitForNotificationScreenshot(record.id);
const targets = getUserDeliveryTargets(record.clientId); const targets = getUserDeliveryTargets(record.clientId);
await Promise.allSettled(targets.map((t) => deliverToUser(t, record, screenshot))); await Promise.allSettled([
...targets.map((t) => deliverToUser(t, record, screenshot)),
deliverWebPushToAll(record, getUserDeliveryTargets),
]);
} }
@@ -2,6 +2,7 @@ import { authenticateRequest } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog"; import { AuditAction, logAudit } from "../../auditLog";
import * as clientManager from "../../clientManager"; import * as clientManager from "../../clientManager";
import { getConfig, updateNotificationsConfig } from "../../config"; import { getConfig, updateNotificationsConfig } from "../../config";
import { savePushSubscription, deletePushSubscription, getPushSubscriptionsByUser } from "../../db";
import { encodeMessage } from "../../protocol"; import { encodeMessage } from "../../protocol";
import { import {
getUserNotificationSettings, getUserNotificationSettings,
@@ -12,6 +13,7 @@ import {
DEFAULT_TELEGRAM_TEMPLATE, DEFAULT_TELEGRAM_TEMPLATE,
renderNotificationTemplate, renderNotificationTemplate,
} from "../notification-delivery"; } from "../notification-delivery";
import { getVapidPublicKey } from "../web-push";
type RequestIpProvider = { type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined; requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -406,5 +408,63 @@ export async function handleNotificationsConfigRoutes(
} }
} }
if (req.method === "GET" && url.pathname === "/api/notifications/vapid-public-key") {
const user = await authenticateRequest(req);
if (!user) {
return new Response(JSON.stringify({ error: "Not authenticated" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return Response.json({ publicKey: getVapidPublicKey() });
}
if (url.pathname === "/api/notifications/push-subscribe") {
const user = await authenticateRequest(req);
if (!user) {
return new Response(JSON.stringify({ error: "Not authenticated" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
if (req.method === "POST") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const endpoint = typeof body?.endpoint === "string" ? body.endpoint.trim() : "";
const p256dh = typeof body?.keys?.p256dh === "string" ? body.keys.p256dh : "";
const auth = typeof body?.keys?.auth === "string" ? body.keys.auth : "";
if (!endpoint || !p256dh || !auth) {
return Response.json({ error: "Missing subscription fields" }, { status: 400 });
}
savePushSubscription(user.userId, endpoint, p256dh, auth);
return Response.json({ ok: true });
}
if (req.method === "DELETE") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const endpoint = typeof body?.endpoint === "string" ? body.endpoint.trim() : "";
if (!endpoint) {
return Response.json({ error: "Missing endpoint" }, { status: 400 });
}
deletePushSubscription(endpoint);
return Response.json({ ok: true });
}
}
return null; return null;
} }
+66
View File
@@ -0,0 +1,66 @@
import webpush from "web-push";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
import { ensureDataDir } from "../paths";
import { logger } from "../logger";
let vapidKeys: { publicKey: string; privateKey: string } | null = null;
function getVapidPath(): string {
return resolve(ensureDataDir(), "vapid-keys.json");
}
export function loadOrGenerateVapidKeys(): { publicKey: string; privateKey: string } {
if (vapidKeys) return vapidKeys;
const vapidPath = getVapidPath();
if (existsSync(vapidPath)) {
try {
const raw = readFileSync(vapidPath, "utf-8");
const parsed = JSON.parse(raw);
if (parsed.publicKey && parsed.privateKey) {
vapidKeys = { publicKey: parsed.publicKey, privateKey: parsed.privateKey };
logger.info("[web-push] loaded VAPID keys from " + vapidPath);
return vapidKeys;
}
} catch (err) {
logger.warn("[web-push] failed to read vapid-keys.json, regenerating", err);
}
}
const generated = webpush.generateVAPIDKeys();
vapidKeys = { publicKey: generated.publicKey, privateKey: generated.privateKey };
try {
writeFileSync(vapidPath, JSON.stringify(vapidKeys, null, 2));
logger.info("[web-push] generated and saved VAPID keys to " + vapidPath);
} catch (err) {
logger.warn("[web-push] failed to persist VAPID keys", err);
}
return vapidKeys;
}
export function getVapidPublicKey(): string {
return loadOrGenerateVapidKeys().publicKey;
}
export async function sendWebPush(
subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
payload: string,
): Promise<{ success: boolean; gone?: boolean; error?: string }> {
const keys = loadOrGenerateVapidKeys();
webpush.setVapidDetails("mailto:overlord@localhost", keys.publicKey, keys.privateKey);
try {
await webpush.sendNotification(subscription, payload, { TTL: 60 * 60 });
return { success: true };
} catch (err: any) {
const statusCode = err?.statusCode;
if (statusCode === 404 || statusCode === 410) {
return { success: false, gone: true, error: `subscription gone (${statusCode})` };
}
return { success: false, error: err?.message || String(err) };
}
}
@@ -7,6 +7,7 @@ import { metrics } from "../metrics";
import { encodeMessage } from "../protocol"; import { encodeMessage } from "../protocol";
import * as sessionManager from "../sessions/sessionManager"; import * as sessionManager from "../sessions/sessionManager";
import type { SocketData } from "../sessions/types"; import type { SocketData } from "../sessions/types";
import { deliverWebPushClientEvent } from "./notification-delivery";
type NotificationRecord = { type NotificationRecord = {
id: string; id: string;
@@ -55,6 +56,7 @@ type CreateDeps = {
pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> }; pluginState: { enabled: Record<string, boolean>; lastError: Record<string, string> };
getNotificationConfig: () => NotificationConfigShape; getNotificationConfig: () => NotificationConfigShape;
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean; canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean;
getUserRole: (userId: number) => string | undefined;
storeNotificationScreenshot: ( storeNotificationScreenshot: (
pending: PendingNotificationScreenshot, pending: PendingNotificationScreenshot,
bytes: Uint8Array, bytes: Uint8Array,
@@ -350,6 +352,8 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
} }
safeSendViewer(session.viewer, item); safeSendViewer(session.viewer, item);
} }
void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole);
}, },
markPluginLoaded, markPluginLoaded,
+17
View File
@@ -0,0 +1,17 @@
declare module "web-push" {
interface VapidKeys {
publicKey: string;
privateKey: string;
}
interface PushSubscription {
endpoint: string;
keys: { p256dh: string; auth: string };
}
interface RequestOptions {
TTL?: number;
}
function generateVAPIDKeys(): VapidKeys;
function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
function sendNotification(subscription: PushSubscription, payload: string | Buffer | null, options?: RequestOptions): Promise<any>;
export { generateVAPIDKeys, setVapidDetails, sendNotification };
}