param( [Parameter(Mandatory = $true)] [string]$Executable, [Parameter(Mandatory = $true)] [string]$Name, [Parameter(Mandatory = $true)] [string]$OutputDirectory, [string]$Arguments = "", [string]$WorkingDirectory = "", [string]$InitialKeys = "", [string]$ScreenshotTool = "", [ValidateRange(1, 60)] [int]$WaitSeconds = 8, [ValidateRange(0, 60)] [int]$SettleSeconds = 3 ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() Add-Type -AssemblyName System.Drawing Add-Type -AssemblyName System.Windows.Forms Add-Type @" using System; using System.Text; using System.Runtime.InteropServices; public static class ScreenshotWindowApi { public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left; public int Top; public int Right; public int Bottom; } [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out Rect rect); [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attribute, out Rect value, int size); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool IsZoomed(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr insertAfter, int x, int y, int width, int height, uint flags); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam); } "@ function Close-StaleWindows { [ScreenshotWindowApi]::EnumWindows({ param($handle, $parameter) if ([ScreenshotWindowApi]::IsWindowVisible($handle)) { $title = [System.Text.StringBuilder]::new(1024) [void][ScreenshotWindowApi]::GetWindowText($handle, $title, $title.Capacity) if ($title.ToString() -match '^slged\.exe\s+-') { [void][ScreenshotWindowApi]::PostMessage($handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) } } return $true }, [IntPtr]::Zero) | Out-Null } function Get-ProcessTreeIds { param([int]$RootProcessId) $ids = [System.Collections.Generic.HashSet[int]]::new() [void]$ids.Add($RootProcessId) do { $changed = $false foreach ($process in Get-CimInstance Win32_Process) { if ($ids.Contains([int]$process.ParentProcessId) -and $ids.Add([int]$process.ProcessId)) { $changed = $true } } } while ($changed) return ,$ids } function Get-ProcessIds { $ids = [System.Collections.Generic.HashSet[int]]::new() foreach ($item in Get-Process -ErrorAction SilentlyContinue) { [void]$ids.Add([int]$item.Id) } return ,$ids } function Stop-NewProcesses { param([System.Collections.Generic.HashSet[int]]$BaselineProcessIds) $currentSessionId = (Get-Process -Id $PID).SessionId $protectedNames = @( "csrss", "dwm", "explorer", "fontdrvhost", "powershell", "sihost", "StartMenuExperienceHost", "taskhostw", "TextInputHost", "winlogon" ) foreach ($item in Get-Process -ErrorAction SilentlyContinue) { if ( $item.Id -ne $PID -and $item.SessionId -eq $currentSessionId -and -not $BaselineProcessIds.Contains([int]$item.Id) -and $protectedNames -notcontains $item.ProcessName ) { taskkill.exe /PID $item.Id /T /F 2>$null | Out-Null } } } function Stop-StaleCatalogProcesses { param([string]$ExecutablePath) $match = [regex]::Match($ExecutablePath, '^(.*\\work)\\', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) if (-not $match.Success) { return } $workRoot = $match.Groups[1].Value.TrimEnd('\') + '\' foreach ($item in Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) { if ($item.ExecutablePath -and $item.ExecutablePath.StartsWith($workRoot, [System.StringComparison]::OrdinalIgnoreCase)) { taskkill.exe /PID $item.ProcessId /T /F 2>$null | Out-Null } } } function Get-ProgramWindows { param([System.Collections.Generic.HashSet[int]]$ProcessIds) $windows = [System.Collections.Generic.List[object]]::new() [ScreenshotWindowApi]::EnumWindows({ param($handle, $parameter) if (-not [ScreenshotWindowApi]::IsWindowVisible($handle)) { return $true } [uint32]$windowProcessId = 0 [void][ScreenshotWindowApi]::GetWindowThreadProcessId($handle, [ref]$windowProcessId) if (-not $ProcessIds.Contains([int]$windowProcessId)) { return $true } $title = [System.Text.StringBuilder]::new(1024) [void][ScreenshotWindowApi]::GetWindowText($handle, $title, $title.Capacity) $fullRect = [ScreenshotWindowApi+Rect]::new() if (-not [ScreenshotWindowApi]::GetWindowRect($handle, [ref]$fullRect)) { return $true } $rect = [ScreenshotWindowApi+Rect]::new() $dwmResult = [ScreenshotWindowApi]::DwmGetWindowAttribute( $handle, 9, [ref]$rect, [System.Runtime.InteropServices.Marshal]::SizeOf([type][ScreenshotWindowApi+Rect]) ) if ($dwmResult -ne 0) { $rect = $fullRect } $width = $rect.Right - $rect.Left $height = $rect.Bottom - $rect.Top if ($width -le 1 -or $height -le 1) { return $true } $windows.Add([pscustomobject]@{ Handle = $handle ProcessId = [int]$windowProcessId Title = $title.ToString() Left = $rect.Left Top = $rect.Top Width = $width Height = $height Area = $width * $height CropLeft = $rect.Left - $fullRect.Left CropTop = $rect.Top - $fullRect.Top }) return $true }, [IntPtr]::Zero) | Out-Null return $windows } function Crop-WindowShadow { param( [string]$Path, [int]$Left, [int]$Top, [int]$Width, [int]$Height ) $source = [System.Drawing.Bitmap]::FromFile($Path) try { if ($Left -eq 0 -and $Top -eq 0 -and $source.Width -eq $Width -and $source.Height -eq $Height) { return } if ($Left -lt 0 -or $Top -lt 0 -or $Left + $Width -gt $source.Width -or $Top + $Height -gt $source.Height) { return } $cropped = [System.Drawing.Bitmap]::new($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) $graphics = [System.Drawing.Graphics]::FromImage($cropped) try { $destination = [System.Drawing.Rectangle]::new(0, 0, $Width, $Height) $graphics.DrawImage($source, $destination, $Left, $Top, $Width, $Height, [System.Drawing.GraphicsUnit]::Pixel) $temporaryPath = "$Path.cropped.png" $cropped.Save($temporaryPath, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $graphics.Dispose() $cropped.Dispose() } } finally { $source.Dispose() } Move-Item -LiteralPath $temporaryPath -Destination $Path -Force } function Clear-LowAlphaBorder { param( [string]$Path, [byte]$AlphaThreshold = 51, [int]$BorderWidth = 16 ) $bitmap = [System.Drawing.Bitmap]::FromFile($Path) try { $horizontalBorder = [Math]::Min($BorderWidth, [Math]::Floor($bitmap.Height / 2)) $verticalBorder = [Math]::Min($BorderWidth, [Math]::Floor($bitmap.Width / 2)) $rectangle = [System.Drawing.Rectangle]::new(0, 0, $bitmap.Width, $bitmap.Height) $bitmapData = $bitmap.LockBits( $rectangle, [System.Drawing.Imaging.ImageLockMode]::ReadWrite, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb ) try { $stride = [Math]::Abs($bitmapData.Stride) $pixels = [byte[]]::new($stride * $bitmap.Height) [System.Runtime.InteropServices.Marshal]::Copy($bitmapData.Scan0, $pixels, 0, $pixels.Length) for ($y = 0; $y -lt $bitmap.Height; $y++) { $isHorizontalBorder = $y -lt $horizontalBorder -or $y -ge $bitmap.Height - $horizontalBorder for ($x = 0; $x -lt $bitmap.Width; $x++) { if (-not $isHorizontalBorder -and $x -ge $verticalBorder -and $x -lt $bitmap.Width - $verticalBorder) { continue } $alphaOffset = $y * $stride + $x * 4 + 3 if ($pixels[$alphaOffset] -lt $AlphaThreshold) { $pixels[$alphaOffset] = 0 } } } [System.Runtime.InteropServices.Marshal]::Copy($pixels, 0, $bitmapData.Scan0, $pixels.Length) } finally { $bitmap.UnlockBits($bitmapData) } $temporaryPath = "$Path.alpha.png" $bitmap.Save($temporaryPath, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() } Move-Item -LiteralPath $temporaryPath -Destination $Path -Force } function Save-Result { param([hashtable]$Result) $Result | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 (Join-Path $OutputDirectory "$Name.json") } New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null $process = $null $baselineProcessIds = $null try { Stop-StaleCatalogProcesses -ExecutablePath $Executable Close-StaleWindows $baselineProcessIds = Get-ProcessIds $startParameters = @{ FilePath = $Executable WorkingDirectory = $(if ($WorkingDirectory.Length -gt 0) { $WorkingDirectory } else { Split-Path $Executable }) PassThru = $true } if ($Arguments.Length -gt 0) { $startParameters.ArgumentList = $Arguments } $process = Start-Process @startParameters $deadline = (Get-Date).AddSeconds($WaitSeconds) $windows = @() do { Start-Sleep -Milliseconds 500 $processIds = Get-ProcessTreeIds -RootProcessId $process.Id $windows = @(Get-ProgramWindows -ProcessIds $processIds) } while ($windows.Count -eq 0 -and (Get-Date) -lt $deadline) if ($windows.Count -eq 0) { $reason = if ($process.HasExited) { "process-exited" } else { "no-window" } Save-Result @{ success = $false reason = $reason exitCode = $(if ($process.HasExited) { $process.ExitCode } else { $null }) } exit 1 } $settleDeadline = (Get-Date).AddSeconds($SettleSeconds) do { Start-Sleep -Milliseconds 500 $processIds = Get-ProcessTreeIds -RootProcessId $process.Id $settledWindows = @(Get-ProgramWindows -ProcessIds $processIds) if ($settledWindows.Count -gt 0) { $windows = $settledWindows } } while ((Get-Date) -lt $settleDeadline) $preferredTitle = "" if ($InitialKeys.Length -gt 0) { foreach ($initialKey in $InitialKeys.Split(',')) { $rawKey = $initialKey.Trim() $key = $rawKey.ToUpperInvariant() if ($key.StartsWith("SELECT-TITLE=")) { $preferredTitle = $rawKey.Substring(13) } elseif ($key.StartsWith("TEXT=")) { $initialWindow = $windows | Sort-Object Area -Descending | Select-Object -First 1 [void][ScreenshotWindowApi]::SetForegroundWindow($initialWindow.Handle) [System.Windows.Forms.SendKeys]::SendWait($rawKey.Substring(5)) } elseif ($key -eq "CLOSE-SMALL") { $dialog = $windows | Sort-Object Area | Select-Object -First 1 [void][ScreenshotWindowApi]::PostMessage($dialog.Handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) } elseif ($key -eq "CLOSE-LARGEST") { $dialog = $windows | Sort-Object Area -Descending | Select-Object -First 1 [void][ScreenshotWindowApi]::PostMessage($dialog.Handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) } else { $initialWindow = $windows | Sort-Object Area -Descending | Select-Object -First 1 [void][ScreenshotWindowApi]::SetForegroundWindow($initialWindow.Handle) $sendKeys = switch ($key) { "ALT+C" { "%c" } "ALT+N" { "%n" } "ALT+Y" { "%y" } "ALT+F4" { "%{F4}" } default { "{$key}" } } [System.Windows.Forms.SendKeys]::SendWait($sendKeys) } Start-Sleep -Seconds 2 $processIds = Get-ProcessTreeIds -RootProcessId $process.Id $dismissedWindows = @(Get-ProgramWindows -ProcessIds $processIds) if ($dismissedWindows.Count -gt 0) { $windows = $dismissedWindows } } } $window = if ($preferredTitle.Length -gt 0) { $windows | Where-Object Title -Like "*$preferredTitle*" | Sort-Object Area -Descending | Select-Object -First 1 } else { $windows | Sort-Object Area -Descending | Select-Object -First 1 } if ($null -eq $window) { throw "No window matched preferred title: $preferredTitle" } if ([ScreenshotWindowApi]::IsIconic($window.Handle)) { [void][ScreenshotWindowApi]::ShowWindow($window.Handle, 9) } if (-not [ScreenshotWindowApi]::IsZoomed($window.Handle)) { [void][ScreenshotWindowApi]::SetWindowPos($window.Handle, [IntPtr]::Zero, 20, 20, 0, 0, 0x0015) Start-Sleep -Milliseconds 300 $processIds = Get-ProcessTreeIds -RootProcessId $process.Id $movedWindows = @(Get-ProgramWindows -ProcessIds $processIds) $movedWindow = $movedWindows | Where-Object Handle -eq $window.Handle | Select-Object -First 1 if ($null -ne $movedWindow) { $window = $movedWindow $windows = $movedWindows } } [void][ScreenshotWindowApi]::SetForegroundWindow($window.Handle) Close-StaleWindows Start-Sleep -Milliseconds 500 $screenshotPath = Join-Path $OutputDirectory "$Name.png" $borderInset = 2 if ($ScreenshotTool.Length -gt 0 -and (Test-Path -LiteralPath $ScreenshotTool)) { $screenshotToolName = [System.IO.Path]::GetFileName($ScreenshotTool) if ($screenshotToolName -ieq "winapp.exe") { $winappExitCode = 1 $winappOutput = @() for ($attempt = 1; $attempt -le 2; $attempt++) { Remove-Item -LiteralPath $screenshotPath -Force -ErrorAction SilentlyContinue $winappOutput = @(& $ScreenshotTool ui screenshot -w $window.Handle.ToInt64() --output $screenshotPath --json 2>&1) $winappExitCode = $LASTEXITCODE if ($winappExitCode -eq 0) { break } Start-Sleep -Milliseconds 500 } if ($winappExitCode -ne 0) { Remove-Item -LiteralPath $screenshotPath -Force -ErrorAction SilentlyContinue $winappOutput = @(& $ScreenshotTool ui screenshot -a $window.ProcessId --output $screenshotPath --json 2>&1) $winappExitCode = $LASTEXITCODE } if ($winappExitCode -ne 0) { $winappDetails = ($winappOutput | Out-String).Trim() throw "winapp CLI failed for window '$($window.Title)' with exit code ${winappExitCode}: $winappDetails" } } else { [void][ScreenshotWindowApi]::SetForegroundWindow($window.Handle) Start-Sleep -Milliseconds 200 & $ScreenshotTool savescreenshotwin $screenshotPath } $screenshotDeadline = (Get-Date).AddSeconds(3) while ( (-not (Test-Path -LiteralPath $screenshotPath) -or (Get-Item -LiteralPath $screenshotPath).Length -eq 0) -and (Get-Date) -lt $screenshotDeadline ) { Start-Sleep -Milliseconds 100 } if (-not (Test-Path -LiteralPath $screenshotPath) -or (Get-Item -LiteralPath $screenshotPath).Length -eq 0) { throw "Window screenshot tool failed for window '$($window.Title)'." } if ($screenshotToolName -ieq "winapp.exe") { Clear-LowAlphaBorder -Path $screenshotPath } else { Crop-WindowShadow ` -Path $screenshotPath ` -Left ($window.CropLeft + $borderInset) ` -Top ($window.CropTop + $borderInset) ` -Width ($window.Width - 2 * $borderInset) ` -Height ($window.Height - 2 * $borderInset) } } else { $bitmap = [System.Drawing.Bitmap]::new($window.Width, $window.Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) $graphics = [System.Drawing.Graphics]::FromImage($bitmap) try { $graphics.CopyFromScreen($window.Left, $window.Top, 0, 0, $bitmap.Size) $bitmap.Save($screenshotPath, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $graphics.Dispose() $bitmap.Dispose() } Crop-WindowShadow ` -Path $screenshotPath ` -Left $borderInset ` -Top $borderInset ` -Width ($window.Width - 2 * $borderInset) ` -Height ($window.Height - 2 * $borderInset) } Save-Result @{ success = $true title = $window.Title width = $window.Width height = $window.Height windows = @($windows | ForEach-Object { @{ processId = $_.ProcessId title = $_.Title width = $_.Width height = $_.Height } }) } } catch { Save-Result @{ success = $false reason = "error" error = $_.Exception.Message } exit 1 } finally { if ($null -ne $process -and -not $process.HasExited) { taskkill.exe /PID $process.Id /T /F 2>$null | Out-Null } if ($null -ne $baselineProcessIds) { Stop-NewProcesses -BaselineProcessIds $baselineProcessIds } }