// Web Push enablement for the current device. The service worker itself is // registered globally by nav.js; this module owns the *subscription* handshake: // ask permission, subscribe with the server's VAPID key, and register the // resulting endpoint against the signed-in user (via account.js's cookie-aware // apiFetch). Used by the /alerts page to render a "Notifications on this device" // control. // // Push + service workers require a secure context (HTTPS, or http://localhost), // so on a plain-HTTP LAN origin this cleanly reports "unsupported" instead of // throwing. import { apiFetch, uv } from "./account.js"; export function supported() { return ( typeof window !== "undefined" && window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window ); } // The applicationServerKey must be a Uint8Array of the base64url-decoded VAPID // public key. function urlB64ToUint8Array(base64) { const padding = "=".repeat((4 - (base64.length % 4)) % 4); const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(b64); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } async function registration() { // nav.js registered sw.js already; ready resolves once it's active. return navigator.serviceWorker.ready; } // Does an existing subscription's baked-in applicationServerKey still match the // server's current VAPID public key? After a key rotation it won't: the browser // keeps signing pushes the server can no longer authenticate, so we must replace // the subscription. `options.applicationServerKey` is an ArrayBuffer of the raw // key bytes (or null on browsers that don't expose it — treat that as a mismatch // and re-subscribe rather than leaving a possibly-stale subscription in place). function applicationServerKeyMatches(sub, serverKey) { const current = sub.options && sub.options.applicationServerKey; if (!current) return false; const a = new Uint8Array(current); if (a.length !== serverKey.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== serverKey[i]) return false; return true; } // Is this device currently subscribed? (a PushSubscription exists locally) export async function isEnabled() { if (!supported()) return false; try { const reg = await registration(); return !!(await reg.pushManager.getSubscription()); } catch (e) { return false; } } export function permission() { return supported() ? Notification.permission : "unsupported"; } // Subscribe this device and register it server-side. Throws on failure so the // caller can surface a message. export async function enable() { if (!supported()) throw new Error("This browser doesn't support push notifications."); const perm = await Notification.requestPermission(); if (perm !== "granted") throw new Error("Notification permission was not granted."); const reg = await registration(); let sub = await reg.pushManager.getSubscription(); // Always resolve the server's current VAPID key: it's needed to subscribe, and // — when a subscription already exists — to detect a key rotation. A subscription // minted under an old key silently stops receiving pushes, so on a mismatch we // drop it and re-subscribe with the new key. Matching key = happy path, untouched. const keyRes = await apiFetch(uv("push/vapid-key")); if (!keyRes.ok) throw new Error("Couldn't fetch the server key."); const { key } = await keyRes.json(); const serverKey = urlB64ToUint8Array(key); if (sub && !applicationServerKeyMatches(sub, serverKey)) { await sub.unsubscribe(); sub = null; } if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: serverKey, }); } const res = await apiFetch(uv("push/subscribe"), { method: "POST", json: sub.toJSON() }); if (!res.ok) throw new Error("Couldn't register this device."); } // Unsubscribe locally and remove the endpoint server-side. export async function disable() { if (!supported()) return; const reg = await registration(); const sub = await reg.pushManager.getSubscription(); if (!sub) return; try { await apiFetch(uv("push/subscribe"), { method: "DELETE", json: { endpoint: sub.endpoint } }); } catch (e) { /* best-effort server cleanup */ } await sub.unsubscribe(); } // Ask the server to push a test notification to every device on the account. export async function sendTest() { const res = await apiFetch(uv("push/test"), { method: "POST" }); if (!res.ok) throw new Error("Couldn't send a test notification."); return res.json(); }