import { desktopBridge } from "./desktop"; import { rpc } from "./rpc"; const SERVICE_WORKER_URL = "/sw.js"; const SERVICE_WORKER_SCOPE = "/"; /** * Web push needs the Push API, a service worker and a secure context (https or localhost). The * desktop app keeps its in-app notifications instead. */ export function webPushSupported(): boolean { return ( typeof window !== "undefined" && window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !desktopBridge() ); } /** Registers the push service worker where web push works; safe to call on every start. */ export async function registerPushServiceWorker(): Promise { if (!webPushSupported()) return; await navigator.serviceWorker .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }) .catch(() => undefined); } async function currentSubscription(): Promise { const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE); return (await registration?.pushManager.getSubscription()) ?? null; } /** True when this browser has granted notifications and holds a push subscription. */ export async function webPushEnabled(): Promise { if (!webPushSupported() || Notification.permission !== "granted") return false; return Boolean(await currentSubscription().catch(() => null)); } function base64UrlToBytes(value: string): Uint8Array { const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); const bytes = new Uint8Array(new ArrayBuffer(binary.length)); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes; } function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean { if (!current || current.byteLength !== expected.byteLength) return false; const bytes = new Uint8Array(current); return bytes.every((byte, index) => byte === expected[index]); } /** * Turns on push for this browser and registers it with the server. Call it straight from a click: * browsers only show the permission prompt during a user gesture. Throws when the server cannot * be reached. */ export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> { if (!webPushSupported()) return "unsupported"; const permission = Notification.permission === "default" ? await Notification.requestPermission() : Notification.permission; if (permission !== "granted") return "denied"; const config = await rpc.notifications.webPush.config(); if (!config.available || !config.publicKey) return "unsupported"; const applicationServerKey = base64UrlToBytes(config.publicKey); let subscription: PushSubscription; try { await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }); const registration = await navigator.serviceWorker.ready; const existing = await registration.pushManager.getSubscription(); // A subscription made for other server keys cannot receive this server's pushes. if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) { await existing.unsubscribe(); } subscription = existing && sameKey(existing.options.applicationServerKey, applicationServerKey) ? existing : await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); } catch { // Browsers without a push service (or with it switched off) reject the subscription. return "unsupported"; } const keys = subscription.toJSON().keys ?? {}; await rpc.notifications.webPush.subscribe({ endpoint: subscription.endpoint, keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" }, }); return "enabled"; } /** Stops push for this browser, on the server first so no push arrives after it returns. */ export async function disableWebPush(): Promise { if (!webPushSupported()) return; const subscription = await currentSubscription(); if (!subscription) return; await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint }); await subscription.unsubscribe().catch(() => undefined); }