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
+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