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)
+})