// 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 = ` Theseus NAVIGATOR `; 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);