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
+5 -43
View File
@@ -20,7 +20,10 @@ import {
PwaShortName,
PwaShortcuts,
PwaStartPath,
PwaAppIconMarkScale,
PwaMaskableMarkScale,
} from "../data/pwa-data";
import renderAppIcon from "../functions/server/pwa/render-app-icon";
const ROOT = path.resolve(import.meta.dir, "../..");
const LOGO_SVG = path.join(ROOT, "public/logos/logo.svg");
@@ -31,17 +34,6 @@ const MAIN_CSS = path.join(ROOT, "src/styles/main.css");
const ICO_SIZES = [16, 24, 32, 48] as const;
const PNG_SIZES = [...ICO_SIZES, 180, 512] as const;
/**
* The app icon is the stone on paper, with room around it for the
* launcher's mask. The favicons keep the transparent stone.
*/
const AppIconMarkScale = 0.8;
/**
* A maskable icon is cropped to a circle of 80% of the canvas, so the
* stone stays inside it with room to spare.
*/
const MaskableMarkScale = 0.52;
function buildIco(pngs: Record<number, Buffer>): Buffer {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
@@ -182,36 +174,6 @@ async function grabThemeColors(): Promise<ThemeColors> {
};
}
async function renderAppIcon({
svg,
size,
scale,
background,
}: {
svg: Buffer;
size: number;
scale: number;
background: string;
}) {
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();
}
function buildManifest({ colors }: { colors: ThemeColors }) {
return {
id: "/",
@@ -637,7 +599,7 @@ async function main() {
await renderAppIcon({
svg,
size: 180,
scale: AppIconMarkScale,
scale: PwaAppIconMarkScale,
background: colors.paper,
}),
);
@@ -655,7 +617,7 @@ async function main() {
await renderAppIcon({
svg,
size,
scale: maskable ? MaskableMarkScale : AppIconMarkScale,
scale: maskable ? PwaMaskableMarkScale : PwaAppIconMarkScale,
background: colors.paper,
}),
);