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