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