feat(t5): App-Icons, Manifest und Offline-Cache v5 (T5a A9)
- Honigzelle auf Tinte als Master-SVG; scripts/generate-icons.mjs erzeugt any/maskable 192/512, Monochrom 512, drei Shortcut-Icons, Apple-Icon und favicon.ico (16/32/48, ICO-Container von Hand) byte-gleich bei jedem Lauf. - manifest.ts exakt nach Spec 12.3, Farben gleich dem hellen --background. - Service Worker: SHELL_CACHE apicore-shell-v5, Icons im Shell-Cache; ein fehlendes Icon hält das Vorladen der Seiten nicht auf. - Proxy-Matcher: nur echte Icon-Dateien (icons/*.png) ohne Sicherheits-Header, Punkte maskiert. - BrandMark-Deckel mittig (linke Kante 18.8). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
No files matched your search
@@ -47,6 +47,7 @@
|
|||||||
"eslint-config-next": "16.3.8",
|
"eslint-config-next": "16.3.8",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
|
"sharp": "0.35.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
@@ -1781,8 +1782,8 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -11158,8 +11159,8 @@
|
|||||||
"version": "0.35.5",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -11208,8 +11209,8 @@
|
|||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"typecheck": "next typegen && tsc --noEmit",
|
"typecheck": "next typegen && tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"check:3d-budget": "node scripts/check-3d-budget.mjs"
|
"check:3d-budget": "node scripts/check-3d-budget.mjs",
|
||||||
|
"icons": "node scripts/generate-icons.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"eslint-config-next": "16.3.8",
|
"eslint-config-next": "16.3.8",
|
||||||
"fake-indexeddb": "^6.2.5",
|
"fake-indexeddb": "^6.2.5",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^30.0.1",
|
||||||
|
"sharp": "0.35.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- ApiCore mark (spec 12.1): a capped honeycomb cell. Master for scripts/generate-icons.mjs;
|
||||||
|
the geometry equals BRAND_CELL_PATHS in src/components/brand-mark.tsx. -->
|
||||||
|
<path id="cell" d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" />
|
||||||
|
<path id="lid" d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 477 B |
@@ -1,8 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112.64" fill="#2c1b0f"/><g transform="translate(51.2 51.2) scale(6.4)"><path d="M32 4 L56 18 V46 L32 60 L8 46 V18 Z" fill="#f0b13f" stroke="#2c1b0f" stroke-width="3.84" stroke-linejoin="round" /><path d="M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z" fill="#f9d280" /></g></svg>
|
||||||
<rect width="512" height="512" rx="96" fill="#0a0a0a" />
|
|
||||||
<g fill="#f5c518">
|
|
||||||
<rect x="156" y="176" width="200" height="52" rx="10" />
|
|
||||||
<rect x="156" y="242" width="200" height="52" rx="10" />
|
|
||||||
<rect x="156" y="308" width="200" height="52" rx="10" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 620 B |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -37,11 +37,28 @@ const {
|
|||||||
buildAssetsToPrune,
|
buildAssetsToPrune,
|
||||||
} = self.ApiCoreSwRoutes;
|
} = self.ApiCoreSwRoutes;
|
||||||
|
|
||||||
const SHELL_CACHE = "apicore-shell-v4";
|
// v5: new app icons and colour tokens (T5 spec 12.3) reach installed apps.
|
||||||
|
const SHELL_CACHE = "apicore-shell-v5";
|
||||||
// v2: v1 could hold answers this version never keeps (exports, downloads).
|
// v2: v1 could hold answers this version never keeps (exports, downloads).
|
||||||
const DATA_CACHE = "apicore-data-v2";
|
const DATA_CACHE = "apicore-data-v2";
|
||||||
const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE];
|
const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE];
|
||||||
|
|
||||||
|
// The manifest icons (src/app/manifest.ts; sw.spec checks that every one of
|
||||||
|
// them is kept): kept with the shell, so an offline start and the shortcuts
|
||||||
|
// show the app's own icons. Not the favicon: Next links it as
|
||||||
|
// /favicon.ico?<hash>, so a cached /favicon.ico would never answer it.
|
||||||
|
const SHELL_ICONS = [
|
||||||
|
"/icon.svg",
|
||||||
|
"/icons/icon-192.png",
|
||||||
|
"/icons/icon-512.png",
|
||||||
|
"/icons/maskable-192.png",
|
||||||
|
"/icons/maskable-512.png",
|
||||||
|
"/icons/monochrome-512.png",
|
||||||
|
"/icons/shortcut-inspection-96.png",
|
||||||
|
"/icons/shortcut-varroa-96.png",
|
||||||
|
"/icons/shortcut-today-96.png",
|
||||||
|
];
|
||||||
|
|
||||||
// Same budget as every request of the app (lib/http/fetch-with-timeout.ts).
|
// Same budget as every request of the app (lib/http/fetch-with-timeout.ts).
|
||||||
const ASSET_TIMEOUT_MS = 4000;
|
const ASSET_TIMEOUT_MS = 4000;
|
||||||
const RUNTIME_CONFIG_PATH = "/api/runtime-config";
|
const RUNTIME_CONFIG_PATH = "/api/runtime-config";
|
||||||
@@ -453,6 +470,7 @@ async function warmShell() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await keepRuntimeConfig(cache).catch(() => undefined);
|
await keepRuntimeConfig(cache).catch(() => undefined);
|
||||||
|
await keepIcons(cache);
|
||||||
// Only with the full picture of the current build may old assets go.
|
// Only with the full picture of the current build may old assets go.
|
||||||
if (allPagesKept) {
|
if (allPagesKept) {
|
||||||
await pruneOldBuildAssets(cache, [...assets]);
|
await pruneOldBuildAssets(cache, [...assets]);
|
||||||
@@ -497,6 +515,21 @@ async function keepAsset(cache, asset) {
|
|||||||
await cache.put(asset, response);
|
await cache.put(asset, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the icons fresh on every warm-up (their URLs carry no build hash).
|
||||||
|
* A missing icon is skipped; the pages do not depend on it.
|
||||||
|
*/
|
||||||
|
async function keepIcons(cache) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
SHELL_ICONS.map(async (icon) => {
|
||||||
|
const response = await fetch(icon, { cache: "no-cache" });
|
||||||
|
if (isGoodAnswer(response)) {
|
||||||
|
await cache.put(icon, response);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function keepRuntimeConfig(cache) {
|
async function keepRuntimeConfig(cache) {
|
||||||
const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" });
|
const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" });
|
||||||
if (isGoodAnswer(response)) {
|
if (isGoodAnswer(response)) {
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// App icons from the master mark (spec 12.2): `npm run icons`.
|
||||||
|
// Reads public/brand/apicore-mark.svg and writes the PNG/SVG/ICO files listed
|
||||||
|
// in ICON_FILES. Idempotent: the same input gives byte-identical files, and a
|
||||||
|
// file whose bytes would not change is not rewritten. The output is committed,
|
||||||
|
// so the Docker image never runs this script.
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const MASTER = "public/brand/apicore-mark.svg";
|
||||||
|
|
||||||
|
// Spec 12.1: dark ink oklch(0.24 0.035 55) behind the cell, light wax for shortcuts.
|
||||||
|
const INK = "#2c1b0f";
|
||||||
|
const WAX = "#f9d280";
|
||||||
|
const WHITE = "#ffffff";
|
||||||
|
|
||||||
|
// Every variant is drawn on a 512 grid; the 64 grid of the mark is scaled into it.
|
||||||
|
const GRID = 512;
|
||||||
|
const MARK_GRID = 64;
|
||||||
|
// Share of the edge the 64 box of the mark takes. "any": the cell is 70 % high.
|
||||||
|
// Maskable/monochrome: the cell (stroke included) stays inside the safe circle
|
||||||
|
// of 40 % radius and below 66 % of the edge (spec 12.2): 0.68 gives 32 % / 64 %.
|
||||||
|
const MARK_BOX = { any: 0.8, maskable: 0.68, favicon: 1 };
|
||||||
|
// Spec 12.2: "Ecken 22 % gerundet".
|
||||||
|
const ANY_CORNER_RADIUS = 0.22;
|
||||||
|
// Shortcut icons: the 24 px area icon takes half the edge, inside any launcher mask.
|
||||||
|
const SHORTCUT_ICON_SHARE = 0.5;
|
||||||
|
const LUCIDE_GRID = 24;
|
||||||
|
|
||||||
|
/** Shortcut icon per manifest shortcut: the lucide icon of its area (spec 11). */
|
||||||
|
export const SHORTCUT_ICONS = [
|
||||||
|
{ file: "public/icons/shortcut-inspection-96.png", area: "inspections", lucide: "clipboard-check" },
|
||||||
|
{ file: "public/icons/shortcut-varroa-96.png", area: "varroa", lucide: "bug" },
|
||||||
|
{ file: "public/icons/shortcut-today-96.png", area: "dashboard", lucide: "layout-dashboard" },
|
||||||
|
];
|
||||||
|
const SHORTCUT_SIZE = 96;
|
||||||
|
|
||||||
|
export const ICON_FILES = [
|
||||||
|
"public/icon.svg",
|
||||||
|
"public/icons/icon-192.png",
|
||||||
|
"public/icons/icon-512.png",
|
||||||
|
"public/icons/maskable-192.png",
|
||||||
|
"public/icons/maskable-512.png",
|
||||||
|
"public/icons/monochrome-512.png",
|
||||||
|
...SHORTCUT_ICONS.map((shortcut) => shortcut.file),
|
||||||
|
"src/app/apple-icon.png",
|
||||||
|
"src/app/favicon.ico",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAVICON_SIZES = [16, 32, 48];
|
||||||
|
const PNG_OPTIONS = { compressionLevel: 9, adaptiveFiltering: true, palette: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `d` of the cell and the lid in the master SVG.
|
||||||
|
* @param {string} svg
|
||||||
|
* @returns {{ cell: string, lid: string }}
|
||||||
|
*/
|
||||||
|
export function readMasterPaths(svg) {
|
||||||
|
return { cell: pathElement(svg, "cell").d, lid: pathElement(svg, "lid").d };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} svg
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {{ element: string, d: string }}
|
||||||
|
*/
|
||||||
|
function pathElement(svg, id) {
|
||||||
|
const element = svg.match(new RegExp(`<path\\b[^>]*\\bid="${id}"[^>]*/>`))?.[0];
|
||||||
|
const d = element?.match(/\bd="([^"]+)"/)?.[1];
|
||||||
|
if (!element || !d) {
|
||||||
|
throw new Error(`${MASTER}: no <path id="${id}" d="…"/>`);
|
||||||
|
}
|
||||||
|
return { element, d };
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (value) => Number(value.toFixed(4));
|
||||||
|
|
||||||
|
/** The mark scaled into the 512 grid, centred, taking `box` of the edge. */
|
||||||
|
function markGroup(content, box) {
|
||||||
|
const scale = round((box * GRID) / MARK_GRID);
|
||||||
|
const offset = round((GRID - box * GRID) / 2);
|
||||||
|
return `<g transform="translate(${offset} ${offset}) scale(${scale})">${content}</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgDocument(body, size) {
|
||||||
|
const dimensions = size ? ` width="${size}" height="${size}"` : "";
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg"${dimensions} viewBox="0 0 ${GRID} ${GRID}">${body}</svg>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SVG bodies of every mark variant, from the master. */
|
||||||
|
function variants(master) {
|
||||||
|
const cell = pathElement(master, "cell");
|
||||||
|
const lid = pathElement(master, "lid");
|
||||||
|
const withoutId = (element) => element.replace(/\sid="[^"]*"/, "");
|
||||||
|
const colouredMark = `${withoutId(cell.element)}${withoutId(lid.element)}`;
|
||||||
|
const silhouette = withoutId(cell.element)
|
||||||
|
.replace(/\bfill="[^"]*"/, `fill="${WHITE}"`)
|
||||||
|
.replace(/\bstroke="[^"]*"/, `stroke="${WHITE}"`);
|
||||||
|
const radius = round(ANY_CORNER_RADIUS * GRID);
|
||||||
|
return {
|
||||||
|
any: `<rect width="${GRID}" height="${GRID}" rx="${radius}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
|
||||||
|
apple: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.any)}`,
|
||||||
|
maskable: `<rect width="${GRID}" height="${GRID}" fill="${INK}"/>${markGroup(colouredMark, MARK_BOX.maskable)}`,
|
||||||
|
monochrome: markGroup(silhouette, MARK_BOX.maskable),
|
||||||
|
favicon: markGroup(colouredMark, MARK_BOX.favicon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lucide icon node (`[tag, attrs][]`) of an icon name, from the installed lucide-react. */
|
||||||
|
async function lucideNode(name) {
|
||||||
|
const icon = await import(`lucide-react/dist/esm/icons/${name}.mjs`);
|
||||||
|
return icon.__iconNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lucideMarkup(node) {
|
||||||
|
return node
|
||||||
|
.map(([tag, attrs]) => {
|
||||||
|
const attributes = Object.entries(attrs)
|
||||||
|
.filter(([key]) => key !== "key")
|
||||||
|
.map(([key, value]) => `${key}="${value}"`)
|
||||||
|
.join(" ");
|
||||||
|
return `<${tag} ${attributes}/>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Area icon in ink on wax, full bleed (spec 12.2). */
|
||||||
|
function shortcutSvg(markup) {
|
||||||
|
const scale = round((SHORTCUT_ICON_SHARE * GRID) / LUCIDE_GRID);
|
||||||
|
const offset = round((GRID - SHORTCUT_ICON_SHARE * GRID) / 2);
|
||||||
|
return (
|
||||||
|
`<rect width="${GRID}" height="${GRID}" fill="${WAX}"/>` +
|
||||||
|
`<g transform="translate(${offset} ${offset}) scale(${scale})" fill="none" stroke="${INK}" ` +
|
||||||
|
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${markup}</g>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPng(body, size) {
|
||||||
|
return sharp(Buffer.from(svgDocument(body, size))).png(PNG_OPTIONS).toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICO container with PNG entries (sharp writes no ICO): ICONDIR, one
|
||||||
|
* ICONDIRENTRY per image, then the PNG data.
|
||||||
|
* @param {{ size: number, png: Buffer }[]} images
|
||||||
|
* @returns {Buffer}
|
||||||
|
*/
|
||||||
|
export function buildIco(images) {
|
||||||
|
const HEADER = 6;
|
||||||
|
const ENTRY = 16;
|
||||||
|
const header = Buffer.alloc(HEADER);
|
||||||
|
header.writeUInt16LE(0, 0);
|
||||||
|
header.writeUInt16LE(1, 2);
|
||||||
|
header.writeUInt16LE(images.length, 4);
|
||||||
|
let offset = HEADER + ENTRY * images.length;
|
||||||
|
const entries = images.map(({ size, png }) => {
|
||||||
|
const entry = Buffer.alloc(ENTRY);
|
||||||
|
// One byte each: 256 is written as 0.
|
||||||
|
entry.writeUInt8(size >= 256 ? 0 : size, 0);
|
||||||
|
entry.writeUInt8(size >= 256 ? 0 : size, 1);
|
||||||
|
entry.writeUInt8(0, 2);
|
||||||
|
entry.writeUInt8(0, 3);
|
||||||
|
entry.writeUInt16LE(1, 4);
|
||||||
|
entry.writeUInt16LE(32, 6);
|
||||||
|
entry.writeUInt32LE(png.length, 8);
|
||||||
|
entry.writeUInt32LE(offset, 12);
|
||||||
|
offset += png.length;
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIfChanged(file, content) {
|
||||||
|
mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
current = readFileSync(file);
|
||||||
|
} catch {
|
||||||
|
// Not there yet.
|
||||||
|
}
|
||||||
|
if (!current || !current.equals(content)) {
|
||||||
|
writeFileSync(file, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders every icon into `outRoot` (same relative paths as in the app).
|
||||||
|
* @param {{ root?: string, outRoot?: string }} [options]
|
||||||
|
* @returns {Promise<string[]>} the files, relative to `outRoot`
|
||||||
|
*/
|
||||||
|
export async function generateIcons({ root = ROOT, outRoot = root } = {}) {
|
||||||
|
const master = readFileSync(path.join(root, MASTER), "utf8");
|
||||||
|
const bodies = variants(master);
|
||||||
|
const outputs = new Map();
|
||||||
|
|
||||||
|
outputs.set("public/icon.svg", Buffer.from(svgDocument(bodies.any)));
|
||||||
|
for (const size of [192, 512]) {
|
||||||
|
outputs.set(`public/icons/icon-${size}.png`, await renderPng(bodies.any, size));
|
||||||
|
outputs.set(`public/icons/maskable-${size}.png`, await renderPng(bodies.maskable, size));
|
||||||
|
}
|
||||||
|
outputs.set("public/icons/monochrome-512.png", await renderPng(bodies.monochrome, 512));
|
||||||
|
for (const shortcut of SHORTCUT_ICONS) {
|
||||||
|
const markup = lucideMarkup(await lucideNode(shortcut.lucide));
|
||||||
|
outputs.set(shortcut.file, await renderPng(shortcutSvg(markup), SHORTCUT_SIZE));
|
||||||
|
}
|
||||||
|
outputs.set("src/app/apple-icon.png", await renderPng(bodies.apple, 180));
|
||||||
|
const favicons = [];
|
||||||
|
for (const size of FAVICON_SIZES) {
|
||||||
|
favicons.push({ size, png: await renderPng(bodies.favicon, size) });
|
||||||
|
}
|
||||||
|
outputs.set("src/app/favicon.ico", buildIco(favicons));
|
||||||
|
|
||||||
|
const files = ICON_FILES.filter((file) => outputs.has(file));
|
||||||
|
for (const file of files) {
|
||||||
|
writeIfChanged(path.join(outRoot, ...file.split("/")), outputs.get(file));
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
if (isCli) {
|
||||||
|
generateIcons()
|
||||||
|
.then((files) => {
|
||||||
|
process.stdout.write(`${files.join("\n")}\n`);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { BRAND_CELL_PATHS } from "@/components/brand-mark";
|
||||||
|
import { AREAS } from "@/lib/areas";
|
||||||
|
import { ICON_FILES, SHORTCUT_ICONS, buildIco, generateIcons, readMasterPaths } from "./generate-icons.mjs";
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
|
describe("generate-icons (spec 12.2)", () => {
|
||||||
|
let outDir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
outDir = mkdtempSync(path.join(tmpdir(), "icons-"));
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(outDir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("the master SVG has the BrandMark geometry", () => {
|
||||||
|
expect(readMasterPaths(readFileSync(path.join(ROOT, "public/brand/apicore-mark.svg"), "utf8"))).toEqual(
|
||||||
|
BRAND_CELL_PATHS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the lid sits centred over the cell, only lifted", () => {
|
||||||
|
const xs = (d: string) => [...d.matchAll(/L([\d.]+)\s/g)].map((match) => Number(match[1]));
|
||||||
|
const lidXs = xs(BRAND_CELL_PATHS.lid);
|
||||||
|
expect(Math.min(...lidXs) + Math.max(...lidXs)).toBeCloseTo(64, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shortcut icons use the icon of their area", () => {
|
||||||
|
const pascal = (name: string) => name.replace(/(^|-)(\w)/g, (_, __, char: string) => char.toUpperCase());
|
||||||
|
for (const shortcut of SHORTCUT_ICONS) {
|
||||||
|
expect(AREAS[shortcut.area as keyof typeof AREAS].icon.displayName, shortcut.file).toBe(pascal(shortcut.lucide));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("running the icon script twice leaves the files byte-identical", async () => {
|
||||||
|
const first = await generateIcons({ root: ROOT, outRoot: outDir });
|
||||||
|
const snapshot = new Map(first.map((file) => [file, readFileSync(path.join(outDir, file))]));
|
||||||
|
const second = await generateIcons({ root: ROOT, outRoot: outDir });
|
||||||
|
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect([...first].sort()).toEqual([...ICON_FILES].sort());
|
||||||
|
for (const file of second) {
|
||||||
|
expect(readFileSync(path.join(outDir, file)).equals(snapshot.get(file) as Buffer), file).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildIco writes a header, one entry per PNG and the PNGs behind it", () => {
|
||||||
|
const png = (size: number) => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(size)]);
|
||||||
|
const ico = buildIco([
|
||||||
|
{ size: 16, png: png(10) },
|
||||||
|
{ size: 256, png: png(20) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([ico.readUInt16LE(0), ico.readUInt16LE(2), ico.readUInt16LE(4)]).toEqual([0, 1, 2]);
|
||||||
|
// 256 px is written as 0 in the one-byte width and height fields.
|
||||||
|
expect([ico.readUInt8(6), ico.readUInt8(22)]).toEqual([16, 0]);
|
||||||
|
expect(ico.readUInt16LE(6 + 6)).toBe(32);
|
||||||
|
const secondOffset = ico.readUInt32LE(22 + 12);
|
||||||
|
expect(ico.readUInt32LE(6 + 12)).toBe(6 + 2 * 16);
|
||||||
|
expect(secondOffset).toBe(6 + 2 * 16 + 14);
|
||||||
|
expect(ico.length).toBe(secondOffset + 24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,42 +0,0 @@
|
|||||||
import { ImageResponse } from "next/og";
|
|
||||||
|
|
||||||
// iOS's "Add to Home Screen" doesn't read the web manifest at all - it needs
|
|
||||||
// its own <link rel="apple-touch-icon">, which only exists if this special
|
|
||||||
// file (or a literal apple-icon.png) is present. Without it, iOS falls back
|
|
||||||
// to a screenshot of whatever page was open, not the app's brand mark.
|
|
||||||
// Mirrors public/icon.svg's existing design (used in the manifest for
|
|
||||||
// Android/Chrome) as plain positioned divs rather than inline SVG paths,
|
|
||||||
// since that renders reliably through Satori (next/og's renderer). iOS
|
|
||||||
// applies its own rounded-square mask on top, so this stays a plain square.
|
|
||||||
export const size = { width: 180, height: 180 };
|
|
||||||
export const contentType = "image/png";
|
|
||||||
|
|
||||||
export default function AppleIcon() {
|
|
||||||
const barStyle = {
|
|
||||||
position: "absolute" as const,
|
|
||||||
left: "30%",
|
|
||||||
width: "40%",
|
|
||||||
height: "10%",
|
|
||||||
borderRadius: 6,
|
|
||||||
background: "#f5c518",
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
position: "relative",
|
|
||||||
background: "#0a0a0a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ ...barStyle, top: "34%" }} />
|
|
||||||
<div style={{ ...barStyle, top: "47%" }} />
|
|
||||||
<div style={{ ...barStyle, top: "60%" }} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ ...size },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,175 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hexOf, oklchOf, tokenSet } from "@/styles/css-tokens";
|
||||||
|
import { config as proxyConfig } from "@/proxy";
|
||||||
|
import manifest from "./manifest";
|
||||||
|
|
||||||
|
// Spec 12.2/12.3 and 13.1: the web manifest, its files and what serves them.
|
||||||
|
|
||||||
|
const ROOT = process.cwd();
|
||||||
|
const PUBLIC_DIR = path.join(ROOT, "public");
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
interface Icon {
|
||||||
|
src: string;
|
||||||
|
sizes?: string;
|
||||||
|
type?: string;
|
||||||
|
purpose?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Width and height from the IHDR chunk of a PNG. */
|
||||||
|
function pngSize(png: Buffer): { width: number; height: number } {
|
||||||
|
expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true);
|
||||||
|
expect(png.toString("latin1", 12, 16)).toBe("IHDR");
|
||||||
|
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicFile(src: string): string {
|
||||||
|
return path.join(PUBLIC_DIR, ...src.split("/").filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = manifest();
|
||||||
|
const icons: Icon[] = data.icons ?? [];
|
||||||
|
const shortcutIcons: Icon[] = (data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []);
|
||||||
|
|
||||||
|
describe("web manifest (spec 12.3)", () => {
|
||||||
|
it("every manifest icon exists under public with the declared size", () => {
|
||||||
|
const all = [...icons, ...shortcutIcons];
|
||||||
|
expect(all.length).toBeGreaterThan(0);
|
||||||
|
for (const icon of all) {
|
||||||
|
const file = publicFile(icon.src);
|
||||||
|
expect(existsSync(file), icon.src).toBe(true);
|
||||||
|
if (icon.sizes === "any") {
|
||||||
|
expect(icon.type).toBe("image/svg+xml");
|
||||||
|
expect(readFileSync(file, "utf8")).toContain("<svg");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(icon.type ?? "image/png").toBe("image/png");
|
||||||
|
const [width, height] = (icon.sizes ?? "").split("x").map(Number);
|
||||||
|
expect(pngSize(readFileSync(file)), icon.src).toEqual({ width, height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares any and maskable icons at 192 and 512 and one monochrome icon", () => {
|
||||||
|
const sizesOf = (purpose: string) =>
|
||||||
|
icons.filter((icon) => (icon.purpose ?? "any") === purpose).map((icon) => icon.sizes);
|
||||||
|
|
||||||
|
expect(sizesOf("any")).toEqual(expect.arrayContaining(["192x192", "512x512", "any"]));
|
||||||
|
expect(sizesOf("maskable")).toEqual(["192x192", "512x512"]);
|
||||||
|
expect(sizesOf("monochrome")).toEqual(["512x512"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("background_color and theme_color equal the light --background", () => {
|
||||||
|
const css = readFileSync(path.join(ROOT, "src/app/globals.css"), "utf8");
|
||||||
|
const lightBackground = hexOf(oklchOf(tokenSet(css, "light").background));
|
||||||
|
|
||||||
|
expect(lightBackground).toBe("#f9f4ea");
|
||||||
|
expect(data.background_color).toBe(lightBackground);
|
||||||
|
expect(data.theme_color).toBe(lightBackground);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is installable from the root with German metadata", () => {
|
||||||
|
expect(data).toMatchObject({
|
||||||
|
id: "/",
|
||||||
|
name: "ApiCore – Imkereiverwaltung",
|
||||||
|
short_name: "ApiCore",
|
||||||
|
lang: "de",
|
||||||
|
dir: "ltr",
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
categories: ["productivity", "business"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers exactly the three spec shortcuts with their 96 px icons", () => {
|
||||||
|
expect(
|
||||||
|
(data.shortcuts ?? []).map((shortcut) => ({
|
||||||
|
name: shortcut.name,
|
||||||
|
url: shortcut.url,
|
||||||
|
icons: shortcut.icons?.map((icon) => `${icon.src} ${icon.sizes}`),
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "Durchsicht erfassen", url: "/inspections/new", icons: ["/icons/shortcut-inspection-96.png 96x96"] },
|
||||||
|
{ name: "Varroa messen", url: "/varroa/new", icons: ["/icons/shortcut-varroa-96.png 96x96"] },
|
||||||
|
{ name: "Heute", url: "/dashboard", icons: ["/icons/shortcut-today-96.png 96x96"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every shortcut url is in SHELL_ROUTES", () => {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { SHELL_ROUTES } = require(path.join(PUBLIC_DIR, "sw-routes.js")) as {
|
||||||
|
SHELL_ROUTES: readonly string[];
|
||||||
|
};
|
||||||
|
for (const shortcut of data.shortcuts ?? []) {
|
||||||
|
expect(SHELL_ROUTES, shortcut.url).toContain(shortcut.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("app icons (spec 12.2)", () => {
|
||||||
|
it("favicon.ico holds 16, 32 and 48 px entries", () => {
|
||||||
|
const ico = readFileSync(path.join(ROOT, "src/app/favicon.ico"));
|
||||||
|
expect(ico.readUInt16LE(0)).toBe(0);
|
||||||
|
expect(ico.readUInt16LE(2)).toBe(1);
|
||||||
|
const count = ico.readUInt16LE(4);
|
||||||
|
const entries = Array.from({ length: count }, (_, index) => {
|
||||||
|
const at = 6 + index * 16;
|
||||||
|
const width = ico.readUInt8(at);
|
||||||
|
const length = ico.readUInt32LE(at + 8);
|
||||||
|
const offset = ico.readUInt32LE(at + 12);
|
||||||
|
const image = ico.subarray(offset, offset + length);
|
||||||
|
expect(image.length).toBe(length);
|
||||||
|
expect(pngSize(image)).toEqual({ width, height: ico.readUInt8(at + 1) });
|
||||||
|
return width;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries).toEqual([16, 32, 48]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("apple-icon.png is a 180 px PNG and replaces the generated apple-icon route", () => {
|
||||||
|
expect(pngSize(readFileSync(path.join(ROOT, "src/app/apple-icon.png")))).toEqual({ width: 180, height: 180 });
|
||||||
|
expect(existsSync(path.join(ROOT, "src/app/apple-icon.tsx"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxy matcher", () => {
|
||||||
|
const [matcher] = proxyConfig.matcher;
|
||||||
|
const runsProxy = (pathname: string) => new RegExp(`^${matcher}$`).test(pathname);
|
||||||
|
|
||||||
|
it("the proxy matcher excludes icons and apple-icon.png", () => {
|
||||||
|
for (const pathname of [
|
||||||
|
"/icons/icon-192.png",
|
||||||
|
"/icons/maskable-512.png",
|
||||||
|
"/icons/shortcut-today-96.png",
|
||||||
|
"/apple-icon.png",
|
||||||
|
"/icon.svg",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/sw.js",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
]) {
|
||||||
|
expect(runsProxy(pathname), pathname).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the security headers on pages that only resemble an excluded file", () => {
|
||||||
|
for (const pathname of [
|
||||||
|
"/",
|
||||||
|
"/dashboard",
|
||||||
|
"/inspections/new",
|
||||||
|
"/icons",
|
||||||
|
"/icons/x/y",
|
||||||
|
"/icons/missing",
|
||||||
|
"/iconsx/page",
|
||||||
|
"/apple-iconxpng",
|
||||||
|
"/iconxsvg",
|
||||||
|
"/faviconxico",
|
||||||
|
"/swxjs",
|
||||||
|
]) {
|
||||||
|
expect(runsProxy(pathname), pathname).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,49 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
import { THEME_COLORS } from "@/lib/appearance/appearance-core";
|
||||||
|
|
||||||
|
// Spec 12.3. The icon files come from `npm run icons` (scripts/generate-icons.mjs);
|
||||||
|
// the shortcut URLs are shell routes of the service worker, so they open offline too.
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
|
id: "/",
|
||||||
name: "ApiCore – Imkereiverwaltung",
|
name: "ApiCore – Imkereiverwaltung",
|
||||||
short_name: "ApiCore",
|
short_name: "ApiCore",
|
||||||
description: "Digitale Imkereiverwaltung für Wabenhain",
|
description: "Digitale Imkereiverwaltung für Wabenhain",
|
||||||
|
lang: "de",
|
||||||
|
dir: "ltr",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#ffffff",
|
// = light --background (manifest.spec checks it against globals.css); dark
|
||||||
theme_color: "#0a0a0a",
|
// comes from viewport.themeColor in the root layout.
|
||||||
|
background_color: THEME_COLORS.light,
|
||||||
|
theme_color: THEME_COLORS.light,
|
||||||
|
categories: ["productivity", "business"],
|
||||||
icons: [
|
icons: [
|
||||||
|
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||||
|
{ src: "/icons/maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||||
|
{ src: "/icons/monochrome-512.png", sizes: "512x512", type: "image/png", purpose: "monochrome" },
|
||||||
|
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
{
|
{
|
||||||
src: "/icon.svg",
|
name: "Durchsicht erfassen",
|
||||||
sizes: "any",
|
short_name: "Durchsicht",
|
||||||
type: "image/svg+xml",
|
url: "/inspections/new",
|
||||||
|
icons: [{ src: "/icons/shortcut-inspection-96.png", sizes: "96x96", type: "image/png" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Varroa messen",
|
||||||
|
short_name: "Varroa",
|
||||||
|
url: "/varroa/new",
|
||||||
|
icons: [{ src: "/icons/shortcut-varroa-96.png", sizes: "96x96", type: "image/png" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Heute",
|
||||||
|
url: "/dashboard",
|
||||||
|
icons: [{ src: "/icons/shortcut-today-96.png", sizes: "96x96", type: "image/png" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export const BRAND_CELL_PATHS = {
|
export const BRAND_CELL_PATHS = {
|
||||||
cell: "M32 4 L56 18 V46 L32 60 L8 46 V18 Z",
|
cell: "M32 4 L56 18 V46 L32 60 L8 46 V18 Z",
|
||||||
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L19.8 36.7 V21.3 Z",
|
lid: "M32 13.6 L45.2 21.3 V36.7 L32 44.4 L18.8 36.7 V21.3 Z",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface BrandMarkProps {
|
interface BrandMarkProps {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import manifest from "@/app/manifest";
|
||||||
|
|
||||||
// public/sw.js itself, run in a sandbox with an in-memory Cache Storage and a
|
// public/sw.js itself, run in a sandbox with an in-memory Cache Storage and a
|
||||||
// fake network - the worker's wiring, beyond the pure rules in sw-routes.js.
|
// fake network - the worker's wiring, beyond the pure rules in sw-routes.js.
|
||||||
@@ -10,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
const APP = "https://apicore.example";
|
const APP = "https://apicore.example";
|
||||||
const API = "https://apicorebackend.example";
|
const API = "https://apicorebackend.example";
|
||||||
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
|
const PUBLIC_DIR = path.resolve(process.cwd(), "public");
|
||||||
|
// Spec 12.3: raised so installed apps fetch the new icons and tokens.
|
||||||
|
const SHELL = "apicore-shell-v5";
|
||||||
|
|
||||||
interface MatchOptions {
|
interface MatchOptions {
|
||||||
ignoreSearch?: boolean;
|
ignoreSearch?: boolean;
|
||||||
@@ -154,7 +157,7 @@ function page(asset: string, marker: string): string {
|
|||||||
|
|
||||||
async function workerWithBackend(network: ReturnType<typeof vi.fn>): Promise<Worker> {
|
async function workerWithBackend(network: ReturnType<typeof vi.fn>): Promise<Worker> {
|
||||||
const caches = new FakeCacheStorage();
|
const caches = new FakeCacheStorage();
|
||||||
const shell = await caches.open("apicore-shell-v4");
|
const shell = await caches.open(SHELL);
|
||||||
await shell.put("/api/runtime-config", json({ apiUrl: `${API}/api/v1` }));
|
await shell.put("/api/runtime-config", json({ apiUrl: `${API}/api/v1` }));
|
||||||
const worker = loadWorker(caches, network);
|
const worker = loadWorker(caches, network);
|
||||||
// Let the worker read the backend origin from the cached config.
|
// Let the worker read the backend origin from the cached config.
|
||||||
@@ -289,7 +292,7 @@ describe("service worker", () => {
|
|||||||
describe("background refresh of a cached offline page", () => {
|
describe("background refresh of a cached offline page", () => {
|
||||||
async function workerWithOldOfflinePage(): Promise<Worker> {
|
async function workerWithOldOfflinePage(): Promise<Worker> {
|
||||||
const caches = new FakeCacheStorage();
|
const caches = new FakeCacheStorage();
|
||||||
const shell = await caches.open("apicore-shell-v4");
|
const shell = await caches.open(SHELL);
|
||||||
await shell.put("/offline/colony", html(page("/_next/static/chunks/old.js", "alt")));
|
await shell.put("/offline/colony", html(page("/_next/static/chunks/old.js", "alt")));
|
||||||
await shell.put("/_next/static/chunks/old.js", new Response("old()"));
|
await shell.put("/_next/static/chunks/old.js", new Response("old()"));
|
||||||
caches.log.length = 0;
|
caches.log.length = 0;
|
||||||
@@ -297,7 +300,7 @@ describe("service worker", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cachedOfflinePage(worker: Worker): Promise<string | undefined> {
|
async function cachedOfflinePage(worker: Worker): Promise<string | undefined> {
|
||||||
const shell = await worker.caches.open("apicore-shell-v4");
|
const shell = await worker.caches.open(SHELL);
|
||||||
return (await shell.match("/offline/colony"))?.text();
|
return (await shell.match("/offline/colony"))?.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,8 +320,8 @@ describe("service worker", () => {
|
|||||||
// ... and the new one is kept with its assets, the assets first.
|
// ... and the new one is kept with its assets, the assets first.
|
||||||
expect(await cachedOfflinePage(worker)).toContain("neu");
|
expect(await cachedOfflinePage(worker)).toContain("neu");
|
||||||
expect(worker.caches.log).toEqual([
|
expect(worker.caches.log).toEqual([
|
||||||
"apicore-shell-v4 /_next/static/chunks/new.js",
|
`${SHELL} /_next/static/chunks/new.js`,
|
||||||
"apicore-shell-v4 /offline/colony",
|
`${SHELL} /offline/colony`,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,4 +338,65 @@ describe("service worker", () => {
|
|||||||
expect(await cachedOfflinePage(worker)).toContain("alt");
|
expect(await cachedOfflinePage(worker)).toContain("alt");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("app icons in the shell (spec 12.3)", () => {
|
||||||
|
function answerEverything() {
|
||||||
|
network.mockImplementation(async (request: RequestInfo) => {
|
||||||
|
const { pathname } = new URL(keyOf(request));
|
||||||
|
if (pathname === "/api/runtime-config") return json({ apiUrl: `${API}/api/v1` });
|
||||||
|
if (pathname.startsWith("/_next/static/")) return new Response("asset()");
|
||||||
|
if (/\.(png|ico|svg)$/.test(pathname)) {
|
||||||
|
return new Response("image", { headers: { "Content-Type": "image/png" } });
|
||||||
|
}
|
||||||
|
return html(page("/_next/static/chunks/app.js", pathname));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("the service worker cache version is apicore-shell-v5", async () => {
|
||||||
|
const caches = new FakeCacheStorage();
|
||||||
|
await (await caches.open("apicore-shell-v4")).put("/dashboard", html("alt"));
|
||||||
|
answerEverything();
|
||||||
|
const worker = loadWorker(caches, network);
|
||||||
|
|
||||||
|
await dispatch(worker, "install", {});
|
||||||
|
await dispatch(worker, "activate", {});
|
||||||
|
|
||||||
|
expect(await caches.keys()).toContain(SHELL);
|
||||||
|
expect(await caches.keys()).not.toContain("apicore-shell-v4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("install keeps every manifest icon in the shell cache", async () => {
|
||||||
|
answerEverything();
|
||||||
|
const worker = loadWorker(new FakeCacheStorage(), network);
|
||||||
|
|
||||||
|
await dispatch(worker, "install", {});
|
||||||
|
|
||||||
|
const data = manifest();
|
||||||
|
const icons = [
|
||||||
|
...(data.icons ?? []),
|
||||||
|
...(data.shortcuts ?? []).flatMap((shortcut) => shortcut.icons ?? []),
|
||||||
|
].map((icon) => icon.src);
|
||||||
|
const shell = await worker.caches.open(SHELL);
|
||||||
|
for (const src of icons) {
|
||||||
|
expect(await shell.match(src), src).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a missing icon does not stop the warm-up of the pages", async () => {
|
||||||
|
answerEverything();
|
||||||
|
const pages = network.getMockImplementation() as (request: RequestInfo) => Promise<Response>;
|
||||||
|
network.mockImplementation(async (request: RequestInfo) =>
|
||||||
|
new URL(keyOf(request)).pathname.startsWith("/icons/")
|
||||||
|
? new Response("gone", { status: 404 })
|
||||||
|
: pages(request),
|
||||||
|
);
|
||||||
|
const worker = loadWorker(new FakeCacheStorage(), network);
|
||||||
|
|
||||||
|
await dispatch(worker, "install", {});
|
||||||
|
|
||||||
|
const shell = await worker.caches.open(SHELL);
|
||||||
|
expect(await shell.match("/dashboard")).toBeDefined();
|
||||||
|
expect(await shell.match("/icons/icon-192.png")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -66,8 +66,13 @@ export function proxy() {
|
|||||||
return withSecurityHeaders(NextResponse.next());
|
return withSecurityHeaders(NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static files without security headers: build assets, the worker, the
|
||||||
|
// manifest and the app icons (public/icons/*.png, src/app/apple-icon.png).
|
||||||
|
// Dots are escaped and file names end at `$`, so no page that merely starts
|
||||||
|
// like one of them (`/iconsx`, `/favicon.icox`, `/icons/x/y`) loses its
|
||||||
|
// headers; manifest.spec checks it.
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|favicon.ico|sw.js|manifest.webmanifest|icon.svg).*)",
|
"/((?!_next/static/|_next/image$|favicon\\.ico$|sw\\.js$|manifest\\.webmanifest$|icon\\.svg$|apple-icon\\.png$|icons/[\\w-]+\\.png$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||