diff --git a/AGENTS.md b/AGENTS.md
index 3cf219f..946bf18 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ This file is a map: it names the rules, the invariants and where things live. Fo
`assets/handoff/` (gitignored) is the project's foundation, not its design. Never use its layouts, `assets/screens/*.png`, right-hand "working on" panel or PRODUCT/IMPLEMENTATION briefs as reference for the UI, docs, screenshots or marketing. Still authoritative:
-- **Brand assets** (never change): stone icons (`public/brand/icons/*.svg`, mirrored in `src/data/stone-geometry.ts`), coordinator (`public/brand/coordinator/`), app icon (`public/brand/app-icon/`), wordmark (`public/brand/wordmark/`, mirrored in `src/data/wordmark-paths.ts`). The mark is a stone with exactly one flat cut; a bot's colour is its identity (never status); the coordinator is hue-less.
+- **Brand assets** (never change): stone icons (`public/brand/icons/*.svg`, mirrored in `src/data/stone-geometry.ts`), coordinator (`public/brand/coordinator/`), app icon (`public/brand/app-icon/`), wordmark (`public/brand/wordmark/`, mirrored in `src/data/wordmark-paths.ts`). The mark is a stone with exactly one flat cut; a bot's colour is its identity (never status); the coordinator is hue-less. The one sanctioned override is an install's own app icon (Settings > General): another brand stone, or an uploaded image with a brand stone as its top-left corner mark.
- **Tokens:** `src/styles/main.css` is now the source of truth (the handoff `tokens.css` is history).
For the current UI read `src/layouts/bots/` (rail, workspace rail, user popover), `src/pages/admin/bots/[bot_id]/` (header, thread, composer, modals) and `src/pages/admin/workspaces/` (dashboard).
@@ -148,6 +148,7 @@ Set in Settings > Environment (super admin), not `.env`. `envs` (private; encryp
- Generated assets (manifest, icons, `offline.html`, `loading.html`) come from `bun run gen:favicon`, which reads `main.css`; outputs are committed. Shared paths in `src/data/pwa-data.ts`.
- **Install name:** app setting `install_name` (Settings > General, super admin). `handlePwaFile` rewrites the manifest's `name`/`short_name` per request (`namePwaManifest`), the root head uses it for iOS tags, the loading page's `
` is the `{{name}}` placeholder. Devices pick up a new name only on reinstall.
+- **Install icon:** app setting `install_icon` (JSON, `parseInstallIcon`): a stone, or an upload (decoded, cropped to `source.png`, never stored as sent, no SVG) with the stone at `PwaCornerMarkInset`/`PwaCornerMarkScale`, inside a round launcher's crop. `saveInstallIcon` draws the set (`renderInstallIcons`, shared with `gen:favicon` via `renderAppIcon`) into `DATA_DIR/install-icon//`, served publicly and `immutable` by `handleInstallIconFile`; the manifest, head links and push payloads point at it. Ember stone = stock icons, no set.
- Notification `badge` is `icons/badge-96.png` (white silhouette; Android masks by alpha, so never the opaque app icon). Brave on Android has no WebAPK, so its notifications always carry Brave's name and icon.
- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. It never reloads on its own. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`).
- **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this.
diff --git a/src/data/pwa-data.ts b/src/data/pwa-data.ts
index 5d05832..ab987d1 100644
--- a/src/data/pwa-data.ts
+++ b/src/data/pwa-data.ts
@@ -1,3 +1,4 @@
+import type { BotHue } from "@/src/dict/bot-dict";
import { SiteData } from "./site-data";
/**
@@ -110,3 +111,82 @@ export const PwaLoadingNamePlaceholder = "{{name}}";
*/
export const PwaInstallNameSettingKey = "install_name";
export const PwaInstallNameMaxLength = 30;
+
+/**
+ * How much of the canvas the stone fills on the app icon, and on a
+ * maskable icon (cropped to a circle of 80% of the canvas, so the stone
+ * stays well inside it).
+ */
+export const PwaAppIconMarkScale = 0.8;
+export const PwaMaskableMarkScale = 0.52;
+
+/**
+ * The install's own icon (Settings > General): one of the stones, or an
+ * uploaded image with the stone as a corner mark. Saved as JSON in
+ * `app_settings`; the generated PNGs live in `DATA_DIR/`
+ * and are served from `//.png`.
+ */
+export const PwaInstallIconSettingKey = "install_icon";
+export const PwaInstallIconDir = "install-icon";
+export const PwaInstallIconRoute = "/install-icon";
+export const PwaInstallIconModes = ["stone", "image"] as const;
+export type PwaInstallIconMode = (typeof PwaInstallIconModes)[number];
+
+export type PwaInstallIcon = {
+ mode: PwaInstallIconMode;
+ stone: BotHue;
+ /**
+ * Image mode: put the stone in the top-left corner.
+ */
+ mark: boolean;
+ /**
+ * The generated set's folder, or null for the stock ember icon.
+ */
+ version: string | null;
+};
+
+export const PwaDefaultInstallIcon: PwaInstallIcon = {
+ mode: "stone",
+ stone: "ember",
+ mark: true,
+ version: null,
+};
+
+/**
+ * The corner mark on an uploaded image: inside a launcher's circle crop
+ * (the centre 80%), top-left, clear of the browser's badge bottom-right.
+ * Fractions of the icon's side.
+ */
+export const PwaCornerMarkInset = 0.2;
+export const PwaCornerMarkScale = 0.22;
+
+/**
+ * Uploads must be at least this many pixels on their shorter side, and are
+ * kept as a square of the source size.
+ */
+export const PwaInstallIconMinSide = 512;
+export const PwaInstallIconSourceSide = 1024;
+
+export type PwaInstallIconFile = {
+ name: string;
+ size: number;
+ /**
+ * `plain` is the image without the mark (the settings preview);
+ * `favicon` never gets the mark (unreadable at 32px).
+ */
+ kind: "any" | "maskable" | "favicon" | "plain";
+};
+
+/**
+ * Every file a custom icon set holds. The `icon-*` and `maskable-*` names
+ * match `PwaIcons`, so the manifest only swaps the folder.
+ */
+export const PwaInstallIconFiles: PwaInstallIconFile[] = [
+ { name: "icon-192", size: 192, kind: "any" },
+ { name: "icon-512", size: 512, kind: "any" },
+ { name: "maskable-192", size: 192, kind: "maskable" },
+ { name: "maskable-512", size: 512, kind: "maskable" },
+ { name: "apple-touch-icon", size: 180, kind: "any" },
+ { name: "favicon", size: 32, kind: "favicon" },
+ { name: "plain-512", size: 512, kind: "plain" },
+];
diff --git a/src/dict/push-dict.ts b/src/dict/push-dict.ts
index d19415f..c516596 100644
--- a/src/dict/push-dict.ts
+++ b/src/dict/push-dict.ts
@@ -10,6 +10,11 @@ export type PushPayload = {
url: string;
tag: string;
requireInteraction?: boolean;
+ /**
+ * The install's own icon (`deliverToUser` fills it in); the worker
+ * falls back to the stock one.
+ */
+ icon?: string;
};
/**
diff --git a/src/functions/backend/db/app-settings/grab-install-icon.ts b/src/functions/backend/db/app-settings/grab-install-icon.ts
new file mode 100644
index 0000000..3fdd6ba
--- /dev/null
+++ b/src/functions/backend/db/app-settings/grab-install-icon.ts
@@ -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 }) });
+}
diff --git a/src/functions/backend/install-icon/grab-install-icon-dir.ts b/src/functions/backend/install-icon/grab-install-icon-dir.ts
new file mode 100644
index 0000000..2964028
--- /dev/null
+++ b/src/functions/backend/install-icon/grab-install-icon-dir.ts
@@ -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);
+}
diff --git a/src/functions/backend/install-icon/grab-pwa-background.ts b/src/functions/backend/install-icon/grab-pwa-background.ts
new file mode 100644
index 0000000..1e4b5f5
--- /dev/null
+++ b/src/functions/backend/install-icon/grab-pwa-background.ts
@@ -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";
+}
diff --git a/src/functions/backend/install-icon/normalize-install-icon-upload.ts b/src/functions/backend/install-icon/normalize-install-icon-upload.ts
new file mode 100644
index 0000000..6801304
--- /dev/null
+++ b/src/functions/backend/install-icon/normalize-install-icon-upload.ts
@@ -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();
+}
diff --git a/src/functions/backend/install-icon/save-install-icon.ts b/src/functions/backend/install-icon/save-install-icon.ts
new file mode 100644
index 0000000..8505302
--- /dev/null
+++ b/src/functions/backend/install-icon/save-install-icon.ts
@@ -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;
+}
diff --git a/src/functions/server/pwa/handle-install-icon-file.test.ts b/src/functions/server/pwa/handle-install-icon-file.test.ts
new file mode 100644
index 0000000..627c6e0
--- /dev/null
+++ b/src/functions/server/pwa/handle-install-icon-file.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/functions/server/pwa/handle-install-icon-file.ts b/src/functions/server/pwa/handle-install-icon-file.ts
new file mode 100644
index 0000000..dde115d
--- /dev/null
+++ b/src/functions/server/pwa/handle-install-icon-file.ts
@@ -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 {
+ 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;
+}
diff --git a/src/functions/server/pwa/handle-pwa-file.ts b/src/functions/server/pwa/handle-pwa-file.ts
index cf5dccf..806fc3f 100644
--- a/src/functions/server/pwa/handle-pwa-file.ts
+++ b/src/functions/server/pwa/handle-pwa-file.ts
@@ -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 }) {
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;
}
diff --git a/src/functions/server/pwa/render-app-icon.ts b/src/functions/server/pwa/render-app-icon.ts
new file mode 100644
index 0000000..6b3756b
--- /dev/null
+++ b/src/functions/server/pwa/render-app-icon.ts
@@ -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();
+}
diff --git a/src/functions/server/pwa/render-install-icons.ts b/src/functions/server/pwa/render-install-icons.ts
new file mode 100644
index 0000000..6c7ea8a
--- /dev/null
+++ b/src/functions/server/pwa/render-install-icons.ts
@@ -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/.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 = {};
+
+ 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();
+}
diff --git a/src/pages/__root.server.ts b/src/pages/__root.server.ts
index 3e3312d..d8da684 100644
--- a/src/pages/__root.server.ts
+++ b/src/pages/__root.server.ts
@@ -7,6 +7,7 @@ import { AdminHomePath } from "../dict/admin-sections-dict";
import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
import { SettingsDefaultPath } from "../dict/settings-pages-dict";
import grabInstallName from "../functions/backend/db/app-settings/grab-install-name";
+import grabInstallIcon from "../functions/backend/db/app-settings/grab-install-icon";
/**
* Routing for a local install. There are no public pages:
@@ -82,6 +83,7 @@ const server: BunextPageServerFn = async ({ req, url }) => {
user,
user_types,
install_name: await grabInstallName().catch(() => ""),
+ install_icon: await grabInstallIcon().catch(() => null),
},
};
};
diff --git a/src/pages/__root.tsx b/src/pages/__root.tsx
index be4d214..cf63e08 100755
--- a/src/pages/__root.tsx
+++ b/src/pages/__root.tsx
@@ -14,6 +14,7 @@ import type {
} from "@/src/types";
import { SiteData } from "@/src/data/site-data";
import installDisplayName from "@/src/utils/install-display-name";
+import installIconBase from "@/src/utils/install-icon-base";
import Layout from "../layouts/main";
import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots";
@@ -97,9 +98,12 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
/**
* iOS names a home-screen app from these tags, not the manifest.
*/
- const app_name = installDisplayName({
- install_name: (serverRes?.props as PagePropsType | undefined)?.install_name,
- });
+ const root_props = serverRes?.props as PagePropsType | undefined;
+ const app_name = installDisplayName({ install_name: root_props?.install_name });
+ /**
+ * The install's own icon set, when it has one (Settings > General).
+ */
+ const icon_base = installIconBase({ icon: root_props?.install_icon });
return (
<>
@@ -126,21 +130,23 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400..900&display=swap"
rel="stylesheet"
/>
-
+ {icon_base ? null : (
+
+ )}
>
);
diff --git a/src/pages/admin/settings/general/(data)/install-icon-modes.ts b/src/pages/admin/settings/general/(data)/install-icon-modes.ts
new file mode 100644
index 0000000..58f809c
--- /dev/null
+++ b/src/pages/admin/settings/general/(data)/install-icon-modes.ts
@@ -0,0 +1,25 @@
+import { Gem, ImageIcon } from "lucide-react";
+import type { PwaInstallIconMode } from "@/src/data/pwa-data";
+
+/**
+ * The two kinds of app icon an install can have (Settings > General).
+ */
+export const InstallIconModes: {
+ value: PwaInstallIconMode;
+ title: string;
+ description: string;
+ Icon: typeof Gem;
+}[] = [
+ {
+ value: "stone",
+ title: "Stone",
+ description: "One of the stones on paper.",
+ Icon: Gem,
+ },
+ {
+ value: "image",
+ title: "Image",
+ description: "Your own image, with a stone in the corner.",
+ Icon: ImageIcon,
+ },
+];
diff --git a/src/pages/admin/settings/general/(partials)/install-icon-form.tsx b/src/pages/admin/settings/general/(partials)/install-icon-form.tsx
new file mode 100644
index 0000000..e0ec8e3
--- /dev/null
+++ b/src/pages/admin/settings/general/(partials)/install-icon-form.tsx
@@ -0,0 +1,195 @@
+import { useState } from "react";
+import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
+import type { APIResponseObject } from "@moduletrace/bunext/types";
+import Checkbox from "@moduletrace/twui/form/Checkbox";
+import Form from "@moduletrace/twui/form/Form";
+import Row from "@moduletrace/twui/layout/Row";
+import Span from "@moduletrace/twui/layout/Span";
+import Stack from "@moduletrace/twui/layout/Stack";
+import ImagePickField from "@/src/components/general/image-pick-field";
+import {
+ PwaDefaultInstallIcon,
+ PwaInstallIconMinSide,
+ type PwaInstallIcon,
+} from "@/src/data/pwa-data";
+import { BotHues, type BotHue } from "@/src/dict/bot-dict";
+import type { SaveInstallIconRequest } from "@/src/schemas/install-schema";
+import installIconBase from "@/src/utils/install-icon-base";
+import SettingsSaveBar from "../../(partials)/settings-save-bar";
+import { InstallIconModes } from "../(data)/install-icon-modes";
+import InstallIconModeOption from "./install-icon-mode-option";
+import InstallIconPreview from "./install-icon-preview";
+import InstallIconShape from "./install-icon-shape";
+import InstallIconStoneOption from "./install-icon-stone-option";
+
+type Props = {
+ install_icon?: PwaInstallIcon | null;
+};
+
+/**
+ * The app icon: a stone, or an uploaded image with the picked stone in its
+ * top-left corner. Previewed as launchers crop it, saved on its own
+ * without a reload.
+ */
+export default function InstallIconForm({ install_icon }: Props) {
+ const [saved, setSaved] = useState(install_icon || PwaDefaultInstallIcon);
+ const [mode, setMode] = useState(saved.mode);
+ const [stone, setStone] = useState(saved.stone);
+ const [mark, setMark] = useState(saved.mark);
+ /**
+ * A newly picked image, sent with the next save.
+ */
+ const [data_url, setDataUrl] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [status, setStatus] = useState<{
+ msg?: string;
+ error?: boolean;
+ success?: boolean;
+ }>();
+
+ const saved_base = installIconBase({ icon: saved });
+ const image_src =
+ data_url || (saved.mode == "image" && saved_base ? `${saved_base}/plain-512.png` : null);
+ const dirty =
+ Boolean(data_url) || mode != saved.mode || stone != saved.stone || mark != saved.mark;
+
+ const change = () => setStatus(undefined);
+
+ const save = async () => {
+ if (mode == "image" && !image_src) {
+ setStatus({ error: true, msg: `Upload an image first.` });
+ return;
+ }
+
+ const question =
+ mode == "stone" && saved.mode == "image"
+ ? "Switch the app icon to a stone? The uploaded image is removed."
+ : "Save the app icon?";
+
+ if (!window.confirm(question)) return;
+
+ setLoading(true);
+ setStatus(undefined);
+
+ const res = await fetchApi>(
+ "/api/admin/settings/install-icon",
+ { method: "POST", body: { mode, stone, mark, data_url } },
+ ).catch(() => null);
+
+ setLoading(false);
+
+ if (!res?.success || !res.singleRes) {
+ setStatus({ error: true, msg: res?.msg || `Couldn't save the icon.` });
+ return;
+ }
+
+ setSaved(res.singleRes);
+ setMode(res.singleRes.mode);
+ setStone(res.singleRes.stone);
+ setMark(res.singleRes.mark);
+ setDataUrl(null);
+ setStatus({
+ success: true,
+ msg: `Saved. Reinstall the app on a device to see the new icon.`,
+ });
+ };
+
+ const discard = () => {
+ setMode(saved.mode);
+ setStone(saved.stone);
+ setMark(saved.mark);
+ setDataUrl(null);
+ setStatus(undefined);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/pages/admin/settings/general/(partials)/install-icon-mode-option.tsx b/src/pages/admin/settings/general/(partials)/install-icon-mode-option.tsx
new file mode 100644
index 0000000..d8af310
--- /dev/null
+++ b/src/pages/admin/settings/general/(partials)/install-icon-mode-option.tsx
@@ -0,0 +1,49 @@
+import Button from "@moduletrace/twui/layout/Button";
+import Span from "@moduletrace/twui/layout/Span";
+import Stack from "@moduletrace/twui/layout/Stack";
+import { Check } from "lucide-react";
+import { twMerge } from "tailwind-merge";
+import type { InstallIconModes } from "../(data)/install-icon-modes";
+
+type Props = (typeof InstallIconModes)[number] & {
+ selected: boolean;
+ onSelect: () => void;
+};
+
+/**
+ * One radio tile, like the theme picker's: a strong border plus a check
+ * when selected.
+ */
+export default function InstallIconModeOption({ title, description, Icon, selected, onSelect }: Props) {
+ return (
+
+ );
+}
diff --git a/src/pages/admin/settings/general/(partials)/install-icon-preview.tsx b/src/pages/admin/settings/general/(partials)/install-icon-preview.tsx
new file mode 100644
index 0000000..7292d54
--- /dev/null
+++ b/src/pages/admin/settings/general/(partials)/install-icon-preview.tsx
@@ -0,0 +1,28 @@
+import Row from "@moduletrace/twui/layout/Row";
+import Span from "@moduletrace/twui/layout/Span";
+import Stack from "@moduletrace/twui/layout/Stack";
+import type { ComponentProps } from "react";
+import InstallIconShape from "./install-icon-shape";
+
+type Props = Omit, "shape" | "className">;
+
+/**
+ * The icon in the two crops launchers use, before it's saved.
+ */
+export default function InstallIconPreview(props: Props) {
+ return (
+
+ Preview
+
+
+
+ Round
+
+
+
+ Rounded square
+
+
+
+ );
+}
diff --git a/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx b/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx
new file mode 100644
index 0000000..5718703
--- /dev/null
+++ b/src/pages/admin/settings/general/(partials)/install-icon-shape.tsx
@@ -0,0 +1,61 @@
+import Img from "@moduletrace/twui/layout/Img";
+import Span from "@moduletrace/twui/layout/Span";
+import { twMerge } from "tailwind-merge";
+import type { PwaInstallIconMode } from "@/src/data/pwa-data";
+
+type Props = {
+ /**
+ * `circle` is a round launcher, which keeps only the centre 80% of
+ * the maskable icon (so the content is drawn 125% and cropped);
+ * `rounded` is the plain icon with rounded corners.
+ */
+ shape: "circle" | "rounded";
+ mode: PwaInstallIconMode;
+ stone: string;
+ mark: boolean;
+ image_src?: string | null;
+ className?: string;
+};
+
+/**
+ * The app icon as a launcher would show it, laid out like the generated
+ * files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
+ * or the image with the stone 20% in from the top-left at 22% of the side.
+ */
+export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
+ const stone_src = `/brand/icons/${stone}.svg`;
+
+ return (
+
+
+ {mode == "image" && image_src ? (
+ <>
+
+ {mark ? (
+
+ ) : null}
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/admin/settings/general/(partials)/install-icon-stone-option.tsx b/src/pages/admin/settings/general/(partials)/install-icon-stone-option.tsx
new file mode 100644
index 0000000..226ff27
--- /dev/null
+++ b/src/pages/admin/settings/general/(partials)/install-icon-stone-option.tsx
@@ -0,0 +1,37 @@
+import Button from "@moduletrace/twui/layout/Button";
+import Img from "@moduletrace/twui/layout/Img";
+import { twMerge } from "tailwind-merge";
+import type { BotHues } from "@/src/dict/bot-dict";
+
+type Props = (typeof BotHues)[number] & {
+ selected: boolean;
+ onSelect: () => void;
+};
+
+/**
+ * One stone to pick, drawn from its brand icon. Selected is a strong
+ * border, never a colour change.
+ */
+export default function InstallIconStoneOption({ title, value, selected, onSelect }: Props) {
+ return (
+
+ );
+}
diff --git a/src/pages/admin/settings/general/(sections)/install-icon-section.tsx b/src/pages/admin/settings/general/(sections)/install-icon-section.tsx
new file mode 100644
index 0000000..63deeb4
--- /dev/null
+++ b/src/pages/admin/settings/general/(sections)/install-icon-section.tsx
@@ -0,0 +1,25 @@
+import { useContext } from "react";
+import { AppContext } from "@/src/pages/__root";
+import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
+import SettingsGroup from "../../(partials)/settings-group";
+import InstallIconForm from "../(partials)/install-icon-form";
+
+/**
+ * The icon this install goes by once installed as an app, beside its name.
+ * Super admin only.
+ */
+export default function InstallIconSection() {
+ const { pageProps } = useContext(AppContext);
+
+ if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/pages/admin/settings/general/index.tsx b/src/pages/admin/settings/general/index.tsx
index 9cf83e6..c69d2d1 100644
--- a/src/pages/admin/settings/general/index.tsx
+++ b/src/pages/admin/settings/general/index.tsx
@@ -1,4 +1,5 @@
import AppearanceSection from "./(sections)/appearance-section";
+import InstallIconSection from "./(sections)/install-icon-section";
import InstallNameSection from "./(sections)/install-name-section";
import NotificationsSection from "./(sections)/notifications-section";
import PromptSection from "./(sections)/prompt-section";
@@ -15,8 +16,9 @@ export default function AdminSettingsGeneralPage() {
title="General"
description="How the app looks and behaves."
>
-
+
+
diff --git a/src/pages/api/admin/settings/install-icon.ts b/src/pages/api/admin/settings/install-icon.ts
new file mode 100644
index 0000000..d3c595a
--- /dev/null
+++ b/src/pages/api/admin/settings/install-icon.ts
@@ -0,0 +1,57 @@
+import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
+import saveInstallIcon from "@/src/functions/backend/install-icon/save-install-icon";
+import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
+import { AppData } from "@/src/data/app-data";
+import type { PwaInstallIcon } from "@/src/data/pwa-data";
+import type { BotHue } from "@/src/dict/bot-dict";
+import { SaveInstallIconRequestSchema } from "@/src/schemas/install-schema";
+import AppError from "@/src/utils/app-error";
+import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
+import parseWithSchema from "@/src/utils/parse-with-schema";
+import safeErrorMessage from "@/src/utils/safe-error-message";
+import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
+
+/**
+ * Super admin only: the icon this install goes by as an installed app, a
+ * stone or an uploaded image with the stone in its corner.
+ */
+export const handler: BunextAPIRouteHandler> = async ({
+ req,
+ body,
+}) => {
+ try {
+ if (req.method != "POST") throw new AppError(`Method not allowed`);
+
+ const user = await grabSuperAdminUser({ req });
+
+ if (!user?.id) throw new AppError(`Only a super admin can change the app icon.`);
+
+ const { mode, stone, mark, data_url } = parseWithSchema({
+ schema: SaveInstallIconRequestSchema,
+ data: body,
+ });
+
+ const image = data_url ? (await bufferFromBase64(data_url)).buffer : null;
+
+ if (image && image.length > AppData.ProfileImageMaxBytes) {
+ throw new AppError(
+ `That image is too big. Images can be up to ${Math.round(AppData.ProfileImageMaxBytes / 1024 / 1024)} MB.`,
+ );
+ }
+
+ const icon = await saveInstallIcon({ mode, stone: stone as BotHue, mark, image });
+
+ await logEvent({
+ title: "App icon changed",
+ category: "system",
+ user_id: user.id,
+ plain_text: `User #${user.id} set the app icon to ${mode == "image" ? `an image${mark ? ` with the ${stone} mark` : ""}` : `the ${stone} stone`}.`,
+ metadata: { mode, stone, mark, version: icon.version, new_image: Boolean(image) },
+ ip_address: grabRequestIP(req),
+ });
+
+ return { success: true, singleRes: icon };
+ } catch (error: unknown) {
+ return { success: false, msg: safeErrorMessage({ error }) };
+ }
+};
diff --git a/src/pages/api/admin/settings/install-name.ts b/src/pages/api/admin/settings/install-name.ts
index 0852503..c777873 100644
--- a/src/pages/api/admin/settings/install-name.ts
+++ b/src/pages/api/admin/settings/install-name.ts
@@ -3,7 +3,7 @@ import grabInstallName from "@/src/functions/backend/db/app-settings/grab-instal
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { PwaInstallNameSettingKey } from "@/src/data/pwa-data";
-import { SaveInstallNameRequestSchema } from "@/src/schemas/push-schema";
+import { SaveInstallNameRequestSchema } from "@/src/schemas/install-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
diff --git a/src/push/deliver-to-user.ts b/src/push/deliver-to-user.ts
index a74d9e3..c89ff5d 100644
--- a/src/push/deliver-to-user.ts
+++ b/src/push/deliver-to-user.ts
@@ -1,10 +1,13 @@
import type { PushPayload } from "@/src/dict/push-dict";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
+import grabInstallIcon from "@/src/functions/backend/db/app-settings/grab-install-icon";
+import installIconBase from "@/src/utils/install-icon-base";
import sendWebPush from "./send-web-push";
/**
- * Sends to every device the user subscribed. Best effort: one failed
- * device does not stop the others.
+ * Sends to every device the user subscribed, with the install's own icon
+ * when it has one. Best effort: one failed device does not stop the
+ * others.
*/
export default async function deliverToUser({
user_id,
@@ -15,9 +18,14 @@ export default async function deliverToUser({
}) {
const subscriptions = await grabPushSubscriptions({ user_id });
+ if (!subscriptions.length) return;
+
+ const icon_base = installIconBase({ icon: await grabInstallIcon().catch(() => null) });
+ const sent = icon_base ? { ...payload, icon: `${icon_base}/icon-192.png` } : payload;
+
await Promise.all(
subscriptions.map((subscription) =>
- sendWebPush({ subscription, payload }),
+ sendWebPush({ subscription, payload: sent }),
),
);
}
diff --git a/src/schemas/install-schema.ts b/src/schemas/install-schema.ts
new file mode 100644
index 0000000..146aef2
--- /dev/null
+++ b/src/schemas/install-schema.ts
@@ -0,0 +1,20 @@
+import { z } from "zod";
+import { BotHues } from "@/src/dict/bot-dict";
+import { PwaInstallIconModes, PwaInstallNameMaxLength } from "@/src/data/pwa-data";
+
+export const SaveInstallNameRequestSchema = z.object({
+ install_name: z.string().trim().max(PwaInstallNameMaxLength),
+});
+
+export const SaveInstallIconRequestSchema = z.object({
+ mode: z.enum(PwaInstallIconModes),
+ stone: z.enum(BotHues.map((hue) => hue.value) as [string, ...string[]], "Pick a stone"),
+ mark: z.boolean(),
+ /**
+ * A newly picked image as a base64 data URL; omitted to keep the
+ * stored one.
+ */
+ data_url: z.string().startsWith("data:image/", "Send the image as a data URL").nullish(),
+});
+
+export type SaveInstallIconRequest = z.infer;
diff --git a/src/schemas/push-schema.ts b/src/schemas/push-schema.ts
index 0b7f0a4..60a17a1 100644
--- a/src/schemas/push-schema.ts
+++ b/src/schemas/push-schema.ts
@@ -1,5 +1,4 @@
import { z } from "zod";
-import { PwaInstallNameMaxLength } from "@/src/data/pwa-data";
const PushEndpointField = z
.string()
@@ -35,7 +34,3 @@ export const PushEndpointRequestSchema = z.object({
export const PushSubscriptionIdRequestSchema = z.object({
id: z.coerce.number().int().positive("Pick a device"),
});
-
-export const SaveInstallNameRequestSchema = z.object({
- install_name: z.string().trim().max(PwaInstallNameMaxLength),
-});
diff --git a/src/scripts/generate-brand-assets.ts b/src/scripts/generate-brand-assets.ts
index e355a27..49cc703 100644
--- a/src/scripts/generate-brand-assets.ts
+++ b/src/scripts/generate-brand-assets.ts
@@ -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): Buffer {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
@@ -182,36 +174,6 @@ async function grabThemeColors(): Promise {
};
}
-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,
}),
);
diff --git a/src/server.ts b/src/server.ts
index 14531cf..80b2314 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -11,6 +11,7 @@ import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
+import handleInstallIconFile from "./functions/server/pwa/handle-install-icon-file";
import handleBuildInfo from "./functions/server/pwa/handle-build-info";
import handlePrecacheGate from "./functions/server/pwa/handle-precache-gate";
import grabPrecacheManifest from "./functions/server/pwa/grab-precache-manifest";
@@ -85,6 +86,10 @@ const server = Bun.serve({
if (pwa_file) return pwa_file;
+ const install_icon = await handleInstallIconFile({ pathname: url.pathname });
+
+ if (install_icon) return install_icon;
+
const build_info = handleBuildInfo({ pathname: url.pathname });
if (build_info) return build_info;
diff --git a/src/types/index.ts b/src/types/index.ts
index 7cf4b9d..ae9eb52 100755
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -36,6 +36,7 @@ import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict";
+import type { PwaInstallIcon } from "../data/pwa-data";
import type {
CustomToolHttpMethod,
CustomToolKind,
@@ -1443,6 +1444,11 @@ export type PagePropsType = {
* General, and the root's head tags). Empty when never named.
*/
install_name?: string | null;
+ /**
+ * The icon the install goes by as an installed app (Settings >
+ * General, and the root's icon links).
+ */
+ install_icon?: PwaInstallIcon | null;
/**
* The release the server is running, for the General settings page.
*/
diff --git a/src/utils/icon-pwa-manifest.test.ts b/src/utils/icon-pwa-manifest.test.ts
new file mode 100644
index 0000000..f925413
--- /dev/null
+++ b/src/utils/icon-pwa-manifest.test.ts
@@ -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" }] },
+ ]);
+ });
+});
diff --git a/src/utils/icon-pwa-manifest.ts b/src/utils/icon-pwa-manifest.ts
new file mode 100644
index 0000000..0d359ef
--- /dev/null
+++ b/src/utils/icon-pwa-manifest.ts
@@ -0,0 +1,38 @@
+type ManifestIcon = { src?: unknown };
+
+type Params = {
+ manifest: Record;
+ /**
+ * 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) => ({
+ ...shortcut,
+ icons: move(shortcut.icons),
+ }))
+ : manifest.shortcuts,
+ };
+}
diff --git a/src/utils/install-icon-base.ts b/src/utils/install-icon-base.ts
new file mode 100644
index 0000000..a0d63ca
--- /dev/null
+++ b/src/utils/install-icon-base.ts
@@ -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;
+}
diff --git a/src/utils/parse-install-icon.test.ts b/src/utils/parse-install-icon.test.ts
new file mode 100644
index 0000000..cfe884f
--- /dev/null
+++ b/src/utils/parse-install-icon.test.ts
@@ -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);
+ });
+});
diff --git a/src/utils/parse-install-icon.ts b/src/utils/parse-install-icon.ts
new file mode 100644
index 0000000..52123bf
--- /dev/null
+++ b/src/utils/parse-install-icon.ts
@@ -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 = {};
+
+ 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,
+ };
+}