param( [Parameter(Mandatory = $true)] [string]$InstallerPath, [string]$ValidationRoot = 'C:\HyperTwistInstallerValidation', [string]$ReportPath = '', [int]$LaunchSmokeSeconds = 20, [switch]$KeepInstalled ) $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 12 $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($Path, $Json, $Utf8NoBom) } function Invoke-InstallerProcess { param( [Parameter(Mandatory = $true)] [string]$ExecutablePath, [Parameter(Mandatory = $true)] [string[]]$Arguments, [Parameter(Mandatory = $true)] [string]$Label ) $Process = Start-Process ` -FilePath $ExecutablePath ` -ArgumentList $Arguments ` -PassThru ` -Wait if ($Process.ExitCode -notin @(0, 3010)) { throw "$Label failed with exit code $($Process.ExitCode)." } return [int]$Process.ExitCode } function Invoke-InstallerProcessForExitCode { param( [Parameter(Mandatory = $true)] [string]$ExecutablePath, [Parameter(Mandatory = $true)] [string[]]$Arguments ) $Process = Start-Process ` -FilePath $ExecutablePath ` -ArgumentList $Arguments ` -PassThru ` -Wait return [int]$Process.ExitCode } function Resolve-PayloadRelativeFilePath { param( [Parameter(Mandatory = $true)] [string]$RootPath, [Parameter(Mandatory = $true)] [string]$RelativePath ) if ([string]::IsNullOrWhiteSpace($RelativePath)) { throw 'A payload-relative path cannot be empty.' } $WindowsRelativePath = $RelativePath.Replace('/', '\') if ([System.IO.Path]::IsPathRooted($WindowsRelativePath)) { throw "Payload-relative path '$RelativePath' must not be rooted." } $ResolvedRootPath = [System.IO.Path]::GetFullPath($RootPath) $RootPrefix = $ResolvedRootPath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $ResolvedFilePath = [System.IO.Path]::GetFullPath( (Join-Path $ResolvedRootPath $WindowsRelativePath) ) if (-not $ResolvedFilePath.StartsWith( $RootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Payload-relative path '$RelativePath' escapes root '$RootPath'." } return $ResolvedFilePath } function Test-PayloadManifest { param( [Parameter(Mandatory = $true)] [string]$InstallRoot, [Parameter(Mandatory = $true)] [string]$ManifestPath ) $Manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json $Failures = New-Object System.Collections.ArrayList $ExpectedPaths = @{} foreach ($Entry in @($Manifest.files)) { $EntryRelativePath = [string]$Entry.relativePath $EntryPathKey = $EntryRelativePath.Replace('\', '/').ToLowerInvariant() if ($ExpectedPaths.ContainsKey($EntryPathKey)) { [void]$Failures.Add("duplicate:$EntryRelativePath") continue } $ExpectedPaths[$EntryPathKey] = $true try { $InstalledPath = Resolve-PayloadRelativeFilePath ` -RootPath $InstallRoot ` -RelativePath $EntryRelativePath } catch { [void]$Failures.Add("invalid-path:$EntryRelativePath") continue } if (-not (Test-Path -LiteralPath $InstalledPath -PathType Leaf)) { [void]$Failures.Add("missing:$EntryRelativePath") continue } $InstalledItem = Get-Item -LiteralPath $InstalledPath if ($InstalledItem.Length -ne [Int64]$Entry.sizeBytes) { [void]$Failures.Add("size:$EntryRelativePath") continue } $InstalledHash = (Get-FileHash -LiteralPath $InstalledPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($InstalledHash -ne ([string]$Entry.sha256).ToLowerInvariant()) { [void]$Failures.Add("sha256:$EntryRelativePath") } } $AllowedInstallerPaths = @{} @( 'Installer/payload-manifest.json', 'Installer/Test-HyperTwistWindowsReadiness.ps1', 'Installer/HyperTwist.install-marker', 'Uninstall HyperTwist.exe' ) | ForEach-Object { $AllowedInstallerPaths[$_.ToLowerInvariant()] = $true } $InstallRootPrefix = [System.IO.Path]::GetFullPath($InstallRoot).TrimEnd('\') + '\' $UnexpectedFiles = @( Get-ChildItem -LiteralPath $InstallRoot -File -Force -Recurse | ForEach-Object { $RelativePath = $_.FullName.Substring($InstallRootPrefix.Length).Replace('\', '/') $RelativePathKey = $RelativePath.ToLowerInvariant() if (-not $ExpectedPaths.ContainsKey($RelativePathKey) ` -and -not $AllowedInstallerPaths.ContainsKey($RelativePathKey)) { $RelativePath } } ) $UnexpectedPdbFiles = @( Get-ChildItem -LiteralPath $InstallRoot -Filter '*.pdb' -File -Recurse -ErrorAction SilentlyContinue ) return [pscustomobject][ordered]@{ passed = $Failures.Count -eq 0 ` -and $UnexpectedPdbFiles.Count -eq 0 ` -and $UnexpectedFiles.Count -eq 0 manifestEntryCount = @($Manifest.files).Count failureCount = $Failures.Count failures = @($Failures) unexpectedFiles = $UnexpectedFiles unexpectedPdbFiles = @($UnexpectedPdbFiles | Select-Object -ExpandProperty FullName) } } function Get-OwnedProcessRecords { param( [Parameter(Mandatory = $true)] [string]$InstallRoot ) $InstallPrefix = $InstallRoot.TrimEnd('\') + '\' return @( Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.ExecutablePath) ` -and ([string]$_.ExecutablePath).StartsWith( $InstallPrefix, [System.StringComparison]::OrdinalIgnoreCase) } ) } function Stop-OwnedProcesses { param( [Parameter(Mandatory = $true)] [string]$InstallRoot ) $StoppedProcessIds = New-Object 'System.Collections.Generic.HashSet[int]' for ($Attempt = 0; $Attempt -lt 8; $Attempt += 1) { $OwnedProcesses = @(Get-OwnedProcessRecords -InstallRoot $InstallRoot) if ($OwnedProcesses.Count -eq 0) { break } foreach ($OwnedProcess in $OwnedProcesses) { Stop-Process -Id ([int]$OwnedProcess.ProcessId) -Force -ErrorAction SilentlyContinue [void]$StoppedProcessIds.Add([int]$OwnedProcess.ProcessId) } Start-Sleep -Milliseconds 250 } return @($StoppedProcessIds) } function Invoke-InstalledLaunchSmoke { param( [Parameter(Mandatory = $true)] [string]$InstallRoot, [Parameter(Mandatory = $true)] [string]$EvidenceDirectory, [int]$SmokeSeconds ) $ExecutablePath = Join-Path $InstallRoot 'UnrealHyperTwist.exe' $RuntimeLogPath = Join-Path $EvidenceDirectory 'installed-runtime.log' $DiagnosticsPath = Join-Path $EvidenceDirectory 'installed-runtime.hyperdiagnostics.log' $UserDirectory = Join-Path $EvidenceDirectory 'isolated-user' New-Item -ItemType Directory -Force -Path $UserDirectory | Out-Null $Arguments = @( '-NullRHI', '-nosound', '-windowed', '-ResX=1280', '-ResY=720', '-log', '-FORCELOGFLUSH', "-abslog=$RuntimeLogPath", "-HyperTwistDiagnosticsLog=`"$DiagnosticsPath`"", "-UserDir=`"$UserDirectory`"" ) $StartedAtUtc = [DateTime]::UtcNow $LaunchProcess = Start-Process ` -FilePath $ExecutablePath ` -ArgumentList $Arguments ` -PassThru Start-Sleep -Seconds $SmokeSeconds $OwnedProcesses = @(Get-OwnedProcessRecords -InstallRoot $InstallRoot) $OwnedProcessIds = @($OwnedProcesses | Select-Object -ExpandProperty ProcessId) $ListeningEndpoints = @( if ($OwnedProcessIds.Count -gt 0) { Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $OwnedProcessIds -contains [int]$_.OwningProcess } | ForEach-Object { [pscustomobject][ordered]@{ processId = [int]$_.OwningProcess localAddress = [string]$_.LocalAddress localPort = [int]$_.LocalPort } } } ) $FatalPatterns = @( 'Fatal error:', 'LowLevelFatalError', 'Assertion failed:', 'Critical error:', 'Unhandled Exception:', 'StaticShutdownAfterError' ) $RuntimeLines = @( if (Test-Path -LiteralPath $RuntimeLogPath) { Get-Content -LiteralPath $RuntimeLogPath } ) $DiagnosticsLines = @( if (Test-Path -LiteralPath $DiagnosticsPath) { Get-Content -LiteralPath $DiagnosticsPath } ) $FatalLines = @( @($RuntimeLines + $DiagnosticsLines) | Where-Object { $Line = $_.ToString() @($FatalPatterns | Where-Object { $Line -like "*$_*" }).Count -gt 0 } ) $Initialized = @( $DiagnosticsLines | Where-Object { $_.ToString().IndexOf( '[Process] HyperTwist runtime module initialized.', [System.StringComparison]::OrdinalIgnoreCase ) -ge 0 } ).Count -gt 0 $StoppedProcessIds = @(Stop-OwnedProcesses -InstallRoot $InstallRoot) $Passed = $OwnedProcessIds.Count -gt 0 ` -and (Test-Path -LiteralPath $DiagnosticsPath -PathType Leaf) ` -and $Initialized ` -and $FatalLines.Count -eq 0 ` -and @($ListeningEndpoints | Where-Object { $_.localPort -eq 1985 }).Count -eq 0 return [pscustomobject][ordered]@{ passed = $Passed startedAtUtc = $StartedAtUtc.ToString('o') launcherProcessId = [int]$LaunchProcess.Id ownedProcessIds = $OwnedProcessIds stoppedProcessIds = $StoppedProcessIds runtimeLogPath = $RuntimeLogPath diagnosticsPath = $DiagnosticsPath diagnosticsInitialized = $Initialized fatalLines = $FatalLines listeningEndpoints = $ListeningEndpoints } } function Get-ShortcutEvidence { param( [Parameter(Mandatory = $true)] [string]$Path ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return [pscustomobject][ordered]@{ path = $Path exists = $false targetPath = $null arguments = $null } } $Shell = New-Object -ComObject WScript.Shell $Shortcut = $null try { $Shortcut = $Shell.CreateShortcut($Path) return [pscustomobject][ordered]@{ path = $Path exists = $true targetPath = [string]$Shortcut.TargetPath arguments = [string]$Shortcut.Arguments } } finally { if ($null -ne $Shortcut) { [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($Shortcut) } [void][System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($Shell) } } $CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() $CurrentPrincipal = New-Object Security.Principal.WindowsPrincipal($CurrentIdentity) if (-not $CurrentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Installer lifecycle validation requires an elevated Windows session.' } if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) { throw "Installer '$InstallerPath' was not found." } $ResolvedInstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path $ResolvedValidationRoot = [System.IO.Path]::GetFullPath($ValidationRoot) $RunId = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') $RunDirectory = Join-Path $ResolvedValidationRoot $RunId $InstallRoot = Join-Path $RunDirectory 'HyperTwist' $EvidenceDirectory = Join-Path $RunDirectory 'evidence' New-Item -ItemType Directory -Force -Path $EvidenceDirectory | Out-Null if ([string]::IsNullOrWhiteSpace($ReportPath)) { $ReportPath = Join-Path $EvidenceDirectory 'windows-installer-lifecycle-report.json' } $RepositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path $ReadinessScriptPath = Join-Path $RepositoryRoot 'scripts\Test-HyperTwistWindowsReadiness.ps1' $UninstallRegistryPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\HyperTwist' $ProductRegistryPath = 'HKLM:\SOFTWARE\HyperTwist\HyperTwist' $CommonProgramsPath = [Environment]::GetFolderPath('CommonPrograms') $CommonDesktopPath = [Environment]::GetFolderPath('CommonDesktopDirectory') $StartMenuDirectory = Join-Path $CommonProgramsPath 'HyperTwist' $DesktopShortcutPath = Join-Path $CommonDesktopPath 'HyperTwist.lnk' if (Test-Path -LiteralPath $UninstallRegistryPath) { throw 'A registered HyperTwist installation already exists; validation will not replace it.' } $InstallerItem = Get-Item -LiteralPath $ResolvedInstallerPath $Report = [ordered]@{ reportVersion = 'ht-windows-installer-lifecycle/v1' generatedAtUtc = [DateTime]::UtcNow.ToString('o') result = 'failed' validationClass = 'isolated-current-host' cleanMachineAvailable = $false cleanMachineBoundary = 'No Windows Sandbox, clean Hyper-V VM, or base VHD was available on the validation host.' installerPath = $ResolvedInstallerPath installerSizeBytes = [Int64]$InstallerItem.Length installerSha256 = (Get-FileHash -LiteralPath $ResolvedInstallerPath -Algorithm SHA256).Hash.ToLowerInvariant() installerSignatureStatus = (Get-AuthenticodeSignature -LiteralPath $ResolvedInstallerPath).Status.ToString() installRoot = $InstallRoot unownedNonEmptyRefusal = $null firstInstallExitCode = $null upgradeInstallExitCode = $null payloadParityAfterFirstInstall = $null payloadParityAfterUpgrade = $null readiness = $null launchSmoke = $null uninstallExitCode = $null registrationVerified = $false shortcutsVerified = $false shortcutDetails = $null userDataPreserved = $false installRootRemoved = $false registrationRemoved = $false shortcutsRemoved = $false cleanupCompleted = $false error = $null } $UninstallerPath = Join-Path $InstallRoot 'Uninstall HyperTwist.exe' $SentinelDirectory = Join-Path $env:LOCALAPPDATA 'UnrealHyperTwist\Saved\InstallerValidation' $SentinelPath = Join-Path $SentinelDirectory "$RunId.txt" $Installed = $false try { New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null $UnownedSentinelPath = Join-Path $InstallRoot 'unrelated-owner-sentinel.txt' [System.IO.File]::WriteAllText( $UnownedSentinelPath, "Unowned install-root refusal sentinel $RunId", (New-Object System.Text.UTF8Encoding($false)) ) $UnownedRefusalExitCode = Invoke-InstallerProcessForExitCode ` -ExecutablePath $ResolvedInstallerPath ` -Arguments @('/S', "/D=$InstallRoot") if ($UnownedRefusalExitCode -eq 0) { $Installed = Test-Path -LiteralPath $UninstallerPath -PathType Leaf } $Report.unownedNonEmptyRefusal = [pscustomobject][ordered]@{ passed = $UnownedRefusalExitCode -eq 14 ` -and (Test-Path -LiteralPath $UnownedSentinelPath -PathType Leaf) ` -and -not (Test-Path -LiteralPath $UninstallRegistryPath) ` -and -not (Test-Path -LiteralPath $ProductRegistryPath) exitCode = $UnownedRefusalExitCode expectedExitCode = 14 sentinelPreserved = Test-Path -LiteralPath $UnownedSentinelPath -PathType Leaf registrationAbsent = ` -not (Test-Path -LiteralPath $UninstallRegistryPath) ` -and -not (Test-Path -LiteralPath $ProductRegistryPath) } if (-not $Report.unownedNonEmptyRefusal.passed) { throw 'The installer did not safely refuse an unowned non-empty installation root.' } Remove-Item -LiteralPath $InstallRoot -Recurse -Force $Report.firstInstallExitCode = Invoke-InstallerProcess ` -ExecutablePath $ResolvedInstallerPath ` -Arguments @('/S', "/D=$InstallRoot") ` -Label 'Silent installer' $Installed = $true $ManifestPath = Join-Path $InstallRoot 'Installer\payload-manifest.json' if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { throw 'The installed payload manifest is missing.' } $Report.payloadParityAfterFirstInstall = Test-PayloadManifest ` -InstallRoot $InstallRoot ` -ManifestPath $ManifestPath if (-not $Report.payloadParityAfterFirstInstall.passed) { throw 'Installed payload parity failed after first install.' } $RegisteredProduct = Get-ItemProperty -LiteralPath $UninstallRegistryPath $RegisteredOwner = Get-ItemProperty -LiteralPath $ProductRegistryPath $Report.registrationVerified = ` [System.IO.Path]::GetFullPath([string]$RegisteredProduct.InstallLocation) -eq $InstallRoot ` -and [System.IO.Path]::GetFullPath([string]$RegisteredOwner.InstallLocation) -eq $InstallRoot if (-not $Report.registrationVerified) { throw 'Add/Remove Programs or product registration does not match the isolated install root.' } $ExpectedApplicationPath = Join-Path $InstallRoot 'UnrealHyperTwist.exe' $ExpectedReadinessScriptPath = Join-Path $InstallRoot 'Installer\Test-HyperTwistWindowsReadiness.ps1' $ExpectedManifestPath = Join-Path $InstallRoot 'Installer\payload-manifest.json' $ExpectedUninstallerPath = Join-Path $InstallRoot 'Uninstall HyperTwist.exe' $ApplicationShortcut = Get-ShortcutEvidence ` -Path (Join-Path $StartMenuDirectory 'HyperTwist.lnk') $ReadinessShortcut = Get-ShortcutEvidence ` -Path (Join-Path $StartMenuDirectory 'System Readiness Report.lnk') $UninstallShortcut = Get-ShortcutEvidence ` -Path (Join-Path $StartMenuDirectory 'Uninstall HyperTwist.lnk') $DesktopShortcut = Get-ShortcutEvidence -Path $DesktopShortcutPath $Report.shortcutDetails = [pscustomobject][ordered]@{ application = $ApplicationShortcut readiness = $ReadinessShortcut uninstall = $UninstallShortcut desktop = $DesktopShortcut } $Report.shortcutsVerified = ` $ApplicationShortcut.exists ` -and $ApplicationShortcut.targetPath -eq $ExpectedApplicationPath ` -and $ReadinessShortcut.exists ` -and $ReadinessShortcut.targetPath.EndsWith( '\WindowsPowerShell\v1.0\powershell.exe', [System.StringComparison]::OrdinalIgnoreCase ) ` -and $ReadinessShortcut.arguments.Contains($ExpectedReadinessScriptPath) ` -and $ReadinessShortcut.arguments.Contains($ExpectedManifestPath) ` -and $ReadinessShortcut.arguments.Contains('-WaitForUser') ` -and $UninstallShortcut.exists ` -and $UninstallShortcut.targetPath -eq $ExpectedUninstallerPath ` -and $DesktopShortcut.exists ` -and $DesktopShortcut.targetPath -eq $ExpectedApplicationPath if (-not $Report.shortcutsVerified) { throw 'One or more installer-managed shortcuts are missing or have incorrect targets or arguments.' } $ReadinessReportPath = Join-Path $EvidenceDirectory 'installed-readiness-report.json' $Report.readiness = & $ReadinessScriptPath ` -PayloadRoot $InstallRoot ` -TargetInstallRoot $InstallRoot ` -ExpectedPayloadManifestPath $ManifestPath ` -ReportPath $ReadinessReportPath ` -VerifyPayloadHashes ` -PassThru if ($Report.readiness.result -eq 'blocked') { throw 'Installed-host readiness validation is blocked.' } New-Item -ItemType Directory -Force -Path $SentinelDirectory | Out-Null [System.IO.File]::WriteAllText( $SentinelPath, "HyperTwist installer validation sentinel $RunId", (New-Object System.Text.UTF8Encoding($false)) ) $Report.launchSmoke = Invoke-InstalledLaunchSmoke ` -InstallRoot $InstallRoot ` -EvidenceDirectory $EvidenceDirectory ` -SmokeSeconds $LaunchSmokeSeconds if (-not $Report.launchSmoke.passed) { throw 'Installed executable launch smoke failed.' } $Report.upgradeInstallExitCode = Invoke-InstallerProcess ` -ExecutablePath $ResolvedInstallerPath ` -Arguments @('/S', "/D=$InstallRoot") ` -Label 'Silent in-place upgrade' $ManifestPath = Join-Path $InstallRoot 'Installer\payload-manifest.json' $Report.payloadParityAfterUpgrade = Test-PayloadManifest ` -InstallRoot $InstallRoot ` -ManifestPath $ManifestPath if (-not $Report.payloadParityAfterUpgrade.passed) { throw 'Installed payload parity failed after in-place upgrade.' } if (-not $KeepInstalled) { $Report.uninstallExitCode = Invoke-InstallerProcess ` -ExecutablePath $UninstallerPath ` -Arguments @('/S') ` -Label 'Silent uninstaller' $Installed = $false Start-Sleep -Seconds 3 $Report.userDataPreserved = Test-Path -LiteralPath $SentinelPath -PathType Leaf $Report.installRootRemoved = -not (Test-Path -LiteralPath $InstallRoot) $Report.registrationRemoved = ` -not (Test-Path -LiteralPath $UninstallRegistryPath) ` -and -not (Test-Path -LiteralPath $ProductRegistryPath) $Report.shortcutsRemoved = ` -not (Test-Path -LiteralPath $StartMenuDirectory) ` -and -not (Test-Path -LiteralPath $DesktopShortcutPath) if (-not $Report.userDataPreserved) { throw 'Default uninstall removed current-user data unexpectedly.' } if (-not $Report.installRootRemoved) { throw 'The isolated install root remains after uninstall.' } if (-not $Report.registrationRemoved) { throw 'Installer registry state remains after uninstall.' } if (-not $Report.shortcutsRemoved) { throw 'Installer-managed shortcuts remain after uninstall.' } } $Report.cleanupCompleted = -not $KeepInstalled $Report.result = 'passed' } catch { $Report.error = $_.Exception.Message throw } finally { [void](Stop-OwnedProcesses -InstallRoot $InstallRoot) if ($Installed -and -not $KeepInstalled -and (Test-Path -LiteralPath $UninstallerPath -PathType Leaf)) { try { [void](Invoke-InstallerProcess ` -ExecutablePath $UninstallerPath ` -Arguments @('/S') ` -Label 'Failure-cleanup uninstaller') Start-Sleep -Seconds 3 $Installed = $false } catch { if ([string]::IsNullOrWhiteSpace([string]$Report.error)) { $Report.error = $_.Exception.Message } else { $Report.error = "$($Report.error) | Cleanup: $($_.Exception.Message)" } } } if (-not $KeepInstalled ` -and (Test-Path -LiteralPath $InstallRoot) ` -and -not (Test-Path -LiteralPath $UninstallRegistryPath) ` -and -not (Test-Path -LiteralPath $ProductRegistryPath)) { Remove-Item -LiteralPath $InstallRoot -Recurse -Force } if (Test-Path -LiteralPath $SentinelPath) { Remove-Item -LiteralPath $SentinelPath -Force } if ((Test-Path -LiteralPath $SentinelDirectory) ` -and @(Get-ChildItem -LiteralPath $SentinelDirectory -Force).Count -eq 0) { Remove-Item -LiteralPath $SentinelDirectory -Force } if (-not $KeepInstalled) { $Report.installRootRemoved = -not (Test-Path -LiteralPath $InstallRoot) $Report.registrationRemoved = ` -not (Test-Path -LiteralPath $UninstallRegistryPath) ` -and -not (Test-Path -LiteralPath $ProductRegistryPath) $Report.shortcutsRemoved = ` -not (Test-Path -LiteralPath $StartMenuDirectory) ` -and -not (Test-Path -LiteralPath $DesktopShortcutPath) $Report.cleanupCompleted = ` -not $Installed ` -and $Report.installRootRemoved ` -and $Report.registrationRemoved ` -and $Report.shortcutsRemoved } Write-Utf8JsonFile -Path $ReportPath -Value $Report } [Console]::Out.WriteLine('HyperTwist Windows installer lifecycle validation passed.') [Console]::Out.WriteLine("Report: $([System.IO.Path]::GetFullPath($ReportPath))")