// Browser half of aviso: enrol this device for push against the app's // server. The app supplies `save` and `remove` — same-origin fetches // to the Subscribe and Unsubscribe handlers — and owns the service // worker registration. Nothing here prompts except `enable`, and it // prompts synchronously inside the caller's gesture, because a prompt // after an await is denied by browsers and resented by people. // // Callback contract: `save(body)` and `remove(body)` may resolve to // anything. A rejection, or a resolved value shaped like a failed // Response ({ok: false}), counts as failure; anything else — including // undefined from a callback that checked its own response — is success. function toBytes(base64url) { const pad = "=".repeat((4 - (base64url.length % 4)) % 4); const b64 = (base64url + pad).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; } function toBase64url(buf) { let s = ""; const bytes = new Uint8Array(buf); for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // sameKey reports whether an existing subscription was made under the // server's current key. A rotated key means every browser re-enrols. function sameKey(subscription, publicKey) { const key = subscription.options && subscription.options.applicationServerKey; if (!key) return false; return toBase64url(key) === publicKey; } // body projects the browser's subscription to exactly what Subscribe // reads — endpoint and keys — rather than forwarding toJSON() whole. function body(subscription, publicKey, previousEndpoint) { const json = subscription.toJSON(); const out = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey }; if (previousEndpoint) out.previousEndpoint = previousEndpoint; return out; } function failed(resp) { return resp && typeof resp === "object" && resp.ok === false; } async function persist(save, payload) { const resp = await save(payload); if (failed(resp)) { throw new Error("aviso: save rejected: " + resp.status); } } // capabilities reports what this browser can do. `standalone` is what // the installability recipe keys its coaching on: iOS delivers push // only to an installed app. export function capabilities(env = globalThis) { const nav = env.navigator || {}; const standalone = nav.standalone === true || (typeof env.matchMedia === "function" && env.matchMedia("(display-mode: standalone)").matches) || false; return { serviceWorker: !!nav.serviceWorker, push: typeof env.PushManager !== "undefined", notifications: typeof env.Notification !== "undefined", standalone, }; } // status resolves the current permission and subscription, prompting // nothing. export async function status(registration, env = globalThis) { const permission = env.Notification ? env.Notification.permission : "default"; const subscription = await registration.pushManager.getSubscription(); return { permission, subscription }; } // enable asks permission (synchronously, first), subscribes, and // saves. Resolves the subscription, or null when permission was // denied. Anything else rejects. export async function enable({ registration, publicKey, save }, env = globalThis) { const permission = await env.Notification.requestPermission(); if (permission !== "granted") return null; const existing = await registration.pushManager.getSubscription(); if (existing && sameKey(existing, publicKey)) { await persist(save, body(existing, publicKey, "")); return existing; } let previous = ""; if (existing) { previous = existing.endpoint; await existing.unsubscribe(); } const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(publicKey), }); await persist(save, body(sub, publicKey, previous)); return sub; } // reconcile repairs an EXISTING subscription on every page load // without prompting: re-saves it so the server's last_confirmed_at // moves, or re-subscribes if the server key changed. It never creates // one from nothing — permission stays granted after disable(), and a // reconcile that subscribed whenever it could would silently undo the // person's opt-out on their next visit. Creating is enable's job. // Resolves the subscription or null. export async function reconcile({ registration, publicKey, save }, env = globalThis) { if (!env.Notification || env.Notification.permission !== "granted") return null; const existing = await registration.pushManager.getSubscription(); if (!existing) return null; if (sameKey(existing, publicKey)) { await persist(save, body(existing, publicKey, "")); return existing; } const previous = existing.endpoint; await existing.unsubscribe(); const sub = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(publicKey), }); await persist(save, body(sub, publicKey, previous)); return sub; } // disable removes the server row first, then the browser subscription: // a crash between the two leaves a harmless orphan in the browser // rather than a server row that sends to nothing. export async function disable({ registration, remove }) { const existing = await registration.pushManager.getSubscription(); if (!existing) return; const resp = await remove({ endpoint: existing.endpoint }); if (failed(resp)) { throw new Error("aviso: remove rejected: " + resp.status); } await existing.unsubscribe(); }