From 8783caa93c0a40048ca8fc76e17a9476d2adea61 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 1 Aug 2026 22:40:09 +0100 Subject: [PATCH] Add Windows MSI build script and integrate into artifact finalization - Introduced build-windows-msi.ps1 script for generating MSI installers, enhancing the Windows packaging process. - Updated finalize-desktop-artifacts.mjs to include a new function for building MSI packages, ensuring proper integration with existing artifact generation workflows. - Enhanced error handling and logging during the MSI build process for better feedback on failures. --- scripts/build-windows-msi.ps1 | 441 +++++++++++++++++++++++++ scripts/finalize-desktop-artifacts.mjs | 48 ++- 2 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 scripts/build-windows-msi.ps1 diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 new file mode 100644 index 0000000..0109bc8 --- /dev/null +++ b/scripts/build-windows-msi.ps1 @@ -0,0 +1,441 @@ +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 Invoke-InstallerExecute { + param( + [object]$Database, + [string]$Sql + ) + + $null = $Database.GetType().InvokeMember( + "Execute", + "InvokeMethod", + $null, + $Database, + @($Sql) + ) +} + +function Invoke-InstallerViewInsert { + param( + [object]$Installer, + [object]$Database, + [string]$Sql, + [object[]]$Values + ) + + $view = $Database.GetType().InvokeMember( + "OpenView", + "InvokeMethod", + $null, + $Database, + @($Sql) + ) + + $record = $Installer.GetType().InvokeMember( + "CreateRecord", + "InvokeMethod", + $null, + $Installer, + @($Values.Count) + ) + + for ($index = 0; $index -lt $Values.Count; $index++) { + $value = $Values[$index] + if ($value -is [int]) { + $record.GetType().InvokeMember( + "IntegerData", + "SetProperty", + $null, + $record, + @($index + 1, $value) + ) + } else { + $record.GetType().InvokeMember( + "StringData", + "SetProperty", + $null, + $record, + @($index + 1, [string]$value) + ) + } + } + + $view.GetType().InvokeMember("Execute", "InvokeMethod", $null, $view, @($record)) + $view.GetType().InvokeMember("Close", "InvokeMethod", $null, $view, $null) +} + +function Set-InstallerBinaryStream { + param( + [object]$Installer, + [object]$Database, + [string]$Name, + [string]$FilePath + ) + + $deleteSql = "DELETE FROM `Binary` WHERE `Name` = '$Name'" + Invoke-InstallerExecute -Database $Database -Sql $deleteSql + + $insertSql = "INSERT INTO `Binary` (`Name`, `Data`) VALUES (?, ?)" + $view = $Database.GetType().InvokeMember( + "OpenView", + "InvokeMethod", + $null, + $Database, + @($insertSql) + ) + + $record = $Installer.GetType().InvokeMember( + "CreateRecord", + "InvokeMethod", + $null, + $Installer, + @(2) + ) + + $record.GetType().InvokeMember( + "StringData", + "SetProperty", + $null, + $record, + @(1, $Name) + ) + + $record.GetType().InvokeMember( + "SetStream", + "InvokeMethod", + $null, + $record, + @(2, (Resolve-Path -LiteralPath $FilePath).Path) + ) + + $view.GetType().InvokeMember("Execute", "InvokeMethod", $null, $view, @($record)) + $view.GetType().InvokeMember("Close", "InvokeMethod", $null, $view, $null) +} + +function New-WrapperMsiDatabase { + param( + [string]$OutputPath, + [string]$SetupExePath, + [string]$MsiVersion, + [string]$ProductCode, + [string]$UpgradeCodeValue + ) + + if (Test-Path $OutputPath) { + Remove-Item -LiteralPath $OutputPath -Force + } + + $outputDir = Split-Path $OutputPath -Parent + if ($outputDir -and -not (Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + } + + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.GetType().InvokeMember( + "OpenDatabase", + "InvokeMethod", + $null, + $installer, + @($OutputPath, 1) + ) + + 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", "", "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", "", "Empty", "", 0, 0, "") + + 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", "", 1400) + + Invoke-InstallerViewInsert -Installer $installer -Database $database ` + -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` + -Values @("InstallInitialize", "", 1500) + + Invoke-InstallerViewInsert -Installer $installer -Database $database ` + -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` + -Values @("ProcessComponents", "", 1600) + + Invoke-InstallerViewInsert -Installer $installer -Database $database ` + -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` + -Values @("RunInstaller", "", 1601) + + Invoke-InstallerViewInsert -Installer $installer -Database $database ` + -Sql "INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (?, ?, ?)" ` + -Values @("InstallFinalize", "", 6600) + + $summary = $database.GetType().InvokeMember( + "GetSummaryInformation", + "InvokeMethod", + $null, + $database, + @(20) + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(1, ";\1033") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(2, "Farm Control Server") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(3, [Guid]::NewGuid().ToString().ToUpper()) + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(4, "Farm Control Server") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(7, "x64;1033") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(9, "Farm Control Server") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(14, "200") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(15, "2") + ) + + $summary.GetType().InvokeMember( + "Property", + "SetProperty", + $null, + $summary, + @(19, "2") + ) + + $summary.GetType().InvokeMember( + "Persist", + "InvokeMethod", + $null, + $summary, + $null + ) + + $database.GetType().InvokeMember( + "Commit", + "InvokeMethod", + $null, + $database, + $null + ) + + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($summary) | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($database) | Out-Null + [System.Runtime.InteropServices.Marshal]::ReleaseComObject($installer) | Out-Null +} + +$setupExePath = (Resolve-Path -LiteralPath $SetupExe).Path +$outputMsiPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputMsi) +$msiVersion = Get-MsiVersion $Version + +New-WrapperMsiDatabase ` + -OutputPath $outputMsiPath ` + -SetupExePath $setupExePath ` + -MsiVersion $msiVersion ` + -ProductCode $ProductId ` + -UpgradeCodeValue $UpgradeCode + +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 c1b5d45..8801696 100644 --- a/scripts/finalize-desktop-artifacts.mjs +++ b/scripts/finalize-desktop-artifacts.mjs @@ -386,6 +386,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); @@ -430,7 +475,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"));