# Shared helpers for the retrobar.dev Windows test scripts: logging, screenshots, # input and UI Automation. Adapted from win11debloat.dev. Dot-source after setting $OutName. $ErrorActionPreference = 'Continue' $ProgressPreference = 'SilentlyContinue' $root = Split-Path -Parent $MyInvocation.MyCommand.Path $repo = Split-Path -Parent $root $out = Join-Path $root $(if ($OutName) { $OutName } else { 'results' }) New-Item -ItemType Directory -Force -Path $out | Out-Null $log = Join-Path $out 'run-log.txt' $facts = [ordered]@{} function Log($m) { $line = "[{0:HH:mm:ss}] {1}" -f (Get-Date), $m; Write-Host $line; Add-Content -Path $log -Value $line } function Save($name, $obj) { $obj | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $out $name) -Encoding UTF8 } Add-Type -AssemblyName System.Windows.Forms, System.Drawing, UIAutomationClient, UIAutomationTypes Add-Type @" using System; using System.Runtime.InteropServices; public static class N { [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, int flags, IntPtr extra); [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); [DllImport("user32.dll")] public static extern void mouse_event(int f, int x, int y, int d, IntPtr e); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindow(string cls, string title); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; } } public static class Disp { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct DEVMODE { [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName; public short dmSpecVersion, dmDriverVersion, dmSize, dmDriverExtra; public int dmFields; public int dmPositionX, dmPositionY, dmDisplayOrientation, dmDisplayFixedOutput; public short dmColor, dmDuplex, dmYResolution, dmTTOption, dmCollate; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName; public short dmLogPixels; public int dmBitsPerPel, dmPelsWidth, dmPelsHeight, dmDisplayFlags, dmDisplayFrequency; public int dmICMMethod, dmICMIntent, dmMediaType, dmDitherType, dmReserved1, dmReserved2, dmPanningWidth, dmPanningHeight; } [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern bool EnumDisplaySettings(string d, int mode, ref DEVMODE dm); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int ChangeDisplaySettings(ref DEVMODE dm, int flags); public static int Set(int w, int h) { DEVMODE dm = new DEVMODE(); dm.dmSize = (short)Marshal.SizeOf(typeof(DEVMODE)); if (!EnumDisplaySettings(null, -1, ref dm)) return -99; dm.dmPelsWidth = w; dm.dmPelsHeight = h; dm.dmFields = 0x80000 | 0x100000; return ChangeDisplaySettings(ref dm, 1); } } "@ [N]::SetProcessDPIAware() | Out-Null function Set-Screen { foreach ($r in @(@(1600, 900), @(1440, 900), @(1366, 768), @(1280, 800))) { $res = [Disp]::Set($r[0], $r[1]); Log "ChangeDisplaySettings $($r[0])x$($r[1]) -> $res" if ($res -eq 0) { break } } Start-Sleep 3 $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds Log "screen now $($b.Width)x$($b.Height)" } function Hide-Consoles { Get-Process WindowsTerminal, conhost, OpenConsole, powershell, pwsh -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Id -ne $PID -and $_.MainWindowTitle -ne 'RetroBar' } | ForEach-Object { [N]::ShowWindow($_.MainWindowHandle, 6) | Out-Null } } # The Arm runner image keeps popping up a "Windows Subsystem for Linux must be updated" prompt # in Windows Terminal; close it before every screenshot so it never covers RetroBar. function Close-WslPrompt { Get-Process WindowsTerminal -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'wsl\.exe' } | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } Get-Process wsl -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } } # Full-screen PNG; with -Window, also a crop of that window's rectangle. function Shot($name, $hwnd = 0, [switch]$KeepCursor) { Close-WslPrompt try { if (-not $KeepCursor) { $sb = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; [N]::SetCursorPos([int]($sb.Width / 2), [int]($sb.Height / 2)) | Out-Null }; Start-Sleep -Milliseconds 350 $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height $g = [System.Drawing.Graphics]::FromImage($bmp) $g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size) $bmp.Save((Join-Path $out "$name.png"), [System.Drawing.Imaging.ImageFormat]::Png) if ($hwnd -ne 0) { $r = New-Object N+RECT if ([N]::GetWindowRect($hwnd, [ref]$r)) { $x = [Math]::Max(0, $r.Left); $y = [Math]::Max(0, $r.Top) $w = [Math]::Min($b.Width, $r.Right) - $x; $h = [Math]::Min($b.Height, $r.Bottom) - $y if ($w -gt 50 -and $h -gt 50) { $crop = $bmp.Clone((New-Object System.Drawing.Rectangle $x, $y, $w, $h), $bmp.PixelFormat) $crop.Save((Join-Path $out "$name-window.png"), [System.Drawing.Imaging.ImageFormat]::Png); $crop.Dispose() } } } $g.Dispose(); $bmp.Dispose() Log "screenshot $name" } catch { Log "screenshot $name FAILED: $_" } } function Key([byte[]]$keys) { foreach ($k in $keys) { [N]::keybd_event($k, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 40 } [array]::Reverse($keys) foreach ($k in $keys) { [N]::keybd_event($k, 0, 2, [IntPtr]::Zero); Start-Sleep -Milliseconds 40 } } function Click($x, $y) { [N]::SetCursorPos($x, $y) | Out-Null; Start-Sleep -Milliseconds 150 [N]::mouse_event(0x02, 0, 0, 0, [IntPtr]::Zero); [N]::mouse_event(0x04, 0, 0, 0, [IntPtr]::Zero) } function RightClick($x, $y) { [N]::SetCursorPos($x, $y) | Out-Null; Start-Sleep -Milliseconds 150 [N]::mouse_event(0x08, 0, 0, 0, [IntPtr]::Zero); [N]::mouse_event(0x10, 0, 0, 0, [IntPtr]::Zero) } function Hover($x, $y) { [N]::SetCursorPos($x, $y) | Out-Null } function Wheel($x, $y, $delta) { [N]::SetCursorPos($x, $y) | Out-Null; Start-Sleep -Milliseconds 100; [N]::mouse_event(0x0800, 0, 0, $delta, [IntPtr]::Zero) } $UIA = [System.Windows.Automation.AutomationElement] function FindName($name, $timeout = 20, $scope = $null) { $cond = New-Object System.Windows.Automation.PropertyCondition($UIA::NameProperty, $name) $end = (Get-Date).AddSeconds($timeout) while ((Get-Date) -lt $end) { try { $el = $(if ($scope) { $scope } else { $UIA::RootElement }).FindFirst([System.Windows.Automation.TreeScope]::Descendants, $cond); if ($el) { return $el } } catch { } Start-Sleep -Milliseconds 600 } return $null } # Like FindName, but only elements that are actually on screen (WPF exposes collapsed text too). function FindVisible($name, $timeout = 5) { $cond = New-Object System.Windows.Automation.PropertyCondition($UIA::NameProperty, $name) $end = (Get-Date).AddSeconds($timeout) do { try { foreach ($el in $UIA::RootElement.FindAll([System.Windows.Automation.TreeScope]::Descendants, $cond)) { $r = $el.Current.BoundingRectangle if (-not $el.Current.IsOffscreen -and $r.Width -gt 0 -and $r.Height -gt 0) { return $el } } } catch { } Start-Sleep -Milliseconds 500 } while ((Get-Date) -lt $end) return $null } function FindTopWindow($title, $timeout = 60) { $cond = New-Object System.Windows.Automation.PropertyCondition($UIA::NameProperty, $title) $end = (Get-Date).AddSeconds($timeout) while ((Get-Date) -lt $end) { try { $el = $UIA::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond); if ($el) { return $el } } catch { } Start-Sleep -Milliseconds 800 } return $null } function Invoke-El($el) { if (-not $el) { return $false } try { $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke(); return $true } catch { } try { $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Toggle(); return $true } catch { } try { $el.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select(); return $true } catch { } try { $r = $el.Current.BoundingRectangle; Click ([int]($r.X + $r.Width / 2)) ([int]($r.Y + $r.Height / 2)); return $true } catch { return $false } } function Press($name, $timeout = 15, $scope = $null) { $el = FindName $name $timeout $scope $ok = Invoke-El $el Log "press '$name' -> $ok" return $ok } # Fresh Windows 11 runner images show the first-run "Choose privacy settings for # your device" page on top of every window. It is hosted by CloudExperienceHost; # close it so screenshots show the desktop. Logs the top-level windows around it. function Get-TopWindows { $rows = foreach ($e in $UIA::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition)) { $procName = try { (Get-Process -Id $e.Current.ProcessId -ErrorAction Stop).ProcessName } catch { '?' } "{0} | {1} | {2}" -f $e.Current.ClassName, $e.Current.Name, $procName } return @($rows) } function Dismiss-Oobe { # Runner images also open a "System Properties" paging-file notice; close it too. foreach ($e in $UIA::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, (New-Object System.Windows.Automation.PropertyCondition($UIA::NameProperty, 'System Properties')))) { try { $e.GetCurrentPattern([System.Windows.Automation.WindowPattern]::Pattern).Close(); Log 'closed System Properties notice' } catch { } } # And a "Windows Subsystem for Linux must be updated" prompt in Windows Terminal. # Only that window: on Windows Server the runner's own console lives in Windows Terminal. Get-Process WindowsTerminal -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'wsl\.exe' } | ForEach-Object { Log "closing WSL prompt window ($($_.Id))"; Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } Get-Process wsl -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } $before = Get-TopWindows if (-not ($before -match 'Shell_OOBEProxy|CloudExperienceHost|WWAHost')) { Log 'no first-run privacy page'; return } Log ('top windows: ' + ($before -join ' ;; ')) Get-Process CloudExperienceHostBroker, WWAHost, CloudExperienceHost -ErrorAction SilentlyContinue | ForEach-Object { Log "closing $($_.ProcessName) ($($_.Id))"; Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } Start-Sleep 3 Log ('top windows after: ' + ((Get-TopWindows) -join ' ;; ')) } # Every element inside a UIA element, with its rectangle (for slicing screenshots # and debugging selectors). function Dump-Tree($el, $file) { try { $all = $el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) $rows = foreach ($e in $all) { $r = $e.Current.BoundingRectangle if ($r.Width -le 0 -or $r.Height -le 0 -or [double]::IsInfinity($r.Width)) { continue } [ordered]@{ type = $e.Current.ControlType.ProgrammaticName -replace 'ControlType\.', ''; cls = $e.Current.ClassName; id = $e.Current.AutomationId; name = $e.Current.Name x = [int]$r.X; y = [int]$r.Y; w = [int]$r.Width; h = [int]$r.Height; off = $e.Current.IsOffscreen } } $rows | ConvertTo-Json -Depth 3 | Set-Content -Path (Join-Path $out $file) -Encoding UTF8 return @($rows) } catch { Log "dump tree failed: $_"; return @() } } # Crop a rectangle out of a saved PNG (used for taskbar strips and element slices). function Crop-Png($src, $dst, $x, $y, $w, $h, $scale = 1) { try { $bmp = [System.Drawing.Bitmap]::FromFile($src) $x = [Math]::Max(0, [int]$x); $y = [Math]::Max(0, [int]$y) $w = [Math]::Min($bmp.Width - $x, [int]$w); $h = [Math]::Min($bmp.Height - $y, [int]$h) if ($w -le 0 -or $h -le 0) { $bmp.Dispose(); return } $crop = $bmp.Clone((New-Object System.Drawing.Rectangle $x, $y, $w, $h), $bmp.PixelFormat) if ($scale -ne 1) { $big = New-Object System.Drawing.Bitmap ($w * $scale), ($h * $scale) $g = [System.Drawing.Graphics]::FromImage($big); $g.InterpolationMode = 'NearestNeighbor'; $g.PixelOffsetMode = 'Half' $g.DrawImage($crop, 0, 0, $w * $scale, $h * $scale); $g.Dispose(); $crop.Dispose(); $crop = $big } $crop.Save($dst, [System.Drawing.Imaging.ImageFormat]::Png); $crop.Dispose(); $bmp.Dispose() } catch { Log "crop failed ($dst): $_" } }