const basePath = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '') const SW_PATH = `${basePath}/sw.js` const SW_SCOPE = basePath ? `${basePath}/` : '/' const PWA_INSTALL_DISMISSED_KEY = 'pwa_install_dismissed_at' const PWA_INSTALL_DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000 export const PUSH_SUBSCRIPTION_ID_STORAGE_KEY = 'push_subscription_id' export const isBrowser = () => typeof window !== 'undefined' export const isIosDevice = () => { if (!isBrowser()) return false const ua = window.navigator.userAgent // iPadOS 13+ reports as "MacIntel" but, unlike a real Mac, exposes touch points. const isIpadOs13Plus = window.navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1 return /iphone|ipad|ipod/i.test(ua) || isIpadOs13Plus } /** * iOS Safari never fires `beforeinstallprompt`, so usePwa's normal * install-prompt flow is a permanent no-op there. Any "installed" UI on iOS * has to fall back to showing manual "Share → Add to Home Screen" steps. */ export const isIosSafari = () => { if (!isIosDevice()) return false const ua = window.navigator.userAgent // Chrome/Firefox/Edge on iOS all embed WebKit and match /safari/i too, so // they must be excluded explicitly. const isOtherIosBrowser = /crios|fxios|edgios|opios|duckduckgo/i.test(ua) return /safari/i.test(ua) && !isOtherIosBrowser } export const isPwaStandalone = () => { if (!isBrowser()) return false return ( window.matchMedia('(display-mode: standalone)').matches || (window.navigator as Navigator & { standalone?: boolean }).standalone === true ) } export const isPushSupported = () => { if (!isBrowser()) return false return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window } export const shouldShowInstallPrompt = () => { if (!isBrowser() || isPwaStandalone()) return false const dismissedAt = Number(localStorage.getItem(PWA_INSTALL_DISMISSED_KEY) ?? 0) if (dismissedAt && Date.now() - dismissedAt < PWA_INSTALL_DISMISS_TTL_MS) { return false } return true } export const dismissInstallPrompt = () => { if (!isBrowser()) return localStorage.setItem(PWA_INSTALL_DISMISSED_KEY, String(Date.now())) } export const registerServiceWorker = async (): Promise => { if (process.env.NODE_ENV !== 'production' || !isBrowser() || !('serviceWorker' in navigator)) return null try { const registration = await navigator.serviceWorker.register(SW_PATH, { scope: SW_SCOPE }) if (registration.waiting) { window.dispatchEvent(new CustomEvent('ghabilee:sw-update', { detail: registration })) } registration.addEventListener('updatefound', () => { const installing = registration.installing installing?.addEventListener('statechange', () => { if (installing.state === 'installed' && navigator.serviceWorker.controller) { window.dispatchEvent(new CustomEvent('ghabilee:sw-update', { detail: registration })) } }) }) return registration } catch { return null } } export const getServiceWorkerRegistration = async (): Promise => { if (!isBrowser() || !('serviceWorker' in navigator)) return null // `navigator.serviceWorker.ready` never rejects and may remain pending // forever when registration is blocked or unavailable (for example private // browsing or an E2E context). Logout/push cleanup must never wait on it. return (await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null)) ?? null } export const getStoredPushSubscriptionId = (): string | null => { if (!isBrowser()) return null return localStorage.getItem(PUSH_SUBSCRIPTION_ID_STORAGE_KEY) } export const setStoredPushSubscriptionId = (id: string) => { if (!isBrowser()) return localStorage.setItem(PUSH_SUBSCRIPTION_ID_STORAGE_KEY, id) } export const clearStoredPushSubscriptionId = () => { if (!isBrowser()) return localStorage.removeItem(PUSH_SUBSCRIPTION_ID_STORAGE_KEY) } /** * Tears down the browser-level PushSubscription for *this* device only. * Does not talk to the server — pair with a call that deletes the matching * server-side row (see services/push.ts unsubscribeCurrentDevicePush). */ export const unsubscribeBrowserPushManager = async (): Promise => { const registration = await getServiceWorkerRegistration() if (!registration) return try { const subscription = await registration.pushManager.getSubscription() if (subscription) { await subscription.unsubscribe() } } catch { // Best-effort: if the browser can't tear it down, the server-side // delete still stops delivery, and a stale local subscription will // self-correct next time syncSubscription() runs. } } export const urlBase64ToUint8Array = (base64String: string) => { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = window.atob(base64) const outputArray = new Uint8Array(rawData.length) for (let i = 0; i < rawData.length; i += 1) { outputArray[i] = rawData.charCodeAt(i) } return outputArray } export const arrayBufferToBase64Url = (buffer: ArrayBuffer) => { const bytes = new Uint8Array(buffer) let binary = '' for (let i = 0; i < bytes.byteLength; i += 1) { binary += String.fromCharCode(bytes[i]) } return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } export const getVapidPublicKey = () => process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY?.trim() ?? ''