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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user