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,