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;
}