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
+2 -1
View File
@@ -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: `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). - **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). 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`. - 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 `<title>` is the `{{name}}` placeholder. Devices pick up a new name only on reinstall. - **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 `<title>` 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/<version>/`, 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. - 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`). - `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-<build_id>`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this. - **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-<build_id>`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this.
+80
View File
@@ -1,3 +1,4 @@
import type { BotHue } from "@/src/dict/bot-dict";
import { SiteData } from "./site-data"; import { SiteData } from "./site-data";
/** /**
@@ -110,3 +111,82 @@ export const PwaLoadingNamePlaceholder = "{{name}}";
*/ */
export const PwaInstallNameSettingKey = "install_name"; export const PwaInstallNameSettingKey = "install_name";
export const PwaInstallNameMaxLength = 30; 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/<PwaInstallIconDir>`
* and are served from `<PwaInstallIconRoute>/<version>/<file>.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" },
];
+5
View File
@@ -10,6 +10,11 @@ export type PushPayload = {
url: string; url: string;
tag: string; tag: string;
requireInteraction?: boolean; requireInteraction?: boolean;
/**
* The install's own icon (`deliverToUser` fills it in); the worker
* falls back to the stock one.
*/
icon?: string;
}; };
/** /**
@@ -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 { PwaManifestPath, PwaServiceWorkerPath } from "@/src/data/pwa-data";
import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name"; import grabInstallName from "@/src/functions/backend/db/app-settings/grab-install-name";
import namePwaManifest from "@/src/utils/name-pwa-manifest"; 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"); 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 > * The generated manifest under the install's own name and icon (Settings >
* General). Unnamed, or when the file isn't JSON, it goes out as it is. * General). Stock, or when the file isn't JSON, it goes out as it is.
*/ */
async function namedManifest({ file }: { file: ReturnType<typeof Bun.file> }) { async function namedManifest({ file }: { file: ReturnType<typeof Bun.file> }) {
const text = await file.text(); const text = await file.text();
const install_name = await grabInstallName().catch(() => ""); 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 { 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 { } catch {
return text; 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();
}
+2
View File
@@ -7,6 +7,7 @@ import { AdminHomePath } from "../dict/admin-sections-dict";
import isUnknownSettingsPath from "../utils/is-unknown-settings-path"; import isUnknownSettingsPath from "../utils/is-unknown-settings-path";
import { SettingsDefaultPath } from "../dict/settings-pages-dict"; import { SettingsDefaultPath } from "../dict/settings-pages-dict";
import grabInstallName from "../functions/backend/db/app-settings/grab-install-name"; 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: * Routing for a local install. There are no public pages:
@@ -82,6 +83,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
user, user,
user_types, user_types,
install_name: await grabInstallName().catch(() => ""), install_name: await grabInstallName().catch(() => ""),
install_icon: await grabInstallIcon().catch(() => null),
}, },
}; };
}; };
+11 -5
View File
@@ -14,6 +14,7 @@ import type {
} from "@/src/types"; } from "@/src/types";
import { SiteData } from "@/src/data/site-data"; import { SiteData } from "@/src/data/site-data";
import installDisplayName from "@/src/utils/install-display-name"; import installDisplayName from "@/src/utils/install-display-name";
import installIconBase from "@/src/utils/install-icon-base";
import Layout from "../layouts/main"; import Layout from "../layouts/main";
import AuthLayout from "../layouts/auth"; import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots"; 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. * iOS names a home-screen app from these tags, not the manifest.
*/ */
const app_name = installDisplayName({ const root_props = serverRes?.props as PagePropsType | undefined;
install_name: (serverRes?.props as PagePropsType | undefined)?.install_name, 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 ( return (
<> <>
@@ -126,21 +130,23 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
href="https://fonts.googleapis.com/css2?family=Archivo:[email protected]&display=swap" href="https://fonts.googleapis.com/css2?family=Archivo:[email protected]&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
{icon_base ? null : (
<link <link
rel="icon" rel="icon"
href="/favicon.ico" href="/favicon.ico"
sizes="16x16 24x24 32x32 48x48" sizes="16x16 24x24 32x32 48x48"
/> />
)}
<link <link
rel="icon" rel="icon"
type="image/png" type="image/png"
sizes="32x32" sizes="32x32"
href="/favicon.png" href={icon_base ? `${icon_base}/favicon.png` : "/favicon.png"}
/> />
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
sizes="180x180" sizes="180x180"
href="/apple-touch-icon.png" href={icon_base ? `${icon_base}/apple-touch-icon.png` : "/apple-touch-icon.png"}
/> />
</> </>
); );
@@ -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,
},
];
@@ -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<BotHue>(saved.stone);
const [mark, setMark] = useState(saved.mark);
/**
* A newly picked image, sent with the next save.
*/
const [data_url, setDataUrl] = useState<string | null>(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<SaveInstallIconRequest, APIResponseObject<PwaInstallIcon>>(
"/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 (
<Form
onSubmit={(event) => {
event.preventDefault();
save().catch(() => {});
}}
className="w-full gap-4! items-stretch"
>
<Row
role="radiogroup"
aria-label="Icon"
className="w-full grid grid-cols-1 sm:grid-cols-2 gap-3!"
>
{InstallIconModes.map((option) => (
<InstallIconModeOption
key={option.value}
{...option}
selected={mode == option.value}
onSelect={() => {
setMode(option.value);
change();
}}
/>
))}
</Row>
{mode == "image" ? (
<ImagePickField
preview={
<InstallIconShape
shape="rounded"
mode="image"
stone={stone}
mark={false}
image_src={image_src}
className="size-16"
/>
}
has_image={Boolean(image_src)}
onPick={({ data_url }) => {
setDataUrl(data_url);
change();
}}
onRemove={() => {
setDataUrl(null);
setMode("stone");
change();
}}
loading={loading}
note={`PNG, JPG or WebP, at least ${PwaInstallIconMinSide} pixels on each side. It's cropped to a square from the centre.`}
/>
) : null}
<Stack className="w-full gap-2! items-start">
<Span className="text-sm font-bold leading-5 text-ink">
{mode == "image" ? "Corner stone" : "Stone"}
</Span>
<Row role="radiogroup" aria-label="Stone" className="gap-2!">
{BotHues.map((hue) => (
<InstallIconStoneOption
key={hue.value}
{...hue}
selected={stone == hue.value}
onSelect={() => {
setStone(hue.value);
change();
}}
/>
))}
</Row>
</Stack>
{mode == "image" ? (
<Checkbox
title="Show the stone in the top-left corner"
label="Show the stone in the top-left corner"
checked={mark}
changeHandler={(checked: boolean) => {
setMark(checked);
change();
}}
/>
) : null}
<InstallIconPreview mode={mode} stone={stone} mark={mark} image_src={image_src} />
<SettingsSaveBar
dirty={dirty}
loading={loading}
status={status}
save_label="Save icon"
onDiscard={discard}
/>
</Form>
);
}
@@ -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 (
<Button
title={title}
type="button"
role="radio"
aria-checked={selected}
variant="ghost"
color="gray"
onClick={onSelect}
className={twMerge(
"relative w-full min-w-0 h-auto p-4! pr-10! justify-start! rounded-default text-left",
"bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink",
"border border-solid border-hairline transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "border-ink dark:border-ink bg-ink/5 dark:bg-ink/5",
)}
>
<Stack className="w-full min-w-0 gap-3! items-start">
<Icon size={20} aria-hidden />
<Stack className="w-full min-w-0 gap-0! items-start">
<Span className="text-sm font-bold leading-5 text-ink">{title}</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">{description}</Span>
</Stack>
</Stack>
{selected ? (
<Span className="absolute top-3 right-3 size-5 rounded-full bg-ink text-on-ink flex items-center justify-center">
<Check size={12} strokeWidth={3} aria-hidden />
</Span>
) : null}
</Button>
);
}
@@ -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<ComponentProps<typeof InstallIconShape>, "shape" | "className">;
/**
* The icon in the two crops launchers use, before it's saved.
*/
export default function InstallIconPreview(props: Props) {
return (
<Stack className="w-full gap-2! items-start">
<Span className="text-sm font-bold leading-5 text-ink">Preview</Span>
<Row className="gap-6! items-end">
<Stack className="gap-2! items-center">
<InstallIconShape shape="circle" {...props} />
<Span className="text-[13px] leading-[18px] text-ink-secondary">Round</Span>
</Stack>
<Stack className="gap-2! items-center">
<InstallIconShape shape="rounded" {...props} />
<Span className="text-[13px] leading-[18px] text-ink-secondary">Rounded square</Span>
</Stack>
</Row>
</Stack>
);
}
@@ -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 (
<Span
className={twMerge(
"relative block size-24 shrink-0 overflow-hidden bg-paper border border-solid border-hairline",
shape == "circle" ? "rounded-full" : "rounded-[22%]",
className,
)}
>
<Span className={twMerge("absolute inset-0 block", shape == "circle" && "scale-125")}>
{mode == "image" && image_src ? (
<>
<Img src={image_src} alt="" className="absolute inset-0 size-full object-cover" />
{mark ? (
<Img
src={stone_src}
alt=""
className="absolute left-[20%] top-[20%] size-[22%]"
/>
) : null}
</>
) : (
<Img
src={stone_src}
alt=""
className={twMerge(
"absolute inset-0 m-auto",
shape == "circle" ? "size-[52%]" : "size-[80%]",
)}
/>
)}
</Span>
</Span>
);
}
@@ -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 (
<Button
title={title}
type="button"
role="radio"
aria-checked={selected}
aria-label={title}
variant="ghost"
color="gray"
onClick={onSelect}
className={twMerge(
"size-12 p-2! rounded-default",
"bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken",
"border border-solid border-hairline transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "border-ink dark:border-ink bg-ink/5 dark:bg-ink/5",
)}
>
<Img src={`/brand/icons/${value}.svg`} alt="" className="size-full" />
</Button>
);
}
@@ -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 (
<SettingsGroup
title="App icon"
description="The icon this install shows on home screens, app lists and notifications. A device that already installed the app keeps the old icon until it's installed again."
id="install-icon"
>
<InstallIconForm install_icon={pageProps?.install_icon} />
</SettingsGroup>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import AppearanceSection from "./(sections)/appearance-section"; import AppearanceSection from "./(sections)/appearance-section";
import InstallIconSection from "./(sections)/install-icon-section";
import InstallNameSection from "./(sections)/install-name-section"; import InstallNameSection from "./(sections)/install-name-section";
import NotificationsSection from "./(sections)/notifications-section"; import NotificationsSection from "./(sections)/notifications-section";
import PromptSection from "./(sections)/prompt-section"; import PromptSection from "./(sections)/prompt-section";
@@ -15,8 +16,9 @@ export default function AdminSettingsGeneralPage() {
title="General" title="General"
description="How the app looks and behaves." description="How the app looks and behaves."
> >
<AppearanceSection />
<InstallNameSection /> <InstallNameSection />
<InstallIconSection />
<AppearanceSection />
<NotificationsSection /> <NotificationsSection />
<PromptSection /> <PromptSection />
<RunLimitsSection /> <RunLimitsSection />
@@ -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<APIResponseObject<PwaInstallIcon>> = 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 }) };
}
};
+1 -1
View File
@@ -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 saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger"; import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { PwaInstallNameSettingKey } from "@/src/data/pwa-data"; 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 AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
+11 -3
View File
@@ -1,10 +1,13 @@
import type { PushPayload } from "@/src/dict/push-dict"; import type { PushPayload } from "@/src/dict/push-dict";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions"; 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"; import sendWebPush from "./send-web-push";
/** /**
* Sends to every device the user subscribed. Best effort: one failed * Sends to every device the user subscribed, with the install's own icon
* device does not stop the others. * when it has one. Best effort: one failed device does not stop the
* others.
*/ */
export default async function deliverToUser({ export default async function deliverToUser({
user_id, user_id,
@@ -15,9 +18,14 @@ export default async function deliverToUser({
}) { }) {
const subscriptions = await grabPushSubscriptions({ user_id }); 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( await Promise.all(
subscriptions.map((subscription) => subscriptions.map((subscription) =>
sendWebPush({ subscription, payload }), sendWebPush({ subscription, payload: sent }),
), ),
); );
} }
+20
View File
@@ -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<typeof SaveInstallIconRequestSchema>;
-5
View File
@@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { PwaInstallNameMaxLength } from "@/src/data/pwa-data";
const PushEndpointField = z const PushEndpointField = z
.string() .string()
@@ -35,7 +34,3 @@ export const PushEndpointRequestSchema = z.object({
export const PushSubscriptionIdRequestSchema = z.object({ export const PushSubscriptionIdRequestSchema = z.object({
id: z.coerce.number().int().positive("Pick a device"), id: z.coerce.number().int().positive("Pick a device"),
}); });
export const SaveInstallNameRequestSchema = z.object({
install_name: z.string().trim().max(PwaInstallNameMaxLength),
});
+5 -43
View File
@@ -20,7 +20,10 @@ import {
PwaShortName, PwaShortName,
PwaShortcuts, PwaShortcuts,
PwaStartPath, PwaStartPath,
PwaAppIconMarkScale,
PwaMaskableMarkScale,
} from "../data/pwa-data"; } from "../data/pwa-data";
import renderAppIcon from "../functions/server/pwa/render-app-icon";
const ROOT = path.resolve(import.meta.dir, "../.."); const ROOT = path.resolve(import.meta.dir, "../..");
const LOGO_SVG = path.join(ROOT, "public/logos/logo.svg"); 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 ICO_SIZES = [16, 24, 32, 48] as const;
const PNG_SIZES = [...ICO_SIZES, 180, 512] 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 { function buildIco(pngs: Record<number, Buffer>): Buffer {
const header = Buffer.alloc(6); const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); 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 }) { function buildManifest({ colors }: { colors: ThemeColors }) {
return { return {
id: "/", id: "/",
@@ -637,7 +599,7 @@ async function main() {
await renderAppIcon({ await renderAppIcon({
svg, svg,
size: 180, size: 180,
scale: AppIconMarkScale, scale: PwaAppIconMarkScale,
background: colors.paper, background: colors.paper,
}), }),
); );
@@ -655,7 +617,7 @@ async function main() {
await renderAppIcon({ await renderAppIcon({
svg, svg,
size, size,
scale: maskable ? MaskableMarkScale : AppIconMarkScale, scale: maskable ? PwaMaskableMarkScale : PwaAppIconMarkScale,
background: colors.paper, background: colors.paper,
}), }),
); );
+5
View File
@@ -11,6 +11,7 @@ import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys"; import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy"; import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file"; 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 handleBuildInfo from "./functions/server/pwa/handle-build-info";
import handlePrecacheGate from "./functions/server/pwa/handle-precache-gate"; import handlePrecacheGate from "./functions/server/pwa/handle-precache-gate";
import grabPrecacheManifest from "./functions/server/pwa/grab-precache-manifest"; import grabPrecacheManifest from "./functions/server/pwa/grab-precache-manifest";
@@ -85,6 +86,10 @@ const server = Bun.serve({
if (pwa_file) return pwa_file; 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 }); const build_info = handleBuildInfo({ pathname: url.pathname });
if (build_info) return build_info; if (build_info) return build_info;
+6
View File
@@ -36,6 +36,7 @@ import type { BotFace, BotMessageReactionEmoji } from "../dict/bot-dict";
import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types"; import type { HarnessCallSettings, TokenUsageSummary } from "../harness/usage/types";
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict"; import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict"; import type { ExtensionProposalKind, ExtensionProposalStatus } from "../dict/extension-proposal-dict";
import type { PwaInstallIcon } from "../data/pwa-data";
import type { import type {
CustomToolHttpMethod, CustomToolHttpMethod,
CustomToolKind, CustomToolKind,
@@ -1443,6 +1444,11 @@ export type PagePropsType = {
* General, and the root's head tags). Empty when never named. * General, and the root's head tags). Empty when never named.
*/ */
install_name?: string | null; 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. * The release the server is running, for the General settings page.
*/ */
+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,
};
}