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 { stageWindowsDeeplink } from './build-windows-deeplink.mjs' import { patchWindowsBinaries } from './patch-windows-binaries.mjs' import { codesignMacAppBundle } from './codesign-macos-app.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.farmcontrol' const artifactPrefix = `farmcontrol-${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_INSTALLER_ICONSET_PATH = path.join(rootDir, 'assets/installer.iconset') 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.app' const MAC_DMG_WINDOW_WIDTH = 540 const MAC_DMG_WINDOW_HEIGHT = 380 function findCommand(command) { const which = spawnSync('which', [command], { encoding: 'utf8' }) if (which.status === 0 && which.stdout.trim()) { return which.stdout.trim() } return null } function buildInstallerIcns(destinationPath) { if (!existsSync(MAC_INSTALLER_ICONSET_PATH)) { throw new Error( `Installer iconset not found at ${MAC_INSTALLER_ICONSET_PATH}. Run \`bun run generate-app-icons\` first.` ) } mkdirSync(path.dirname(destinationPath), { recursive: true }) rmSync(destinationPath, { force: true }) const iconutil = spawnSync( 'iconutil', ['-c', 'icns', '-o', destinationPath, MAC_INSTALLER_ICONSET_PATH], { stdio: 'inherit' } ) if (iconutil.status !== 0) { throw new Error( `iconutil failed to create installer icns with exit code ${iconutil.status ?? 1}` ) } return destinationPath } function applyMacInstallerFileIcon(targetPath, icnsPath) { if (!existsSync(icnsPath)) { console.warn( `finalize-desktop-artifacts: installer icns not found at ${icnsPath}, skipping custom icon` ) return false } const fileicon = findCommand('fileicon') if (fileicon) { const result = spawnSync(fileicon, ['set', targetPath, icnsPath], { stdio: 'inherit' }) if (result.status === 0) { return true } } const escapedTarget = targetPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"') const escapedIcon = icnsPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"') const result = spawnSync( 'osascript', [ '-e', `tell application "Finder" to set icon of (POSIX file "${escapedTarget}") to (read (POSIX file "${escapedIcon}") as picture)` ], { stdio: 'inherit' } ) if (result.status === 0) { return true } console.warn( 'finalize-desktop-artifacts: could not apply installer icon; install fileicon or run on macOS with Finder access' ) return false } function ensureMacDmgAssets() { mkdirSync(MAC_DMG_ASSETS_DIR, { recursive: true }) const voliconPath = path.join(MAC_DMG_ASSETS_DIR, 'volicon.icns') buildInstallerIcns(voliconPath) 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: voliconPath, 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', '--window-pos', '200', '120', '--window-size', String(MAC_DMG_WINDOW_WIDTH), String(MAC_DMG_WINDOW_HEIGHT), '--icon-size', '100', '--icon', appBundleName, '130', '212', '--hide-extension', appBundleName, '--app-drop-link', '410', '212', '--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 ) applyMacInstallerFileIcon(builtDmgPath, dmgAssets.volicon) } 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}`) } const installerIcnsPath = path.join(MAC_DMG_ASSETS_DIR, 'volicon.icns') if (!existsSync(installerIcnsPath)) { buildInstallerIcns(installerIcnsPath) } applyMacInstallerFileIcon(pkgPath, installerIcnsPath) console.log(`Published ${pkgPath}`) return pkgPath } function stageWindowsDeeplinkScript(appDir) { return stageWindowsDeeplink(appDir) } 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 buildInfoPath = path.join(rootDir, 'src/buildInfo.json') const buildInfo = existsSync(buildInfoPath) ? JSON.parse(readFileSync(buildInfoPath, 'utf8')) : {} const buildNumber = process.env.BUILD_NUMBER || process.env.VITE_BUILD_NUMBER || buildInfo.buildNumber || 'dev' 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, '-BuildNumber', buildNumber ], { stdio: 'inherit' } ) if (result.status !== 0) { throw new Error( `build-windows-nsis.ps1 failed with exit code ${result.status ?? 1}` ) } const minInstallerBytes = 10 * 1024 * 1024 const installerSize = statSync(exePath).size if (installerSize < minInstallerBytes) { throw new Error( `Windows installer ${path.basename(exePath)} is only ${(installerSize / 1024).toFixed(1)} KiB; expected at least ${minInstallerBytes / (1024 * 1024)} MiB` ) } console.log(`Published ${exePath}`) return exePath } 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 ) stageWindowsDeeplinkScript(appDir) await patchWindowsBinaries(appDir) let published try { const setupExe = buildWindowsNsis(appDir, arch) published = [setupExe] 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()