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/<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. - `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. 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/<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" }, +]; 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<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; +} 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<typeof Bun.file> }) { 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/<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(); +} 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<PagePropsType> = 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" /> - <link - rel="icon" - href="/favicon.ico" - sizes="16x16 24x24 32x32 48x48" - /> + {icon_base ? null : ( + <link + rel="icon" + href="/favicon.ico" + sizes="16x16 24x24 32x32 48x48" + /> + )} <link rel="icon" type="image/png" sizes="32x32" - href="/favicon.png" + href={icon_base ? `${icon_base}/favicon.png` : "/favicon.png"} /> <link rel="apple-touch-icon" sizes="180x180" - href="/apple-touch-icon.png" + href={icon_base ? `${icon_base}/apple-touch-icon.png` : "/apple-touch-icon.png"} /> </> ); 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<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> + ); +} 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 ( + <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> + ); +} 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<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> + ); +} 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 ( + <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> + ); +} 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 ( + <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> + ); +} 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 ( + <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> + ); +} 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." > - <AppearanceSection /> <InstallNameSection /> + <InstallIconSection /> + <AppearanceSection /> <NotificationsSection /> <PromptSection /> <RunLimitsSection /> 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<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 }) }; + } +}; 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<typeof SaveInstallIconRequestSchema>; 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<number, Buffer>): Buffer { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); @@ -182,36 +174,6 @@ async function grabThemeColors(): Promise<ThemeColors> { }; } -async function renderAppIcon({ - svg, - size, - scale, - background, -}: { - svg: Buffer; - size: number; - scale: number; - background: string; -}) { - const mark_size = Math.max(1, Math.round(size * scale)); - const mark = await sharp(svg) - .resize(mark_size, mark_size, { fit: "contain" }) - .png() - .toBuffer(); - - return sharp({ - create: { - width: size, - height: size, - channels: 4, - background, - }, - }) - .composite([{ input: mark, gravity: "center" }]) - .png() - .toBuffer(); -} - function buildManifest({ colors }: { colors: ThemeColors }) { return { id: "/", @@ -637,7 +599,7 @@ async function main() { await renderAppIcon({ svg, size: 180, - scale: AppIconMarkScale, + scale: PwaAppIconMarkScale, background: colors.paper, }), ); @@ -655,7 +617,7 @@ async function main() { await renderAppIcon({ svg, size, - scale: maskable ? MaskableMarkScale : AppIconMarkScale, + scale: maskable ? PwaMaskableMarkScale : PwaAppIconMarkScale, background: colors.paper, }), ); 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<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, + }; +} 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<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, + }; +}