diff --git a/package.json b/package.json index 54848d2..8d2abc8 100644 --- a/package.json +++ b/package.json @@ -227,7 +227,8 @@ }, "win": { "target": [ - "nsis" + "nsis", + "msiWrapped" ], "protocols": [ { @@ -246,6 +247,12 @@ "allowToChangeInstallationDirectory": true, "include": "scripts/installer.nsh", "perMachine": false + }, + "msiWrapped": { + "upgradeCode": "{735812DB-E33B-57A0-8FBC-5FC3155925AA}", + "perMachine": false, + "impersonate": true, + "wrappedInstallerArgs": "/S /UPDATE /RESTARTFC" } } } diff --git a/packaging/windows/msi-wrapped.wxs b/packaging/windows/msi-wrapped.wxs new file mode 100644 index 0000000..e5fdaf0 --- /dev/null +++ b/packaging/windows/msi-wrapped.wxs @@ -0,0 +1,89 @@ + + + + + + + = 601]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NOT Installed + + + diff --git a/scripts/build-windows-msi.ps1 b/scripts/build-windows-msi.ps1 new file mode 100644 index 0000000..f986c76 --- /dev/null +++ b/scripts/build-windows-msi.ps1 @@ -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" diff --git a/scripts/finalize-desktop-artifacts.mjs b/scripts/finalize-desktop-artifacts.mjs index 767e6fe..6b8db20 100644 --- a/scripts/finalize-desktop-artifacts.mjs +++ b/scripts/finalize-desktop-artifacts.mjs @@ -561,6 +561,47 @@ 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, 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') { console.log('finalize-desktop-artifacts: skipping dev build') process.exit(0) @@ -616,7 +657,7 @@ async function main() { let published try { const setupExe = buildWindowsNsis(appDir, arch) - published = [setupExe] + published = [setupExe, buildWindowsMsi(setupExe, arch)] if (installerFiles.setupZip) { published.push(publishArtifact(installerFiles.setupZip, arch, 'zip'))