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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user