From d4b22e37344cf62e7c8e2cb234b9286b40ae5496 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 1 Aug 2026 23:52:59 +0100 Subject: [PATCH] Enhance Windows app binary preparation and icon embedding - Added a new script to embed icons into Windows executables, improving visual consistency across application binaries. - Updated the NSIS installer script to conditionally embed an icon if provided, enhancing flexibility in icon management. - Refactored the Windows app binary preparation process to utilize the new icon embedding functionality, ensuring icons are correctly applied to the launcher and runtime executables. --- scripts/build-windows-nsis.ps1 | 5 + scripts/embed-windows-exe-icon.mjs | 159 +++++++++++++++++++++++ scripts/patch-electrobun-src.mjs | 30 +++++ scripts/pre-build.mjs | 8 ++ scripts/prepare-windows-app-binaries.mjs | 74 +++-------- 5 files changed, 217 insertions(+), 59 deletions(-) create mode 100644 scripts/embed-windows-exe-icon.mjs diff --git a/scripts/build-windows-nsis.ps1 b/scripts/build-windows-nsis.ps1 index b6e7a3a..524ae89 100644 --- a/scripts/build-windows-nsis.ps1 +++ b/scripts/build-windows-nsis.ps1 @@ -78,4 +78,9 @@ if (-not (Test-Path $outputExePath)) { throw "NSIS installer was not created at $outputExePath" } +if (Test-Path $iconPath) { + $embedIconScript = Join-Path $rootDir "scripts/embed-windows-exe-icon.mjs" + & bun $embedIconScript $outputExePath --icon $iconPath +} + Write-Host "Created NSIS installer at $outputExePath" diff --git a/scripts/embed-windows-exe-icon.mjs b/scripts/embed-windows-exe-icon.mjs new file mode 100644 index 0000000..7e5e752 --- /dev/null +++ b/scripts/embed-windows-exe-icon.mjs @@ -0,0 +1,159 @@ +import { createRequire } from "node:module"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pngToIco from "png-to-ico"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(path.join(rootDir, "package.json")); + +/** Default icon path from electrobun.config.ts build.win.icon */ +export const DEFAULT_WINDOWS_ICON = "assets/icon.ico"; + +function resolveRceditExe() { + const projectRcedit = path.join(rootDir, "node_modules/rcedit/package.json"); + const rceditPkgPath = existsSync(projectRcedit) + ? projectRcedit + : require.resolve("rcedit/package.json"); + const rceditDir = path.dirname(rceditPkgPath); + const rceditX64 = path.join(rceditDir, "bin", "rcedit-x64.exe"); + if (existsSync(rceditX64)) { + return rceditX64; + } + + const rceditExe = path.join(rceditDir, "bin", "rcedit.exe"); + if (!existsSync(rceditExe)) { + throw new Error(`embed-windows-exe-icon: rcedit not found under ${rceditDir}`); + } + + return rceditExe; +} + +/** + * Resolve the Windows app icon path per Electrobun docs: + * https://framework.blackboard.sh/electrobun/apis/application-icons/ + */ +export function resolveWindowsIconSource(iconConfigPath = DEFAULT_WINDOWS_ICON) { + const candidates = []; + + if (iconConfigPath) { + const configured = + iconConfigPath.startsWith("/") || /^[a-zA-Z]:/.test(iconConfigPath) + ? iconConfigPath + : path.join(rootDir, iconConfigPath); + candidates.push(configured); + } + + candidates.push( + path.join(rootDir, "assets/icon.ico"), + path.join(rootDir, "assets/icon.iconset/icon_256x256.png"), + path.join(rootDir, "assets/icon.png"), + ); + + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + throw new Error( + "embed-windows-exe-icon: Windows icon not found. Set build.win.icon to assets/icon.ico or run scripts/prepare-app-icons.mjs", + ); + } + + return found; +} + +async function materializeIco(iconSourcePath, workDir) { + if (iconSourcePath.toLowerCase().endsWith(".ico")) { + return { icoPath: iconSourcePath, temp: false }; + } + + if (!iconSourcePath.toLowerCase().endsWith(".png")) { + throw new Error( + `embed-windows-exe-icon: unsupported icon format: ${iconSourcePath}`, + ); + } + + mkdirSync(workDir, { recursive: true }); + const icoPath = path.join(workDir, "app-icon.ico"); + const icoBuffer = await pngToIco(iconSourcePath); + writeFileSync(icoPath, new Uint8Array(icoBuffer)); + console.log(`embed-windows-exe-icon: converted PNG to ICO (${icoPath})`); + return { icoPath, temp: true }; +} + +/** + * Embed an icon into a Windows executable using rcedit, matching Electrobun's + * build.win.icon behavior (launcher, runtime, and installer executables). + */ +export async function embedWindowsExeIcon(exePath, options = {}) { + if (process.platform !== "win32") { + console.log(`embed-windows-exe-icon: skipped ${path.basename(exePath)} (not Windows)`); + return; + } + + if (!existsSync(exePath)) { + throw new Error(`embed-windows-exe-icon: executable not found: ${exePath}`); + } + + const iconSourcePath = resolveWindowsIconSource(options.icon); + const workDir = + options.workDir || path.join(rootDir, "build", ".windows-icon-work"); + const { icoPath, temp } = await materializeIco(iconSourcePath, workDir); + const rceditExe = resolveRceditExe(); + const resolvedExe = path.resolve(exePath); + const resolvedIcon = path.resolve(icoPath); + + console.log( + `embed-windows-exe-icon: embedding icon into ${path.basename(resolvedExe)}`, + ); + + try { + execFileSync(rceditExe, [resolvedExe, "--set-icon", resolvedIcon], { + stdio: "inherit", + windowsHide: true, + }); + } finally { + if (temp && options.cleanupTemp) { + unlinkSync(icoPath); + } + } +} + +/** + * Embed icons into the launcher and Bun runtime executables (Electrobun docs). + */ +export async function embedWindowsAppIcons(exePaths, options = {}) { + for (const exePath of exePaths) { + await embedWindowsExeIcon(exePath, options); + } +} + +async function main() { + const args = process.argv.slice(2); + const exePath = args.find((arg) => !arg.startsWith("-")); + if (!exePath) { + console.error("embed-windows-exe-icon: usage: bun scripts/embed-windows-exe-icon.mjs [--icon path]"); + process.exit(1); + } + + const iconArgIndex = args.indexOf("--icon"); + const icon = + iconArgIndex >= 0 && args[iconArgIndex + 1] + ? args[iconArgIndex + 1] + : DEFAULT_WINDOWS_ICON; + + await embedWindowsExeIcon(exePath, { icon, cleanupTemp: true }); +} + +const invokedPath = path.resolve(process.argv[1] ?? ""); +const modulePath = path.resolve(fileURLToPath(import.meta.url)); +if (invokedPath === modulePath) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/patch-electrobun-src.mjs b/scripts/patch-electrobun-src.mjs index 2d41d08..671c9c6 100644 --- a/scripts/patch-electrobun-src.mjs +++ b/scripts/patch-electrobun-src.mjs @@ -170,6 +170,36 @@ if (!cliSource.includes("hookBunBinary")) { "console.error(\"Tried to run with bun at:\", hookBunBinary);", ); writeFileSync(cliPath, cliSource); + cliSource = readFileSync(cliPath, "utf8"); +} + +if (!cliSource.includes("resolveProjectRceditPkgPath")) { + cliSource = cliSource.replaceAll( + "const rceditPkgPath = require.resolve(\"rcedit/package.json\");", + "const rceditPkgPath = resolveProjectRceditPkgPath(projectRoot);", + ); + cliSource = cliSource.replace( + "function getPlatformPaths(", + `function resolveProjectRceditPkgPath(projectRoot) { + const projectRcedit = join(projectRoot, "node_modules/rcedit/package.json"); + if (existsSync(projectRcedit)) { + return projectRcedit; + } + + const nestedRcedit = join( + projectRoot, + "node_modules/electrobun/node_modules/rcedit/package.json", + ); + if (existsSync(nestedRcedit)) { + return nestedRcedit; + } + + return require.resolve("rcedit/package.json"); +} + +function getPlatformPaths(`, + ); + writeFileSync(cliPath, cliSource); } const markerPath = path.join(electrobunDir, ".farmcontrol-electrobun-patched"); diff --git a/scripts/pre-build.mjs b/scripts/pre-build.mjs index a42879e..3b8b54f 100644 --- a/scripts/pre-build.mjs +++ b/scripts/pre-build.mjs @@ -52,6 +52,14 @@ if (prepareIcons.status !== 0) { process.exit(prepareIcons.status ?? 1); } +const windowsIconPath = path.join(rootDir, "assets/icon.ico"); +if (!existsSync(windowsIconPath)) { + console.error( + "pre-build: assets/icon.ico not found after prepare-app-icons (required for build.win.icon)", + ); + process.exit(1); +} + const writeBuildInfo = spawnSync( "bun", [path.join(rootDir, "scripts/write-build-info.mjs")], diff --git a/scripts/prepare-windows-app-binaries.mjs b/scripts/prepare-windows-app-binaries.mjs index 43a58bd..9adfe63 100644 --- a/scripts/prepare-windows-app-binaries.mjs +++ b/scripts/prepare-windows-app-binaries.mjs @@ -7,8 +7,11 @@ import { writeFileSync, } from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + DEFAULT_WINDOWS_ICON, + embedWindowsAppIcons, +} from "./embed-windows-exe-icon.mjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -26,37 +29,6 @@ const LEGACY_RUNTIME_BINARY = "bun.exe"; const LAUNCHER_RUNTIME_REFERENCE = Buffer.from(`${LEGACY_RUNTIME_BINARY}\0`); const PATCHED_RUNTIME_REFERENCE = Buffer.from(`${WINDOWS_RUNTIME_EXECUTABLE}\0`); -function ensureIconIco() { - const iconPath = path.join(rootDir, "assets/icon.ico"); - if (existsSync(iconPath)) { - return iconPath; - } - - const sources = [ - path.join(rootDir, "assets/farmcontrolhosticon.png"), - path.join(rootDir, "assets/icon.png"), - ]; - const source = sources.find((candidate) => existsSync(candidate)); - if (!source) { - throw new Error( - "prepare-windows-app-binaries: assets/icon.ico not found and no source PNG to generate it", - ); - } - - console.log(`prepare-windows-app-binaries: generating icon.ico from ${source}`); - const result = spawnSync( - "bun", - [path.join(rootDir, "scripts/prepare-app-icons.mjs"), source], - { cwd: rootDir, stdio: "inherit", env: process.env }, - ); - - if (result.status !== 0 || !existsSync(iconPath)) { - throw new Error("prepare-windows-app-binaries: failed to generate assets/icon.ico"); - } - - return iconPath; -} - function patchLauncherRuntimeReference(launcherPath) { const binary = readFileSync(launcherPath); const index = binary.indexOf(LAUNCHER_RUNTIME_REFERENCE); @@ -122,22 +94,9 @@ function createBrandedAppExecutable(binDir) { return appExecutablePath; } -async function applyWindowsExecutableMetadata(exePath, iconPath) { - const rcedit = (await import("rcedit")).default; - await rcedit(exePath, { - icon: iconPath, - "version-string": { - CompanyName: "Tom Butcher", - FileDescription: "Farm Control Server", - ProductName: "Farm Control Server", - OriginalFilename: path.basename(exePath), - }, - }); -} - /** - * Brand Windows app binaries for install: farmcontrol-server.exe entry point, - * farm.exe runtime (patched launcher reference), and embedded icons. + * Brand Windows app binaries and embed icons per Electrobun application-icons docs: + * launcher executable, Bun runtime executable, then NSIS installer (separately). */ export async function prepareWindowsAppBinaries(appDir) { if (process.platform !== "win32") { @@ -150,19 +109,16 @@ export async function prepareWindowsAppBinaries(appDir) { throw new Error(`prepare-windows-app-binaries: bin directory not found: ${binDir}`); } - const iconPath = ensureIconIco(); + const launcherPath = path.join(binDir, LAUNCHER_BINARY); + if (existsSync(launcherPath)) { + await embedWindowsAppIcons([launcherPath], { icon: DEFAULT_WINDOWS_ICON }); + } + const runtimePath = renameWindowsRuntime(binDir); const appExecutablePath = createBrandedAppExecutable(binDir); - for (const exePath of [appExecutablePath, runtimePath]) { - console.log(`prepare-windows-app-binaries: embedding icon into ${exePath}`); - try { - await applyWindowsExecutableMetadata(exePath, iconPath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `prepare-windows-app-binaries: failed to update ${path.basename(exePath)}: ${message}`, - ); - } - } + await embedWindowsAppIcons([appExecutablePath, runtimePath], { + icon: DEFAULT_WINDOWS_ICON, + cleanupTemp: true, + }); }