Files
edgeever/scripts/prepare-desktop-icons.mjs
tianma-if 0767d72603 fix(desktop): make the menu-bar cat match other status icons
Ship a 22pt template glyph with a heavier stroke so the cat reads at
the same size and ink weight as neighboring macOS menu-bar extras.
2026-09-15 23:36:15 +08:00

169 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Build a complete macOS ICNS (and 1024 PNG master) for the desktop app.
*
* Why this exists:
* electron-builder converting a lone 512 PWA PNG yields a thin ICNS that often
* omits ic10 (1024). Dock then falls back to the generic empty placeholder —
* including after clean installs. We prebuild a full iconutil pack and commit it.
*
* Usage: bun run prepare:desktop:icons
*/
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { assertMacIcnsComplete } from "./desktop-icns.mjs";
const projectRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const sourcePng = join(projectRoot, "apps/web/public/pwa-512x512.png");
const brandMarkPath = join(projectRoot, "assets/brand/edgeever-mark.svg");
const assetsDir = join(projectRoot, "apps/desktop/assets");
/** iconutil expects these exact basenames inside a .iconset directory. */
const ICONSET_FILES = [
{ name: "icon_16x16.png", size: 16 },
{ name: "icon_16x16@2x.png", size: 32 },
{ name: "icon_32x32.png", size: 32 },
{ name: "icon_32x32@2x.png", size: 64 },
{ name: "icon_128x128.png", size: 128 },
{ name: "icon_128x128@2x.png", size: 256 },
{ name: "icon_256x256.png", size: 256 },
{ name: "icon_256x256@2x.png", size: 512 },
{ name: "icon_512x512.png", size: 512 },
{ name: "icon_512x512@2x.png", size: 1024 },
];
const run = (command, args, options = {}) => {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
...options,
});
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout || result.error}`,
);
}
return result;
};
const writeResizedPng = async (sourceBuffer, size, destination) => {
await sharp(sourceBuffer)
.resize(size, size, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
kernel: sharp.kernel.lanczos3,
})
.png({ compressionLevel: 9 })
.toFile(destination);
};
const TRAY_TEMPLATE_ALPHA_THRESHOLD = 80;
const TRAY_MARK_CENTER = { x: 627, y: 580 };
// 22pt matches other macOS menu-bar extras. A round stroke adds ink so the
// open cat face keeps up with filled marks like Notion and WeChat.
export const TRAY_MARK_SCALE = 1.1;
export const TRAY_MARK_STROKE_WIDTH = 36;
export const TRAY_TEMPLATE_SIZE = 22;
export const TRAY_TEMPLATE_SIZE_2X = 44;
export const buildMacTrayTemplateSvg = (markSvg) => {
const pathMatch = markSvg.match(/<path\b[^>]*\sd="([^"]+)"/);
if (!pathMatch) throw new Error("Brand mark is missing a path");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="EdgeEver">
<g transform="translate(512 512) scale(${TRAY_MARK_SCALE}) translate(-${TRAY_MARK_CENTER.x} -${TRAY_MARK_CENTER.y})">
<path fill="#000000" stroke="#000000" stroke-width="${TRAY_MARK_STROKE_WIDTH}" stroke-linejoin="round" paint-order="stroke fill" fill-rule="evenodd" d="${pathMatch[1]}" />
</g>
</svg>
`;
};
export const prepareTrayIcons = async ({
markPath = brandMarkPath,
assetsDirectory = assetsDir,
} = {}) => {
await mkdir(assetsDirectory, { recursive: true });
const markSvg = await readFile(markPath, "utf8");
const templateSvg = buildMacTrayTemplateSvg(markSvg);
await writeFile(join(assetsDirectory, "trayTemplate.svg"), templateSvg);
const sourceBuffer = Buffer.from(templateSvg);
const render = async (size, density, fileName) => {
const { data, info } = await sharp(sourceBuffer, { density: 384 })
.resize(size, size, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
kernel: sharp.kernel.lanczos3,
})
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
for (let offset = 0; offset < data.length; offset += 4) {
data[offset] = 0;
data[offset + 1] = 0;
data[offset + 2] = 0;
data[offset + 3] = data[offset + 3] >= TRAY_TEMPLATE_ALPHA_THRESHOLD ? 255 : 0;
}
await sharp(data, {
raw: { width: info.width, height: info.height, channels: 4 },
})
.withMetadata({ density })
.png({ compressionLevel: 9 })
.toFile(join(assetsDirectory, fileName));
};
await render(TRAY_TEMPLATE_SIZE, 72, "trayTemplate.png");
await render(TRAY_TEMPLATE_SIZE_2X, 144, "trayTemplate@2x.png");
console.log("[prepare-desktop-icons] wrote macOS tray template icons from the brand mark");
};
export const prepareDesktopIcons = async ({
sourcePath = sourcePng,
assetsDirectory = assetsDir,
requireIconutil = process.platform === "darwin",
} = {}) => {
await mkdir(assetsDirectory, { recursive: true });
await prepareTrayIcons({ assetsDirectory });
const sourceBuffer = await readFile(sourcePath);
// Master PNG used by electron-builder on Windows/Linux and as a Dock fallback.
await writeResizedPng(sourceBuffer, 1024, join(assetsDirectory, "icon.png"));
if (!requireIconutil) {
console.log("[prepare-desktop-icons] skipped iconutil on non-macOS; master PNG written.");
return { icnsPath: join(assetsDirectory, "icon.icns"), pngPath: join(assetsDirectory, "icon.png") };
}
const workRoot = await mkdtemp(join(tmpdir(), "edgeever-desktop-icon-"));
const iconsetDir = join(workRoot, "EdgeEver.iconset");
await mkdir(iconsetDir, { recursive: true });
try {
for (const { name, size } of ICONSET_FILES) {
await writeResizedPng(sourceBuffer, size, join(iconsetDir, name));
}
const icnsPath = join(assetsDirectory, "icon.icns");
run("iconutil", ["-c", "icns", iconsetDir, "-o", icnsPath]);
const icnsData = await readFile(icnsPath);
assertMacIcnsComplete(icnsData, icnsPath);
console.log(`[prepare-desktop-icons] wrote ${icnsPath} (${icnsData.length} bytes)`);
console.log(`[prepare-desktop-icons] wrote ${join(assetsDirectory, "icon.png")} (1024×1024 master)`);
return { icnsPath, pngPath: join(assetsDirectory, "icon.png") };
} finally {
await rm(workRoot, { recursive: true, force: true });
}
};
if (import.meta.main) {
try {
await prepareDesktopIcons();
} catch (error) {
console.error(`[prepare-desktop-icons] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}