// Installability lives outside React. The browser fires `beforeinstallprompt` once, // whenever it likes, and the event has to be held until a click spends it — so this // is a module-level slot plus a subscriber list, and no store, hook or context. // // The state the visitor owns is the answer: a refusal, or an install. Remember either // one, and the offer never comes back. A no is an answer, not a snooze. type InstallEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; }; const DISMISSED = 'rc.install.dismissed.v1'; // The other answer: installed, and never asked again — including from a plain browser // tab, which has no `standalone` to tell it so. const INSTALLED = 'rc.install.installed.v1'; // The window event a control elsewhere in the app (the Library's own button, say) // fires to pull the offer forward instead of waiting out the delay. export const OFFER_EVENT = 'rc:install-offer'; let deferred: InstallEvent | null = null; const listeners = new Set<() => void>(); const notify = () => { for (const listener of listeners) listener(); }; // Registers `listener` for every change in installability and returns the // unsubscribe — the shape `useEffect` already has. export function subscribe(listener: () => void): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } export function canInstall(): boolean { return deferred !== null && !dismissed() && !isInstalled(); } // Spends the browser's prompt and reports the answer. Null means there was nothing // to spend: the event never arrived, or this is a second call. // // Either answer is remembered, because either answer is the visitor's: yes is the app // being installed, and the browser's own sheet being waved away is a no — the event is // spent now, and it never fires again for this install. export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> { const event = deferred; if (!event) return null; // Spent either way, so it is cleared before the await: a second prompt() on the // same event rejects. deferred = null; notify(); await event.prompt(); const { outcome } = await event.userChoice; if (outcome === 'accepted') markInstalled(); else dismissInstall(); return outcome; } // The installed app is its own window; a Safari tab that was added to the home // screen keeps `standalone` on the navigator instead. export function isStandalone(): boolean { return ( window.matchMedia('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true ); } // Every browser on iOS is WebKit, but only Safari's own UI has "Add to Home Screen", // and only there does the manual instruction make sense. export function isIosSafari(): boolean { const ua = navigator.userAgent; const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); return ios && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua); } export function dismissed(): boolean { return localStorage.getItem(DISMISSED) === '1'; } export function dismissInstall(): void { localStorage.setItem(DISMISSED, '1'); notify(); } // True in the installed window itself, and also in any browser tab that watched the // install happen — that tab keeps running the site, and must not ask for the app it // just installed. export function isInstalled(): boolean { return isStandalone() || localStorage.getItem(INSTALLED) === '1'; } export function markInstalled(): void { localStorage.setItem(INSTALLED, '1'); notify(); } // Asks for the offer now. export function offerInstall(): void { window.dispatchEvent(new Event(OFFER_EVENT)); } window.addEventListener('beforeinstallprompt', (event) => { // Without this Chromium shows its own mini-infobar and the event is spent; the // app's dialog is the one the visitor gets. event.preventDefault(); deferred = event as InstallEvent; notify(); }); // Fires once the app is installed, from our prompt or from the browser's own menu, // and is the only signal that the offer is done. window.addEventListener('appinstalled', () => { deferred = null; markInstalled(); });