From f50c65bd77521aabcf1759a79d7759d42b53b9d9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:32:37 +0200 Subject: [PATCH] feat: ship the 192 and 512 PNG icons Chrome's install prompt wants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO recorded these as needing a human because binary assets cannot be generated in the agent environment. That turned out to be wrong: Chrome is on PATH here and rasterizes the SVG exactly, so scripts/make-icons.ts generates them and the output is committed. Verified pixel-accurate against src/icon.svg before trusting it (background #0f172a, bars #38bdf8, underline #e2e8f0, transparent rounded corners). The generator uses an explicit Bun.spawn with a timeout rather than a shell template, because Chrome did not always exit on its own here. Both sizes now appear in the manifest, get copied into dist, are served by the dev server, and are listed in the service worker precache — along with icon.svg and manifest.webmanifest, which were also absent from it because the list is derived from bundler outputs and these are written separately. Without that an installed app could not render its own icon offline. src/icons.test.ts checks the committed files are genuine 8-bit RGBA images of the right dimensions by inflating IDAT and asserting the exact pixel-data length, so a truncated or stub file cannot pass. It cannot detect staleness if src/icon.svg changes — re-run the generator. Co-Authored-By: Claude Opus 5 (1M context) --- build.ts | 28 ++++++++++++++-- scripts/make-icons.ts | 75 ++++++++++++++++++++++++++++++++++++++++++ src/icon-192.png | Bin 0 -> 2360 bytes src/icon-512.png | Bin 0 -> 7047 bytes src/icons.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 +++++ src/manifest.ts | 8 ++++- 7 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 scripts/make-icons.ts create mode 100644 src/icon-192.png create mode 100644 src/icon-512.png create mode 100644 src/icons.test.ts 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 0000000000000000000000000000000000000000..12d105a6e5461d1da8523b734ce0e7d37b02179f GIT binary patch literal 2360 zcmai03pmtS8~@LY)EL&CNK0%ep~2Lk#Ii2A%v1`44-G~#aw&41N$DcF3mL|)nyhO| zO+@29GS&#Qp>Y?=Z773g%&hkLzTM~9ufFd*&pGFL&ii}MdCz-(&+ojq&`vfIV#;Cw z07%%`TAkdQ8^81&qFduhcF=YJ*s)+|W#N38G(Q{~l!e>fxIFriEusCX@|tIry?3!x zQ3FGPV%-Ma)#{fZWhD{VF+otCJl5`@5j@eMX)T+nB#Q`r?W@1q9%;3fH3Kvc{dUNLj6aHb(8x@GP?d`e--3N@+VFhqO3>BSh2c2NlmI~d;T*928(^N9j7@tgng4epU$2vzx zkG>&b{L|9k<4z5ZNb+okd<%wWX_azg{v>{jF%?xlUq;g9w_SkxDm%IZ3xgv}S9t!W zM$o~tCffqiWDidX2bXY3qi43ms$LldL;#dxLmEqItsd^52slpQ63u8H0xqX#*`-Bz zJkv;*$aPr4LeKw;bAEjddeD(y&N<`Q#f9Ngg(vQfr9g7uX!iNd~^IK}oGI^SIW0FfIIr z9q~n%{%!8*z@|g{a!PxPu0w47qFr{L=dR;6zXz!|Fa40Cmzt}kCzx6P)p`-Pt2Q)- zf-4|E=BmWwtsIe>xZlF)&G4a)O-j9FTIV$RaXY{B7&;%2p30dD-&~Iki-^IomK0SA zhTxdC35Hl8M0QyM}?7LZnx`rY2M zFKP`eeG>F5Z;$}pF8$FQW-zmB&9p6ZQPKfp9Ycx4tNV;fsc8m2^&Z{`TkdIH$ke-1 z{*(!m(ztbP(0Gu$3vBBb?#{2N#t>cMsSiwJen+tbwFWsP0PgEB!IPzWDw>;Q^6F_U zOK@v?dUi23mb46V%}iT@N?`>JS3C`Z* z0+SRGbBGoel-u8+6OA`;lQPd6>&*7()hC>R7Nx85>}aBOC?K)9`a%Ih1juf#Cd>H) zvzNSjrGPAhVtX|kpl6>7uM0rysPJBK@ID};6)ah&!2$s$28sZz9YCox;H&|ZSOe%g zK#?-Y{NIL*1WMJH~O2~{KJ4@cq!hTSurxG-C^gZvE++sI{NFL42@q7`{k+Im8u#SuhGk>{JfqUS3WX}ni z+PyvZ5UM^LdQb0z;m5%X#WIL=nD-`KRk5}21FP(ZKy~>>lJOJ2~2w!I#kv`IDv>ApU^1 zd`g^-MTj$Shd)4N5!U1j!uNvYp!8TImp%eL6lM}C46z`OhBK>2 zcMr1D2sq-Ll)8XaCN8O9P5;cgI&Q7PP}3f#e-@OD3WWhZLjzer>&WwJGr^=ukc~^_ z=mm_rc~C9kJF;$h)@v%L@|dbx2qFqY+zBJir>%(+X0J;I9mjpVpsZ20?WU4&NSNN~ zJzhLX9S*T}l@c)(x%snN2-ATbVF}DI+AOX$KYW(P8N*N%847Bnp={+ z8XUoZVNWw2WRR_&ZQKKcFpUGQm)vzM6J@D&_#Y`_y#$8Vbk}blT+_Bxm{e$c?ZX>4 zlJp(lKkFqryDy9qjx1`rP| zR<`iI{F)degE9uWVbva+5F(znBe9K3lT<&J>U~CJJ5hfFwX)iZ| z-UTho*Ivc(Z{6G7h}imkQSNZ^ihf>v^RW&vNO{U;!&u0S*IHzK@bzFSiULs6hao{QDr!4hVsorRK8t063NpY zo^YT-X-RX_Lssy^(H?n99Ofb<-p+80Dvs2GTrq?wT7CALU60DM5JCVzD5TWl3%BYY e(JzG$1epEKN2I^L+F@%2u=~l$s`5wggg*eH@JiGG literal 0 HcmV?d00001 diff --git a/src/icon-512.png b/src/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..07fab0f895c0eac111d837af786d5ff8f4eb5c52 GIT binary patch literal 7047 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 bX9W1JA3x{idk2DG09-V-xKQ-F%k6&!)iS$V literal 0 HcmV?d00001 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,