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
@@ -18,9 +18,7 @@ self.addEventListener("notificationclick", (event) => {
try {
const clientUrl = new URL(client.url);
if (clientUrl.origin === self.location.origin) {
if (clientUrl.pathname !== "/notifications") {
client.navigate(targetUrl);
}
client.navigate(targetUrl);
return client.focus();
}
} 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) => {
const data = event.data;
if (!data || data.type !== "show_notification") return;
@@ -40,7 +61,7 @@ self.addEventListener("message", (event) => {
icon: data.icon || "/assets/overlord.png",
badge: "/assets/overlord.png",
tag: data.tag || `overlord-${Date.now()}`,
data: { url: "/notifications" },
data: { url: data.url || "/notifications" },
requireInteraction: false,
};
+127 -29
View File
@@ -3,6 +3,7 @@ import { decodeMsgpack } from "./msgpack-helpers.js";
const STORAGE_KEY = "overlord_notifications_enabled";
const UNREAD_KEY = "overlord_notifications_unread";
const DESKTOP_NOTIF_KEY = "overlord_desktop_notifications_enabled";
const PUSH_SUBSCRIBED_KEY = "overlord_push_subscribed";
const CLIENT_EVENT_KEYS = {
client_online: "overlord_desktop_notify_client_online",
client_offline: "overlord_desktop_notify_client_offline",
@@ -27,6 +28,7 @@ if (
}
let ws = null;
let started = false;
let swRegistration = null;
let readyHandlers = new Set();
let notificationHandlers = new Set();
let statusHandlers = new Set();
@@ -99,6 +101,11 @@ export function getDesktopNotificationsEnabled() {
export function setDesktopNotificationsEnabled(value) {
localStorage.setItem(DESKTOP_NOTIF_KEY, value ? "1" : "0");
if (value) {
subscribeToPush();
} else {
unsubscribeFromPush();
}
}
export function getClientEventNotificationEnabled(eventType) {
@@ -124,20 +131,105 @@ export async function requestDesktopNotificationPermission() {
}
}
function fireNotification(title, body, tag, clickUrl) {
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.ready
.then((reg) =>
reg.showNotification(title, {
body,
icon: "/assets/overlord.png",
tag,
data: { url: clickUrl || "/notifications" },
requireInteraction: false,
}),
)
.catch(() => fireNotificationFallback(title, body, tag, clickUrl));
return;
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function getSwRegistration() {
if (swRegistration) return swRegistration;
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);
}
@@ -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() {
if (started) return;
started = true;
@@ -309,23 +415,15 @@ export async function startNotificationClient() {
console.warn("[notifications] failed to load msgpackr", err);
}
if ("serviceWorker" in navigator && window.isSecureContext) {
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 */});
}
await registerServiceWorker();
if (getDesktopNotificationsEnabled()) {
requestDesktopNotificationPermission().then((perm) => {
if (perm !== "granted") {
setDesktopNotificationsEnabled(false);
}
});
const perm = await requestDesktopNotificationPermission();
if (perm !== "granted") {
setDesktopNotificationsEnabled(false);
} else {
subscribeToPush();
}
}
connect();