Add WiX configuration for Windows MSI packaging
Some checks failed
farmcontrol/farmcontrol-server/pipeline/head There was a failure building this commit

- 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.
This commit is contained in:
Tom Butcher 2026-08-01 23:02:40 +01:00
parent e33643e709
commit ce1aeee8d5
2 changed files with 126 additions and 253 deletions

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product
Id="__PRODUCT_ID__"
Name="Farm Control Server"
Language="1033"
Version="__VERSION__"
Manufacturer="Tom Butcher"
UpgradeCode="__UPGRADE_CODE__">
<Package
InstallerVersion="500"
Compressed="yes"
InstallScope="perMachine"
Platform="x64" />
<Condition Message="Windows 7 and above is required"><![CDATA[Installed OR VersionNT >= 601]]></Condition>
<MajorUpgrade
AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of [ProductName] is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Property Id="DISABLEADVTSHORTCUTS" Value="1" />
<Property Id="MSIINSTALLPERUSER" Value="1" />
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
<CustomAction
Id="RunInstaller"
BinaryKey="WrappedExe"
ExeCommand="/S"
Execute="deferred"
Impersonate="no"
Return="check" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="TempFolder" Name="Temp">
<Component Id="EmptyComponent" Guid="7145262D-7149-4F1A-9A69-F377E38F04DA">
<CreateFolder />
</Component>
</Directory>
</Directory>
<Feature Id="EmptyFeature" Level="0">
<ComponentRef Id="EmptyComponent" />
</Feature>
<InstallExecuteSequence>
<Custom Action="RunInstaller" After="ProcessComponents">NOT Installed</Custom>
</InstallExecuteSequence>
</Product>
</Wix>

View File

@ -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
}
$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
}
}
}
$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
}
}
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 Invoke-InstallerViewInsert {
param(
[object]$Installer,
[object]$Database,
[string]$Sql,
[object[]]$Values
)
function Escape-WixSourcePath {
param([string]$Path)
$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
}
}
$view.Execute($record)
$view.Close()
return $Path.Replace("\", "\\")
}
function Set-InstallerBinaryStream {
param(
[object]$Installer,
[object]$Database,
[string]$Name,
[string]$FilePath
)
$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
try {
Invoke-InstallerExecute -Database $Database -Sql "DELETE FROM `Binary` WHERE `Name` = '$Name'"
} catch {
# Ignore when the Binary table is empty on first insert.
}
$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()
}
function New-WrapperMsiDatabase {
param(
[string]$OutputPath,
[string]$SetupExePath,
[string]$MsiVersion,
[string]$ProductCode,
[string]$UpgradeCodeValue
)
$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
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"