maksit-postclient/utils/plugins/Desktop/WindowsInstaller.psm1

306 lines
12 KiB
PowerShell

#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Windows installer plugin for published .NET desktop apps (Community).
.DESCRIPTION
Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps
it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS
stay in a staging folder and are not added to the portable zip.
`wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so
per-machine installs go to `C:\Program Files`, not Program Files (x86).
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
`wix extension add -g WixToolset.BootstrapperApplications.wixext` and
`wix extension add -g WixToolset.Util.wixext`.
Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Test-WixMissingException {
param(
[Parameter(Mandatory = $true)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
$message = [string]$ErrorRecord.Exception.Message
if ($ErrorRecord.Exception -is [System.Management.Automation.CommandNotFoundException]) {
return $true
}
return ($message -match '(?i)(not recognized|command not found|The term .+ is not recognized|marked unavailable)')
}
function Get-WixMissingInstallMessage {
return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext; wix extension add -g WixToolset.Util.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
}
function Get-WixCliVersionText {
$raw = Invoke-ExternalCommand -Name wix -ArgumentList @('--version')
return ((@($raw) | ForEach-Object { [string]$_ }) -join '').Trim()
}
function Get-WixAcceptEulaArguments {
param(
[Parameter(Mandatory = $false)]
[string]$VersionText
)
if ($VersionText -match '^7\.') {
return @('-acceptEula', 'wix7')
}
return @()
}
function Get-WixBundleExtensionName {
param(
[Parameter(Mandatory = $false)]
[string]$VersionText
)
if ($VersionText -match '^[5-9]\.') {
return 'WixToolset.BootstrapperApplications.wixext'
}
return 'WixToolset.Bal.wixext'
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Add-ReleaseAssetPaths"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-WixPackageXml"
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$version = [string]$sharedSettings.version
$artifactsDirectory = $sharedSettings.artifactsDirectory
$appName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'appName')
$upgradeCodeRaw = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'upgradeCode')
if ([string]::IsNullOrWhiteSpace($appName)) {
throw "WindowsInstaller plugin requires appName."
}
if ([string]::IsNullOrWhiteSpace($upgradeCodeRaw)) {
throw "WindowsInstaller plugin requires a stable upgradeCode GUID (per product, never change it)."
}
$upgradeCode = [guid]$upgradeCodeRaw
$manufacturer = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'manufacturer' -Default 'MaksIT')
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'win-x64')
$wixArch = Get-WixArchitectureFromRuntimeIdentifier -RuntimeIdentifier $runtimeIdentifier
$archArgs = @('-arch', $wixArch)
$installScope = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installScope' -Default 'perMachine')
$installFolderName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installFolderName')
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
$logoSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'logoPath')
$logoSideSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'logoSidePath')
$themeSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'themePath')
if ([string]::IsNullOrWhiteSpace([string]$artifactsDirectory)) {
throw "WindowsInstaller plugin requires an artifacts directory in the shared context."
}
if (-not (Test-Path -LiteralPath $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$publishDirectory = Resolve-DesktopPublishDirectory `
-Context $sharedSettings `
-RuntimeIdentifier $runtimeIdentifier `
-PublishDir $publishDirSetting `
-ScriptDir $scriptDir
$executablePath = Resolve-DesktopExecutablePath `
-PublishDirectory $publishDirectory `
-ExecutableName $executableName `
-Windows
$brandDir = Join-Path $PSScriptRoot 'brand'
$resolvePackPath = {
param([string]$Setting)
if ([string]::IsNullOrWhiteSpace($Setting)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($Setting)) {
return $Setting
}
return [System.IO.Path]::GetFullPath((Join-Path $scriptDir $Setting))
}
$iconPath = & $resolvePackPath $iconSetting
if (-not [string]::IsNullOrWhiteSpace($iconSetting) -and -not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
throw "WindowsInstaller iconPath not found: $iconPath"
}
if ([string]::IsNullOrWhiteSpace($iconPath)) {
$fallbackIcon = Join-Path $brandDir 'mark.ico'
if (Test-Path -LiteralPath $fallbackIcon -PathType Leaf) {
$iconPath = $fallbackIcon
}
}
$logoPath = & $resolvePackPath $logoSetting
if (-not [string]::IsNullOrWhiteSpace($logoSetting) -and -not (Test-Path -LiteralPath $logoPath -PathType Leaf)) {
throw "WindowsInstaller logoPath not found: $logoPath"
}
if ([string]::IsNullOrWhiteSpace($logoPath)) {
$fallbackLogo = Join-Path $brandDir 'logo-64.png'
if (Test-Path -LiteralPath $fallbackLogo -PathType Leaf) {
$logoPath = $fallbackLogo
}
}
$logoSidePath = & $resolvePackPath $logoSideSetting
if (-not [string]::IsNullOrWhiteSpace($logoSideSetting) -and -not (Test-Path -LiteralPath $logoSidePath -PathType Leaf)) {
throw "WindowsInstaller logoSidePath not found: $logoSidePath"
}
if ([string]::IsNullOrWhiteSpace($logoSidePath)) {
$fallbackSide = Join-Path $brandDir 'sidebar.png'
if (Test-Path -LiteralPath $fallbackSide -PathType Leaf) {
$logoSidePath = $fallbackSide
}
}
$themePath = & $resolvePackPath $themeSetting
if (-not [string]::IsNullOrWhiteSpace($themeSetting) -and -not (Test-Path -LiteralPath $themePath -PathType Leaf)) {
throw "WindowsInstaller themePath not found: $themePath"
}
if ([string]::IsNullOrWhiteSpace($themePath)) {
$fallbackTheme = Join-Path $brandDir 'HyperlinkSidebarTheme.xml'
if (Test-Path -LiteralPath $fallbackTheme -PathType Leaf) {
$themePath = $fallbackTheme
}
}
$productVersion = Get-MsiProductVersion -Version $version
$safeName = ($appName -replace '[^A-Za-z0-9._-]', '-').Trim('-')
$exeNamePattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'exeNamePattern' -Default '{name}-{version}.exe')
$exeFileName = $exeNamePattern.Replace('{version}', $version).Replace('{name}', $safeName)
$exePath = Join-Path $artifactsDirectory $exeFileName
$stageDir = Join-Path $artifactsDirectory '.wix-stage'
if (Test-Path -LiteralPath $stageDir) {
Remove-Item -LiteralPath $stageDir -Recurse -Force
}
New-Item -ItemType Directory -Path $stageDir | Out-Null
$wxsPath = Join-Path $stageDir ($safeName + '-' + $version + '.wxs')
$msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi')
$bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs')
Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)"
$xml = New-WixPackageXml `
-AppName $appName `
-Manufacturer $manufacturer `
-ProductVersion $productVersion `
-UpgradeCode $upgradeCode `
-PublishDirectory $publishDirectory `
-ExecutablePath $executablePath `
-InstallScope $installScope `
-InstallFolderName $installFolderName `
-Architecture $wixArch `
-IconPath $iconPath
$xml.Save($wxsPath)
Write-Log -Level "OK" -Message " WiX source: $wxsPath"
try {
$wixVersion = Get-WixCliVersionText
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
$eulaArgs = @(Get-WixAcceptEulaArguments -VersionText $wixVersion)
$bundleExt = Get-WixBundleExtensionName -VersionText $wixVersion
Write-Log -Level "STEP" -Message "Building MSI with WiX ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($wxsPath, '-o', $msiPath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
if (-not (Test-Path -LiteralPath $msiPath -PathType Leaf)) {
throw "WiX completed but MSI was not produced: $msiPath"
}
$bundleXml = New-WixBundleXml `
-AppName $appName `
-Manufacturer $manufacturer `
-ProductVersion $productVersion `
-UpgradeCode $upgradeCode `
-MsiPath $msiPath `
-IconPath $iconPath `
-LogoPath $logoPath `
-LogoSidePath $logoSidePath `
-ThemePath $themePath `
-InstallScope $installScope `
-InstallFolderName $installFolderName `
-Architecture $wixArch
[System.IO.File]::WriteAllText($bundleWxsPath, $bundleXml, [System.Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $exePath -PathType Leaf) {
Remove-Item -LiteralPath $exePath -Force
}
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
try {
Invoke-ExternalCommand -Name wix -ArgumentList (@('extension', 'add', '-g', 'WixToolset.Util.wixext') + $eulaArgs) | Out-Null
Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-ext', 'WixToolset.Util.wixext', '-o', $exePath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {
throw (Get-WixMissingInstallMessage)
}
throw
}
if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {
throw "WiX completed but installer exe was not produced: $exePath"
}
Write-Log -Level "OK" -Message " Windows installer ready: $exePath"
Set-EngineState -Context $sharedSettings -Name 'releaseDir' -Value $artifactsDirectory
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'windowsInstallerPath' -Value $exePath -Overwrite Replace
Add-ReleaseAssetPaths -Context $sharedSettings -Path @($exePath)
}
Export-ModuleMember -Function Invoke-Plugin