535 lines
19 KiB
PowerShell
535 lines
19 KiB
PowerShell
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
|
|
)
|
|
|
|
$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(3)
|
|
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
|
|
}
|
|
}
|