theseus/nsis/make-icons.mjs
Local Dev 5ea4515085 Ship Theseus 0.3.2 09331b2f (tab context menu + branded installer)
Setup    09331b2fd9ccf136e2183b7cd85354cfd56e2ed50260b7aadeed63c7ea450251
Portable 21752d0fc85fb39ec1e65192920461e9ae395a22d9a68abd27f12e638d0fdd07

Right-click a tab: floating context menu with Reload, Duplicate, Group
(submenu: None / Red / Orange / Yellow / Green / Cyan / Blue / Purple),
Add to Bookmarks, Mute (also Unmute; 🔇 shows next to the title when
muted), Close. Menus close on outside click or Escape.

Group state is per-tab. A grouped tab shows a colored dot before the
title and a matching 2-px accent stripe on the top edge, so a cluster
of same-group tabs reads visually. Palette is drawn from existing
provenance colors (err/warn/acid/srv/sia/blue).

Backend IPCs are all tab-scoped (not "active tab"): tab-reload,
tab-duplicate, tab-mute (toggle or explicit boolean), tab-group,
tab-bookmark. emitTabs payload gains muted, group, and url so the
menu can read current state.

Installer wizard branding: 164×314 sidebar BMP with the compass mark
centered + "Theseus / NAVIGATOR" wordmark under it, plus a 150×57
top-strip header with a mini compass on the right. Sharp can't write
BMP directly (only png/webp/etc), so nsis/make-icons.mjs renders raw
RGB via sharp and wraps it in a hand-rolled 24-bit uncompressed BMP
header. Uninstaller reuses the same sidebar.

Silent-install fix: nsis/installer.nsh's AriadnePageCreate now checks
IfSilent BEFORE touching nsDialogs::Create. In /S mode the flag is
zeroed and the function returns cleanly, so the installer no longer
hangs waiting for a page it will never draw. This is why 0.3.2 needed
two builds — the first hung on /S install; the fixed hash is the one
that ships.

Deployed: scp + sia-upload of both trees. Verified VPS hash matches
local 09331b2f. Fresh /S install to D:\Program Files\Theseus Navigator\
placed 0.3.2 with the correct HKCU Uninstall registry entry.
2026-08-31 19:30:30 +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);