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
+71
View File
@@ -0,0 +1,71 @@
// The manifest needs its icons at fixed sizes, and the only artwork there is ships
// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
// engine grades through it — so it does the resize here rather than a new image
// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
// committed, so a build never has to decode and rescale anything.
//
// node scripts/make-icons.mjs
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
const SOURCE = 'public/assets/RecipesCamIcon.png';
const OUT = 'public/icons';
const CanvasKit = await CanvasKitInit({
locateFile: () =>
fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
});
// Buffers are typed arrays already, but Skia's binding wants one it can read as a
// plain byte view, not node's pooled slab.
const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
// `scale` is the art's share of the square. The plain icons bleed to the edge,
// exactly as the source does. The maskable one cannot: the launcher supplies the
// mask, and most of them are circles, so the art has to stay inside the 80%
// safe zone the spec draws — the background around it is the app's own #0a0a0a
// rather than nothing at all, because a masked icon with transparent corners
// shows whatever the launcher paints behind it.
const icons = [
{ file: 'icon-192.png', size: 192, scale: 1, background: false },
{ file: 'icon-512.png', size: 512, scale: 1, background: false },
{ file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
];
mkdirSync(OUT, { recursive: true });
for (const { file, size, scale, background } of icons) {
const surface = CanvasKit.MakeSurface(size, size);
if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
const canvas = surface.getCanvas();
if (background) {
const paint = new CanvasKit.Paint();
paint.setColor(CanvasKit.Color(10, 10, 10));
canvas.drawPaint(paint);
paint.delete();
}
const side = size * scale;
const offset = (size - side) / 2;
// Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
// nearest or bilinear would drop detail off the camera's thin outlines.
canvas.drawImageRectCubic(
art,
CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
CanvasKit.XYWHRect(offset, offset, side, side),
1 / 3,
1 / 3,
null,
);
surface.flush();
const png = surface.makeImageSnapshot().encodeToBytes();
surface.dispose();
if (!png) throw new Error(`could not encode ${file}`);
writeFileSync(`${OUT}/${file}`, png);
console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
}
art.delete();
+86
View File
@@ -0,0 +1,86 @@
// Install support is not visible in the source. It is the manifest the browser
// reads, the worker it registers, and what that worker still holds once the network
// is gone — so unlike the other checks here, which read a module and assert on it,
// this one has to drive a real browser at a real build. Four assertions, one line
// each, against a preview server:
//
// npx vite build && npx vite preview --port 4183 &
// node scripts/pwa-check.mjs
//
// The browser is Playwright's own, installed by whoever runs this — there is no
// test runner in this repo, and software GL is what the studio needs to paint at
// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
const BASE = process.argv[2] ?? 'http://localhost:4183';
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
if (!playwright) {
console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
const { chromium } = playwright;
const browser = await chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// Every assertion prints, rather than throwing on the first one: which of the four
// works and which does not is the whole answer.
let failed = 0;
const check = (ok, label) => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
if (!ok) failed++;
};
const page = await context.newPage();
await page.goto(`${BASE}/app`);
// (a) The manifest, as the browser fetches it before it offers anything.
const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
// with a registration that has an `active` worker; that worker's own state string
// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
const registration = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return { scope: reg.scope, state: reg.active?.state ?? null };
});
check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
// (c) What it put away. Read after a reload, because the first load is the one that
// installs it and the reload is the first one it is in control of.
await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
await page.reload();
const cached = await page.evaluate(async () => {
const out = {};
for (const name of await caches.keys()) {
out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
}
return out;
});
const names = Object.keys(cached);
const urls = Object.values(cached).flat().sort();
check(
urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
`worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
);
// (d) The point of all of it: the network gone, the shell still paints.
await context.setOffline(true);
let brand = 'nothing';
try {
await page.reload();
brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
} catch (err) {
brand = `reload failed: ${err.message.split('\n')[0]}`;
}
check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
await browser.close();
if (failed) {
console.error(`pwa-check: ${failed} of 4 failed`);
process.exit(1);
}
console.log('pwa-check ok');