feat: ship the 192 and 512 PNG icons Chrome's install prompt wants
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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
71e8fc948b
commit
f50c65bd77
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
`<!doctype html><meta charset="utf-8">
|
||||
<style>html,body{margin:0;padding:0;background:transparent}
|
||||
img{display:block;width:100vw;height:100vh}</style>
|
||||
<img src="icon.svg">`,
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" && {
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user