Let each install pick its app icon

Settings > General gets an App icon (super admin): one of the seven
stones, or an uploaded image with the picked stone as a top-left corner
mark (inside a round launcher's crop, clear of the browser's badge),
previewed in both launcher crops before saving.

Uploads are decoded and cropped to a stored square (no SVG); the set is
drawn into DATA_DIR/install-icon/<version>/ and served publicly and
immutable. The manifest, head icon links and push notifications point at
it; the ember stone keeps the stock icons. The generator shares the icon
renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 10:38:25 +01:00
co-authored by Claude Opus 5.5
parent 5a603bf61f
commit e462806438
36 changed files with 1190 additions and 68 deletions
@@ -0,0 +1,10 @@
import { PwaInstallIconSettingKey } from "@/src/data/pwa-data";
import parseInstallIcon from "@/src/utils/parse-install-icon";
import grabAppSetting from "./grab-app-setting";
/**
* The install's own icon (Settings > General), else the stock ember one.
*/
export default async function grabInstallIcon() {
return parseInstallIcon({ saved: await grabAppSetting({ key: PwaInstallIconSettingKey }) });
}
@@ -0,0 +1,11 @@
import path from "node:path";
import { PwaInstallIconDir } from "@/src/data/pwa-data";
import grabDirNames from "@/src/utils/grab-dir-names";
/**
* `DATA_DIR/install-icon`: the stored upload (`source.png`) and one folder
* per generated icon set.
*/
export default function grabInstallIconDir() {
return path.join(grabDirNames().DATA_DIR, PwaInstallIconDir);
}
@@ -0,0 +1,16 @@
import path from "node:path";
import grabDirNames from "@/src/utils/grab-dir-names";
/**
* The paper colour the app icons sit on: the generated manifest's
* `background_color` (from `main.css`, by `bun run gen:favicon`).
*/
export default async function grabPwaBackground() {
const manifest = await Bun.file(path.join(grabDirNames().ROOT_DIR, "public/manifest.webmanifest"))
.json()
.catch(() => null);
const color = manifest?.background_color;
return typeof color == "string" && /^#[0-9a-f]{6}$/i.test(color) ? color : "#ffffff";
}
@@ -0,0 +1,41 @@
import sharp from "sharp";
import { PwaInstallIconMinSide, PwaInstallIconSourceSide } from "@/src/data/pwa-data";
import AppError from "@/src/utils/app-error";
type Params = {
buffer: Buffer;
background: string;
};
/**
* An uploaded icon image made into the square PNG the icon sets are drawn
* from: decoded (never stored as sent), turned upright, cropped to the
* centre and laid on paper. SVG is refused; it stays out of the server's
* image decoder.
*/
export default async function normalizeInstallIconUpload({ buffer, background }: Params) {
const meta = await sharp(buffer)
.metadata()
.catch(() => null);
if (!meta?.width || !meta.height || !meta.format) {
throw new AppError(`That file isn't an image.`);
}
if (!["png", "jpeg", "webp", "gif"].includes(meta.format)) {
throw new AppError(`Upload a PNG, JPG or WebP image.`);
}
if (Math.min(meta.width, meta.height) < PwaInstallIconMinSide) {
throw new AppError(
`The image is ${meta.width} x ${meta.height}. Upload one at least ${PwaInstallIconMinSide} pixels on each side.`,
);
}
return sharp(buffer, { animated: false })
.rotate()
.resize(PwaInstallIconSourceSide, PwaInstallIconSourceSide, { fit: "cover" })
.flatten({ background })
.png()
.toBuffer();
}
@@ -0,0 +1,95 @@
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
PwaInstallIconSettingKey,
type PwaInstallIcon,
type PwaInstallIconMode,
} from "@/src/data/pwa-data";
import type { BotHue } from "@/src/dict/bot-dict";
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
import renderInstallIcons from "@/src/functions/server/pwa/render-install-icons";
import AppError from "@/src/utils/app-error";
import grabDirNames from "@/src/utils/grab-dir-names";
import grabInstallIconDir from "./grab-install-icon-dir";
import grabPwaBackground from "./grab-pwa-background";
import normalizeInstallIconUpload from "./normalize-install-icon-upload";
type Params = {
mode: PwaInstallIconMode;
stone: BotHue;
mark: boolean;
/**
* A newly picked image, decoded already.
*/
image?: Buffer | null;
};
/**
* Saves the install's icon and draws its icon set. A new image replaces
* the stored one; going back to a stone drops it. The ember stone is the
* stock icon, so it needs no set. Older sets are removed once the new one
* is in place; each has its own folder, so a cached icon is never stale.
*/
export default async function saveInstallIcon({ mode, stone, mark, image }: Params) {
const dir = grabInstallIconDir();
const source_path = path.join(dir, "source.png");
const background = await grabPwaBackground();
await mkdir(dir, { recursive: true });
if (image) {
await writeFile(source_path, await normalizeInstallIconUpload({ buffer: image, background }));
}
if (mode == "stone") {
await rm(source_path, { force: true });
}
if (mode == "image" && !existsSync(source_path)) {
throw new AppError(`Upload an image first.`);
}
const source = mode == "image" ? Buffer.from(await Bun.file(source_path).arrayBuffer()) : null;
const is_stock = mode == "stone" && stone == "ember";
const version = is_stock
? null
: createHash("sha256")
.update(JSON.stringify({ mode, stone, mark }))
.update(source || "")
.digest("hex")
.slice(0, 12);
const icon: PwaInstallIcon = { mode, stone, mark, version };
if (version) {
const stone_svg = Buffer.from(
await Bun.file(
path.join(grabDirNames().ROOT_DIR, "public/brand/icons", `${stone}.svg`),
).arrayBuffer(),
);
const files = await renderInstallIcons({ icon, stone_svg, image: source, background });
const set_dir = path.join(dir, version);
await mkdir(set_dir, { recursive: true });
for (const [name, bytes] of Object.entries(files)) {
await writeFile(path.join(set_dir, `${name}.png`), bytes);
}
}
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name != version) {
await rm(path.join(dir, entry.name), { recursive: true, force: true });
}
}
await saveAppSetting({
key: PwaInstallIconSettingKey,
value: is_stock ? null : JSON.stringify(icon),
});
return icon;
}
@@ -0,0 +1,46 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import handleInstallIconFile from "./handle-install-icon-file";
const previous_data_dir = process.env.DATA_DIR;
const data_dir = mkdtempSync(path.join(tmpdir(), "cobble-install-icon-"));
beforeAll(() => {
process.env.DATA_DIR = data_dir;
mkdirSync(path.join(data_dir, "install-icon/0123456789ab"), { recursive: true });
writeFileSync(path.join(data_dir, "install-icon/0123456789ab/icon-192.png"), "png");
writeFileSync(path.join(data_dir, "install-icon/source.png"), "source");
});
afterAll(() => {
process.env.DATA_DIR = previous_data_dir;
rmSync(data_dir, { recursive: true, force: true });
});
describe("handleInstallIconFile", () => {
test("lets other paths through", async () => {
expect(await handleInstallIconFile({ pathname: "/icons/icon-192.png" })).toBeNull();
});
test("serves a set's file, cached for good", async () => {
const res = await handleInstallIconFile({ pathname: "/install-icon/0123456789ab/icon-192.png" });
expect(res?.status).toBe(200);
expect(res?.headers.get("content-type")).toBe("image/png");
expect(res?.headers.get("cache-control")).toContain("immutable");
expect(await res?.text()).toBe("png");
});
test("never serves the stored upload, unknown names or paths outside a set", async () => {
for (const pathname of [
"/install-icon/source.png",
"/install-icon/0123456789ab/source.png",
"/install-icon/0123456789ab/../source.png",
"/install-icon/0123456789ab/maskable-192.png",
]) {
expect((await handleInstallIconFile({ pathname }))?.status).toBe(404);
}
});
});
@@ -0,0 +1,50 @@
import path from "node:path";
import { PwaInstallIconFiles, PwaInstallIconRoute } from "@/src/data/pwa-data";
import grabInstallIconDir from "@/src/functions/backend/install-icon/grab-install-icon-dir";
const FilePattern = new RegExp(
`^${PwaInstallIconRoute}/([a-f0-9]{12})/([a-z0-9-]+)\\.png$`,
);
/**
* Serves the install's own icon set. Public, like `/icons/`: a browser
* fetches install and notification icons without the session. Each set
* has its own folder, so a file never changes and is cached for good.
* Null for any other path.
*/
export default async function handleInstallIconFile({
pathname,
}: {
pathname: string;
}): Promise<Response | null> {
if (!pathname.startsWith(`${PwaInstallIconRoute}/`)) return null;
const match = pathname.match(FilePattern);
const version = match?.[1];
const name = match?.[2];
if (!version || !name || !PwaInstallIconFiles.some((file) => file.name == name)) {
return new Response("Not Found", { status: 404 });
}
const file = Bun.file(path.join(grabInstallIconDir(), version, `${name}.png`));
if (!(await file.exists())) {
return new Response("Not Found", { status: 404 });
}
const response = new Response(file, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
"X-Content-Type-Options": "nosniff",
},
});
/**
* `Bun.file` adds a filename disposition; an icon is shown inline.
*/
response.headers.delete("content-disposition");
return response;
}
+10 -4
View File
@@ -2,6 +2,9 @@ import path from "path";
import { PwaManifestPath, PwaServiceWorkerPath } from "@/src/data/pwa-data";
import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name";
import namePwaManifest from "@/src/utils/name-pwa-manifest";
import grabInstallIcon from "@/src/functions/backend/db/app-settings/grab-install-icon";
import iconPwaManifest from "@/src/utils/icon-pwa-manifest";
import installIconBase from "@/src/utils/install-icon-base";
const PUBLIC_DIR = path.resolve(import.meta.dir, "../../../../public");
@@ -94,17 +97,20 @@ export default async function handlePwaFile({
}
/**
* The generated manifest under the install's own name (Settings >
* General). Unnamed, or when the file isn't JSON, it goes out as it is.
* The generated manifest under the install's own name and icon (Settings >
* General). Stock, or when the file isn't JSON, it goes out as it is.
*/
async function namedManifest({ file }: { file: ReturnType<typeof Bun.file> }) {
const text = await file.text();
const install_name = await grabInstallName().catch(() => "");
const icon_base = installIconBase({ icon: await grabInstallIcon().catch(() => null) });
if (!install_name) return text;
if (!install_name && !icon_base) return text;
try {
return JSON.stringify(namePwaManifest({ manifest: JSON.parse(text), install_name }), null, 4);
const named = namePwaManifest({ manifest: JSON.parse(text), install_name });
return JSON.stringify(iconPwaManifest({ manifest: named, icon_base }), null, 4);
} catch {
return text;
}
@@ -0,0 +1,32 @@
import sharp from "sharp";
type Params = {
svg: Buffer;
size: number;
scale: number;
background: string;
};
/**
* A stone centred on opaque paper, with room around it for the launcher's
* mask: the app icon (`bun run gen:favicon`) and a picked stone's icon.
*/
export default async function renderAppIcon({ svg, size, scale, background }: Params) {
const mark_size = Math.max(1, Math.round(size * scale));
const mark = await sharp(svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background,
},
})
.composite([{ input: mark, gravity: "center" }])
.png()
.toBuffer();
}
@@ -0,0 +1,97 @@
import sharp from "sharp";
import {
PwaAppIconMarkScale,
PwaCornerMarkInset,
PwaCornerMarkScale,
PwaInstallIconFiles,
PwaMaskableMarkScale,
type PwaInstallIcon,
type PwaInstallIconFile,
} from "@/src/data/pwa-data";
import renderAppIcon from "./render-app-icon";
type Params = {
icon: PwaInstallIcon;
/**
* The picked stone (`public/brand/icons/<stone>.svg`).
*/
stone_svg: Buffer;
/**
* The stored square upload, for image mode.
*/
image?: Buffer | null;
background: string;
};
/**
* Every file of an install's icon set (`PwaInstallIconFiles`), by name.
* A stone icon is the stone on paper, like the stock one. An image fills
* the icon, with the stone in the top-left corner when `mark` is on.
*/
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
const files: Record<string, Buffer> = {};
for (const file of PwaInstallIconFiles) {
files[file.name] =
icon.mode == "image" && image
? await renderImageFile({ file, image, stone_svg, mark: icon.mark, background })
: await renderStoneFile({ file, stone_svg, background });
}
return files;
}
async function renderStoneFile({
file,
stone_svg,
background,
}: {
file: PwaInstallIconFile;
stone_svg: Buffer;
background: string;
}) {
if (file.kind == "favicon") {
return sharp(stone_svg).resize(file.size, file.size).png().toBuffer();
}
return renderAppIcon({
svg: stone_svg,
size: file.size,
scale: file.kind == "maskable" ? PwaMaskableMarkScale : PwaAppIconMarkScale,
background,
});
}
async function renderImageFile({
file,
image,
stone_svg,
mark,
background,
}: {
file: PwaInstallIconFile;
image: Buffer;
stone_svg: Buffer;
mark: boolean;
background: string;
}) {
const base = sharp(image)
.resize(file.size, file.size, { fit: "cover" })
.flatten({ background });
if (!mark || file.kind == "favicon" || file.kind == "plain") {
return base.png().toBuffer();
}
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale));
const inset = Math.round(file.size * PwaCornerMarkInset);
const stone = await sharp(stone_svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
return base
.composite([{ input: stone, left: inset, top: inset }])
.png()
.toBuffer();
}