import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { getReleaseArch, getReleaseArtifactName, getReleaseVersion, } from "./release-artifact-utils.mjs"; import { cleanExpandedWindowsApp, expandWindowsAppFromArchive, } from "./expand-windows-installer.mjs"; import { codesignMacAppBundle } from "./codesign-macos-app.mjs"; import { prepareWindowsAppBinaries } from "./prepare-windows-app-binaries.mjs"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const packageJson = JSON.parse( readFileSync(path.join(rootDir, "package.json"), "utf8"), ); const buildEnv = process.env.ELECTROBUN_BUILD_ENV || "stable"; const targetOs = process.env.ELECTROBUN_OS || (process.platform === "darwin" ? "macos" : process.platform === "win32" ? "win" : process.platform === "linux" ? "linux" : null); const buildArch = getReleaseArch( process.env.ELECTROBUN_ARCH || process.arch, ); const version = process.env.ELECTROBUN_APP_VERSION || getReleaseVersion(packageJson); const artifactDir = process.env.ELECTROBUN_ARTIFACT_DIR || path.join(rootDir, "app_dist"); const identifier = process.env.ELECTROBUN_APP_IDENTIFIER || "com.tombutcher.farmcontrolserver"; const artifactPrefix = `farmcontrol-server-${version}-`; function getBuildRoot() { const electrobunBuildDir = process.env.ELECTROBUN_BUILD_DIR; if (!electrobunBuildDir) { return path.join(rootDir, "build"); } const baseName = path.basename(electrobunBuildDir); if (baseName.startsWith("stable-")) { return path.dirname(electrobunBuildDir); } return electrobunBuildDir; } function walkFiles(dir) { const files = []; if (!existsSync(dir)) { return files; } for (const entry of readdirSync(dir)) { const fullPath = path.join(dir, entry); const stats = statSync(fullPath); if (stats.isDirectory()) { files.push(...walkFiles(fullPath)); } else { files.push(fullPath); } } return files; } function findByExtension(root, extension) { return walkFiles(root).find((filePath) => filePath.toLowerCase().endsWith(extension.toLowerCase()), ); } function findMacAppBundle(arch) { const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`); if (!existsSync(platformDir)) { return null; } for (const child of readdirSync(platformDir)) { if (child.endsWith(".app")) { return path.join(platformDir, child); } } return null; } function findMacDmgSource(arch) { const prefixedArtifact = walkFiles(artifactDir).find( (filePath) => filePath.includes(`stable-macos-${arch}`) && filePath.toLowerCase().endsWith(".dmg"), ); if (prefixedArtifact) { return prefixedArtifact; } const buildDmg = findByExtension( path.join(getBuildRoot(), `stable-macos-${arch}`), ".dmg", ); if (buildDmg) { return buildDmg; } return findByExtension(artifactDir, ".dmg"); } function findWindowsInstallerFiles() { const buildRoot = getBuildRoot(); if (!existsSync(buildRoot)) { return null; } for (const entry of readdirSync(buildRoot)) { if (!entry.startsWith("stable-win-")) { continue; } const platformDir = path.join(buildRoot, entry); const files = walkFiles(platformDir); const setupExe = files.find((filePath) => /-setup\.exe$/i.test(filePath)); const setupArchive = files.find((filePath) => /-setup\.tar\.zst$/i.test(filePath), ); const setupMetadata = files.find((filePath) => /-setup\.metadata\.json$/i.test(filePath), ); const setupZip = files.find((filePath) => /-setup\.zip$/i.test(filePath)); if (setupExe && setupArchive && setupMetadata) { return { setupExe, setupArchive, setupMetadata, setupZip }; } } return null; } function publishArtifact(sourcePath, arch, ext) { if (!sourcePath || !existsSync(sourcePath)) { throw new Error( `Missing source artifact for ${arch}.${ext}: ${sourcePath ?? "not found"}`, ); } mkdirSync(artifactDir, { recursive: true }); const destination = path.join( artifactDir, getReleaseArtifactName(version, arch, ext), ); cpSync(sourcePath, destination); console.log(`Published ${destination}`); return destination; } function cleanStagingArtifacts(keepNames) { if (!existsSync(artifactDir)) { return; } for (const entry of readdirSync(artifactDir)) { if (keepNames.includes(entry) || entry.startsWith(artifactPrefix)) { continue; } if ( entry.includes("stable-macos-") || entry.includes("stable-win-") || entry.endsWith("-update.json") || entry.endsWith(".tar.gz") ) { rmSync(path.join(artifactDir, entry), { recursive: true, force: true }); } } } const MAC_DMG_ASSETS_DIR = path.join(rootDir, "packaging/macos/dmg"); const MAC_DMG_BACKGROUND_PATH = path.join(rootDir, "assets/dmg/background.png"); const MAC_DMG_BACKGROUND_RETINA_PATH = path.join( rootDir, "assets/dmg/background@2x.png", ); const MAC_DMG_APP_NAME = "Farm Control Server.app"; const MAC_DMG_WINDOW_WIDTH = 380; const MAC_DMG_WINDOW_HEIGHT = 540; function ensureMacDmgAssets() { mkdirSync(MAC_DMG_ASSETS_DIR, { recursive: true }); const voliconPath = path.join(MAC_DMG_ASSETS_DIR, "volicon.icns"); const iconsetPath = path.join(rootDir, "assets/icon.iconset"); if (!existsSync(voliconPath) && existsSync(iconsetPath)) { const iconutil = spawnSync( "iconutil", ["-c", "icns", "-o", voliconPath, iconsetPath], { stdio: "inherit" }, ); if (iconutil.status !== 0) { throw new Error( `iconutil failed to create DMG volicon with exit code ${iconutil.status ?? 1}`, ); } } if (!existsSync(MAC_DMG_BACKGROUND_PATH)) { throw new Error(`DMG background not found at ${MAC_DMG_BACKGROUND_PATH}`); } if (!existsSync(MAC_DMG_BACKGROUND_RETINA_PATH)) { console.warn( `finalize-desktop-artifacts: retina DMG background not found at ${MAC_DMG_BACKGROUND_RETINA_PATH}`, ); } return { volicon: existsSync(voliconPath) ? voliconPath : null, background: MAC_DMG_BACKGROUND_PATH, }; } function findCreateDmgCommand() { const which = spawnSync("which", ["create-dmg"], { encoding: "utf8" }); if (which.status === 0 && which.stdout.trim()) { return which.stdout.trim(); } for (const candidate of [ "/opt/homebrew/bin/create-dmg", "/usr/local/bin/create-dmg", ]) { if (existsSync(candidate)) { return candidate; } } return null; } function buildMacDmgWithCreateDmg( createDmg, dmgPath, sourceFolder, appBundleName, dmgAssets, ) { rmSync(dmgPath, { force: true }); const args = [ "--volname", "Farm Control Server", "--window-pos", "200", "120", "--window-size", String(MAC_DMG_WINDOW_WIDTH), String(MAC_DMG_WINDOW_HEIGHT), "--icon-size", "120", "--icon", appBundleName, "98", "270", "--hide-extension", appBundleName, "--app-drop-link", "282", "270", "--format", "UDZO", ]; if (dmgAssets.volicon) { args.push("--volicon", dmgAssets.volicon); } if (dmgAssets.background) { args.push("--background", dmgAssets.background); } args.push(dmgPath, sourceFolder); const result = spawnSync(createDmg, args, { stdio: "inherit" }); if (result.status !== 0) { throw new Error(`create-dmg failed with exit code ${result.status ?? 1}`); } if (!existsSync(dmgPath)) { throw new Error(`create-dmg did not produce ${dmgPath}`); } return dmgPath; } function copyMacAppBundle(sourcePath, destinationPath) { rmSync(destinationPath, { recursive: true, force: true }); mkdirSync(path.dirname(destinationPath), { recursive: true }); const result = spawnSync("ditto", [sourcePath, destinationPath], { stdio: "inherit", }); if (result.status !== 0) { throw new Error(`ditto failed with exit code ${result.status ?? 1}`); } } function canUseDirectDmgSourceFolder(appBundlePath) { const platformDir = path.dirname(appBundlePath); const appName = path.basename(appBundlePath); const entries = readdirSync(platformDir).filter( (entry) => !entry.startsWith(".") && entry !== ".finalize-dmg-staging", ); return entries.length === 1 && entries[0] === appName; } async function buildMacDmg(appBundlePath, arch) { const createDmg = findCreateDmgCommand(); if (!createDmg) { throw new Error( "create-dmg not found. Install with: brew install create-dmg", ); } const appBundleName = path.basename(appBundlePath); if (appBundleName !== MAC_DMG_APP_NAME) { console.warn( `finalize-desktop-artifacts: expected ${MAC_DMG_APP_NAME}, found ${appBundleName}`, ); } const dmgPath = path.join( artifactDir, getReleaseArtifactName(version, arch, "dmg"), ); mkdirSync(artifactDir, { recursive: true }); const stagingDir = path.join( path.dirname(appBundlePath), ".finalize-dmg-staging", ); const useDirectSource = canUseDirectDmgSourceFolder(appBundlePath); const sourceFolder = useDirectSource ? path.dirname(appBundlePath) : stagingDir; if (!useDirectSource) { rmSync(stagingDir, { recursive: true, force: true }); mkdirSync(stagingDir, { recursive: true }); const stagedAppPath = path.join( stagingDir, path.basename(appBundlePath), ); copyMacAppBundle(appBundlePath, stagedAppPath); codesignMacAppBundle(stagedAppPath); } let builtDmgPath; const dmgAssets = ensureMacDmgAssets(); try { builtDmgPath = buildMacDmgWithCreateDmg( createDmg, dmgPath, sourceFolder, appBundleName, dmgAssets, ); } finally { if (!useDirectSource) { rmSync(stagingDir, { recursive: true, force: true }); } } console.log(`Published ${builtDmgPath}`); return builtDmgPath; } function cleanMacBuildDir(arch) { const platformDir = path.join(getBuildRoot(), `stable-macos-${arch}`); if (existsSync(platformDir)) { rmSync(platformDir, { recursive: true, force: true }); console.log(`Removed build output ${platformDir}`); } } function buildMacPkg(appBundlePath, arch) { const pkgPath = path.join( artifactDir, getReleaseArtifactName(version, arch, "pkg"), ); const result = spawnSync( "pkgbuild", [ "--component", appBundlePath, "--install-location", "/Applications", "--identifier", identifier, "--version", version, pkgPath, ], { stdio: "inherit" }, ); if (result.status !== 0) { throw new Error(`pkgbuild failed with exit code ${result.status ?? 1}`); } console.log(`Published ${pkgPath}`); return pkgPath; } function cleanWinBuildDir(arch) { const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`); if (existsSync(platformDir)) { rmSync(platformDir, { recursive: true, force: true }); console.log(`Removed build output ${platformDir}`); } } function buildWindowsNsis(appDir, arch) { const scriptPath = path.join(rootDir, "scripts/build-windows-nsis.ps1"); const exePath = path.join( artifactDir, getReleaseArtifactName(version, arch, "exe"), ); const powershell = process.env.SystemRoot ? path.join( process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe", ) : "powershell.exe"; const result = spawnSync( powershell, [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-AppDir", appDir, "-OutputExe", exePath, "-Version", version, ], { stdio: "inherit" }, ); if (result.status !== 0) { throw new Error( `build-windows-nsis.ps1 failed with exit code ${result.status ?? 1}`, ); } console.log(`Published ${exePath}`); return exePath; } function buildWindowsMsi(setupExePath, arch) { const scriptPath = path.join(rootDir, "scripts/build-windows-msi.ps1"); const msiPath = path.join( artifactDir, getReleaseArtifactName(version, arch, "msi"), ); const powershell = process.env.SystemRoot ? path.join( process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe", ) : "powershell.exe"; const result = spawnSync( powershell, [ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, "-SetupExe", setupExePath, "-OutputMsi", msiPath, "-Version", version, ], { stdio: "inherit" }, ); if (result.status !== 0) { throw new Error( `build-windows-msi.ps1 failed with exit code ${result.status ?? 1}`, ); } console.log(`Published ${msiPath}`); return msiPath; } if (buildEnv === "dev") { console.log("finalize-desktop-artifacts: skipping dev build"); process.exit(0); } async function main() { if (targetOs === "macos") { const appBundle = findMacAppBundle(buildArch); if (!appBundle) { console.log( `finalize-desktop-artifacts: no macOS ${buildArch} app bundle found, skipping`, ); process.exit(0); } codesignMacAppBundle(appBundle); const existingDmg = findMacDmgSource(buildArch); const dmgPath = existingDmg ? publishArtifact(existingDmg, buildArch, "dmg") : await buildMacDmg(appBundle, buildArch); const published = [dmgPath, buildMacPkg(appBundle, buildArch)]; cleanStagingArtifacts(published.map((filePath) => path.basename(filePath))); cleanMacBuildDir(buildArch); return; } if (targetOs === "win") { const arch = getReleaseArch(process.env.ELECTROBUN_ARCH || "x64"); const installerFiles = findWindowsInstallerFiles(); if (!installerFiles?.setupArchive) { throw new Error( "Could not find the Windows setup archive (.tar.zst) to expand", ); } const platformDir = path.dirname(installerFiles.setupArchive); const appDir = expandWindowsAppFromArchive( installerFiles.setupArchive, platformDir, ); await prepareWindowsAppBinaries(appDir); let published; try { const setupExe = buildWindowsNsis(appDir, arch); published = [setupExe, buildWindowsMsi(setupExe, arch)]; if (installerFiles.setupZip) { published.push(publishArtifact(installerFiles.setupZip, arch, "zip")); } } finally { cleanExpandedWindowsApp(platformDir); } cleanStagingArtifacts(published.map((filePath) => path.basename(filePath))); cleanWinBuildDir(arch); return; } console.log( "finalize-desktop-artifacts: no desktop packaging configured for", targetOs ?? "unknown target", ); process.exit(0); } await main();