import { useEffect, useState } from "react"; /** Chrome's non-standard install event. Not in lib.dom, so it is declared here. */ interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } export interface PwaInstall { /** True while the browser is willing to show its own install prompt. */ canInstall: boolean; /** True when the app is already running as an installed app. */ installed: boolean; /** * Why there is no prompt, when there is none. Empty while one is available. * * The button used to appear only when the browser offered a prompt, so when * installing was impossible there was nothing on screen at all — no button, no * reason, nothing to act on. Every one of these is fixable by whoever is * holding the phone, which is why it is worth saying. */ reason: "" | "installed" | "insecure" | "manual"; promptInstall: () => Promise; } const isStandalone = () => window.matchMedia?.("(display-mode: standalone)")?.matches || // iOS keeps its own flag rather than reporting the display mode. (navigator as unknown as { standalone?: boolean }).standalone === true; /** * The offer, if the browser made one before the app was running. * * `beforeinstallprompt` fires once, early, and is never fired again. A listener * added from a component is routinely too late — which is why the install button * never appeared even on a correctly served site. `index.html` catches it at the * top of the document and leaves it here. */ const stashed = (): BeforeInstallPromptEvent | null => (window as unknown as { __inknoteInstallPrompt?: BeforeInstallPromptEvent }).__inknoteInstallPrompt ?? null; export function usePwaInstall(): PwaInstall { const [event, setEvent] = useState(stashed); const [installed, setInstalled] = useState(isStandalone); useEffect(() => { const onPrompt = (e: Event) => { e.preventDefault(); setEvent(e as BeforeInstallPromptEvent); }; // Fired by the catcher in index.html when the offer arrives after mount. const onStashed = () => setEvent(stashed()); const onInstalled = () => { setInstalled(true); setEvent(null); }; window.addEventListener("beforeinstallprompt", onPrompt); window.addEventListener("inknote:installable", onStashed); window.addEventListener("appinstalled", onInstalled); // One more look, in case it landed between the first render and this effect. setEvent((current) => current ?? stashed()); return () => { window.removeEventListener("beforeinstallprompt", onPrompt); window.removeEventListener("inknote:installable", onStashed); window.removeEventListener("appinstalled", onInstalled); }; }, []); const promptInstall = async () => { if (!event) return; await event.prompt(); const { outcome } = await event.userChoice; // The captured event is single-use; drop it either way so the button does // not linger after a decision. if (outcome === "accepted") { setEvent(null); (window as unknown as { __inknoteInstallPrompt?: unknown }).__inknoteInstallPrompt = null; } }; /* An app can only be installed from a secure origin. Served over plain HTTP * the service worker never registers either, so this one condition takes the * install *and* offline with it — which is worth naming rather than leaving * as two separate mysteries. */ const reason: PwaInstall["reason"] = installed ? "installed" : event ? "" : window.isSecureContext ? "manual" : "insecure"; return { canInstall: event !== null, installed, reason, promptInstall }; }