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
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import iconPwaManifest from "./icon-pwa-manifest";
const manifest = {
name: "cobble",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192" },
{ src: "/icons/maskable-512.png", sizes: "512x512", purpose: "maskable" },
{ src: "https://example.com/other.png" },
],
shortcuts: [{ name: "Help", url: "/admin/help", icons: [{ src: "/icons/icon-192.png" }] }],
};
describe("iconPwaManifest", () => {
test("leaves the stock icons alone", () => {
expect(iconPwaManifest({ manifest, icon_base: null })).toEqual(manifest);
});
test("moves every /icons/ file to the install's set", () => {
const named = iconPwaManifest({ manifest, icon_base: "/install-icon/0123456789ab" });
expect(named.icons).toEqual([
{ src: "/install-icon/0123456789ab/icon-192.png", sizes: "192x192" },
{ src: "/install-icon/0123456789ab/maskable-512.png", sizes: "512x512", purpose: "maskable" },
{ src: "https://example.com/other.png" },
]);
expect(named.shortcuts).toEqual([
{ name: "Help", url: "/admin/help", icons: [{ src: "/install-icon/0123456789ab/icon-192.png" }] },
]);
});
});
+38
View File
@@ -0,0 +1,38 @@
type ManifestIcon = { src?: unknown };
type Params = {
manifest: Record<string, unknown>;
/**
* The install's icon set (`installIconBase`), or null for the stock one.
*/
icon_base?: string | null;
};
/**
* The manifest pointed at the install's own icon set: every icon under
* `/icons/` (the app's and its shortcuts') moves to `icon_base`, which
* holds the same file names.
*/
export default function iconPwaManifest({ manifest, icon_base }: Params) {
if (!icon_base) return manifest;
const move = (icons: unknown) =>
Array.isArray(icons)
? icons.map((icon: ManifestIcon) =>
typeof icon?.src == "string" && icon.src.startsWith("/icons/")
? { ...icon, src: `${icon_base}/${icon.src.slice("/icons/".length)}` }
: icon,
)
: icons;
return {
...manifest,
icons: move(manifest.icons),
shortcuts: Array.isArray(manifest.shortcuts)
? manifest.shortcuts.map((shortcut: Record<string, unknown>) => ({
...shortcut,
icons: move(shortcut.icons),
}))
: manifest.shortcuts,
};
}
+13
View File
@@ -0,0 +1,13 @@
import { PwaInstallIconRoute, type PwaInstallIcon } from "@/src/data/pwa-data";
type Params = {
icon?: PwaInstallIcon | null;
};
/**
* Where the install's own icon set is served, or null for the stock icons
* under `/icons`.
*/
export default function installIconBase({ icon }: Params) {
return icon?.version ? `${PwaInstallIconRoute}/${icon.version}` : null;
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { PwaDefaultInstallIcon } from "@/src/data/pwa-data";
import parseInstallIcon from "./parse-install-icon";
describe("parseInstallIcon", () => {
test("falls back to the stock icon when nothing or junk is saved", () => {
expect(parseInstallIcon({})).toEqual(PwaDefaultInstallIcon);
expect(parseInstallIcon({ saved: "not json" })).toEqual(PwaDefaultInstallIcon);
expect(parseInstallIcon({ saved: "[1]" })).toEqual(PwaDefaultInstallIcon);
});
test("reads a saved icon", () => {
expect(
parseInstallIcon({
saved: JSON.stringify({ mode: "image", stone: "cobalt", mark: false, version: "0123456789ab" }),
}),
).toEqual({ mode: "image", stone: "cobalt", mark: false, version: "0123456789ab" });
});
test("drops unknown values field by field", () => {
expect(
parseInstallIcon({
saved: JSON.stringify({ mode: "video", stone: "plaid", mark: "yes", version: "../etc" }),
}),
).toEqual(PwaDefaultInstallIcon);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { BotHues, type BotHue } from "@/src/dict/bot-dict";
import {
PwaDefaultInstallIcon,
PwaInstallIconModes,
type PwaInstallIcon,
} from "@/src/data/pwa-data";
type Params = {
/**
* The `install_icon` app setting, as saved.
*/
saved?: string | null;
};
/**
* The install's icon from its saved JSON, field by field: anything missing
* or unknown falls back to the stock ember icon's value.
*/
export default function parseInstallIcon({ saved }: Params): PwaInstallIcon {
let raw: Record<string, unknown> = {};
try {
const parsed = saved ? JSON.parse(saved) : null;
if (parsed && typeof parsed == "object") raw = parsed;
} catch {
raw = {};
}
const mode = PwaInstallIconModes.find((value) => value == raw.mode);
const stone = BotHues.find((hue) => hue.value == raw.stone)?.value as BotHue | undefined;
const version =
typeof raw.version == "string" && /^[a-f0-9]{12}$/.test(raw.version) ? raw.version : null;
return {
mode: mode || PwaDefaultInstallIcon.mode,
stone: stone || PwaDefaultInstallIcon.stone,
mark: typeof raw.mark == "boolean" ? raw.mark : PwaDefaultInstallIcon.mark,
version,
};
}