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