Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind. Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er præcis, hvad det var før. - `apiURL()` og `authorize()`: relative stier og cookie i en browser; den valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`. - Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra telefonen, også når serveren ikke svarede. - Live-synk i appen beder om en billet og åbner socket'en på serveren med den. - Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af første konto vises ikke i appen; begge hører til serverens eget domæne. - Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob, i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse. - Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem. Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en, viser et billede fra en blob og logger ud. Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer, socket uden billet, billeder ikke hentet, token efterladt ved log ud. Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
4.1 KiB
JavaScript
118 lines
4.1 KiB
JavaScript
/**
|
|
* 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';
|
|
import { t } from './i18n.svelte.js';
|
|
import { isNativeApp } from './platform.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' &&
|
|
// 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() &&
|
|
'serviceWorker' in navigator &&
|
|
'PushManager' in window &&
|
|
'Notification' in window
|
|
);
|
|
}
|
|
|
|
export function permission() {
|
|
return supported() ? Notification.permission : 'unsupported';
|
|
}
|
|
|
|
async function registration() {
|
|
// 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');
|
|
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'
|
|
? t('push.deniedByBrowser')
|
|
: t('push.notEnabled')
|
|
);
|
|
}
|
|
|
|
const reg = await registration();
|
|
const { public_key } = await api.pushKey();
|
|
if (!public_key) throw new Error(t('push.noVapidKey'));
|
|
|
|
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;
|
|
}
|