// Web Push helpers, driven by the chat page's onboarding flow (see shell.js). export function idbSet(key, value) { return new Promise((resolve, reject) => { const req = indexedDB.open("lchat", 1); req.onupgradeneeded = () => req.result.createObjectStore("kv"); req.onsuccess = () => { const db = req.result; // Close as soon as the write lands, and yield to a schema upgrade from // elsewhere: a connection held open with no versionchange handler would // block any future version bump in another tab forever (its open would // stall pre-paint). Pinned at v1 today, so this is armour, not a fix. db.onversionchange = () => db.close(); const tx = db.transaction("kv", "readwrite"); tx.objectStore("kv").put(value, key); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => { db.close(); reject(tx.error); }; }; req.onerror = () => reject(req.error); req.onblocked = () => reject(new Error("blocked")); }); } function urlB64ToUint8Array(b64) { const pad = "=".repeat((4 - (b64.length % 4)) % 4); const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(base64); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } const isiOS = /iphone|ipad|ipod/i.test(navigator.userAgent); const isStandalone = window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; // iOS Safari can't do web push until the app is added to the Home Screen. export function needsHomeScreen() { return isiOS && !isStandalone; } export function pushSupported() { return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; } // Push is usable here and now (not "iOS, but needs install first"). export function pushAvailable() { return pushSupported() && !needsHomeScreen(); } export function pushEnabled() { return pushSupported() && Notification.permission === "granted"; } export function pushDenied() { return "Notification" in window && Notification.permission === "denied"; } let swReg = null; // Register the service worker. The per-thread keys it needs to decrypt push // payloads are written to IndexedDB separately by shell.js (idbSet "threadKeys"). // Best-effort; keeps any existing subscription in sync. export async function registerSW(session) { if (!("serviceWorker" in navigator)) return; try { swReg = await navigator.serviceWorker.register("/sw.js"); } catch (e) { console.warn("service worker registration failed", e); return; } if (pushEnabled()) { // Re-validate the subscription against the current VAPID key (it changes if // the server DB was reset) and recreate it if stale — self-heals on load. await subscribePush(session); } } // Request permission (if needed) and subscribe. Returns "on" | "denied" | "error". export async function subscribePush(session) { if (!pushAvailable()) return "error"; if (Notification.permission === "default") { let perm; try { perm = await Notification.requestPermission(); } catch { return "error"; } if (perm !== "granted") return "denied"; } if (Notification.permission !== "granted") return "denied"; try { // Ensure the worker is registered AND active before subscribing. Right after // first registration the worker is still "installing"; subscribing then fails // silently on iOS — permission reads "granted" but no subscription ever // reaches the server, so notifications look allowed yet stay off until a // later menu toggle. serviceWorker.ready resolves only once a worker is active. await navigator.serviceWorker.register("/sw.js").catch(() => {}); const reg = await navigator.serviceWorker.ready; swReg = reg; const { publicKey } = await (await fetch("/api/push/vapid")).json(); const appKey = urlB64ToUint8Array(publicKey); let sub = await reg.pushManager.getSubscription(); // If an existing subscription was made with a different (old) VAPID key, the // push service will reject sends — recreate it against the current key. if (sub && !sameAppServerKey(sub, appKey)) { try { await sub.unsubscribe(); } catch {} sub = null; } if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: appKey, }); } await sendSubscription(session.token, sub); return "on"; } catch (e) { console.warn("push subscribe failed", e); return "error"; } } function sameAppServerKey(sub, appKey) { try { const cur = sub.options && sub.options.applicationServerKey; if (!cur) return false; const a = new Uint8Array(cur); if (a.length !== appKey.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== appKey[i]) return false; } return true; } catch { return false; } } export async function isSubscribed() { if (!("serviceWorker" in navigator)) return false; try { const reg = swReg || (await navigator.serviceWorker.getRegistration()); if (!reg) return false; return !!(await reg.pushManager.getSubscription()); } catch { return false; } } // Turn push off: remove the subscription from the server, then the browser. export async function unsubscribePush() { try { const reg = swReg || (await navigator.serviceWorker.getRegistration()); if (!reg) return; const sub = await reg.pushManager.getSubscription(); if (!sub) return; await fetch("/api/push/unsubscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: session.token, endpoint: sub.endpoint }), }); await sub.unsubscribe(); } catch (e) { console.warn("unsubscribe failed", e); } } function sendSubscription(token, sub) { return fetch("/api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, subscription: sub }), }); }