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