hypertwist/scripts/Build-HyperTwistWindowsInstaller.ps1
2026-07-26 09:41:16 +00:00

464 lines
17 KiB
PowerShell

param(
[string]$PackageRoot = 'C:\HyperTwist_worktrees\phase10validate_packaged_alpha_release19_shipping_20260723',
[string]$OutputDirectory = 'C:\HyperTwist_worktrees\installer-release19-20260726',
[string]$InstallerFileName = 'HyperTwist-Alpha-Test-20260723-R19-Setup.exe',
[string]$ProductVersion = '0.19.0.0',
[string]$DisplayVersion = 'Alpha R19',
[string]$ReleaseId = '20260723-R19',
[string]$MakensisPath = 'C:\Program Files (x86)\NSIS\makensis.exe',
[string]$VcRedistPath = '',
[string]$ReportPath = '',
[switch]$KeepBuildContext
)
$ErrorActionPreference = 'Stop'
$ExpectedSolverTableSizeBytes = 676207080
$ExpectedSolverTableSha256 = 'dc6de3d909a37afe2ba7f1f978543a13eae215bd20d954d66813524a8ca20034'
function Write-Utf8JsonFile {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[object]$Value
)
$ParentPath = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($ParentPath))
{
New-Item -ItemType Directory -Force -Path $ParentPath | Out-Null
}
$Json = $Value | ConvertTo-Json -Depth 12
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Resolve-WindowsPayloadRoot {
param(
[Parameter(Mandatory = $true)]
[string]$RootPath
)
$ResolvedRootPath = (Resolve-Path -LiteralPath $RootPath).Path
$WindowsCandidate = Join-Path $ResolvedRootPath 'Windows'
if (Test-Path -LiteralPath (Join-Path $WindowsCandidate 'UnrealHyperTwist.exe') -PathType Leaf)
{
return $WindowsCandidate
}
if (Test-Path -LiteralPath (Join-Path $ResolvedRootPath 'UnrealHyperTwist.exe') -PathType Leaf)
{
return $ResolvedRootPath
}
throw "No packaged Windows payload was found beneath '$RootPath'."
}
function Get-NormalizedRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$RootPath,
[Parameter(Mandatory = $true)]
[string]$FilePath
)
$RootPrefix = $RootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$ResolvedFilePath = [System.IO.Path]::GetFullPath($FilePath)
if (-not $ResolvedFilePath.StartsWith(
$RootPrefix,
[System.StringComparison]::OrdinalIgnoreCase))
{
throw "File '$ResolvedFilePath' is outside payload root '$RootPath'."
}
return $ResolvedFilePath.Substring($RootPrefix.Length).Replace('\', '/')
}
function Assert-RequiredPayloadFile {
param(
[Parameter(Mandatory = $true)]
[string]$PayloadRoot,
[Parameter(Mandatory = $true)]
[string]$RelativePath,
[Int64]$ExpectedSizeBytes = -1,
[string]$ExpectedSha256 = ''
)
$RequiredPath = Join-Path $PayloadRoot $RelativePath
if (-not (Test-Path -LiteralPath $RequiredPath -PathType Leaf))
{
throw "Required payload file '$RelativePath' was not found."
}
$RequiredItem = Get-Item -LiteralPath $RequiredPath
if ($RequiredItem.Length -le 0)
{
throw "Required payload file '$RelativePath' is empty."
}
if ($ExpectedSizeBytes -ge 0 -and $RequiredItem.Length -ne $ExpectedSizeBytes)
{
throw "Required payload file '$RelativePath' has an unexpected size."
}
$ActualSha256 = (Get-FileHash -LiteralPath $RequiredPath -Algorithm SHA256).Hash.ToLowerInvariant()
if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256) `
-and $ActualSha256 -ne $ExpectedSha256.ToLowerInvariant())
{
throw "Required payload file '$RelativePath' has an unexpected SHA-256."
}
}
function Resolve-VcRedistPath {
param(
[string]$RequestedPath
)
if (-not [string]::IsNullOrWhiteSpace($RequestedPath))
{
if (-not (Test-Path -LiteralPath $RequestedPath -PathType Leaf))
{
throw "Requested VC++ redistributable '$RequestedPath' was not found."
}
return (Resolve-Path -LiteralPath $RequestedPath).Path
}
$CandidateRoots = @(
'C:\Program Files\Epic Games\UE_5.7\Engine\Extras\Redist',
'C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Redist',
'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Redist'
)
$Candidates = @(
foreach ($CandidateRoot in $CandidateRoots)
{
if (Test-Path -LiteralPath $CandidateRoot)
{
Get-ChildItem -LiteralPath $CandidateRoot `
-Filter 'vc_redist.x64.exe' `
-File `
-Recurse `
-ErrorAction SilentlyContinue
}
}
)
$Candidate = $Candidates |
Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending |
Select-Object -First 1
if ($null -eq $Candidate)
{
throw 'No Microsoft Visual C++ 2015-2022 x64 redistributable was found.'
}
return $Candidate.FullName
}
if (-not (Test-Path -LiteralPath $PackageRoot -PathType Container))
{
throw "Package root '$PackageRoot' was not found."
}
if (-not (Test-Path -LiteralPath $MakensisPath -PathType Leaf))
{
throw "NSIS compiler '$MakensisPath' was not found."
}
try
{
$ParsedProductVersion = [version]$ProductVersion
}
catch
{
throw "ProductVersion '$ProductVersion' is not a valid numeric Windows file version."
}
if ($ParsedProductVersion.Build -lt 0 -or $ParsedProductVersion.Revision -lt 0)
{
throw 'ProductVersion must contain exactly four numeric components.'
}
if ($InstallerFileName -ne [System.IO.Path]::GetFileName($InstallerFileName) `
-or [System.IO.Path]::GetExtension($InstallerFileName) -ne '.exe')
{
throw "InstallerFileName '$InstallerFileName' must be a leaf .exe filename."
}
foreach ($MetadataValue in @($DisplayVersion, $ReleaseId))
{
if ([string]::IsNullOrWhiteSpace($MetadataValue) `
-or $MetadataValue -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$')
{
throw "Installer metadata value '$MetadataValue' contains unsupported characters or length."
}
}
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$ResolvedPayloadRoot = Resolve-WindowsPayloadRoot -RootPath $ResolvedPackageRoot
$ResolvedOutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory)
$ResolvedInstallerPath = Join-Path $ResolvedOutputDirectory $InstallerFileName
$PayloadRootPrefix = $ResolvedPayloadRoot.TrimEnd('\') + '\'
if ($ResolvedInstallerPath.StartsWith(
$PayloadRootPrefix,
[System.StringComparison]::OrdinalIgnoreCase))
{
throw 'Installer output must not be written inside its source payload.'
}
$RepositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
$NsiScriptPath = Join-Path $RepositoryRoot 'packaging\windows\HyperTwistInstaller.nsi'
$ReadinessScriptPath = Join-Path $RepositoryRoot 'scripts\Test-HyperTwistWindowsReadiness.ps1'
if (-not (Test-Path -LiteralPath $NsiScriptPath -PathType Leaf))
{
throw "Installer definition '$NsiScriptPath' was not found."
}
if (-not (Test-Path -LiteralPath $ReadinessScriptPath -PathType Leaf))
{
throw "Readiness script '$ReadinessScriptPath' was not found."
}
$ResolvedVcRedistPath = Resolve-VcRedistPath -RequestedPath $VcRedistPath
$VcRedistSignature = Get-AuthenticodeSignature -LiteralPath $ResolvedVcRedistPath
if ($VcRedistSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid)
{
throw "VC++ redistributable '$ResolvedVcRedistPath' does not have a valid Authenticode signature."
}
$RequiredRelativePaths = @(
'UnrealHyperTwist.exe',
'UnrealHyperTwist\Binaries\Win64\UnrealHyperTwist-Win64-Shipping.exe',
'UnrealHyperTwist\Binaries\Win64\tbb12.dll',
'UnrealHyperTwist\Binaries\Win64\tbbmalloc.dll',
'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.pak',
'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.utoc',
'UnrealHyperTwist\Content\Paks\UnrealHyperTwist-Windows.ucas',
'Content\Browser\index.html',
'Content\Browser\src\browser-runtime-bootstrap.js',
'Content\Browser\src\browser-spatial-runtime-fallback.js',
'Content\Browser\dist\browser-spatial-runtime.js'
)
foreach ($RequiredRelativePath in $RequiredRelativePaths)
{
Assert-RequiredPayloadFile `
-PayloadRoot $ResolvedPayloadRoot `
-RelativePath $RequiredRelativePath
}
Assert-RequiredPayloadFile `
-PayloadRoot $ResolvedPayloadRoot `
-RelativePath 'UnrealHyperTwist\Saved\twophase-ht.tbl' `
-ExpectedSizeBytes $ExpectedSolverTableSizeBytes `
-ExpectedSha256 $ExpectedSolverTableSha256
$ReparsePoints = @(
Get-ChildItem -LiteralPath $ResolvedPayloadRoot -Force -Recurse |
Where-Object {
$_.Attributes -band [System.IO.FileAttributes]::ReparsePoint
}
)
if ($ReparsePoints.Count -gt 0)
{
throw (
"The payload contains reparse points, which are not permitted in an installer source: " +
(($ReparsePoints | Select-Object -ExpandProperty FullName) -join ', ')
)
}
New-Item -ItemType Directory -Force -Path $ResolvedOutputDirectory | Out-Null
$ValidationDirectory = Join-Path $ResolvedOutputDirectory 'validation'
$BuildContextDirectory = Join-Path $ResolvedOutputDirectory 'build-context'
New-Item -ItemType Directory -Force -Path $ValidationDirectory | Out-Null
if (Test-Path -LiteralPath $BuildContextDirectory)
{
Remove-Item -LiteralPath $BuildContextDirectory -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $BuildContextDirectory | Out-Null
if ([string]::IsNullOrWhiteSpace($ReportPath))
{
$ReportPath = Join-Path $ValidationDirectory 'windows-installer-build-report.json'
}
$PayloadManifestPath = Join-Path $ValidationDirectory 'windows-installer-payload-manifest.json'
$SourceReadinessReportPath = Join-Path $ValidationDirectory 'windows-installer-source-readiness-report.json'
$MakensisLogPath = Join-Path $ValidationDirectory 'makensis.log'
$InstallerIconPath = Join-Path $BuildContextDirectory 'HyperTwist.ico'
$BuildReport = [ordered]@{
reportVersion = 'ht-windows-installer-build/v1'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
result = 'failed'
releaseId = $ReleaseId
displayVersion = $DisplayVersion
productVersion = $ProductVersion
packageRoot = $ResolvedPackageRoot
payloadRoot = $ResolvedPayloadRoot
installerPath = $ResolvedInstallerPath
payloadManifestPath = $PayloadManifestPath
sourceReadinessReportPath = $SourceReadinessReportPath
makensisPath = (Resolve-Path -LiteralPath $MakensisPath).Path
makensisLogPath = $MakensisLogPath
vcRedist = $null
excludedPatterns = @('*.pdb', 'Manifest_DebugFiles_Win64.txt')
payloadFileCount = 0
payloadSizeBytes = 0
installerSizeBytes = 0
installerSha256 = $null
installerSignatureStatus = $null
incompleteInstallerRemoved = $false
error = $null
}
try
{
$VcRedistItem = Get-Item -LiteralPath $ResolvedVcRedistPath
$BuildReport.vcRedist = [pscustomobject][ordered]@{
path = $ResolvedVcRedistPath
version = [string]$VcRedistItem.VersionInfo.FileVersion
sizeBytes = [Int64]$VcRedistItem.Length
sha256 = (Get-FileHash -LiteralPath $ResolvedVcRedistPath -Algorithm SHA256).Hash.ToLowerInvariant()
signatureStatus = $VcRedistSignature.Status.ToString()
signer = [string]$VcRedistSignature.SignerCertificate.Subject
}
$PayloadFiles = @(
Get-ChildItem -LiteralPath $ResolvedPayloadRoot -Force -File -Recurse |
Where-Object {
$_.Extension -ne '.pdb' `
-and $_.Name -ne 'Manifest_DebugFiles_Win64.txt'
} |
Sort-Object FullName
)
$PayloadManifestEntries = @(
foreach ($PayloadFile in $PayloadFiles)
{
[pscustomobject][ordered]@{
relativePath = Get-NormalizedRelativePath `
-RootPath $ResolvedPayloadRoot `
-FilePath $PayloadFile.FullName
sizeBytes = [Int64]$PayloadFile.Length
sha256 = (Get-FileHash -LiteralPath $PayloadFile.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
}
)
$PayloadSizeBytes = [Int64](($PayloadFiles | Measure-Object Length -Sum).Sum)
$PayloadManifest = [pscustomobject][ordered]@{
manifestVersion = 'ht-windows-installer-payload/v1'
generatedAtUtc = [DateTime]::UtcNow.ToString('o')
releaseId = $ReleaseId
sourcePayloadRoot = $ResolvedPayloadRoot
excludedPatterns = @('*.pdb', 'Manifest_DebugFiles_Win64.txt')
fileCount = $PayloadManifestEntries.Count
totalSizeBytes = $PayloadSizeBytes
files = $PayloadManifestEntries
}
Write-Utf8JsonFile -Path $PayloadManifestPath -Value $PayloadManifest
$BuildReport.payloadFileCount = $PayloadManifestEntries.Count
$BuildReport.payloadSizeBytes = $PayloadSizeBytes
$ReadinessReport = & $ReadinessScriptPath `
-PayloadRoot $ResolvedPayloadRoot `
-TargetInstallRoot (Join-Path ([Environment]::GetFolderPath('ProgramFiles')) 'HyperTwist') `
-ExpectedPayloadManifestPath $PayloadManifestPath `
-ReportPath $SourceReadinessReportPath `
-VerifyPayloadHashes `
-PassThru
if ($ReadinessReport.result -eq 'blocked')
{
throw 'Source payload or Windows host readiness is blocked.'
}
Add-Type -AssemblyName System.Drawing
$PayloadExecutablePath = Join-Path $ResolvedPayloadRoot 'UnrealHyperTwist.exe'
$InstallerIcon = [System.Drawing.Icon]::ExtractAssociatedIcon($PayloadExecutablePath)
if ($null -eq $InstallerIcon)
{
throw "No application icon could be extracted from '$PayloadExecutablePath'."
}
$IconStream = [System.IO.File]::Open(
$InstallerIconPath,
[System.IO.FileMode]::Create,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None
)
try
{
$InstallerIcon.Save($IconStream)
}
finally
{
$IconStream.Dispose()
$InstallerIcon.Dispose()
}
if (Test-Path -LiteralPath $ResolvedInstallerPath)
{
Remove-Item -LiteralPath $ResolvedInstallerPath -Force
}
$PayloadSizeKilobytes = [Math]::Ceiling($PayloadSizeBytes / 1KB)
$MakensisArguments = @(
'/V4',
"/DPRODUCT_VERSION=$ProductVersion",
"/DDISPLAY_VERSION=$DisplayVersion",
"/DRELEASE_ID=$ReleaseId",
"/DOUTPUT_FILE=$ResolvedInstallerPath",
"/DPAYLOAD_ROOT=$ResolvedPayloadRoot",
"/DPAYLOAD_MANIFEST=$PayloadManifestPath",
"/DREADINESS_SCRIPT=$ReadinessScriptPath",
"/DVC_REDIST_PATH=$ResolvedVcRedistPath",
"/DINSTALLER_ICON=$InstallerIconPath",
"/DPAYLOAD_SIZE_KB=$PayloadSizeKilobytes",
$NsiScriptPath
)
$MakensisOutput = @(
& $MakensisPath @MakensisArguments 2>&1 |
ForEach-Object {
$Line = $_.ToString()
[Console]::Out.WriteLine($Line)
$Line
}
)
$MakensisExitCode = $LASTEXITCODE
[System.IO.File]::WriteAllLines(
$MakensisLogPath,
$MakensisOutput,
(New-Object System.Text.UTF8Encoding($false))
)
if ($MakensisExitCode -ne 0)
{
throw "NSIS compilation failed with exit code $MakensisExitCode."
}
if (-not (Test-Path -LiteralPath $ResolvedInstallerPath -PathType Leaf))
{
throw "NSIS did not create '$ResolvedInstallerPath'."
}
$InstallerItem = Get-Item -LiteralPath $ResolvedInstallerPath
if ($InstallerItem.Length -le 0)
{
throw 'The generated installer is empty.'
}
$InstallerSignature = Get-AuthenticodeSignature -LiteralPath $ResolvedInstallerPath
$BuildReport.installerSizeBytes = [Int64]$InstallerItem.Length
$BuildReport.installerSha256 = (Get-FileHash -LiteralPath $ResolvedInstallerPath -Algorithm SHA256).Hash.ToLowerInvariant()
$BuildReport.installerSignatureStatus = $InstallerSignature.Status.ToString()
$BuildReport.result = 'passed'
}
catch
{
$BuildReport.error = $_.Exception.Message
throw
}
finally
{
if ($BuildReport.result -ne 'passed' `
-and (Test-Path -LiteralPath $ResolvedInstallerPath -PathType Leaf))
{
Remove-Item -LiteralPath $ResolvedInstallerPath -Force
$BuildReport.incompleteInstallerRemoved = $true
}
Write-Utf8JsonFile -Path $ReportPath -Value $BuildReport
if (-not $KeepBuildContext -and (Test-Path -LiteralPath $BuildContextDirectory))
{
Remove-Item -LiteralPath $BuildContextDirectory -Recurse -Force
}
}
[Console]::Out.WriteLine('HyperTwist Windows installer built successfully.')
[Console]::Out.WriteLine("Installer: $ResolvedInstallerPath")
[Console]::Out.WriteLine("SHA-256: $($BuildReport.installerSha256)")
[Console]::Out.WriteLine("Report: $([System.IO.Path]::GetFullPath($ReportPath))")