From 0aa26eab661b0ab534dd9cb8939f47e96b97fa95 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 1 Aug 2026 22:21:49 +0100 Subject: [PATCH] Enhance Windows installer with MSI support and shortcut creation - Updated farmcontrol-server.nsi to include desktop and Start Menu shortcut creation. - Added new macros in installer.nsh for managing shortcuts during installation and uninstallation. - Introduced build-windows-msi.ps1 script for generating MSI installers, integrating with existing build processes. - Modified finalize-desktop-artifacts.mjs to call the new MSI build script, ensuring proper artifact generation. --- packaging/windows/farmcontrol-server.nsi | 23 +++ packaging/windows/installer.nsh | 20 +++ packaging/windows/msi-wrapped.wxs | 35 +++++ scripts/build-windows-msi.ps1 | 190 +++++++++++++++++++++++ scripts/finalize-desktop-artifacts.mjs | 48 +++++- 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 packaging/windows/msi-wrapped.wxs create mode 100644 scripts/build-windows-msi.ps1 diff --git a/packaging/windows/farmcontrol-server.nsi b/packaging/windows/farmcontrol-server.nsi index 6aa5ce1..f69e4d5 100644 --- a/packaging/windows/farmcontrol-server.nsi +++ b/packaging/windows/farmcontrol-server.nsi @@ -1,4 +1,5 @@ !include "MUI2.nsh" +!include "LogicLib.nsh" !include "installer.nsh" !ifndef OUTFILE @@ -24,12 +25,20 @@ RequestExecutionLevel admin !define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" !insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_COMPONENTS !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_UNPAGE_CONFIRM !insertmacro MUI_UNPAGE_INSTFILES !insertmacro MUI_LANGUAGE "English" +Function .onInit + ${If} ${Silent} + SetAutoClose true + ${EndIf} +FunctionEnd + Section "Farm Control Server" SecMain + SectionIn RO SetOutPath $INSTDIR File /r "${APP_SOURCE_DIR}\*" @@ -52,6 +61,20 @@ Section "Farm Control Server" SecMain WriteUninstaller "$INSTDIR\Uninstall.exe" SectionEnd +Section "Desktop shortcut" SecDesktop + !insertmacro createDesktopShortcut +SectionEnd + +Section "Start Menu shortcut" SecStartMenu + !insertmacro createStartMenuShortcut +SectionEnd + +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SecMain} "Install Farm Control Server." + !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} "Create a shortcut on the Desktop." + !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} "Create a shortcut in the Start Menu." +!insertmacro MUI_FUNCTION_DESCRIPTION_END + Section "Uninstall" !insertmacro customUnInstall diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index dbe3458..9200c40 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -1,3 +1,21 @@ +!macro createDesktopShortcut + CreateShortCut "$DESKTOP\Farm Control Server.lnk" "$INSTDIR\bin\launcher.exe" +!macroend + +!macro createStartMenuShortcut + CreateDirectory "$SMPROGRAMS\Farm Control Server" + CreateShortCut "$SMPROGRAMS\Farm Control Server\Farm Control Server.lnk" "$INSTDIR\bin\launcher.exe" +!macroend + +!macro removeDesktopShortcut + Delete "$DESKTOP\Farm Control Server.lnk" +!macroend + +!macro removeStartMenuShortcut + Delete "$SMPROGRAMS\Farm Control Server\Farm Control Server.lnk" + RMDir "$SMPROGRAMS\Farm Control Server" +!macroend + !macro customInstall DetailPrint "Register farmcontrolserver URI Handler" DeleteRegKey HKCR "farmcontrolserver" @@ -10,5 +28,7 @@ !macroend !macro customUnInstall + !insertmacro removeDesktopShortcut + !insertmacro removeStartMenuShortcut DeleteRegKey HKCR "farmcontrolserver" !macroend diff --git a/packaging/windows/msi-wrapped.wxs b/packaging/windows/msi-wrapped.wxs new file mode 100644 index 0000000..eb6b6df --- /dev/null +++ b/packaging/windows/msi-wrapped.wxs @@ -0,0 +1,35 @@ + + + + + + = 601]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 new file mode 100644 index 0000000..838f4fb --- /dev/null +++ b/scripts/build-windows-msi.ps1 @@ -0,0 +1,190 @@ +param( + [Parameter(Mandatory = $true)] + [string]$SetupExe, + + [Parameter(Mandatory = $true)] + [string]$OutputMsi, + + [Parameter(Mandatory = $true)] + [string]$Version, + + [string]$UpgradeCode = "194198A0-0A65-52A3-9E49-CCDE26A5BF65", + + [string]$ProductId = "F2AECE98-63E9-5A32-9B4B-9D7C6254CBA8" +) + +$ErrorActionPreference = "Stop" + +function Get-MsiVersion { + param([string]$InputVersion) + + $parts = $InputVersion.Split(".") + while ($parts.Count -lt 4) { + $parts += "0" + } + + return ($parts[0..3] -join ".") +} + +function Find-SevenZip { + $candidates = @( + "${env:ProgramFiles}\7-Zip\7z.exe", + "${env:ProgramFiles(x86)}\7-Zip\7z.exe" + ) + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $null +} + +function Ensure-WixToolset { + $cacheRoot = Join-Path $env:LOCALAPPDATA "farmcontrol-server-build" + $wixRoot = Join-Path $cacheRoot "wix-4.0.0.5512.2" + $candle = Join-Path $wixRoot "candle.exe" + $light = Join-Path $wixRoot "light.exe" + + if ((Test-Path $candle) -and (Test-Path $light)) { + return @{ + Candle = $candle + Light = $light + } + } + + $archivePath = Join-Path $cacheRoot "wix-4.0.0.5512.2.7z" + $archiveUrl = "https://github.com/electron-userland/electron-builder-binaries/releases/download/wix-4.0.0.5512.2/wix-4.0.0.5512.2.7z" + + if (-not (Test-Path $archivePath)) { + New-Item -ItemType Directory -Path $cacheRoot -Force | Out-Null + Write-Host "Downloading WiX toolset..." + Invoke-WebRequest -Uri $archiveUrl -OutFile $archivePath + } + + $sevenZip = Find-SevenZip + if (-not $sevenZip) { + throw "Could not find 7-Zip. Install WiX Toolset or 7-Zip on the Windows build agent." + } + + if (Test-Path $wixRoot) { + Remove-Item $wixRoot -Recurse -Force + } + + New-Item -ItemType Directory -Path $wixRoot -Force | Out-Null + & $sevenZip x $archivePath "-o$wixRoot" -y | Out-Null + + $candleMatches = Get-ChildItem -Path $wixRoot -Filter candle.exe -Recurse -ErrorAction SilentlyContinue + if ($candleMatches.Count -gt 0) { + $candleDir = $candleMatches[0].DirectoryName + $candle = Join-Path $candleDir "candle.exe" + $light = Join-Path $candleDir "light.exe" + } + + if (-not (Test-Path $candle) -or -not (Test-Path $light)) { + throw "WiX download did not provide candle.exe/light.exe under $wixRoot" + } + + return @{ + Candle = $candle + Light = $light + } +} + +function Find-WixToolset { + $searchRoots = @( + $env:WIX_TOOLSET_PATH, + "${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin", + "${env:ProgramFiles}\WiX Toolset v3.11\bin" + ) + + foreach ($root in $searchRoots) { + if (-not $root) { + continue + } + + $candle = Join-Path $root "candle.exe" + $light = Join-Path $root "light.exe" + if ((Test-Path $candle) -and (Test-Path $light)) { + return @{ + Candle = $candle + Light = $light + } + } + } + + $cachedRoot = Join-Path $env:LOCALAPPDATA "farmcontrol-server-build\wix-4.0.0.5512.2" + if (Test-Path $cachedRoot) { + $cachedCandle = Get-ChildItem -Path $cachedRoot -Filter candle.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($cachedCandle) { + $candleDir = $cachedCandle.DirectoryName + return @{ + Candle = Join-Path $candleDir "candle.exe" + Light = Join-Path $candleDir "light.exe" + } + } + } + + $candleCommand = Get-Command candle.exe -ErrorAction SilentlyContinue + $lightCommand = Get-Command light.exe -ErrorAction SilentlyContinue + if ($candleCommand -and $lightCommand) { + return @{ + Candle = $candleCommand.Source + Light = $lightCommand.Source + } + } + + return Ensure-WixToolset +} + +$rootDir = Split-Path -Parent $PSScriptRoot +$templatePath = Join-Path $rootDir "packaging/windows/msi-wrapped.wxs" +$workDir = Join-Path $env:TEMP "farmcontrol-server-msi" + +if (Test-Path $workDir) { + Remove-Item $workDir -Recurse -Force +} +New-Item -ItemType Directory -Path $workDir | Out-Null + +$setupExePath = (Resolve-Path -LiteralPath $SetupExe).Path +$outputMsiPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputMsi) +$outputDir = Split-Path $outputMsiPath -Parent +if (-not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir | Out-Null +} + +$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__", $setupExePath) + +$projectWxs = Join-Path $workDir "project.wxs" +Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8 + +$wix = Find-WixToolset +$wixObj = Join-Path $workDir "project.wixobj" + +Push-Location $workDir +try { + & $wix.Candle "-arch" "x64" $projectWxs + if ($LASTEXITCODE -ne 0) { + throw "candle.exe failed with exit code $LASTEXITCODE" + } + + & $wix.Light "-out" $outputMsiPath "-spdb" "-sw1076" $wixObj + if ($LASTEXITCODE -ne 0) { + throw "light.exe failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location + Remove-Item $workDir -Recurse -Force +} + +if (-not (Test-Path $outputMsiPath)) { + throw "MSI installer was not created at $outputMsiPath" +} + +Write-Host "Created MSI installer at $outputMsiPath" diff --git a/scripts/finalize-desktop-artifacts.mjs b/scripts/finalize-desktop-artifacts.mjs index bc066e6..cea5976 100644 --- a/scripts/finalize-desktop-artifacts.mjs +++ b/scripts/finalize-desktop-artifacts.mjs @@ -358,6 +358,51 @@ function buildWindowsNsis(appDir, arch) { 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); @@ -400,7 +445,8 @@ if (targetOs === "macos") { let published; try { - published = [buildWindowsNsis(appDir, arch)]; + const setupExe = buildWindowsNsis(appDir, arch); + published = [setupExe, buildWindowsMsi(setupExe, arch)]; if (installerFiles.setupZip) { published.push(publishArtifact(installerFiles.setupZip, arch, "zip"));