Files
InkNote/frontend/src/hooks/usePwaInstall.ts
T
marcandClaude Opus 5 8ca1ed6e06 Choose a cover in two parts, and let the app be installed
A cover is a colour *and* a pattern now, chosen in turn. Offering every
combination as its own entry meant fifteen colours times a dozen patterns
in a grid nobody could take in — and in practice only the handful someone
had thought to write out. Each pattern is previewed over the colour in
hand, so what is on screen is the cover as it will be. `cover_pattern` is
its own column: they are two decisions, and folding them into one number
would mean renumbering every cover the day a pattern is added.

For one day the patterns *were* twelve appended entries. Those indices
are read back as the colour-and-pattern pair they were drawn as, so
nothing chosen then looks different now.

And three separate reasons the install button never appeared, none of
them in anyone's configuration:

- `beforeinstallprompt` fires once, early — usually before React has
  mounted — and is never fired again. A listener added from inside a
  component simply missed it. It is caught at the top of index.html now.
- A `<link rel="manifest">` is fetched with credentials omitted unless it
  says otherwise. Behind a reverse proxy that authenticates in front of
  the app, that one request comes back as the proxy's login page and the
  browser reads it as an invalid manifest — while the app itself works
  perfectly, because its own requests carry the cookie.
- The service worker cached whatever came back, so a lapsed session could
  be stored as the application itself. The app would then "work offline"
  by showing a login screen it could never get past, curable only by
  clearing the site's data. Only successful, unredirected responses are
  cached now, and the cache name is bumped to drop anything already
  stored under the old rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:20:37 +02:00

95 lines
3.7 KiB
TypeScript

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<void>;
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<void>;
}
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<BeforeInstallPromptEvent | null>(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 };
}