diff --git a/docker/frontend/scripts/pwa-check.mjs b/docker/frontend/scripts/pwa-check.mjs index f3068eb..5eca073 100644 --- a/docker/frontend/scripts/pwa-check.mjs +++ b/docker/frontend/scripts/pwa-check.mjs @@ -1,7 +1,7 @@ // Install support is not visible in the source. It is the manifest the browser // reads, the worker it registers, and what that worker still holds once the network // is gone — so unlike the other checks here, which read a module and assert on it, -// this one has to drive a real browser at a real build. Four assertions, one line +// this one has to drive a real browser at a real build. Five assertions, one line // each, against a preview server: // // npx vite build && npx vite preview --port 4183 & @@ -25,7 +25,7 @@ const browser = await chromium.launch({ }); const context = await browser.newContext(); -// Every assertion prints, rather than throwing on the first one: which of the four +// Every assertion prints, rather than throwing on the first one: which of the five // works and which does not is the whole answer. let failed = 0; const check = (ok, label) => { @@ -78,9 +78,39 @@ try { } check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`); +// (e) The offer is asked once. The browser's sheet is stubbed, because a check cannot +// click the real one: waved away it is a refusal, and a refusal is stored, so the next +// visit renders no dialog at all. The stub goes in an init script so it is there on +// both loads. +await context.setOffline(false); +await page.addInitScript(() => { + window.__offerInstall = () => { + const event = new Event('beforeinstallprompt'); + event.prompt = () => Promise.resolve(); + event.userChoice = Promise.resolve({ outcome: 'dismissed' }); + window.dispatchEvent(event); + window.dispatchEvent(new Event('rc:install-offer')); + }; +}); +const offer = async () => { + await page.evaluate(() => window.__offerInstall()); + await page.waitForTimeout(150); + return page.locator('[data-key="install-yes"]').count(); +}; +await page.reload(); +const first = await offer(); +await page.locator('[data-key="install-yes"]').click(); +const stored = await page.evaluate(() => localStorage.getItem('rc.install.dismissed.v1')); +await page.reload(); +const again = await offer(); +check( + first === 1 && stored === '1' && again === 0, + `install dialog shown ${first} time(s), refused (localStorage '${stored}'), shown again after reload: ${again}`, +); + await browser.close(); if (failed) { - console.error(`pwa-check: ${failed} of 4 failed`); + console.error(`pwa-check: ${failed} of 5 failed`); process.exit(1); } console.log('pwa-check ok'); diff --git a/docker/frontend/src/pwa/install.ts b/docker/frontend/src/pwa/install.ts index 92298bb..3bb9d77 100644 --- a/docker/frontend/src/pwa/install.ts +++ b/docker/frontend/src/pwa/install.ts @@ -2,8 +2,8 @@ // 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 one piece of state the visitor owns is the refusal: remember it, and the offer -// never comes back. A dismissal is an answer, not a snooze. +// 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; @@ -11,6 +11,9 @@ type InstallEvent = Event & { }; 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. @@ -33,11 +36,15 @@ export function subscribe(listener: () => void): () => void { } export function canInstall(): boolean { - return deferred !== null && !dismissed(); + 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; @@ -47,6 +54,8 @@ export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> notify(); await event.prompt(); const { outcome } = await event.userChoice; + if (outcome === 'accepted') markInstalled(); + else dismissInstall(); return outcome; } @@ -76,6 +85,18 @@ export function dismissInstall(): void { 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)); @@ -93,5 +114,5 @@ window.addEventListener('beforeinstallprompt', (event) => { // and is the only signal that the offer is done. window.addEventListener('appinstalled', () => { deferred = null; - notify(); + markInstalled(); }); diff --git a/docker/frontend/src/ui/InstallPrompt.tsx b/docker/frontend/src/ui/InstallPrompt.tsx index 7d78f8b..2339a4e 100644 --- a/docker/frontend/src/ui/InstallPrompt.tsx +++ b/docker/frontend/src/ui/InstallPrompt.tsx @@ -5,8 +5,8 @@ import { canInstall, dismissInstall, dismissed, + isInstalled, isIosSafari, - isStandalone, promptInstall, subscribe, } from '../pwa/install'; @@ -30,7 +30,7 @@ export function InstallPrompt() { useEffect(() => { // An installed app has nothing to offer, and a refusal is final — the visitor // said no to this dialog, not to this session. - if (isStandalone() || dismissed()) return; + if (isInstalled() || dismissed()) return; const manual = isIosSafari(); let waited = false; @@ -86,8 +86,8 @@ export function InstallPrompt() { type="button" className="btn primary" data-key="install-yes" - // Not a dismissal: taking the offer and waving the browser's own sheet - // away are different answers, and the app's own button can still ask. + // `promptInstall` records the browser's answer itself: accepted means the + // app is installed, waved away means the offer was spent and refused. onClick={() => { void promptInstall(); setFace(null);