// An- und Abmeldung für Push-Benachrichtigungen. // // Ablauf: Der Browser holt sich beim Push-Dienst seines Herstellers einen // Endpunkt, den er zusammen mit Schlüsselmaterial an unseren Server // meldet. Der Server kann darüber verschlüsselte Nachrichten schicken - // lesen kann sie nur dieses Gerät, auch der Push-Dienst nicht. "use strict"; import { get, post } from "./api.js"; /** Der öffentliche VAPID-Schlüssel kommt als base64url ohne * Auffüllzeichen; die Browser-API will ein Uint8Array. */ function decodeKey(base64url) { const padded = base64url.replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4)); return Uint8Array.from(raw, (c) => c.charCodeAt(0)); } /** Kurze Gerätebezeichnung, damit man mehrere Anmeldungen unterscheiden * kann. Bewusst grob - der vollständige User-Agent wäre ein * Wiedererkennungsmerkmal und wird nicht gebraucht. */ function deviceLabel() { const ua = navigator.userAgent; const system = /Android/i.test(ua) ? "Android" : /iPhone|iPad|iPod/i.test(ua) ? "iPhone/iPad" : /Macintosh/i.test(ua) ? "Mac" : /Windows/i.test(ua) ? "Windows" : /Linux/i.test(ua) ? "Linux" : "Gerät"; const browser = /Firefox\//i.test(ua) ? "Firefox" : /Edg\//i.test(ua) ? "Edge" : /Chrome\//i.test(ua) ? "Chrome" : /Safari\//i.test(ua) ? "Safari" : "Browser"; return `${browser} auf ${system}`.slice(0, 80); } /** Läuft die App als installierte PWA? Auf iOS ist das die * Voraussetzung dafür, dass Push überhaupt funktioniert. */ export function isInstalled() { return ( window.matchMedia?.("(display-mode: standalone)")?.matches === true || navigator.standalone === true ); } export function isApple() { return /iPhone|iPad|iPod/i.test(navigator.userAgent) || (/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1); } /** Was der Browser hier grundsätzlich kann. */ export function supported() { return ( "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && window.isSecureContext ); } /** * Aktueller Zustand. * @returns {Promise<{ * supported: boolean, serverEnabled: boolean, permission: string, * subscribed: boolean, needsInstall: boolean, throttleHours: number * }>} */ export async function status() { const base = { supported: supported(), serverEnabled: false, permission: "Notification" in window ? Notification.permission : "unsupported", subscribed: false, // iOS lässt Push nur in der installierten App zu. needsInstall: isApple() && !isInstalled(), throttleHours: 2, }; try { const config = await get("/api/push/config"); base.serverEnabled = config.enabled; base.throttleHours = config.throttle_hours; } catch { return base; } if (!base.supported) return base; try { const registration = await navigator.serviceWorker.ready; base.subscribed = Boolean(await registration.pushManager.getSubscription()); } catch { // Kein Service Worker bereit - dann eben nicht angemeldet. } return base; } /** Fragt die Erlaubnis ab und meldet das Gerät an. * @returns {Promise<{ok: boolean, reason?: string}>} */ export async function subscribe() { if (!supported()) { return { ok: false, reason: "Dieser Browser unterstützt keine Benachrichtigungen." }; } const config = await get("/api/push/config"); if (!config.enabled || !config.public_key) { return { ok: false, reason: "Auf diesem Server sind Benachrichtigungen nicht eingerichtet." }; } const permission = await Notification.requestPermission(); if (permission !== "granted") { return { ok: false, reason: permission === "denied" ? "Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt sich nur in den Browsereinstellungen wieder ändern." : "Keine Erlaubnis erteilt.", }; } const registration = await navigator.serviceWorker.ready; let subscription = await registration.pushManager.getSubscription(); if (!subscription) { subscription = await registration.pushManager.subscribe({ // Pflicht bei allen aktuellen Browsern: Nachrichten ohne // sichtbare Meldung sind nicht erlaubt. userVisibleOnly: true, applicationServerKey: decodeKey(config.public_key), }); } const json = subscription.toJSON(); await post("/api/push/subscribe", { endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, label: deviceLabel(), }); return { ok: true }; } /** Meldet dieses Gerät ab - lokal und auf dem Server. */ export async function unsubscribe() { if (!supported()) return; const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (!subscription) return; const endpoint = subscription.endpoint; // Erst lokal abmelden: Schlägt der Server fehl, sollen trotzdem keine // Meldungen mehr ankommen. await subscription.unsubscribe().catch(() => {}); await post("/api/push/unsubscribe", { endpoint }).catch(() => {}); } export function sendTest() { return post("/api/push/test"); } export function listDevices() { return get("/api/push/subscriptions"); }