2026-08-17 10:40:14 +02:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-08-18 09:46:08 +02:00
|
|
|
import { t } from './i18n.svelte.js';
|
2026-10-23 14:13:14 +02:00
|
|
|
import { isNativeApp } from './platform.js';
|
2026-08-17 10:40:14 +02:00
|
|
|
|
|
|
|
|
/** 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' &&
|
2026-10-23 14:13:14 +02:00
|
|
|
// The iOS app gets its notifications from APNs, not Web Push: a WKWebView
|
|
|
|
|
// has no push service, and a subscription made there would go nowhere.
|
|
|
|
|
!isNativeApp() &&
|
2026-08-17 10:40:14 +02:00
|
|
|
'serviceWorker' in navigator &&
|
|
|
|
|
'PushManager' in window &&
|
|
|
|
|
'Notification' in window
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function permission() {
|
|
|
|
|
return supported() ? Notification.permission : 'unsupported';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function registration() {
|
2026-09-03 22:46:35 +02:00
|
|
|
// SvelteKit registers src/service-worker.js itself on load, so normally there
|
|
|
|
|
// is already a registration here. Register it by hand only if there is not yet
|
|
|
|
|
// one — a push subscribe that races the app's own registration would otherwise
|
|
|
|
|
// find nothing. `ready` waits for an active worker; register() alone resolves
|
|
|
|
|
// while the worker is still installing, and subscribing against that fails.
|
|
|
|
|
const existing = await navigator.serviceWorker.getRegistration();
|
|
|
|
|
if (!existing) await navigator.serviceWorker.register('/service-worker.js');
|
2026-08-17 10:40:14 +02:00
|
|
|
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'
|
2026-08-18 09:46:08 +02:00
|
|
|
? t('push.deniedByBrowser')
|
|
|
|
|
: t('push.notEnabled')
|
2026-08-17 10:40:14 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reg = await registration();
|
|
|
|
|
const { public_key } = await api.pushKey();
|
2026-08-18 09:46:08 +02:00
|
|
|
if (!public_key) throw new Error(t('push.noVapidKey'));
|
2026-08-17 10:40:14 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|