import { desktopBridge } from "./desktop"; import { rpc } from "./rpc"; const SERVICE_WORKER_URL = "/sw.js"; const SERVICE_WORKER_SCOPE = "/"; /** * Web push needs the Push API, a service worker and a secure context (https or localhost). The * desktop app keeps its in-app notifications instead. */ export function webPushSupported(): boolean { return ( typeof window !== "undefined" && window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !desktopBridge() ); } /** Registers the push service worker where web push works; safe to call on every start. */ export async function registerPushServiceWorker(): Promise { if (!webPushSupported()) return; await navigator.serviceWorker .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }) .catch(() => undefined); } async function currentSubscription(): Promise { const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE); return (await registration?.pushManager.getSubscription()) ?? null; } /** True when this browser has granted notifications and holds a push subscription. */ export async function webPushEnabled(): Promise { if (!webPushSupported() || Notification.permission !== "granted") return false; return Boolean(await currentSubscription().catch(() => null)); } function base64UrlToBytes(value: string): Uint8Array { const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); const bytes = new Uint8Array(new ArrayBuffer(binary.length)); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes; } function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean { if (!current || current.byteLength !== expected.byteLength) return false; const bytes = new Uint8Array(current); return bytes.every((byte, index) => byte === expected[index]); } /** * Turns on push for this browser and registers it with the server. Call it straight from a click: * browsers only show the permission prompt during a user gesture. Throws when the server cannot * be reached. */ export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> { if (!webPushSupported()) return "unsupported"; const permission = Notification.permission === "default" ? await Notification.requestPermission() : Notification.permission; if (permission !== "granted") return "denied"; const config = await rpc.notifications.webPush.config(); if (!config.available || !config.publicKey) return "unsupported"; const applicationServerKey = base64UrlToBytes(config.publicKey); let subscription: PushSubscription; try { await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }); const registration = await navigator.serviceWorker.ready; const existing = await registration.pushManager.getSubscription(); // A subscription made for other server keys cannot receive this server's pushes. if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) { await existing.unsubscribe(); } subscription = existing && sameKey(existing.options.applicationServerKey, applicationServerKey) ? existing : await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); } catch { // Browsers without a push service (or with it switched off) reject the subscription. return "unsupported"; } const keys = subscription.toJSON().keys ?? {}; await rpc.notifications.webPush.subscribe({ endpoint: subscription.endpoint, keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" }, }); return "enabled"; } /** Stops push for this browser, on the server first so no push arrives after it returns. */ export async function disableWebPush(): Promise { if (!webPushSupported()) return; const subscription = await currentSubscription(); if (!subscription) return; await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint }); await subscription.unsubscribe().catch(() => undefined); } /** * True on iOS/iPadOS when opened in a browser tab rather than as an installed Home-Screen PWA * (where Apple WebKit only exposes Web Push in standalone display mode). */ export function isIosBrowserNeedsHomeScreen(): boolean { if (typeof window === "undefined" || typeof navigator === "undefined" || desktopBridge()) { return false; } const ua = navigator.userAgent || ""; const isIos = /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); if (!isIos) return false; const standalone = (typeof window.matchMedia === "function" && window.matchMedia("(display-mode: standalone)").matches) || Boolean((navigator as Navigator & { standalone?: boolean }).standalone); return !standalone && !webPushSupported(); } /** Syncs the installed PWA / OS app icon badge with the actionable "Needs You" count. */ export function syncAppBadge(count: number): void { if (typeof navigator === "undefined") return; const nav = navigator as Navigator & { setAppBadge?: (contents?: number) => Promise; clearAppBadge?: () => Promise; }; if (count > 0 && typeof nav.setAppBadge === "function") { void nav.setAppBadge(count).catch(() => undefined); } else if (count <= 0 && typeof nav.clearAppBadge === "function") { void nav.clearAppBadge().catch(() => undefined); } }