theseus/nsis/make-icons.mjs
2026-09-12 00:07:42 +02:00

142 lines
6.2 KiB
JavaScript
Raw 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.

// One-shot script: render site-theseus-x/assets/favicon.svg to the icon
// files electron-builder wants — a 512x512 PNG (main icon) and a multi-
// resolution .ico (Windows taskbar / installer / shortcut icon).
//
// Not in the runtime app bundle; run manually when the brand changes:
// node nsis/make-icons.mjs
// Outputs build/icon.png and build/icon.ico (both gitignored regenerated).
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import pngToIco from "png-to-ico";
// Minimal 24-bit uncompressed BMP writer. Sharp can produce RGB raw pixels
// but not BMP output; NSIS's MUI wizard branding needs BMP.
function writeBmp24(outPath, width, height, rgb) {
const rowBytes = width * 3;
const rowPad = (4 - (rowBytes % 4)) % 4;
const stride = rowBytes + rowPad;
const pixelBytes = stride * height;
const fileSize = 14 + 40 + pixelBytes;
const buf = Buffer.alloc(fileSize);
// BITMAPFILEHEADER
buf.write("BM", 0);
buf.writeUInt32LE(fileSize, 2);
buf.writeUInt32LE(0, 6);
buf.writeUInt32LE(54, 10);
// BITMAPINFOHEADER
buf.writeUInt32LE(40, 14);
buf.writeInt32LE(width, 18);
buf.writeInt32LE(height, 22);
buf.writeUInt16LE(1, 26);
buf.writeUInt16LE(24, 28);
buf.writeUInt32LE(0, 30);
buf.writeUInt32LE(pixelBytes, 34);
buf.writeInt32LE(2835, 38); // 72 DPI
buf.writeInt32LE(2835, 42);
buf.writeUInt32LE(0, 46);
buf.writeUInt32LE(0, 50);
// Pixel rows bottom-up, BGR order.
for (let y = 0; y < height; y++) {
const srcRow = (height - 1 - y) * width * 3;
const dstRow = 54 + y * stride;
for (let x = 0; x < width; x++) {
buf[dstRow + x * 3] = rgb[srcRow + x * 3 + 2]; // B
buf[dstRow + x * 3 + 1] = rgb[srcRow + x * 3 + 1]; // G
buf[dstRow + x * 3 + 2] = rgb[srcRow + x * 3]; // R
}
// trailing padding bytes are already 0-filled by alloc()
}
fs.writeFileSync(outPath, buf);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// SVG source lives in the theseus.x standalone site so the browser icon
// and the site brand stay in lockstep.
const SVG = path.join(__dirname, "..", "..", "site-theseus-x", "assets", "favicon.svg");
// Icons drop into build/, which is gitignored (regenerated artifacts).
// electron-builder picks them up from there via package.json.
const OUT_DIR = path.join(__dirname, "..", "build");
const INSTALLER_SIDEBAR = path.join(OUT_DIR, "installerSidebar.bmp");
const UNINSTALLER_SIDEBAR = path.join(OUT_DIR, "uninstallerSidebar.bmp");
const INSTALLER_HEADER = path.join(OUT_DIR, "installerHeader.bmp");
const PNG_512 = path.join(OUT_DIR, "icon.png");
const ICO = path.join(OUT_DIR, "icon.ico");
const TMP_DIR = path.join(OUT_DIR, ".icon-tmp");
const ICO_SIZES = [16, 24, 32, 48, 64, 128, 256];
if (!fs.existsSync(SVG)) throw new Error("SVG not found at " + SVG);
console.log("source:", SVG);
// Main 512x512 PNG for macOS / linux / electron-builder auto-derived Windows.
await sharp(SVG).resize(512, 512).png().toFile(PNG_512);
console.log("wrote", PNG_512);
// Write individual sized PNGs to a temp dir, then feed their paths to
// png-to-ico so it preserves each as a distinct image inside the .ico.
fs.mkdirSync(TMP_DIR, { recursive: true });
const paths = [];
for (const size of ICO_SIZES) {
const p = path.join(TMP_DIR, `icon-${size}.png`);
await sharp(SVG).resize(size, size).png().toFile(p);
paths.push(p);
}
const ico = await pngToIco(paths);
fs.writeFileSync(ICO, ico);
console.log("wrote", ICO, `(${ICO_SIZES.length} sizes)`);
// Clean up the temp PNGs.
for (const p of paths) fs.unlinkSync(p);
fs.rmdirSync(TMP_DIR);
// ---- NSIS wizard branding ----------------------------------------------
// MUI2's Welcome/Finish sidebar is 164×314 BMP (no alpha). Compose the
// compass mark centered on the Silent Mode dark background so the wizard
// stops shouting "default Electron installer" at the user.
async function makeSidebar(outFile, W, H, markSize) {
const bg = { r: 11, g: 14, b: 20, alpha: 1 };
const canvas = sharp({
create: { width: W, height: H, channels: 3, background: bg },
});
const markX = Math.floor((W - markSize) / 2);
const markY = Math.floor((H - markSize) / 2) - Math.floor(H * 0.08); // slight nudge up
const markPng = await sharp(SVG).resize(markSize, markSize).png().toBuffer();
// "Theseus Navigator" wordmark rendered as SVG text so we don't need a font file.
const wordmarkSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W - 20}" height="60">
<text x="50%" y="24" text-anchor="middle" fill="#e7eaf1"
font-family="Segoe UI, Arial, sans-serif" font-weight="600" font-size="18">Theseus</text>
<text x="50%" y="48" text-anchor="middle" fill="#d6ff3d"
font-family="Segoe UI, Arial, sans-serif" font-weight="600" font-size="14" letter-spacing="1">NAVIGATOR</text>
</svg>`;
const wordmarkPng = await sharp(Buffer.from(wordmarkSvg)).png().toBuffer();
await canvas
.composite([
{ input: markPng, top: markY, left: markX },
{ input: wordmarkPng, top: markY + markSize + 14, left: 10 },
])
.toColorspace("srgb")
.raw({ depth: "uchar" })
.toBuffer({ resolveWithObject: true })
.then(({ data, info }) => writeBmp24(outFile, info.width, info.height, data));
console.log("wrote", outFile);
}
// NSIS MUI2 sidebar: 164x314. Uninstaller reuses the same asset.
await makeSidebar(INSTALLER_SIDEBAR, 164, 314, 96);
fs.copyFileSync(INSTALLER_SIDEBAR, UNINSTALLER_SIDEBAR);
console.log("wrote", UNINSTALLER_SIDEBAR);
// NSIS header (top strip, 150x57) — small compass on dark, right-aligned so
// it doesn't compete with the page title on the left.
async function makeHeader(outFile) {
const W = 150, H = 57;
const bg = { r: 11, g: 14, b: 20, alpha: 1 };
const mark = await sharp(SVG).resize(40, 40).png().toBuffer();
await sharp({ create: { width: W, height: H, channels: 3, background: bg } })
.composite([{ input: mark, top: 8, left: W - 48 }])
.toColorspace("srgb")
.raw({ depth: "uchar" })
.toBuffer({ resolveWithObject: true })
.then(({ data, info }) => writeBmp24(outFile, info.width, info.height, data));
console.log("wrote", outFile);
}
await makeHeader(INSTALLER_HEADER);