From ce1aeee8d5cb3d138412988387291ce77d86ad2a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 1 Aug 2026 23:02:40 +0100 Subject: [PATCH] Add WiX configuration for Windows MSI packaging - Introduced a new msi-wrapped.wxs file containing the WiX configuration for the Farm Control Server MSI installer. - Defined product properties, major upgrade settings, and installation conditions to ensure compatibility with Windows 7 and above. - Added custom actions and directory structure to facilitate the installation process. - Updated the build-windows-msi.ps1 script to improve the search for the WiX Toolset, ensuring proper tool availability for MSI generation. --- packaging/windows/msi-wrapped.wxs | 52 +++++ scripts/build-windows-msi.ps1 | 327 +++++++----------------------- 2 files changed, 126 insertions(+), 253 deletions(-) create mode 100644 packaging/windows/msi-wrapped.wxs diff --git a/packaging/windows/msi-wrapped.wxs b/packaging/windows/msi-wrapped.wxs new file mode 100644 index 0000000..76c637b --- /dev/null +++ b/packaging/windows/msi-wrapped.wxs @@ -0,0 +1,52 @@ + + + + + + = 601]]> + + + + + + + + + + + + + + + + + + + + + + + + + NOT Installed + + + diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 index fd79366..5b1ea15 100644 --- a/scripts/build-windows-msi.ps1 +++ b/scripts/build-windows-msi.ps1 @@ -15,9 +15,6 @@ param( $ErrorActionPreference = "Stop" -# Windows Installer OpenDatabase modes (winuser.h / MSI API). -$msiOpenDatabaseModeCreate = 3 - function Get-MsiVersion { param([string]$InputVersion) @@ -29,272 +26,96 @@ function Get-MsiVersion { return ($parts[0..3] -join ".") } -function Invoke-InstallerExecute { - param( - [object]$Database, - [string]$Sql +function Find-WixToolset { + $searchRoots = @( + $env:WIX, + $env:WIX_TOOLSET_PATH, + "${env:ProgramFiles(x86)}\WiX Toolset v3.14\bin", + "${env:ProgramFiles}\WiX Toolset v3.14\bin", + "${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin", + "${env:ProgramFiles}\WiX Toolset v3.11\bin" ) - $null = $Database.Execute($Sql) -} + foreach ($root in $searchRoots) { + if (-not $root) { + continue + } -function Invoke-InstallerViewInsert { - param( - [object]$Installer, - [object]$Database, - [string]$Sql, - [object[]]$Values - ) - - $view = $Database.OpenView($Sql) - $record = $Installer.CreateRecord($Values.Count) - - for ($index = 0; $index -lt $Values.Count; $index++) { - $value = $Values[$index] - $field = $index + 1 - if ($null -eq $value) { - $record.SetNull($field) - } elseif ($value -is [int]) { - $record.IntegerData($field) = $value - } else { - $record.StringData($field) = [string]$value + $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 + } } } - $view.Execute($record) - $view.Close() -} - -function Set-InstallerBinaryStream { - param( - [object]$Installer, - [object]$Database, - [string]$Name, - [string]$FilePath - ) - - try { - Invoke-InstallerExecute -Database $Database -Sql "DELETE FROM `Binary` WHERE `Name` = '$Name'" - } catch { - # Ignore when the Binary table is empty on first insert. + $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 + } } - $view = $Database.OpenView("INSERT INTO `Binary` (`Name`, `Data`) VALUES (?, ?)") - $record = $Installer.CreateRecord(2) - $record.StringData(1) = $Name - $record.SetStream(2, (Resolve-Path -LiteralPath $FilePath).Path) - $view.Execute($record) - $view.Close() + throw @" +WiX Toolset not found. Install WiX Toolset 3.11+ on the Windows build agent and ensure candle.exe and light.exe are on PATH. +Example: choco install wixtoolset --version=3.14.0.4118 +"@ } -function New-WrapperMsiDatabase { - param( - [string]$OutputPath, - [string]$SetupExePath, - [string]$MsiVersion, - [string]$ProductCode, - [string]$UpgradeCodeValue - ) +function Escape-WixSourcePath { + param([string]$Path) - $outputPath = [System.IO.Path]::GetFullPath($OutputPath) - $outputDir = Split-Path $outputPath -Parent - if ($outputDir -and -not (Test-Path $outputDir)) { - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - } - - if (Test-Path $outputPath) { - Remove-Item -LiteralPath $outputPath -Force - } - - $installer = New-Object -ComObject WindowsInstaller.Installer - $database = $installer.OpenDatabase($outputPath, $msiOpenDatabaseModeCreate) - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `Property` ( - `Property` CHAR(72) NOT NULL PRIMARY KEY, - `Value` CHAR(0) NOT NULL LOCALIZABLE -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `Directory` ( - `Directory` CHAR(72) NOT NULL PRIMARY KEY, - `Directory_Parent` CHAR(72), - `DefaultDir` CHAR(255) NOT NULL -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `Component` ( - `Component` CHAR(72) NOT NULL PRIMARY KEY, - `ComponentId` CHAR(38) NOT NULL, - `Directory_` CHAR(72) NOT NULL, - `Attributes` SHORT NOT NULL -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `Feature` ( - `Feature` CHAR(72) NOT NULL PRIMARY KEY, - `Feature_Parent` CHAR(72), - `Title` CHAR(64) NOT NULL LOCALIZABLE, - `Description` CHAR(255) LOCALIZABLE, - `Display` SHORT, - `Level` SHORT NOT NULL, - `Directory_` CHAR(72) -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `FeatureComponents` ( - `Feature_` CHAR(72) NOT NULL, - `Component_` CHAR(72) NOT NULL PRIMARY KEY -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `Binary` ( - `Name` CHAR(72) NOT NULL PRIMARY KEY, - `Data` OBJECT NOT NULL -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `CustomAction` ( - `Action` CHAR(72) NOT NULL PRIMARY KEY, - `Type` SHORT NOT NULL, - `Source` CHAR(72), - `Target` CHAR(255) -) -"@ - - Invoke-InstallerExecute -Database $database -Sql @" -CREATE TABLE `InstallExecuteSequence` ( - `Action` CHAR(72) NOT NULL, - `Condition` CHAR(255), - `Sequence` SHORT -) -"@ - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("Manufacturer", "Tom Butcher") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("ProductName", "Farm Control Server") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("ProductVersion", $MsiVersion) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("ProductCode", $ProductCode) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("UpgradeCode", $UpgradeCodeValue) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("ProductLanguage", "1033") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("ALLUSERS", "1") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("MSIINSTALLPERUSER", "1") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Property` (`Property`, `Value`) VALUES (?, ?)" ` - -Values @("DISABLEADVTSHORTCUTS", "1") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Directory` (`Directory`, `Directory_Parent`, `DefaultDir`) VALUES (?, ?, ?)" ` - -Values @("TARGETDIR", $null, "SourceDir") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Directory` (`Directory`, `Directory_Parent`, `DefaultDir`) VALUES (?, ?, ?)" ` - -Values @("TempFolder", "TARGETDIR", "Temp") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Component` (`Component`, `ComponentId`, `Directory_`, `Attributes`) VALUES (?, ?, ?, ?)" ` - -Values @( - "EmptyComponent", - "7145262D-7149-4F1A-9A69-F377E38F04DA", - "TempFolder", - 256 - ) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `Feature` (`Feature`, `Feature_Parent`, `Title`, `Description`, `Display`, `Level`, `Directory_`) VALUES (?, ?, ?, ?, ?, ?, ?)" ` - -Values @("EmptyFeature", $null, "Empty", $null, 0, 0, $null) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `FeatureComponents` (`Feature_`, `Component_`) VALUES (?, ?)" ` - -Values @("EmptyFeature", "EmptyComponent") - - Set-InstallerBinaryStream -Installer $installer -Database $database ` - -Name "WrappedExe" -FilePath $SetupExePath - - # EXE from Binary, deferred, no impersonate, check return code. - $customActionType = 2 + 64 + 1024 + 2048 - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `CustomAction` (`Action`, `Type`, `Source`, `Target`) VALUES (?, ?, ?, ?)" ` - -Values @("RunInstaller", $customActionType, "WrappedExe", "/S") - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` - -Values @("InstallValidate", $null, 1400) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` - -Values @("InstallInitialize", $null, 1500) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` - -Values @("ProcessComponents", $null, 1600) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` - -Values @("RunInstaller", $null, 1601) - - Invoke-InstallerViewInsert -Installer $installer -Database $database ` - -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` - -Values @("InstallFinalize", $null, 6600) - - $summary = $database.GetSummaryInformation(20) - $summary.Property(1) = ";\1033" - $summary.Property(2) = "Farm Control Server" - $summary.Property(3) = [Guid]::NewGuid().ToString().ToUpper() - $summary.Property(4) = "Farm Control Server" - $summary.Property(7) = "x64;1033" - $summary.Property(9) = "Farm Control Server" - $summary.Property(14) = "200" - $summary.Property(15) = "2" - $summary.Property(19) = "2" - $summary.Persist() - - $database.Commit() - - [System.Runtime.InteropServices.Marshal]::ReleaseComObject($summary) | Out-Null - [System.Runtime.InteropServices.Marshal]::ReleaseComObject($database) | Out-Null - [System.Runtime.InteropServices.Marshal]::ReleaseComObject($installer) | Out-Null + return $Path.Replace("\", "\\") } +$rootDir = Split-Path -Parent $PSScriptRoot +$templatePath = Join-Path $rootDir "packaging/windows/msi-wrapped.wxs" +$workDir = Join-Path $env:TEMP "farmcontrol-server-wix" +$wix = Find-WixToolset + +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) -$msiVersion = Get-MsiVersion $Version +$outputDir = Split-Path $outputMsiPath -Parent +if ($outputDir -and -not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null +} -New-WrapperMsiDatabase ` - -OutputPath $outputMsiPath ` - -SetupExePath $setupExePath ` - -MsiVersion $msiVersion ` - -ProductCode $ProductId ` - -UpgradeCodeValue $UpgradeCode +$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)) + +$projectWxs = Join-Path $workDir "project.wxs" +Set-Content -LiteralPath $projectWxs -Value $wxsContent -Encoding UTF8 + +$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"