Enhance Windows app binary preparation and icon embedding
All checks were successful
farmcontrol/farmcontrol-server/pipeline/head This commit looks good

- 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.
This commit is contained in:
Tom Butcher 2026-08-01 23:52:59 +01:00
parent c711bd9863
commit d4b22e3734
5 changed files with 217 additions and 59 deletions

View File

@ -78,4 +78,9 @@ if (-not (Test-Path $outputExePath)) {
throw "NSIS installer was not created at $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" Write-Host "Created NSIS installer at $outputExePath"

View File

@ -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 <exe> [--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);
});
}

View File

@ -170,6 +170,36 @@ if (!cliSource.includes("hookBunBinary")) {
"console.error(\"Tried to run with bun at:\", hookBunBinary);", "console.error(\"Tried to run with bun at:\", hookBunBinary);",
); );
writeFileSync(cliPath, cliSource); 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"); const markerPath = path.join(electrobunDir, ".farmcontrol-electrobun-patched");

View File

@ -52,6 +52,14 @@ if (prepareIcons.status !== 0) {
process.exit(prepareIcons.status ?? 1); 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( const writeBuildInfo = spawnSync(
"bun", "bun",
[path.join(rootDir, "scripts/write-build-info.mjs")], [path.join(rootDir, "scripts/write-build-info.mjs")],

View File

@ -7,8 +7,11 @@ import {
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; 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)), ".."); 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 LAUNCHER_RUNTIME_REFERENCE = Buffer.from(`${LEGACY_RUNTIME_BINARY}\0`);
const PATCHED_RUNTIME_REFERENCE = Buffer.from(`${WINDOWS_RUNTIME_EXECUTABLE}\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) { function patchLauncherRuntimeReference(launcherPath) {
const binary = readFileSync(launcherPath); const binary = readFileSync(launcherPath);
const index = binary.indexOf(LAUNCHER_RUNTIME_REFERENCE); const index = binary.indexOf(LAUNCHER_RUNTIME_REFERENCE);
@ -122,22 +94,9 @@ function createBrandedAppExecutable(binDir) {
return appExecutablePath; 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, * Brand Windows app binaries and embed icons per Electrobun application-icons docs:
* farm.exe runtime (patched launcher reference), and embedded icons. * launcher executable, Bun runtime executable, then NSIS installer (separately).
*/ */
export async function prepareWindowsAppBinaries(appDir) { export async function prepareWindowsAppBinaries(appDir) {
if (process.platform !== "win32") { 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}`); 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 runtimePath = renameWindowsRuntime(binDir);
const appExecutablePath = createBrandedAppExecutable(binDir); const appExecutablePath = createBrandedAppExecutable(binDir);
for (const exePath of [appExecutablePath, runtimePath]) { await embedWindowsAppIcons([appExecutablePath, runtimePath], {
console.log(`prepare-windows-app-binaries: embedding icon into ${exePath}`); icon: DEFAULT_WINDOWS_ICON,
try { cleanupTemp: true,
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}`,
);
}
}
} }