// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed // state, and registers the install-only service worker. The pure platform branching lives in // pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts). import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa'; import { insideTelegram } from './telegram'; import { insideVK } from './vk'; /** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web * install makes no sense (the platform manages its own launcher). Kept here, out of the pure * pwa.ts, so that module stays free of app imports for the node unit tests. */ function inMiniApp(): boolean { return insideTelegram() || insideVK(); } // beforeinstallprompt is a Chromium-only event, absent from the DOM lib types. interface BeforeInstallPromptEvent extends Event { readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; prompt(): Promise; } declare global { interface WindowEventMap { beforeinstallprompt: BeforeInstallPromptEvent; appinstalled: Event; } } // Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription). const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({ deferred: null, installed: isStandalone(), }); /** * installMode is the reactive install-CTA mode for the current platform and any captured prompt. * Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives * (beforeinstallprompt fires shortly after load) or the app is installed. */ export function installMode(): InstallMode { return resolveInstallMode({ deferredAvailable: install.deferred !== null, standalone: install.installed, iosSafari: isIosSafari(), inMiniApp: inMiniApp(), }); } /** * initInstallListeners wires the Chromium install prompt and the installed event. Harmless on * any platform — the events fire only in an installable browser — so it may run unconditionally * at startup, early enough to catch a prompt that fires before the CTA mounts. */ export function initInstallListeners(): void { if (typeof window === 'undefined') return; window.addEventListener('beforeinstallprompt', (e) => { // Suppress Chromium's mini-infobar; the app presents its own CTA instead. e.preventDefault(); install.deferred = e; }); window.addEventListener('appinstalled', () => { install.deferred = null; install.installed = true; }); } /** * promptInstall shows the native Chromium install dialog. It MUST be called directly from a user * gesture (the deferred prompt is gesture-gated — no await before it). A deferred prompt is * single-use, so it is cleared here; a no-op when nothing was captured. */ export async function promptInstall(): Promise { const deferred = install.deferred; if (!deferred) return; install.deferred = null; await deferred.prompt(); } /** * registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to * dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches * the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build * (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort — * a failed registration only means the app is not installable and cannot launch offline. */ export function registerServiceWorker(): void { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; if (import.meta.env.MODE === 'mock') return; if (inMiniApp()) return; void navigator.serviceWorker.register('sw.js').catch(() => {}); }