// Web Push in this browser (Services/PushService.cs, imported on demand like js/feed.js). Each persona the reader turns // notifications on for gets a service worker registration of its own: wwwroot/push-worker.js at the scope push/{slot}/, // where the slot is a random name this device keeps. Its push subscription is that persona's alone, with its own // endpoint and keys, so nothing PrivaPub stores ties two personas of one login together, and the worker knows the // persona from its scope without ever reading the payload's token. // // What fails as browsers fail (no permission, a push service that refused) comes back as { error }; what fails // unexpectedly is also reported through window.logFromJs. const workerUrl = new URL("../push-worker.js", import.meta.url).href const scopesRoot = new URL("../push/", import.meta.url).href const scopeOf = slot => `${scopesRoot}${slot}/` function report(e, where) { console.error(e) window.logFromJs?.(e?.message ?? String(e), `push.${where}`) } function ios() { return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) } function installed() { return window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true } // Whether this browser can be pushed to at all, why not, and what the reader allowed: default, granted or denied. On an // iPhone or iPad, Safari offers Web Push only to the app added to the Home Screen and opened from there. export function support() { try { const permission = typeof Notification == "undefined" ? "default" : Notification.permission if (!window.isSecureContext) return { supported: false, reason: "insecure", permission } if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification == "undefined" || !("showNotification" in ServiceWorkerRegistration.prototype)) return { supported: false, reason: ios() && !installed() ? "ios-install" : "unsupported", permission } return { supported: true, reason: null, permission } } catch (e) { report(e, "support") return { supported: false, reason: "unsupported", permission: "default" } } } // Asks the reader once (the browser's own prompt, after a click); what the browser already knows is answered at once. export async function requestPermission() { try { if (Notification.permission !== "default") return Notification.permission return await Notification.requestPermission() } catch (e) { report(e, "requestPermission") return typeof Notification == "undefined" ? "default" : Notification.permission } } async function registrationOf(slot, create) { const scope = scopeOf(slot) // (getRegistration(url) answers the registration that controls the URL: the app's own one when this one is missing) let registration = (await navigator.serviceWorker.getRegistrations()).find(r => r.scope === scope) if (!registration && create) registration = await navigator.serviceWorker.register(workerUrl, { scope, updateViaCache: "none" }) if (registration) await activated(registration) return registration } // a push subscription needs an active worker function activated(registration) { return new Promise(resolve => { const worker = registration.installing ?? registration.waiting if (registration.active || !worker) return resolve() const settle = () => { if (worker.state === "activated" || worker.state === "redundant") resolve() } worker.addEventListener("statechange", settle) setTimeout(resolve, 10000) }) } function fromBase64Url(text) { const normal = text.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/") const raw = atob(normal + "===".slice((normal.length + 3) % 4)) return Uint8Array.from(raw, c => c.charCodeAt(0)) } function sameKey(buffer, bytes) { // a browser that doesn't say which key it subscribed with is taken at its word if (!buffer) return true const held = new Uint8Array(buffer) return held.length === bytes.length && held.every((value, i) => value === bytes[i]) } function describe(subscription) { const keys = subscription.toJSON().keys ?? {} const encodings = PushManager.supportedContentEncodings return { endpoint: subscription.endpoint, p256dh: keys.p256dh ?? null, auth: keys.auth ?? null, // RFC 8291's aes128gcm, which every browser that pushes decrypts; the aesgcm draft only where it is all there is standard: !Array.isArray(encodings) || encodings.includes("aes128gcm"), error: null } } // The persona's subscription in this browser, made (with its registration) when there is none, and made again when it // was made with another server key. Its endpoint and its keys as URL-safe base64. export async function subscribe(slot, serverKey) { try { if (Notification.permission !== "granted") return { error: `permission ${Notification.permission}` } const key = fromBase64Url(serverKey) const registration = await registrationOf(slot, true) let subscription = await registration.pushManager.getSubscription() if (subscription && !sameKey(subscription.options?.applicationServerKey, key)) { await subscription.unsubscribe() subscription = null } subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }) return describe(subscription) } catch (e) { console.error(e) return { error: e?.message ?? String(e) } } } // The persona's subscription and its registration go: the push service forgets the endpoint, and refuses PrivaPub what it // would still send there. export async function unsubscribe(slot) { try { const registration = await registrationOf(slot, false) if (!registration) return true const subscription = await registration.pushManager.getSubscription() await subscription?.unsubscribe() return await registration.unregister() } catch (e) { report(e, "unsubscribe") return false } } // The slots this browser holds registrations for, so the app drops those it no longer knows (a device whose storage was // cleared would otherwise keep showing what PrivaPub pushes). export async function slots() { try { if (!("serviceWorker" in navigator)) return [] return (await navigator.serviceWorker.getRegistrations()) .map(r => r.scope) .filter(scope => scope.startsWith(scopesRoot)) .map(scope => scope.slice(scopesRoot.length).replace(/\/$/, "")) .filter(name => name.length > 0) } catch (e) { report(e, "slots") return [] } } // A notification shown by the persona's registration alone, nothing sent anywhere: it shows how they will look, and its // click goes where theirs would. export async function test(slot, title, body) { try { const registration = await registrationOf(slot, false) if (!registration || Notification.permission !== "granted") return false await registration.showNotification(title, { body, tag: "decepub-test", icon: new URL("../imgs/icon-192.png", import.meta.url).href, data: {} }) return true } catch (e) { report(e, "test") return false } }