diff --git a/assets/installer.ico b/assets/installer.ico new file mode 100644 index 0000000..a6e0876 Binary files /dev/null and b/assets/installer.ico differ diff --git a/assets/installer.iconset/icon_128x128.png b/assets/installer.iconset/icon_128x128.png new file mode 100644 index 0000000..572a905 Binary files /dev/null and b/assets/installer.iconset/icon_128x128.png differ diff --git a/assets/installer.iconset/icon_128x128@2x.png b/assets/installer.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..18d0eb4 Binary files /dev/null and b/assets/installer.iconset/icon_128x128@2x.png differ diff --git a/assets/installer.iconset/icon_16x16.png b/assets/installer.iconset/icon_16x16.png new file mode 100644 index 0000000..d604551 Binary files /dev/null and b/assets/installer.iconset/icon_16x16.png differ diff --git a/assets/installer.iconset/icon_16x16@2x.png b/assets/installer.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..8df3646 Binary files /dev/null and b/assets/installer.iconset/icon_16x16@2x.png differ diff --git a/assets/installer.iconset/icon_256x256.png b/assets/installer.iconset/icon_256x256.png new file mode 100644 index 0000000..18d0eb4 Binary files /dev/null and b/assets/installer.iconset/icon_256x256.png differ diff --git a/assets/installer.iconset/icon_256x256@2x.png b/assets/installer.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..4f8f38e Binary files /dev/null and b/assets/installer.iconset/icon_256x256@2x.png differ diff --git a/assets/installer.iconset/icon_32x32.png b/assets/installer.iconset/icon_32x32.png new file mode 100644 index 0000000..8df3646 Binary files /dev/null and b/assets/installer.iconset/icon_32x32.png differ diff --git a/assets/installer.iconset/icon_32x32@2x.png b/assets/installer.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..b930f1d Binary files /dev/null and b/assets/installer.iconset/icon_32x32@2x.png differ diff --git a/assets/installer.iconset/icon_512x512.png b/assets/installer.iconset/icon_512x512.png new file mode 100644 index 0000000..4f8f38e Binary files /dev/null and b/assets/installer.iconset/icon_512x512.png differ diff --git a/assets/installer.iconset/icon_512x512@2x.png b/assets/installer.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..705fb91 Binary files /dev/null and b/assets/installer.iconset/icon_512x512@2x.png differ diff --git a/packaging/windows/msi-wrapped.wxs b/packaging/windows/msi-wrapped.wxs index 3688f8b..1e5a9d5 100644 --- a/packaging/windows/msi-wrapped.wxs +++ b/packaging/windows/msi-wrapped.wxs @@ -20,6 +20,9 @@ DowngradeErrorMessage="A newer version of [ProductName] is already installed." /> + + + diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 index 7ed0e6f..4c6c364 100644 --- a/scripts/build-windows-msi.ps1 +++ b/scripts/build-windows-msi.ps1 @@ -89,12 +89,21 @@ if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } +$installerIconPath = Join-Path $rootDir "assets\installer.ico" +if (-not (Test-Path $installerIconPath)) { + throw "Installer icon not found at $installerIconPath. Run scripts/prepare-installer-icons.mjs first." +} + +$installerIconWorkPath = Join-Path $workDir "installer.ico" +Copy-Item -LiteralPath $installerIconPath -Destination $installerIconWorkPath -Force + $msiVersion = Get-MsiVersion $Version $wxsContent = Get-Content -LiteralPath $templatePath -Raw $wxsContent = $wxsContent.Replace("__PRODUCT_ID__", $ProductId) $wxsContent = $wxsContent.Replace("__UPGRADE_CODE__", $UpgradeCode) $wxsContent = $wxsContent.Replace("__VERSION__", $msiVersion) $wxsContent = $wxsContent.Replace("__SETUP_EXE__", (Escape-WixSourcePath $setupExePath)) +$wxsContent = $wxsContent.Replace("__INSTALLER_ICON__", (Escape-WixSourcePath $installerIconWorkPath)) $projectWxs = Join-Path $workDir "project.wxs" Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8 diff --git a/scripts/build-windows-nsis.ps1 b/scripts/build-windows-nsis.ps1 index e9aa3e2..fdcf0ec 100644 --- a/scripts/build-windows-nsis.ps1 +++ b/scripts/build-windows-nsis.ps1 @@ -81,10 +81,10 @@ if (-not (Test-Path $requiredDeeplinkScript)) { Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi") Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh") -$iconPath = Join-Path $rootDir "assets\icon.ico" +$iconPath = Join-Path $rootDir "assets\installer.ico" if (Test-Path $iconPath) { Copy-Item -LiteralPath $iconPath -Destination (Join-Path $workDir "icon.ico") -Force - Write-Host "Using custom installer icon from $iconPath" + Write-Host "Using installer icon from $iconPath" } $makensis = Find-Makensis diff --git a/scripts/finalize-desktop-artifacts.mjs b/scripts/finalize-desktop-artifacts.mjs index 23c23c6..205b26e 100644 --- a/scripts/finalize-desktop-artifacts.mjs +++ b/scripts/finalize-desktop-artifacts.mjs @@ -189,6 +189,7 @@ function cleanStagingArtifacts(keepNames) { } 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, @@ -198,25 +199,85 @@ 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 scripts/prepare-installer-icons.mjs 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') - 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}` - ) - } - } + buildInstallerIcns(voliconPath) if (!existsSync(MAC_DMG_BACKGROUND_PATH)) { throw new Error(`DMG background not found at ${MAC_DMG_BACKGROUND_PATH}`) @@ -229,7 +290,7 @@ function ensureMacDmgAssets() { } return { - volicon: existsSync(voliconPath) ? voliconPath : null, + volicon: voliconPath, background: MAC_DMG_BACKGROUND_PATH } } @@ -381,6 +442,7 @@ async function buildMacDmg(appBundlePath, arch) { appBundleName, dmgAssets ) + applyMacInstallerFileIcon(builtDmgPath, dmgAssets.volicon) } finally { if (!useDirectSource) { rmSync(stagingDir, { recursive: true, force: true }) @@ -425,6 +487,12 @@ function buildMacPkg(appBundlePath, arch) { 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 } diff --git a/scripts/pre-build.mjs b/scripts/pre-build.mjs index 307eba0..2dea5a7 100644 --- a/scripts/pre-build.mjs +++ b/scripts/pre-build.mjs @@ -74,6 +74,16 @@ if (prepareIcons.status !== 0) { process.exit(prepareIcons.status ?? 1); } +const prepareInstallerIcons = spawnSync( + "bun", + [path.join(rootDir, "scripts/prepare-installer-icons.mjs")], + { cwd: rootDir, stdio: "inherit", env: process.env }, +); + +if (prepareInstallerIcons.status !== 0) { + process.exit(prepareInstallerIcons.status ?? 1); +} + const windowsIconPath = path.join(rootDir, "assets/icon.ico"); if (!existsSync(windowsIconPath)) { console.error( diff --git a/scripts/prepare-installer-icons.mjs b/scripts/prepare-installer-icons.mjs new file mode 100644 index 0000000..0dc2adb --- /dev/null +++ b/scripts/prepare-installer-icons.mjs @@ -0,0 +1,86 @@ +import { existsSync, mkdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Jimp } from 'jimp' +import pngToIco from 'png-to-ico' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const INSTALLER_SOURCE = path.join( + rootDir, + 'assets/logos/farmcontrolinstaller.png' +) + +const MAC_ICONSET_ENTRIES = [ + { name: 'icon_16x16.png', size: 16 }, + { name: 'icon_16x16@2x.png', size: 32 }, + { name: 'icon_32x32.png', size: 32 }, + { name: 'icon_32x32@2x.png', size: 64 }, + { name: 'icon_128x128.png', size: 128 }, + { name: 'icon_128x128@2x.png', size: 256 }, + { name: 'icon_256x256.png', size: 256 }, + { name: 'icon_256x256@2x.png', size: 512 }, + { name: 'icon_512x512.png', size: 512 }, + { name: 'icon_512x512@2x.png', size: 1024 } +] + +const WIN_ICO_SIZES = [16, 32, 48, 256] + +async function resizePng(sourceImage, size) { + return sourceImage + .clone() + .resize({ w: size, h: size }) + .getBuffer('image/png') +} + +async function writePng(filePath, sourceImage, size) { + const png = await resizePng(sourceImage, size) + await Bun.write(filePath, png) + return png +} + +async function main() { + if (!existsSync(INSTALLER_SOURCE)) { + console.error( + `prepare-installer-icons: source image not found: ${INSTALLER_SOURCE}` + ) + process.exit(1) + } + + const sourceImage = await Jimp.read(INSTALLER_SOURCE) + if (sourceImage.width < 256 || sourceImage.height < 256) { + console.warn( + `prepare-installer-icons: source image is ${sourceImage.width}x${sourceImage.height}; recommend at least 256x256` + ) + } + + const assetsDir = path.join(rootDir, 'assets') + mkdirSync(assetsDir, { recursive: true }) + + const iconsetDir = path.join(assetsDir, 'installer.iconset') + rmSync(iconsetDir, { recursive: true, force: true }) + mkdirSync(iconsetDir, { recursive: true }) + + for (const entry of MAC_ICONSET_ENTRIES) { + await writePng( + path.join(iconsetDir, entry.name), + sourceImage, + entry.size + ) + } + + const icoPngs = [] + for (const size of WIN_ICO_SIZES) { + icoPngs.push(await resizePng(sourceImage, size)) + } + + const icoPath = path.join(assetsDir, 'installer.ico') + await Bun.write(icoPath, await pngToIco(icoPngs)) + + console.log(`prepare-installer-icons: wrote ${iconsetDir}`) + console.log(`prepare-installer-icons: wrote ${icoPath}`) +} + +main().catch((error) => { + console.error(`prepare-installer-icons: ${error.message}`) + process.exit(1) +})