diff --git a/web/src/lib/push.js b/web/src/lib/push.js new file mode 100644 index 0000000..6b2b8cb --- /dev/null +++ b/web/src/lib/push.js @@ -0,0 +1,108 @@ +/** + * Web Push, from the browser's side. + * + * Three parties have to agree before a notification arrives: the browser (which + * needs permission and a registered service worker), the push service it chooses + * (Google's, Mozilla's), and this server (which holds the VAPID pair and does the + * RFC 8291 encryption). This module is the first of those, and nothing else. + */ + +import { api } from './api.js'; + +/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither + * can any browser on a page served over plain HTTP from a non-localhost host. */ +export function supported() { + return ( + typeof window !== 'undefined' && + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); +} + +export function permission() { + return supported() ? Notification.permission : 'unsupported'; +} + +async function registration() { + // `ready` waits for an active worker; register() alone resolves while the + // worker is still installing, and subscribing against that fails. + await navigator.serviceWorker.register('/sw.js'); + return navigator.serviceWorker.ready; +} + +/** The current subscription, or null. Cheap: it reads local state, no network. */ +export async function current() { + if (!supported()) return null; + try { + const reg = await navigator.serviceWorker.getRegistration(); + return (await reg?.pushManager.getSubscription()) ?? null; + } catch { + return null; + } +} + +/** + * Asks permission, subscribes, and tells the server. + * + * Throws with a message meant to be shown. The failures here are not the API's + * error shape — a refused permission prompt is not an HTTP status — so they are + * plain Errors with Danish text. + */ +export async function subscribe() { + if (!supported()) throw new Error('Denne browser kan ikke vise notifikationer.'); + + const granted = await Notification.requestPermission(); + if (granted !== 'granted') { + throw new Error( + granted === 'denied' + ? 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.' + : 'Notifikationer blev ikke slået til.' + ); + } + + const reg = await registration(); + const { public_key } = await api.pushKey(); + if (!public_key) throw new Error('Serveren har ingen VAPID-nøgle.'); + + const subscription = await reg.pushManager.subscribe({ + // Not optional in Chrome: a subscription that is not userVisibleOnly is + // refused outright. + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(public_key) + }); + + const raw = subscription.toJSON(); + await api.subscribePush({ + endpoint: raw.endpoint, + keys: { p256dh: raw.keys.p256dh, auth: raw.keys.auth } + }); + return subscription; +} + +/** Unsubscribes here and forgets it on the server. */ +export async function unsubscribe() { + const subscription = await current(); + if (!subscription) return; + + // Server first: if the browser unsubscribes and then the request fails, the + // server keeps pushing to an endpoint nothing is listening on, and only a + // 410 from the push service will ever clean it up. + await api.unsubscribePush(subscription.endpoint); + await subscription.unsubscribe(); +} + +/** + * The VAPID public key travels as base64url text and `applicationServerKey` wants + * raw bytes. Neither `atob` nor the browser does this conversion — every Web Push + * client on the internet carries some version of these six lines. + */ +function urlBase64ToUint8Array(base64url) { + const padding = '='.repeat((4 - (base64url.length % 4)) % 4); + const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + return bytes; +} diff --git a/web/static/sw.js b/web/static/sw.js new file mode 100644 index 0000000..ea86086 --- /dev/null +++ b/web/static/sw.js @@ -0,0 +1,64 @@ +/** + * verdande's service worker. + * + * It exists for Web Push and nothing else. There is deliberately no offline cache: + * the app is a live view of a task list that other people are editing, and a cached + * shell serving yesterday's data is worse than a browser saying it cannot connect. + * Adding one later means versioning and invalidating it, which is a real feature + * rather than a few lines here. + */ + +// Take over as soon as the browser is willing. Without these two, the first push +// after granting permission arrives with no worker in control to show it. +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); + +self.addEventListener('push', (event) => { + let payload = {}; + try { + payload = event.data ? event.data.json() : {}; + } catch { + // A push whose body is not our JSON still deserves to be shown: browsers + // display their own "site updated in the background" notice otherwise, which + // looks like a bug in the app. + payload = { title: 'verdande', body: event.data ? event.data.text() : '' }; + } + + const title = payload.title || 'verdande'; + event.waitUntil( + self.registration.showNotification(title, { + body: payload.body || '', + icon: '/icon.svg', + badge: '/icon.svg', + // The tag collapses repeats: five comments on one task should replace each + // other rather than stack into five separate notifications. + tag: payload.tag || payload.url || 'verdande', + data: { url: payload.url || '/' } + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const target = event.notification.data?.url || '/'; + + event.waitUntil( + (async () => { + const clientList = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + + // Focus a tab that already has the app open rather than opening a second + // one. Somebody with the app pinned does not want a new tab per reminder. + for (const client of clientList) { + if (new URL(client.url).origin === self.location.origin) { + await client.focus(); + if ('navigate' in client) await client.navigate(target); + return; + } + } + await self.clients.openWindow(target); + })() + ); +});