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
+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();
});