Files
soft/tools/screenshotApp.ps1
T

538 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,
[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
}
}