diff --git a/build.ts b/build.ts index 13ef806..2654572 100644 --- a/build.ts +++ b/build.ts @@ -52,11 +52,27 @@ if (!result.success) { throw new Error("Build failed"); } +/** + * Written to dist below rather than emitted by the bundler, so they are absent + * from `result.outputs` and have to be listed for the precache by name. + * Without them an installed app cannot render its own icon offline, and the + * manifest 404s. + */ +const STATIC_ASSETS = [ + "manifest.webmanifest", + "icon.svg", + "icon-192.png", + "icon-512.png", +]; + // The precache list comes from the build's own outputs, so hashed filenames can // never drift out of sync with the service worker. -const precache = result.outputs - .filter((output) => !output.path.endsWith(".map")) - .map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")); +const precache = [ + ...result.outputs + .filter((output) => !output.path.endsWith(".map")) + .map((output) => basePath + path.relative(outdir, output.path).replaceAll(path.sep, "/")), + ...STATIC_ASSETS.map((file) => basePath + file), +]; const swResult = await Bun.build({ entrypoints: ["src/sw.ts"], @@ -78,6 +94,12 @@ if (!swResult.success) { await Bun.write(path.join(outdir, "manifest.webmanifest"), buildManifest(basePath)); await Bun.write(path.join(outdir, "icon.svg"), Bun.file("src/icon.svg")); +for (const size of [192, 512]) { + await Bun.write( + path.join(outdir, `icon-${size}.png`), + Bun.file(`src/icon-${size}.png`), + ); +} // This is an SPA: every route serves the same shell, so the *document* URL // can be several segments deep (e.g. /budget2.0/month/2026-09). Relative diff --git a/scripts/make-icons.ts b/scripts/make-icons.ts new file mode 100644 index 0000000..96bf6c6 --- /dev/null +++ b/scripts/make-icons.ts @@ -0,0 +1,75 @@ +/** + * Rasterizes src/icon.svg into the PNG sizes Chrome's install prompt wants. + * + * bun scripts/make-icons.ts + * + * The output is COMMITTED rather than produced by build.ts, because this needs + * a Chrome binary on PATH and the build must not. Re-run it whenever + * src/icon.svg changes; src/icons.test.ts checks the committed files are real + * images of the right dimensions, but it cannot tell you they are STALE. + * + * Chrome is the rasterizer because it is the only one present in this + * environment — no ImageMagick, no rsvg-convert, no cairosvg. It renders the + * SVG in a page sized exactly to the target, on a transparent backdrop, so the + * icon's own rounded corners stay transparent. + */ +import { rm, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const SIZES = [192, 512]; +const CHROME = process.env.CHROME ?? "google-chrome"; +/** Chrome has been seen not to exit on its own here, so never wait forever. */ +const TIMEOUT_MS = 60_000; + +const work = await mkdtemp(path.join(tmpdir(), "budget-icons-")); +try { + await Bun.write(path.join(work, "icon.svg"), Bun.file("src/icon.svg")); + await Bun.write( + path.join(work, "wrap.html"), + ` + +`, + ); + + for (const size of SIZES) { + const out = path.join(work, `icon-${size}.png`); + const chrome = Bun.spawn( + [ + CHROME, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--no-first-run", + "--hide-scrollbars", + "--force-device-scale-factor=1", + "--default-background-color=00000000", + `--screenshot=${out}`, + `--window-size=${size},${size}`, + `file://${path.join(work, "wrap.html")}`, + ], + { stdout: "ignore", stderr: "ignore" }, + ); + const timer = setTimeout(() => chrome.kill(), TIMEOUT_MS); + await chrome.exited; + clearTimeout(timer); + if (!(await Bun.file(out).exists())) { + throw new Error(`${CHROME} wrote no screenshot for ${size}px`); + } + + const bytes = new Uint8Array(await Bun.file(out).arrayBuffer()); + const view = new DataView(bytes.buffer); + const width = view.getUint32(16); + const height = view.getUint32(20); + if (width !== size || height !== size) { + throw new Error(`Expected ${size}x${size}, got ${width}x${height}`); + } + + const dest = `src/icon-${size}.png`; + await Bun.write(dest, bytes); + console.log(`${dest} ${width}x${height} ${bytes.length} bytes`); + } +} finally { + await rm(work, { recursive: true, force: true }); +} diff --git a/src/icon-192.png b/src/icon-192.png new file mode 100644 index 0000000..12d105a Binary files /dev/null and b/src/icon-192.png differ diff --git a/src/icon-512.png b/src/icon-512.png new file mode 100644 index 0000000..07fab0f Binary files /dev/null and b/src/icon-512.png differ diff --git a/src/icons.test.ts b/src/icons.test.ts new file mode 100644 index 0000000..fc60930 --- /dev/null +++ b/src/icons.test.ts @@ -0,0 +1,73 @@ +import { test, expect, describe } from "bun:test"; +import { inflateSync } from "node:zlib"; +import { buildManifest } from "./manifest.ts"; + +/** + * Chrome's install prompt wants raster icons at 192 and 512; an SVG alone + * leaves it incomplete. These are generated from src/icon.svg by + * `bun scripts/make-icons.ts` — see that file for why they are committed + * rather than built. + */ +const SIZES = [192, 512] as const; + +/** Width and height straight out of the IHDR chunk, which is always first. */ +function pngHeader(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const signature = [...bytes.slice(0, 8)]; + return { + isPng: String.fromCharCode(...signature.slice(1, 4)) === "PNG", + width: view.getUint32(16), + height: view.getUint32(20), + bitDepth: bytes[24], + colorType: bytes[25], + }; +} + +/** Concatenated IDAT payloads, inflated. */ +function pixelBytes(bytes: Uint8Array): Buffer { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const chunks: Uint8Array[] = []; + let i = 8; + while (i < bytes.length) { + const length = view.getUint32(i); + const type = String.fromCharCode(...bytes.slice(i + 4, i + 8)); + if (type === "IDAT") chunks.push(bytes.slice(i + 8, i + 8 + length)); + i += 12 + length; + } + return inflateSync(Buffer.concat(chunks)); +} + +describe("the app icons", () => { + for (const size of SIZES) { + test(`icon-${size}.png is a real ${size}x${size} image, not a placeholder`, async () => { + const bytes = new Uint8Array(await Bun.file(`src/icon-${size}.png`).arrayBuffer()); + const header = pngHeader(bytes); + + expect(header.isPng).toBe(true); + expect(header.width).toBe(size); + expect(header.height).toBe(size); + expect(header.bitDepth).toBe(8); + expect(header.colorType).toBe(6); // RGBA + + // One filter byte plus 4 channels per pixel, per row. Proves the pixel + // data is really present and really that size, so a truncated or + // stub file cannot pass. + expect(pixelBytes(bytes).length).toBe(size * (1 + size * 4)); + }); + } + + test("the manifest offers both raster sizes as well as the SVG", () => { + const icons = JSON.parse(buildManifest("/budget/")).icons as Array<{ + src: string; + sizes: string; + type: string; + }>; + expect(icons.map((i) => i.sizes)).toEqual(["any", "192x192", "512x512"]); + expect(icons.map((i) => i.src)).toEqual([ + "/budget/icon.svg", + "/budget/icon-192.png", + "/budget/icon-512.png", + ]); + expect(icons.every((i) => i.src.startsWith("/budget/"))).toBe(true); + }); +}); diff --git a/src/index.ts b/src/index.ts index 0acd757..0f6d41e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,14 @@ const server = serve({ new Response(Bun.file(new URL("./icon.svg", import.meta.url)), { headers: { "content-type": "image/svg+xml" }, }), + [`${basePath}icon-192.png`]: () => + new Response(Bun.file(new URL("./icon-192.png", import.meta.url)), { + headers: { "content-type": "image/png" }, + }), + [`${basePath}icon-512.png`]: () => + new Response(Bun.file(new URL("./icon-512.png", import.meta.url)), { + headers: { "content-type": "image/png" }, + }), "/*": index, }, development: process.env.NODE_ENV !== "production" && { diff --git a/src/manifest.ts b/src/manifest.ts index 9eb17d5..6937783 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -16,7 +16,13 @@ export function buildManifest(base: string): string { display: "standalone", background_color: "#0f172a", theme_color: "#0f172a", - icons: [{ src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }], + // The SVG first for anything that will scale it; the raster sizes + // because Chrome's install prompt wants 192 and 512 specifically. + icons: [ + { src: `${base}icon.svg`, sizes: "any", type: "image/svg+xml" }, + { src: `${base}icon-192.png`, sizes: "192x192", type: "image/png" }, + { src: `${base}icon-512.png`, sizes: "512x512", type: "image/png" }, + ], }, null, 2,