/** * Install, offline and explicit-update lifecycle for the control-console PWA. * * Registration is restricted to `/control/`; `/obs/*` is deliberately outside * the scope. The worker caches only the public application shell and static * assets. API responses, WebSockets and secrets remain network-only. A waiting * worker activates only after user confirmation and after every registered * one-time-secret or dirty-form blocker has cleared. */ import { useEffect, useSyncExternalStore } from 'react' import { translate, useI18n } from './i18n' interface InstallChoice { outcome: 'accepted' | 'dismissed' platform: string } interface BeforeInstallPromptEvent extends Event { readonly platforms: string[] readonly userChoice: Promise prompt(): Promise } interface PwaSnapshot { online: boolean standalone: boolean installPrompt?: BeforeInstallPromptEvent registration?: ServiceWorkerRegistration waitingWorker?: ServiceWorker } const listeners = new Set<() => void>() const updateBlockers = new Map() let snapshot: PwaSnapshot = { online: navigator.onLine, standalone: isStandalone(), } let initialized = false let reloadForUpdate = false function isStandalone(): boolean { const iosNavigator = navigator as Navigator & { standalone?: boolean } return window.matchMedia('(display-mode: standalone)').matches || iosNavigator.standalone === true } function emit(patch: Partial) { snapshot = { ...snapshot, ...patch } listeners.forEach(listener => listener()) } function subscribe(listener: () => void) { listeners.add(listener) return () => listeners.delete(listener) } function getSnapshot() { return snapshot } function addManifest() { if (document.head.querySelector('link[rel="manifest"]')) return const manifest = document.createElement('link') manifest.rel = 'manifest' manifest.href = '/control/manifest.webmanifest' document.head.append(manifest) } function observeRegistration(registration: ServiceWorkerRegistration) { emit({ registration, waitingWorker: registration.waiting && navigator.serviceWorker.controller ? registration.waiting : snapshot.waitingWorker, }) registration.addEventListener('updatefound', () => { const worker = registration.installing if (!worker) return worker.addEventListener('statechange', () => { if (worker.state === 'installed' && navigator.serviceWorker.controller) { emit({ waitingWorker: worker }) } }) }) } async function removeLegacyRootWorker() { const registrations = await navigator.serviceWorker.getRegistrations() await Promise.all( registrations.map(async registration => { const scopePath = new URL(registration.scope).pathname const workers = [registration.installing, registration.waiting, registration.active] const isLegacy = scopePath === '/' && workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js') if (isLegacy) await registration.unregister() }), ) const cacheNames = await caches.keys() await Promise.all( cacheNames .filter(name => name.startsWith('lxc-control-shell-')) .map(name => caches.delete(name)), ) } export function cleanupLegacyPwa() { if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return void removeLegacyRootWorker().catch(error => { console.warn('Legacy PWA cleanup failed', error) }) } async function registerWorker() { try { await removeLegacyRootWorker() const registration = await navigator.serviceWorker.register( `/control/sw.js?v=${encodeURIComponent(__PWA_BUILD_ID__)}`, { scope: '/control/', updateViaCache: 'none' }, ) observeRegistration(registration) } catch (error) { // A failed PWA registration must never stop the online control console. console.warn('Console PWA registration failed', error) } } export function initializePwa() { if (initialized || location.pathname === '/obs' || location.pathname.startsWith('/obs/')) return initialized = true addManifest() const displayMode = window.matchMedia('(display-mode: standalone)') const updateConnection = () => emit({ online: navigator.onLine }) const updateDisplayMode = () => emit({ standalone: isStandalone() }) window.addEventListener('online', updateConnection) window.addEventListener('offline', updateConnection) const modernListener = ( displayMode as unknown as { addEventListener?: (type: 'change', listener: () => void) => void } ).addEventListener if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode) else (displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call( displayMode, updateDisplayMode, ) window.addEventListener('beforeinstallprompt', event => { event.preventDefault() emit({ installPrompt: event as BeforeInstallPromptEvent }) }) window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true }), ) if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return navigator.serviceWorker.addEventListener('controllerchange', () => { if (!reloadForUpdate) return reloadForUpdate = false location.reload() }) const start = () => void registerWorker() if (document.readyState === 'complete') start() else window.addEventListener('load', start, { once: true }) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && navigator.onLine) { void snapshot.registration?.update() } }) window.setInterval( () => { if (navigator.onLine) void snapshot.registration?.update() }, 60 * 60 * 1000, ) } export function authRoute(name: 'login' | 'register' | 'setup'): string { const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/') return inControlScope ? `/control/${name}` : `/${name}` } export function usePwaUpdateBlocker(key: string, reason: string, active: boolean) { useEffect(() => { if (active) updateBlockers.set(key, reason) else updateBlockers.delete(key) return () => { updateBlockers.delete(key) } }, [active, key, reason]) } async function requestInstall() { const prompt = snapshot.installPrompt if (!prompt) return await prompt.prompt() await prompt.userChoice emit({ installPrompt: undefined, standalone: isStandalone() }) } function applyUpdate() { const worker = snapshot.waitingWorker if (!worker) return const reasons = [...new Set(updateBlockers.values())] if (reasons.length > 0) { window.alert( translate('pwa.blocked', { reasons: reasons.map(reason => `• ${reason}`).join('\n') }), ) return } const confirmed = window.confirm(translate('pwa.confirm_update')) if (!confirmed) return reloadForUpdate = true worker.postMessage({ type: 'SKIP_WAITING' }) } export function PwaControls() { const { t } = useI18n() const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const canInstall = Boolean(state.installPrompt) && !state.standalone if (state.online && !state.waitingWorker && !canInstall) return null return (
{!state.online && (