web: make the studio installable, and give it a shell that opens offline

The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.

The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.

The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.

  node scripts/make-icons.mjs       192x192 39785B / 512x512 159296B / maskable 512x512 123723B
  node scripts/pwa-check.mjs        manifest 3 icons · worker activated · shell cached
                                    · offline reload of /app paints
  off (https://localhost:8090)      same four, through nginx
This commit is contained in:
2026-09-28 17:46:08 +07:00
parent 3312facd82
commit 0f2e109aa2
14 changed files with 587 additions and 5 deletions
+10
View File
@@ -6,6 +6,16 @@
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" type="image/png" href="/assets/RecipesCamIcon.png" />
<link rel="apple-touch-icon" href="/assets/RecipesCamIcon.png" />
<!-- The installed app. The manifest is a static file in public/ (see
public/manifest.json and scripts/make-icons.mjs for its icons), so a build
needs no plugin to ship it. iOS ignores most of the manifest and reads these
three metas instead: 'capable' is what makes a home-screen bookmark open
without Safari's chrome, 'black-translucent' gives it the app's black bars,
and 'title' is the name under the icon. -->
<link rel="manifest" href="/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="RecipesCam" />
<title>RecipesCam — web</title>
<meta name="description" content="RecipesCam web — grade photos with the same film recipes as the app." />
<link rel="canonical" href="/" />
+18
View File
@@ -51,6 +51,24 @@ server {
try_files $uri =404;
}
# The two files whose names never change but whose contents must: a cached worker
# keeps answering with the previous build's shell long after the deploy that
# replaced it, and a cached manifest keeps pointing at the icon set it shipped with.
# Nothing else would ever flush them, which is exactly why they opt out of the
# long-lived caching above. The worker is a script under the embedder's COEP like
# the .mjs files, so it restates the two isolation headers for the same reason.
location = /sw.js {
add_header Cache-Control "no-cache";
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
try_files $uri =404;
}
location = /manifest.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# The one route that carries a whole data dir back in (see /api/admin/restore
# — who may call it is the API's own admin check, done before it reads a byte).
# The upload cap that holds everywhere else would reject it, and unpacking the
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+37
View File
@@ -0,0 +1,37 @@
{
"name": "RecipesCam — Color Recipes & Film Camera Studio",
"short_name": "RecipesCam",
"description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
"lang": "vi",
"start_url": "/app",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Thư viện",
"short_name": "Thư viện",
"url": "/library"
}
]
}
+76
View File
@@ -0,0 +1,76 @@
// The app shell as a service worker, so an installed RecipesCam opens with no
// network at all. Not bundled on purpose: through vite/rollup it would stop being
// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
// name and the whole update story — bump it when the shell changes, and `activate`
// drops every older cache.
const VERSION = 'recipescam-v1';
// nginx answers all three with the same index.html (SPA fallback), so they are one
// document under three keys: an offline navigation finds it whichever key it asks.
const SHELL = ['/', '/app', '/library'];
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
// the models and the icons never change under a given build.
const STATIC = /^\/(assets|wasm|models|icons)\//;
// Only a complete same-origin 200 is worth keeping — an opaque or error body
// stored here comes back as a failure on the next visit, and nothing in the worker
// can tell the two apart by then.
const cachedFetch = (request) =>
fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(VERSION).then((cache) => cache.put(request, copy));
}
return response;
});
// Offline, the studio the app is for; the landing page for a build without /app.
const offlineShell = async () =>
(await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
self.addEventListener('install', (event) => {
// Best effort, one route at a time: a deploy caught mid-flight must not leave the
// worker uninstalled, so each route fails on its own.
event.waitUntil(
caches.open(VERSION)
.then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {}))))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
// request that is not a plain same-origin read — goes straight out.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
// A navigation is network-first even when it is cached: a shell answered from the
// cache while the network holds a newer build pins the visitor to the old one. It
// is also the path an installed app opens through with no network.
if (request.mode === 'navigate') {
event.respondWith(fetch(request).catch(offlineShell));
return;
}
// ponytail: a navigation is never written back, the precached entry is what
// answers offline. Add a put() here once a route must survive on its own.
if (STATIC.test(url.pathname)) {
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
return;
}
// Everything else — a font, the manifest, an uploaded photo — is network-first,
// and answered from the cache only when that fails.
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
});
+71
View File
@@ -0,0 +1,71 @@
// The manifest needs its icons at fixed sizes, and the only artwork there is ships
// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
// engine grades through it — so it does the resize here rather than a new image
// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
// committed, so a build never has to decode and rescale anything.
//
// node scripts/make-icons.mjs
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const SOURCE = 'public/assets/RecipesCamIcon.png';
const OUT = 'public/icons';
const CanvasKit = await CanvasKitInit({
locateFile: () =>
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
// Buffers are typed arrays already, but Skia's binding wants one it can read as a
// plain byte view, not node's pooled slab.
const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
// `scale` is the art's share of the square. The plain icons bleed to the edge,
// exactly as the source does. The maskable one cannot: the launcher supplies the
// mask, and most of them are circles, so the art has to stay inside the 80%
// safe zone the spec draws — the background around it is the app's own #0a0a0a
// rather than nothing at all, because a masked icon with transparent corners
// shows whatever the launcher paints behind it.
const icons = [
{ file: 'icon-192.png', size: 192, scale: 1, background: false },
{ file: 'icon-512.png', size: 512, scale: 1, background: false },
{ file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
];
mkdirSync(OUT, { recursive: true });
for (const { file, size, scale, background } of icons) {
const surface = CanvasKit.MakeSurface(size, size);
if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
const canvas = surface.getCanvas();
if (background) {
const paint = new CanvasKit.Paint();
paint.setColor(CanvasKit.Color(10, 10, 10));
canvas.drawPaint(paint);
paint.delete();
}
const side = size * scale;
const offset = (size - side) / 2;
// Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
// nearest or bilinear would drop detail off the camera's thin outlines.
canvas.drawImageRectCubic(
art,
CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
CanvasKit.XYWHRect(offset, offset, side, side),
1 / 3,
1 / 3,
null,
);
surface.flush();
const png = surface.makeImageSnapshot().encodeToBytes();
surface.dispose();
if (!png) throw new Error(`could not encode ${file}`);
writeFileSync(`${OUT}/${file}`, png);
console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
}
art.delete();
+86
View File
@@ -0,0 +1,86 @@
// 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
// each, against a preview server:
//
// npx vite build && npx vite preview --port 4183 &
// node scripts/pwa-check.mjs
//
// The browser is Playwright's own, installed by whoever runs this — there is no
// test runner in this repo, and software GL is what the studio needs to paint at
// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
const BASE = process.argv[2] ?? 'http://localhost:4183';
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
const { chromium } = playwright;
const browser = await chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// Every assertion prints, rather than throwing on the first one: which of the four
// works and which does not is the whole answer.
let failed = 0;
const check = (ok, label) => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
if (!ok) failed++;
};
const page = await context.newPage();
await page.goto(`${BASE}/app`);
// (a) The manifest, as the browser fetches it before it offers anything.
const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
// with a registration that has an `active` worker; that worker's own state string
// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
const registration = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return { scope: reg.scope, state: reg.active?.state ?? null };
});
check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
// (c) What it put away. Read after a reload, because the first load is the one that
// installs it and the reload is the first one it is in control of.
await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
await page.reload();
const cached = await page.evaluate(async () => {
const out = {};
for (const name of await caches.keys()) {
out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
}
return out;
});
const names = Object.keys(cached);
const urls = Object.values(cached).flat().sort();
check(
urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
`worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
);
// (d) The point of all of it: the network gone, the shell still paints.
await context.setOffline(true);
let brand = 'nothing';
try {
await page.reload();
brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
} catch (err) {
brand = `reload failed: ${err.message.split('\n')[0]}`;
}
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
await browser.close();
if (failed) {
console.error(`pwa-check: ${failed} of 4 failed`);
process.exit(1);
}
console.log('pwa-check ok');
+30
View File
@@ -13,6 +13,13 @@ export const en: Dict = {
'nav.back': 'Back to home',
'nav.guest': 'Guest',
// The install offer (see vi.ts).
'install.title': 'Install RecipesCam',
'install.body': 'Add it to your home screen to open it faster — smoother, offline, full screen like an app.',
'install.yes': 'INSTALL',
'install.no': 'NO THANKS',
'install.ios': 'Tap the Share button in the toolbar, then choose “Add to Home Screen”.',
'act.undo': 'UNDO',
'act.redo': 'REDO',
'act.reset': 'RESET',
@@ -218,9 +225,32 @@ export const en: Dict = {
'photos.failed': 'That did not work: {msg}',
'photos.noLabels': 'No strip labels yet',
'lib.title': 'Library',
'lib.hint':
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
'lib.add': 'ADD FOLDER',
'lib.stop': 'STOP SCAN',
'lib.rescan': 'RESCAN',
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.all': 'ALL',
'lib.open': 'Open in the studio',
'lib.edited': 'edited',
'lib.size': '{mb} MB',
'lib.count': '{n} photos',
'lib.scanning': 'Scanning {done}/{total} — {added} new…',
'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.',
'lib.empty': 'Nothing in this folder yet.',
'lib.noFolders': 'No folder yet. Hit ADD FOLDER and pick a photo folder on this machine.',
'lib.missing': 'Could not open {name} — the file has been moved or deleted.',
'lib.failed': 'That folder could not be read.',
'lib.unsupported':
'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
'nav.admin': 'Admin',
'nav.photos': 'My photos',
'nav.profile': 'Profile',
'nav.library': 'Library',
'nav.studio': 'Studio',
'adm.title': 'Strip moderation',
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
+32
View File
@@ -17,9 +17,19 @@ export const vi = {
'nav.admin': 'Quản trị',
'nav.photos': 'Ảnh của tôi',
'nav.profile': 'Hồ sơ',
'nav.library': 'Thư viện',
'nav.studio': 'Studio',
'nav.guest': 'Khách',
// The install offer (src/ui/InstallPrompt.tsx). The only dialog that asks for
// something the visitor did not come for, so the copy sells what they get out of
// it and the refusal is one word away.
'install.title': 'Cài RecipesCam lên màn hình chính',
'install.body': 'Cài lên màn hình chính để mở nhanh hơn — mượt mà hơn, offline, toàn màn hình như một app.',
'install.yes': 'ĐỒNG Ý',
'install.no': 'KHÔNG CẢM ƠN',
'install.ios': 'Bấm nút Chia sẻ trên thanh địa chỉ, rồi chọn "Thêm vào màn hình chính".',
'act.undo': 'HOÀN TÁC',
'act.redo': 'LÀM LẠI',
'act.reset': 'ĐẶT LẠI',
@@ -228,6 +238,28 @@ export const vi = {
'photos.failed': 'Không thực hiện được: {msg}',
'photos.noLabels': 'Chưa có nhãn dải phim',
'lib.title': 'Thư viện',
'lib.hint':
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
'lib.add': 'THÊM THƯ MỤC',
'lib.stop': 'DỪNG QUÉT',
'lib.rescan': 'QUÉT LẠI',
'lib.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN',
'lib.all': 'TẤT CẢ',
'lib.open': 'Mở trong studio',
'lib.edited': 'đã chỉnh',
'lib.size': '{mb} MB',
'lib.count': '{n} ảnh',
'lib.scanning': 'Đang quét {done}/{total} — thêm {added}…',
'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.',
'lib.empty': 'Chưa có ảnh nào trong thư mục này.',
'lib.noFolders': 'Chưa có thư mục nào. Bấm THÊM THƯ MỤC để chọn một thư mục ảnh trên máy.',
'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.',
'lib.failed': 'Không đọc được thư mục này.',
'lib.unsupported':
'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
'adm.title': 'Quản trị dải phim',
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
'adm.upload': 'THÊM ẢNH',
+25 -5
View File
@@ -10,11 +10,14 @@ import { Workspace } from './App';
import { Admin } from './Admin';
import { ProfilePage } from './ProfilePage';
import { PhotosPage } from './PhotosPage';
import { Library } from './Library';
import { InstallPrompt } from './ui/InstallPrompt';
import { installTracking } from './track';
// Five routes, no router: the landing page, the workspace, the strip moderation
// screen, the member's own profile and the member's photo folder. nginx serves
// index.html for all of them (SPA fallback), so this is a pathname check.
// Six routes, no router: the landing page, the workspace, the strip moderation
// screen, the member's own profile, the member's photo folder and the local
// catalogue of folders on the visitor's own disk. nginx serves index.html for
// all of them (SPA fallback), so this is a pathname check.
const path = window.location.pathname;
const page = path.startsWith('/app') ? (
<Workspace />
@@ -22,6 +25,8 @@ const page = path.startsWith('/app') ? (
<Admin />
) : path.startsWith('/photos') ? (
<PhotosPage />
) : path.startsWith('/library') ? (
<Library />
) : path.startsWith('/profile') ? (
<ProfilePage />
) : (
@@ -35,9 +40,24 @@ if (!root) throw new Error('#root missing');
installTracking();
// No StrictMode: it double-invokes effects, which would load CanvasKit twice and
// run the render pipeline twice on every mount.
// run the render pipeline twice on every mount. The install offer hangs off the
// i18n provider because its copy comes from the dictionaries.
createRoot(root).render(
<ThemeProvider>
<I18nProvider>{page}</I18nProvider>
<I18nProvider>
{page}
<InstallPrompt />
</I18nProvider>
</ThemeProvider>
);
// Service worker, production only: in dev it would answer the browser with the
// last build's shell instead of vite's modules. Registered after `load` so its
// precache does not race the studio's own 20MB of wasm for the connection, and a
// browser that refuses it (private mode, a plain-http origin) just loses the
// offline shell — nothing else depends on it.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
+97
View File
@@ -0,0 +1,97 @@
// 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 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.
type InstallEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
const DISMISSED = 'rc.install.dismissed.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();
}
// 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.
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;
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();
}
// 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;
notify();
});
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import {
OFFER_EVENT,
canInstall,
dismissInstall,
dismissed,
isIosSafari,
isStandalone,
promptInstall,
subscribe,
} from '../pwa/install';
// The install offer, and the only dialog the app opens without being asked. It waits
// for the browser to say the app is installable, and then for the visitor to have
// stayed long enough to want it — a prompt on the way in is a prompt nobody reads.
// Markup and classes are the auth dialog's (see AuthModal.tsx); there is nothing new
// to draw here.
//
// Long enough to have opened a photo and worked on it, short enough that the offer
// still lands in the visit that earned it.
const DELAY_MS = 120_000;
export function InstallPrompt() {
const { t } = useI18n();
// 'manual' is Safari: no install event exists there, so the only thing to show is
// where the instruction lives.
const [face, setFace] = useState<'native' | 'manual' | null>(null);
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;
const manual = isIosSafari();
let waited = false;
let asked = false;
let shown = false;
// Both ways in have to agree: installability (or Safari, which never fires the
// event) and either the delay running out or something in the app asking for the
// offer — a Library button, say.
const maybeShow = () => {
if (shown || (!waited && !asked)) return;
if (manual) {
shown = true;
setFace('manual');
} else if (canInstall()) {
shown = true;
setFace('native');
}
};
const onOffer = () => {
asked = true;
maybeShow();
};
window.addEventListener(OFFER_EVENT, onOffer);
const unsubscribe = subscribe(maybeShow);
const timer = window.setTimeout(() => {
waited = true;
maybeShow();
}, DELAY_MS);
return () => {
window.clearTimeout(timer);
window.removeEventListener(OFFER_EVENT, onOffer);
unsubscribe();
};
}, []);
if (!face) return null;
const close = () => {
dismissInstall();
setFace(null);
};
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && close()}>
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="install-title">
<h2 id="install-title">{t('install.title')}</h2>
<p className="hint">{t('install.body')}</p>
{face === 'manual' ? <p className="hint">{t('install.ios')}</p> : null}
{face === 'native' ? (
<button
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.
onClick={() => {
void promptInstall();
setFace(null);
}}
>
{t('install.yes')}
</button>
) : null}
<button type="button" className="btn ghost" data-key="install-no" onClick={close}>
{t('install.no')}
</button>
</div>
</div>
);
}