hypertwist/scripts/Launch-HyperTwistDesktopPackage.ps1
2026-07-23 08:42:30 +00:00

496 lines
16 KiB
PowerShell

param(
[string]$PackageRoot = 'C:\HyperTwist\packaged\desktop',
[string]$MapUrl = '',
[int]$SmokeSeconds = 30,
[int]$ResX = 1600,
[int]$ResY = 900,
[string]$ReportPath = '',
[switch]$KeepRunning,
[switch]$UseNullRHI,
[switch]$NoSound,
[switch]$RequireStartupDiagnostics,
[string]$ExpectedStartupDiagnosticsResult = ''
)
$ErrorActionPreference = 'Stop'
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 8
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom)
}
function Resolve-StartupDiagnosticsLogPath {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[Parameter(Mandatory = $true)]
[DateTime]$NotBeforeUtc,
[int]$TimeoutSeconds = 8,
[int]$PollMilliseconds = 250
)
$CandidateDirectories = @(
(Join-Path $ResolvedPackageRoot 'Saved\Logs'),
(Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs'),
(Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs'),
(Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs')
) | Select-Object -Unique
$CandidatePaths = @(
(Join-Path $ResolvedPackageRoot 'Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
(Join-Path $ResolvedPackageRoot 'Windows\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
(Join-Path $ResolvedPackageRoot 'Windows\UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log'),
(Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\Logs\HyperTwistFirstRunLaunch-latest.log')
) | Select-Object -Unique
$DeadlineUtc = [DateTime]::UtcNow.AddSeconds([Math]::Max(0, $TimeoutSeconds))
do
{
foreach ($CandidatePath in $CandidatePaths)
{
if ((Test-Path -LiteralPath $CandidatePath) `
-and (Get-Item -LiteralPath $CandidatePath).LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2))
{
return $CandidatePath
}
}
foreach ($CandidateDirectory in $CandidateDirectories)
{
if (-not (Test-Path -LiteralPath $CandidateDirectory))
{
continue
}
$NewestDiagnosticsLog = Get-ChildItem -LiteralPath $CandidateDirectory -Filter 'HyperTwistFirstRunLaunch*.log' -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTimeUtc -ge $NotBeforeUtc.AddSeconds(-2) } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if ($null -ne $NewestDiagnosticsLog)
{
return $NewestDiagnosticsLog.FullName
}
}
if ([DateTime]::UtcNow -ge $DeadlineUtc)
{
break
}
Start-Sleep -Milliseconds ([Math]::Max(50, $PollMilliseconds))
}
while ($true)
return $null
}
function Get-OwnedPackageProcessIds {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[int[]]$ExcludedProcessIds = @()
)
return @(
Get-OwnedPackageProcessRecords `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExcludedProcessIds |
Select-Object -ExpandProperty ProcessId
)
}
function Get-OwnedPackageProcessRecords {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[int[]]$ExcludedProcessIds = @()
)
$RootPrefix = $ResolvedPackageRoot.TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
) + [System.IO.Path]::DirectorySeparatorChar
return @(
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
([string]$_.Name) -like 'UnrealHyperTwist*.exe' `
-and -not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) `
-and ([string]$_.ExecutablePath).StartsWith(
$RootPrefix,
[System.StringComparison]::OrdinalIgnoreCase
) `
-and $ExcludedProcessIds -notcontains [int]$_.ProcessId
}
)
}
function Get-OwnedListeningTcpEndpoints {
param(
[int[]]$ProcessIds
)
if ($ProcessIds.Count -eq 0)
{
return @()
}
return @(
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
Where-Object { $ProcessIds -contains [int]$_.OwningProcess } |
Sort-Object OwningProcess, LocalPort |
ForEach-Object {
[pscustomobject][ordered]@{
processId = [int]$_.OwningProcess
localAddress = [string]$_.LocalAddress
localPort = [int]$_.LocalPort
}
}
)
}
function Stop-OwnedPackageProcesses {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedPackageRoot,
[int[]]$ExcludedProcessIds = @()
)
$StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]'
for ($Attempt = 0; $Attempt -lt 5; $Attempt += 1)
{
$OwnedProcessIds = @(
Get-OwnedPackageProcessIds `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExcludedProcessIds
)
if ($OwnedProcessIds.Count -eq 0)
{
break
}
foreach ($OwnedProcessId in $OwnedProcessIds)
{
Stop-Process -Id $OwnedProcessId -Force -ErrorAction SilentlyContinue
[void]$StoppedProcessIds.Add([int]$OwnedProcessId)
}
Start-Sleep -Milliseconds 200
}
return @($StoppedProcessIds)
}
function Get-FatalRuntimeLogMatches {
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeLogPath
)
if (-not (Test-Path -LiteralPath $RuntimeLogPath))
{
return @()
}
$FatalPatterns = @(
'Fatal error:',
'LowLevelFatalError',
'Assertion failed:',
'Critical error:',
'appError called:',
'DXGI_ERROR_NOT_CURRENTLY_AVAILABLE',
'CreateSwapChainResult failed',
'Unhandled Exception:',
'StaticShutdownAfterError'
)
return @(Get-Content -LiteralPath $RuntimeLogPath | Where-Object {
$Line = $_
foreach ($Pattern in $FatalPatterns)
{
if ($Line -like "*$Pattern*")
{
return $true
}
}
return $false
})
}
$CandidateExecutablePaths = @(
(Join-Path $PackageRoot 'Windows\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'WindowsNoEditor\UnrealHyperTwist.exe'),
(Join-Path $PackageRoot 'UnrealHyperTwist.exe')
)
$ExecutablePath = $CandidateExecutablePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($null -eq $ExecutablePath)
{
throw "No packaged UnrealHyperTwist executable was found beneath '$PackageRoot'."
}
$LogDirectory = Join-Path $PackageRoot 'validation\logs'
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
$RuntimeLogPath = Join-Path $LogDirectory 'desktop-runtime.log'
$RuntimeDiagnosticsPath = Join-Path $LogDirectory 'desktop-runtime.hyperdiagnostics.log'
Remove-Item -LiteralPath $RuntimeLogPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $RuntimeDiagnosticsPath -Force -ErrorAction SilentlyContinue
$ArgumentList = @(
"-ResX=$ResX",
"-ResY=$ResY",
'-windowed',
'-log',
'-FORCELOGFLUSH',
"-abslog=$RuntimeLogPath",
"-HyperTwistDiagnosticsLog=`"$RuntimeDiagnosticsPath`""
)
if ($UseNullRHI)
{
$ArgumentList += '-NullRHI'
}
if ($NoSound)
{
$ArgumentList += '-nosound'
}
if (-not [string]::IsNullOrWhiteSpace($MapUrl))
{
$ArgumentList = @($MapUrl) + $ArgumentList
}
Write-Host "Launching packaged HyperTwist desktop experience from '$ExecutablePath'..."
$ResolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path
$ExistingPackageProcessIds = @(
Get-OwnedPackageProcessIds -ResolvedPackageRoot $ResolvedPackageRoot
)
$GeneratedAtUtc = [DateTime]::UtcNow.ToString('o')
$Process = $null
$Report = [ordered]@{
reportVersion = 'ht-desktop-package-launch-surface/v2'
generatedAtUtc = $GeneratedAtUtc
packageRoot = $ResolvedPackageRoot
executablePath = $ExecutablePath
mapUrl = $MapUrl
runtimeLogPath = $RuntimeLogPath
runtimeLogExists = $false
runtimeDiagnosticsPath = $RuntimeDiagnosticsPath
runtimeDiagnosticsExists = $false
runtimeDiagnosticsInitialized = $false
detectedFatalLogLines = @()
smokeSeconds = $SmokeSeconds
resolution = [ordered]@{
width = $ResX
height = $ResY
}
keepRunning = [bool]$KeepRunning
useNullRhi = [bool]$UseNullRHI
noSound = [bool]$NoSound
requireStartupDiagnostics = [bool]$RequireStartupDiagnostics
expectedStartupDiagnosticsResult = $ExpectedStartupDiagnosticsResult
startupDiagnosticsLogPath = $null
startupDiagnosticsLogExists = $false
startupDiagnosticsTail = @()
startupDiagnosticsResult = $null
result = 'failed'
processId = $null
processIds = @()
processCommandLines = @()
ownedListeningTcpEndpoints = @()
traceControlListenerLines = @()
traceControlListeningEndpoints = @()
processStopped = $false
stoppedProcessIds = @()
exitCode = $null
error = $null
}
try
{
$LaunchStartedAtUtc = [DateTime]::UtcNow
$Process = Start-Process -FilePath $ExecutablePath -ArgumentList $ArgumentList -PassThru
Start-Sleep -Seconds $SmokeSeconds
$Process.Refresh()
$Report.processId = $Process.Id
$OwnedProcessRecords = @(
Get-OwnedPackageProcessRecords `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExistingPackageProcessIds
)
$Report.processIds = @($OwnedProcessRecords | Select-Object -ExpandProperty ProcessId)
$Report.processCommandLines = @(
$OwnedProcessRecords |
ForEach-Object { [string]$_.CommandLine } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
$Report.ownedListeningTcpEndpoints = @(
Get-OwnedListeningTcpEndpoints -ProcessIds $Report.processIds
)
$Report.traceControlListeningEndpoints = @(
$Report.ownedListeningTcpEndpoints |
Where-Object { [int]$_.localPort -eq 1985 }
)
Start-Sleep -Milliseconds 500
$Report.runtimeLogExists = Test-Path -LiteralPath $RuntimeLogPath
$Report.runtimeDiagnosticsExists = Test-Path -LiteralPath $RuntimeDiagnosticsPath
$Report.detectedFatalLogLines = @(
@(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeLogPath) +
@(Get-FatalRuntimeLogMatches -RuntimeLogPath $RuntimeDiagnosticsPath)
)
$CurrentRuntimeLogLines = @(
if ($Report.runtimeLogExists)
{
Get-Content -LiteralPath $RuntimeLogPath
}
)
$CurrentRuntimeDiagnosticsLines = @(
if ($Report.runtimeDiagnosticsExists)
{
Get-Content -LiteralPath $RuntimeDiagnosticsPath
}
)
$CurrentRuntimeEvidenceLines = @(
$CurrentRuntimeLogLines + $CurrentRuntimeDiagnosticsLines
)
$Report.runtimeDiagnosticsInitialized = @(
$CurrentRuntimeDiagnosticsLines |
Where-Object {
$_.ToString().IndexOf(
'[Process] HyperTwist runtime module initialized.',
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0
}
).Count -gt 0
$Report.traceControlListenerLines = @(
$CurrentRuntimeEvidenceLines |
Where-Object {
$_.ToString().IndexOf(
'Control listening on port',
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0
} |
ForEach-Object { $_.ToString() }
)
$StartupDiagnosticsLogPath = Resolve-StartupDiagnosticsLogPath `
-ResolvedPackageRoot $ResolvedPackageRoot `
-NotBeforeUtc $LaunchStartedAtUtc
$Report.startupDiagnosticsLogPath = $StartupDiagnosticsLogPath
$Report.startupDiagnosticsLogExists = $null -ne $StartupDiagnosticsLogPath
if ($Report.startupDiagnosticsLogExists)
{
$Report.startupDiagnosticsTail = @(
Get-Content -LiteralPath $StartupDiagnosticsLogPath -Tail 40 |
ForEach-Object { $_.ToString() }
)
$Report.startupDiagnosticsResult = [string](
$Report.startupDiagnosticsTail |
Where-Object { $_ -like 'result=*' } |
Select-Object -Last 1
)
}
if (-not $Report.runtimeDiagnosticsExists)
{
throw 'Packaged desktop launch did not write its direct HyperTwist diagnostics log.'
}
if (-not $Report.runtimeDiagnosticsInitialized)
{
throw 'Packaged desktop launch did not record direct HyperTwist runtime initialization.'
}
if ($Report.detectedFatalLogLines.Count -gt 0)
{
throw (
"Packaged desktop runtime diagnostics recorded fatal startup evidence: " +
($Report.detectedFatalLogLines -join ' | ')
)
}
if ($Report.traceControlListenerLines.Count -gt 0 `
-or $Report.traceControlListeningEndpoints.Count -gt 0)
{
throw 'Packaged desktop runtime opened an Unreal trace-control listener.'
}
if ($RequireStartupDiagnostics -and -not $Report.startupDiagnosticsLogExists)
{
throw "Packaged desktop launch did not write HyperTwistFirstRunLaunch-latest.log."
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedStartupDiagnosticsResult) `
-and $Report.startupDiagnosticsResult -ne "result=$ExpectedStartupDiagnosticsResult")
{
throw (
"Expected startup diagnostics result '$ExpectedStartupDiagnosticsResult' but observed " +
"'$($Report.startupDiagnosticsResult)'."
)
}
if ($Report.processIds.Count -eq 0)
{
if ($Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
throw 'Packaged desktop executable exited before the smoke interval completed.'
}
$Report.result = 'passed'
}
catch
{
$Report.error = $_.Exception.Message
if ($null -ne $Process -and $Process.HasExited)
{
$Report.exitCode = $Process.ExitCode
}
if (-not $KeepRunning)
{
$Report.stoppedProcessIds = @(
Stop-OwnedPackageProcesses `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExistingPackageProcessIds
)
$Report.processStopped = $Report.stoppedProcessIds.Count -gt 0
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}
throw
}
Write-Host "Packaged desktop smoke launch succeeded (PID $($Process.Id))."
if (-not $KeepRunning)
{
$Report.stoppedProcessIds = @(
Stop-OwnedPackageProcesses `
-ResolvedPackageRoot $ResolvedPackageRoot `
-ExcludedProcessIds $ExistingPackageProcessIds
)
$Report.processStopped = $Report.stoppedProcessIds.Count -gt 0
Write-Host 'Stopped the complete packaged desktop smoke process tree after successful launch validation.'
}
if (-not [string]::IsNullOrWhiteSpace($ReportPath))
{
Write-Utf8JsonFile -Path $ReportPath -Value $Report
}