Add MSI wrapper support for Windows installer
Some checks reported errors
farmcontrol/farmcontrol-ui/pipeline/head Something is wrong with the build of this commit

- Updated `package.json` to include `msiWrapped` configuration for MSI installer generation.
- Introduced `msi-wrapped.wxs` file to define the MSI wrapper around the NSIS setup, enabling silent updates.
- Created `build-windows-msi.ps1` script to automate the MSI building process, integrating with the existing build workflow.
- Modified `finalize-desktop-artifacts.mjs` to call the new MSI build function, ensuring both NSIS and MSI installers are generated during the build process.
This commit is contained in:
Tom Butcher 2026-08-08 20:21:50 +01:00
parent e32eda100a
commit 4ee72fdbac
4 changed files with 269 additions and 2 deletions

View File

@ -227,7 +227,8 @@
}, },
"win": { "win": {
"target": [ "target": [
"nsis" "nsis",
"msiWrapped"
], ],
"protocols": [ "protocols": [
{ {
@ -246,6 +247,12 @@
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"include": "scripts/installer.nsh", "include": "scripts/installer.nsh",
"perMachine": false "perMachine": false
},
"msiWrapped": {
"upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}",
"perMachine": false,
"impersonate": true,
"wrappedInstallerArgs": "/S /UPDATE /RESTARTFC"
} }
} }
} }

View File

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<!--
MSI wrapper around the compiled NSIS setup.exe.
Runs the bundled installer with the same silent in-app update flags used by
src/desktop/winappupdate.js: /S /UPDATE /RESTARTFC /LOG=...
ALLUSERS=2 + MSIINSTALLPERUSER=1 defaults to per-user (matching NSIS) and is
not blocked by DisableMsi=1 / error 1625 the way a pure InstallScope=perUser
package is.
-->
<Product
Id="*"
Name="Farm Control"
Language="1033"
Version="__VERSION__"
Manufacturer="Tom Butcher"
UpgradeCode="__UPGRADE_CODE__">
<Package
InstallerVersion="500"
Compressed="yes"
InstallPrivileges="limited"
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" />
<Icon Id="InstallerIcon" SourceFile="__INSTALLER_ICON__" />
<Property Id="ARPPRODUCTICON" Value="InstallerIcon" />
<Property Id="DISABLEADVTSHORTCUTS" Value="1" />
<Property Id="ALLUSERS" Secure="yes" Value="2" />
<Property Id="MSIINSTALLPERUSER" Secure="yes" Value="1" />
<Property Id="REBOOT" Value="ReallySuppress" />
<Property Id="FarmControlUpdateLog" Value="[FarmControlUpdatesDir]install.log" />
<Binary Id="WrappedExe" SourceFile="__SETUP_EXE__" />
<CustomAction
Id="RunInstaller"
BinaryKey="WrappedExe"
ExeCommand="/S /UPDATE /RESTARTFC /LOG=&quot;[FarmControlUpdateLog]&quot;"
Execute="immediate"
Impersonate="yes"
Return="check" />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="LocalAppDataFolder">
<Directory Id="FarmControlAppDataDir" Name="FarmControl">
<Directory Id="FarmControlUpdatesDir" Name="Updates">
<Component Id="UpdatesFolderComponent" Guid="A1B2C3D4-E5F6-7890-ABCD-EF1234567890">
<CreateFolder />
<RegistryValue
Root="HKCU"
Key="Software\Tom Butcher\Farm Control"
Name="UpdatesDir"
Type="string"
Value="[FarmControlUpdatesDir]"
KeyPath="yes" />
</Component>
</Directory>
</Directory>
</Directory>
<Component Id="PerUserMarker" Guid="8A3F2E1D-9C4B-4A7E-B6D5-1F0E3C2B4A59">
<RegistryValue
Root="HKCU"
Key="Software\Tom Butcher\Farm Control"
Name="MsiWrapper"
Type="integer"
Value="1"
KeyPath="yes" />
</Component>
</Directory>
<Feature Id="MainFeature" Title="Farm Control" Level="1">
<ComponentRef Id="UpdatesFolderComponent" />
<ComponentRef Id="PerUserMarker" />
</Feature>
<InstallExecuteSequence>
<Custom Action="RunInstaller" After="InstallFiles">NOT Installed</Custom>
</InstallExecuteSequence>
</Product>
</Wix>

View File

@ -0,0 +1,130 @@
param(
[Parameter(Mandatory = $true)]
[string]$SetupExe,
[Parameter(Mandatory = $true)]
[string]$OutputMsi,
[Parameter(Mandatory = $true)]
[string]$Version,
[string]$UpgradeCode = "735812DB-E33B-57A0-8FBC-5FC3155925AA"
)
$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-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"
)
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 Escape-WixSourcePath {
param([string]$Path)
return $Path.Replace("\", "\\")
}
$rootDir = Split-Path -Parent $PSScriptRoot
$templatePath = Join-Path $rootDir "packaging/windows/msi-wrapped.wxs"
$workDir = Join-Path $env:TEMP "farmcontrol-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)
$outputDir = Split-Path $outputMsiPath -Parent
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 'bun run generate-app-icons' 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("__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
$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"

View File

@ -561,6 +561,47 @@ function buildWindowsNsis(appDir, arch) {
return exePath return exePath
} }
function buildWindowsMsi(setupExePath, arch) {
const scriptPath = path.join(rootDir, 'scripts/build-windows-msi.ps1')
const msiPath = path.join(artifactDir, artifactName(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') { if (buildEnv === 'dev') {
console.log('finalize-desktop-artifacts: skipping dev build') console.log('finalize-desktop-artifacts: skipping dev build')
process.exit(0) process.exit(0)
@ -616,7 +657,7 @@ async function main() {
let published let published
try { try {
const setupExe = buildWindowsNsis(appDir, arch) const setupExe = buildWindowsNsis(appDir, arch)
published = [setupExe] published = [setupExe, buildWindowsMsi(setupExe, arch)]
if (installerFiles.setupZip) { if (installerFiles.setupZip) {
published.push(publishArtifact(installerFiles.setupZip, arch, 'zip')) published.push(publishArtifact(installerFiles.setupZip, arch, 'zip'))