gridcraft/packaging/windows/preview-installer-dialogs.ps1

121 lines
5.7 KiB
PowerShell

<#
.SYNOPSIS
Capture native MSI dialog previews without installing the package.
.DESCRIPTION
Opens the MSI read-only and uses Windows Installer's inactive UI preview API.
These show authored dialog layout, not an end-to-end installation or runtime messages.
https://learn.microsoft.com/windows/win32/msi/previewing-the-user-interface
#>
param(
[Parameter(Mandatory = $true)] [string] $Msi,
[Parameter(Mandatory = $true)] [string] $OutputDir
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
throw 'Native MSI dialog previews require Windows.'
}
$Msi = (Resolve-Path -LiteralPath $Msi).Path
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$OutputDir = (Resolve-Path -LiteralPath $OutputDir).Path
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class GridcraftMsiPreview {
[DllImport("msi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
public static extern uint MsiOpenDatabaseW(string path, IntPtr persist, out uint database);
[DllImport("msi.dll")]
public static extern uint MsiEnableUIPreview(uint database, out uint preview);
[DllImport("msi.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
public static extern uint MsiPreviewDialogW(uint preview, string dialog);
[DllImport("msi.dll")]
public static extern uint MsiCloseHandle(uint handle);
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
private delegate bool WindowCallback(IntPtr window, IntPtr state);
[DllImport("user32.dll")]
private static extern bool EnumWindows(WindowCallback callback, IntPtr state);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr window, out uint process);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int GetWindowText(IntPtr window, StringBuilder text, int count);
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")]
public static extern bool PrintWindow(IntPtr window, IntPtr dc, uint flags);
public static IntPtr FindDialog(uint process) {
IntPtr found = IntPtr.Zero;
EnumWindows(delegate(IntPtr window, IntPtr state) {
uint owner;
GetWindowThreadProcessId(window, out owner);
if (owner != process) return true;
var title = new StringBuilder(128);
GetWindowText(window, title, title.Capacity);
if (title.ToString() != "GridCraft Setup") return true;
found = window;
return false;
}, IntPtr.Zero);
return found;
}
}
'@
function Assert-MsiResult([uint32] $Result, [string] $Operation) {
if ($Result -ne 0) { throw "$Operation failed with MSI error $Result" }
}
[uint32] $database = 0
[uint32] $preview = 0
try {
Assert-MsiResult ([GridcraftMsiPreview]::MsiOpenDatabaseW($Msi, [IntPtr]::Zero, [ref] $database)) 'Open read-only MSI'
$dialogs = 'DesktopShortcutDlg', 'SetupCompleteDlg', 'SetupCanceledDlg', 'SetupFailedDlg', 'GridcraftErrorDlg', 'FilesInUse'
foreach ($dialog in $dialogs) {
# Give each dialog its own preview session; closing its handle releases the UI.
Assert-MsiResult ([GridcraftMsiPreview]::MsiEnableUIPreview($database, [ref] $preview)) "Enable preview for $dialog"
Assert-MsiResult ([GridcraftMsiPreview]::MsiPreviewDialogW($preview, $dialog)) "Preview $dialog"
$window = [IntPtr]::Zero
$wait = [Diagnostics.Stopwatch]::StartNew()
while ($window -eq [IntPtr]::Zero -and $wait.Elapsed.TotalSeconds -lt 5) {
[Windows.Forms.Application]::DoEvents()
$window = [GridcraftMsiPreview]::FindDialog([uint32] $PID)
Start-Sleep -Milliseconds 50
}
if ($window -eq [IntPtr]::Zero) { throw "No native window found for $dialog" }
[Windows.Forms.Application]::DoEvents()
$rect = New-Object GridcraftMsiPreview+Rect
if (-not [GridcraftMsiPreview]::GetWindowRect($window, [ref] $rect)) { throw "Cannot measure $dialog" }
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -lt 1 -or $height -lt 1 -or $width -gt 4096 -or $height -gt 4096) { throw "Invalid preview dimensions for $dialog" }
$bitmap = New-Object Drawing.Bitmap($width, $height)
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$dc = $graphics.GetHdc()
try {
if (-not [GridcraftMsiPreview]::PrintWindow($window, $dc, 0)) { throw "Capture failed for $dialog" }
} finally { $graphics.ReleaseHdc($dc) }
$colors = New-Object 'Collections.Generic.HashSet[int]'
for ($y = 0; $y -lt $height; $y += [Math]::Max(1, [int] ($height / 32))) {
for ($x = 0; $x -lt $width; $x += [Math]::Max(1, [int] ($width / 32))) {
$null = $colors.Add($bitmap.GetPixel($x, $y).ToArgb())
}
}
if ($colors.Count -lt 2) { throw "Empty capture for $dialog" }
$bitmap.Save((Join-Path $OutputDir "$dialog.png"), [Drawing.Imaging.ImageFormat]::Png)
} finally {
$graphics.Dispose()
$bitmap.Dispose()
}
Assert-MsiResult ([GridcraftMsiPreview]::MsiCloseHandle($preview)) "Close preview for $dialog"
$preview = 0
Write-Host "Saved native MSI dialog preview: $dialog"
}
'Native MSI dialog previews only. No installation was run. Support dialogs have no live error or process data.' |
Set-Content -LiteralPath (Join-Path $OutputDir 'README.txt') -Encoding utf8
} finally {
if ($preview -ne 0) {
$null = [GridcraftMsiPreview]::MsiCloseHandle($preview)
}
if ($database -ne 0) { $null = [GridcraftMsiPreview]::MsiCloseHandle($database) }
}