-
-
Save cprima/c8a85d6b6d2708cd2edc62dd5f7dfbc2 to your computer and use it in GitHub Desktop.
| <# | |
| .SYNOPSIS | |
| Interactive browser for all SAP-related windows — scripting and native Win32. | |
| .DESCRIPTION | |
| Enumerates every window that belongs to a running SAP process and presents | |
| them in a single numbered table. The user picks one; the script dumps its | |
| control tree. | |
| Two categories of window are covered: | |
| [Script] SAP GUI scripting windows — accessible via the SAP COM scripting | |
| API. Enumerated via an inline VBScript (cscript.exe bridge) to | |
| avoid the TYPE_E_CANTLOADLIBRARY error that prevents .NET COM | |
| interop from loading SAP's type library. Dump output: the full | |
| control tree (id | type | name | text), depth-first. | |
| [Win32] Native Win32 windows owned by SAP processes (saplogon.exe etc.), | |
| including session-timeout dialogs and other modal popups that are | |
| invisible to the SAP scripting API. Enumerated and dumped via | |
| Win32 P/Invoke (EnumWindows / EnumChildWindows). | |
| SAP processes searched by default: saplogon, sapgui, saplgpad. | |
| Prerequisites: | |
| - SAP GUI for Windows must be running. | |
| - Scripting must be enabled in SAP GUI options: | |
| Tools -> Options -> Accessibility & Scripting -> Scripting -> Enable scripting | |
| - cscript.exe must be available (standard on all Windows systems). | |
| .PARAMETER Format | |
| Output format for the control tree dump: text (default), json, csv, or html. | |
| html captures a screenshot of the target window and writes a self-contained | |
| HTML image map to -OutputPath. | |
| .PARAMETER OutputPath | |
| Directory where the .html and .png pair is written when -Format html is used. | |
| Defaults to $env:TEMP\SapDump. Created if absent. | |
| .PARAMETER NoOpen | |
| When -Format html is used, suppress automatically opening the HTML file in | |
| the default browser after writing. | |
| .EXAMPLE | |
| pwsh -File Dump-SapWindow.ps1 | |
| .EXAMPLE | |
| pwsh -File Dump-SapWindow.ps1 -Format json | |
| .EXAMPLE | |
| pwsh -File Dump-SapWindow.ps1 -Format html | |
| .EXAMPLE | |
| pwsh -File Dump-SapWindow.ps1 -Format html -OutputPath C:\Temp\MyDumps -NoOpen | |
| .NOTES | |
| Dump record fields (all formats): | |
| depth, id, type, name, text, screenX, screenY, width, height, actions | |
| text format: indented by depth, pipe-separated fields | |
| json format: array of objects | |
| csv format: RFC 4180 with header row | |
| html format: PNG screenshot + image map with <area> per interactive control | |
| #> | |
| #Requires -Version 5.1 | |
| [CmdletBinding()] | |
| param( | |
| [ValidateSet('text','json','csv','html')] | |
| [string]$Format = 'text', | |
| [string]$OutputPath = (Join-Path $env:TEMP 'SapDump'), | |
| [switch]$NoOpen | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| # ── Win32 P/Invoke helper ────────────────────────────────────────────────────── | |
| Add-Type @' | |
| using System; | |
| using System.Collections.Generic; | |
| using System.Diagnostics; | |
| using System.Runtime.InteropServices; | |
| using System.Text; | |
| public class SapWin32 { | |
| private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); | |
| [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc f, IntPtr lParam); | |
| [DllImport("user32.dll")] static extern bool EnumChildWindows(IntPtr parent, EnumWindowsProc f, IntPtr lParam); | |
| [DllImport("user32.dll")] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); | |
| [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder s, int n); | |
| [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); | |
| [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); | |
| [DllImport("user32.dll")] static extern int GetWindowLong(IntPtr h, int idx); | |
| [DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr h, uint cmd); | |
| const uint GW_CHILD = 5; | |
| const uint GW_HWNDNEXT = 2; | |
| public class WinInfo { | |
| public IntPtr Hwnd; | |
| public string Title; | |
| public string ClassName; | |
| public uint Pid; | |
| public string ProcessName; | |
| public int CtrlId; | |
| } | |
| static string GetText(IntPtr h) { | |
| var sb = new StringBuilder(512); GetWindowText(h, sb, 512); return sb.ToString(); | |
| } | |
| static string GetCls(IntPtr h) { | |
| var sb = new StringBuilder(256); GetClassName(h, sb, 256); return sb.ToString(); | |
| } | |
| public static List<WinInfo> GetTopLevelWindows(string[] processNames) { | |
| var sapPids = new HashSet<uint>(); | |
| foreach (var p in Process.GetProcesses()) | |
| foreach (var n in processNames) | |
| if (p.ProcessName.ToLower().Contains(n.ToLower())) | |
| sapPids.Add((uint)p.Id); | |
| var result = new List<WinInfo>(); | |
| EnumWindows((hwnd, _) => { | |
| if (!IsWindowVisible(hwnd)) return true; | |
| uint pid; GetWindowThreadProcessId(hwnd, out pid); | |
| if (!sapPids.Contains(pid)) return true; | |
| string pname = ""; | |
| try { pname = Process.GetProcessById((int)pid).ProcessName; } catch {} | |
| result.Add(new WinInfo { | |
| Hwnd = hwnd, Title = GetText(hwnd), ClassName = GetCls(hwnd), | |
| Pid = pid, ProcessName = pname | |
| }); | |
| return true; | |
| }, IntPtr.Zero); | |
| return result; | |
| } | |
| public static List<WinInfo> GetDirectChildren(IntPtr parent) { | |
| var result = new List<WinInfo>(); | |
| var child = GetWindow(parent, GW_CHILD); | |
| while (child != IntPtr.Zero) { | |
| result.Add(new WinInfo { | |
| Hwnd = child, Title = GetText(child), ClassName = GetCls(child), | |
| CtrlId = GetWindowLong(child, -12) // GWL_ID | |
| }); | |
| child = GetWindow(child, GW_HWNDNEXT); | |
| } | |
| return result; | |
| } | |
| [StructLayout(LayoutKind.Sequential)] | |
| public struct RECT { public int Left, Top, Right, Bottom; } | |
| [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr hDC, uint nFlags); | |
| [DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleDC(IntPtr hdc); | |
| [DllImport("gdi32.dll")] public static extern IntPtr CreateCompatibleBitmap(IntPtr hdc, int w, int h); | |
| [DllImport("gdi32.dll")] public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj); | |
| [DllImport("gdi32.dll")] public static extern bool DeleteDC(IntPtr hdc); | |
| [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr obj); | |
| [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hwnd); | |
| [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); | |
| [DllImport("gdi32.dll")] public static extern int GetDeviceCaps(IntPtr hdc, int index); | |
| [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect); | |
| // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 | |
| [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext); | |
| } | |
| '@ | |
| # ── VBScript: enumerate SAP scripting windows (pipe-delimited) ──────────────── | |
| $vbsEnum = @' | |
| Option Explicit | |
| Dim SapGuiAuto, app, conn, ses, wnd | |
| Dim iConn, iSes, iWnd, txn, title | |
| Set SapGuiAuto = GetObject("SAPGUI") | |
| Set app = SapGuiAuto.GetScriptingEngine | |
| iConn = 0 | |
| For Each conn In app.Children | |
| iSes = 0 | |
| For Each ses In conn.Children | |
| On Error Resume Next | |
| txn = ses.Info.Transaction | |
| If Err.Number <> 0 Then txn = "" : Err.Clear | |
| On Error GoTo 0 | |
| iWnd = 0 | |
| For Each wnd In ses.Children | |
| On Error Resume Next | |
| title = wnd.Text | |
| If Err.Number <> 0 Then title = "" : Err.Clear | |
| On Error GoTo 0 | |
| WScript.Echo iConn & "|" & iSes & "|" & iWnd & "|" & txn & "|" & title & "|" & wnd.Handle | |
| iWnd = iWnd + 1 | |
| Next | |
| iSes = iSes + 1 | |
| Next | |
| iConn = iConn + 1 | |
| Next | |
| '@ | |
| # ── VBScript: dump SAP scripting control tree ───────────────────────────────── | |
| $vbsDump = @' | |
| Option Explicit | |
| Dim connIndex, sesIndex, wndIndex | |
| Dim SapGuiAuto, app, conn, ses | |
| Dim seen, iConn, iSes | |
| If WScript.Arguments.Count < 3 Then | |
| WScript.Echo "Usage: cscript dump.vbs <conn> <ses> <wnd>" | |
| WScript.Quit 1 | |
| End If | |
| connIndex = CInt(WScript.Arguments(0)) | |
| sesIndex = CInt(WScript.Arguments(1)) | |
| wndIndex = CInt(WScript.Arguments(2)) | |
| Set seen = CreateObject("Scripting.Dictionary") | |
| Set SapGuiAuto = GetObject("SAPGUI") | |
| Set app = SapGuiAuto.GetScriptingEngine | |
| Set conn = Nothing : iConn = 0 | |
| For Each conn In app.Children | |
| If iConn = connIndex Then Exit For | |
| iConn = iConn + 1 | |
| Next | |
| Set ses = Nothing : iSes = 0 | |
| For Each ses In conn.Children | |
| If iSes = sesIndex Then Exit For | |
| iSes = iSes + 1 | |
| Next | |
| DumpControls ses.FindById("wnd[" & wndIndex & "]"), 0 | |
| Sub DumpControls(obj, depth) | |
| Dim id, typ, name, text, child, hasChildren | |
| Dim sl, st, sw, sh, actions | |
| If depth > 20 Then Exit Sub | |
| id = SafeProp(obj,"Id") : typ = SafeProp(obj,"Type") | |
| name = SafeProp(obj,"Name") : text = SafeProp(obj,"Text") | |
| If id = "" Then Exit Sub | |
| If seen.Exists(id) Then Exit Sub | |
| seen.Add id, True | |
| sl = SafeNum(obj,"ScreenLeft") : st = SafeNum(obj,"ScreenTop") | |
| sw = SafeNum(obj,"Width") : sh = SafeNum(obj,"Height") | |
| actions = InteractionHint(typ) | |
| WScript.Echo depth & "|" & id & "|" & typ & "|" & name & "|" & text & "|" & sl & "|" & st & "|" & sw & "|" & sh & "|" & actions | |
| hasChildren = False | |
| On Error Resume Next | |
| hasChildren = (obj.Children.Count > 0) | |
| If Err.Number <> 0 Then Err.Clear : Exit Sub | |
| On Error GoTo 0 | |
| If Not hasChildren Then Exit Sub | |
| On Error Resume Next | |
| For Each child In obj.Children | |
| If Err.Number <> 0 Then Err.Clear : Exit For | |
| DumpControls child, depth + 1 | |
| Next | |
| On Error GoTo 0 | |
| End Sub | |
| Function InteractionHint(typ) | |
| Select Case typ | |
| Case "GuiButton": InteractionHint = "press" | |
| Case "GuiTextField", "GuiCTextField": InteractionHint = "read/write text" | |
| Case "GuiPasswordField": InteractionHint = "write text" | |
| Case "GuiComboBox": InteractionHint = "select key/value" | |
| Case "GuiCheckBox": InteractionHint = "toggle Selected" | |
| Case "GuiRadioButton": InteractionHint = "select" | |
| Case "GuiTab", "GuiTabStrip": InteractionHint = "select" | |
| Case "GuiGridView": InteractionHint = "read/write cells, select rows" | |
| Case "GuiLabel", "GuiStatusbar": InteractionHint = "read only" | |
| Case "GuiShell": InteractionHint = "interact" | |
| Case Else: InteractionHint = "" | |
| End Select | |
| End Function | |
| Function SafeNum(obj, p) | |
| On Error Resume Next | |
| Select Case p | |
| Case "ScreenLeft": SafeNum = obj.ScreenLeft | |
| Case "ScreenTop": SafeNum = obj.ScreenTop | |
| Case "Width": SafeNum = obj.Width | |
| Case "Height": SafeNum = obj.Height | |
| Case Else: SafeNum = "" | |
| End Select | |
| If Err.Number <> 0 Then Err.Clear : SafeNum = "" | |
| On Error GoTo 0 | |
| End Function | |
| Function SafeProp(obj, p) | |
| On Error Resume Next | |
| Select Case p | |
| Case "Id": SafeProp = obj.Id | |
| Case "Type": SafeProp = obj.Type | |
| Case "Name": SafeProp = obj.Name | |
| Case "Text": SafeProp = obj.Text | |
| Case Else: SafeProp = "" | |
| End Select | |
| If Err.Number <> 0 Then Err.Clear : SafeProp = "" | |
| On Error GoTo 0 | |
| End Function | |
| '@ | |
| # ── Helper: HTML-attribute encode ───────────────────────────────────────────── | |
| function ConvertTo-HtmlAttr([string]$s) { | |
| $s -replace '&','&' -replace '"','"' -replace '<','<' -replace '>','>' | |
| } | |
| # ── Helper: automation capabilities per SAP control type ───────────────────── | |
| function Get-Capabilities([string]$type) { | |
| switch ($type) { | |
| { $_ -in @('GuiButton','GuiShell','GuiMenubar','GuiMenu','GuiToolbar') } | |
| { return 'press' } | |
| { $_ -in @('GuiTextField','GuiCTextField','GuiPasswordField') } | |
| { return 'text' } | |
| 'GuiComboBox' { return 'key,text' } | |
| { $_ -in @('GuiCheckBox','GuiRadioButton','GuiTab') } | |
| { return 'selected' } | |
| 'GuiGridView' { return 'text,selected' } | |
| default { return '' } | |
| } | |
| } | |
| # ── Helper: semi-transparent overlay color per SAP control type ─────────────── | |
| function Get-ControlColor([string]$type) { | |
| switch ($type) { | |
| 'GuiButton' { return 'rgba(255,140,0,0.45)' } # orange | |
| { $_ -in @('GuiTextField','GuiCTextField','GuiPasswordField','GuiComboBox') } | |
| { return 'rgba(30,144,255,0.45)' } # blue | |
| { $_ -in @('GuiCheckBox','GuiRadioButton','GuiTab') } | |
| { return 'rgba(50,205,50,0.45)' } # green | |
| { $_ -in @('GuiGridView','GuiMenubar','GuiMenu','GuiToolbar') } | |
| { return 'rgba(148,0,211,0.45)' } # purple | |
| 'GuiShell' { return 'rgba(255,69,0,0.45)' } # red-orange (GOS / embedded toolbar) | |
| default { return 'rgba(200,200,200,0.35)' } # grey (label/status/titlebar) | |
| } | |
| } | |
| # ── Helper: capture HWND as PNG, return window rect ─────────────────────────── | |
| function Get-HwndScreenshot { | |
| param([IntPtr]$hwnd, [string]$pngPath) | |
| Add-Type -AssemblyName System.Drawing | |
| $rect = [SapWin32+RECT]::new() | |
| # Switch thread to per-monitor-aware-v2 so GetWindowRect returns physical pixels, | |
| # matching the physical-pixel coordinates that SAP's scripting API reports. | |
| $prevCtx = [SapWin32]::SetThreadDpiAwarenessContext([IntPtr]::new(-4)) | |
| [SapWin32]::GetWindowRect($hwnd, [ref]$rect) | Out-Null | |
| if ($prevCtx -ne [IntPtr]::Zero) { [SapWin32]::SetThreadDpiAwarenessContext($prevCtx) | Out-Null } | |
| $w = $rect.Right - $rect.Left | |
| $h = $rect.Bottom - $rect.Top | |
| $screenDC = [SapWin32]::GetDC([IntPtr]::Zero) | |
| $memDC = [SapWin32]::CreateCompatibleDC($screenDC) | |
| $hBmp = [SapWin32]::CreateCompatibleBitmap($screenDC, $w, $h) | |
| [SapWin32]::SelectObject($memDC, $hBmp) | Out-Null | |
| [SapWin32]::PrintWindow($hwnd, $memDC, 2) | Out-Null # PW_RENDERFULLCONTENT=2 | |
| $bmp = [System.Drawing.Bitmap]::FromHbitmap($hBmp) | |
| $bmp.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png) | |
| $bmp.Dispose() | |
| [SapWin32]::DeleteObject($hBmp) | Out-Null | |
| [SapWin32]::DeleteDC($memDC) | Out-Null | |
| [SapWin32]::ReleaseDC([IntPtr]::Zero, $screenDC) | Out-Null | |
| # Derive DPI scale factor: physical / logical screen width. | |
| # Used by the caller to display the PNG at logical size in the browser. | |
| $hdc2 = [SapWin32]::GetDC([IntPtr]::Zero) | |
| $physW = [SapWin32]::GetDeviceCaps($hdc2, 118) # DESKTOPHORZRES (physical) | |
| $logW = [SapWin32]::GetDeviceCaps($hdc2, 8) # HORZRES (logical) | |
| [SapWin32]::ReleaseDC([IntPtr]::Zero, $hdc2) | Out-Null | |
| $scale = if ($logW -gt 0) { [double]$physW / $logW } else { 1.0 } | |
| return @{ Width = $w; Height = $h; Left = $rect.Left; Top = $rect.Top; Scale = $scale } | |
| } | |
| # ── Helper: run VBScript via cscript ────────────────────────────────────────── | |
| function Invoke-Vbs { | |
| param([string]$script, [string[]]$vbsArgs = @()) | |
| $tmp = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.vbs' | |
| try { | |
| $script | Set-Content -Encoding ASCII $tmp | |
| $out = & cscript //nologo $tmp @vbsArgs 2>&1 | |
| if ($LASTEXITCODE -ne 0) { throw ($out -join "`n") } | |
| return $out | |
| } finally { | |
| Remove-Item $tmp -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| # ── Enumerate all SAP-related windows ───────────────────────────────────────── | |
| $sapProcessNames = @('saplogon', 'sapgui', 'saplgpad') | |
| $targets = [System.Collections.Generic.List[PSCustomObject]]::new() | |
| # 1. SAP scripting windows — enumerate first to build HWND dedup set | |
| Write-Verbose 'Enumerating SAP scripting windows via cscript...' | |
| $scriptHwnds = [System.Collections.Generic.HashSet[IntPtr]]::new() | |
| $scriptEntries = [System.Collections.Generic.List[PSCustomObject]]::new() | |
| try { | |
| $lines = @(Invoke-Vbs $vbsEnum) | |
| foreach ($line in $lines) { | |
| $p = $line -split '\|', 6 | |
| $hwnd = if ($p.Count -ge 6 -and $p[5]) { [IntPtr][long]$p[5] } else { [IntPtr]::Zero } | |
| if ($hwnd -ne [IntPtr]::Zero) { [void]$scriptHwnds.Add($hwnd) } | |
| $scriptEntries.Add([PSCustomObject]@{ | |
| Kind = 'Script' | |
| Detail = "conn=$($p[0]) ses=$($p[1]) wnd=$($p[2])" | |
| Process = 'sapgui (scripting)' | |
| Info = $p[3] | |
| Title = if ($p.Count -ge 5) { $p[4] } else { '' } | |
| _hwnd = $hwnd | |
| _conn = [int]$p[0]; _ses = [int]$p[1]; _wnd = [int]$p[2] | |
| }) | |
| } | |
| Write-Verbose "Scripting windows found: $($scriptEntries.Count)" | |
| } catch { | |
| Write-Verbose "SAP scripting windows not available: $_" | |
| } | |
| # 2. Win32 windows — skip any whose HWND is already covered by a Script entry | |
| Write-Verbose 'Enumerating Win32 windows from SAP processes...' | |
| $win32Windows = [SapWin32]::GetTopLevelWindows($sapProcessNames) | |
| foreach ($w in $win32Windows) { | |
| if (-not $w.Title) { continue } # skip untitled framework/helper windows | |
| if ($scriptHwnds.Contains($w.Hwnd)) { continue } # skip — already represented as [Script] | |
| $targets.Add([PSCustomObject]@{ | |
| '#' = $targets.Count | |
| Kind = 'Win32' | |
| Detail = "pid=$($w.Pid)" | |
| Process = $w.ProcessName | |
| Info = $w.ClassName | |
| Title = $w.Title | |
| _hwnd = $w.Hwnd | |
| _conn = -1; _ses = -1; _wnd = -1 | |
| }) | |
| } | |
| Write-Verbose "Win32-only windows added: $($targets.Count)" | |
| # 3. Append Script entries | |
| foreach ($e in $scriptEntries) { | |
| $e | Add-Member -NotePropertyName '#' -NotePropertyValue $targets.Count | |
| $targets.Add($e) | |
| } | |
| if ($targets.Count -eq 0) { | |
| Write-Error 'No SAP-related windows found.' | |
| exit 1 | |
| } | |
| # ── Present unified menu ─────────────────────────────────────────────────────── | |
| Write-Host '' | |
| $targets | Format-Table -AutoSize '#', Kind, Detail, Process, Info, Title | |
| $raw = Read-Host 'Select # to dump' | |
| if ($raw -notmatch '^\d+$' -or [int]$raw -ge $targets.Count) { | |
| Write-Error "Invalid selection: $raw" | |
| exit 1 | |
| } | |
| $sel = $targets[[int]$raw] | |
| # Store for use in Out-DumpRecords (needed by -Format html) | |
| $script:selectedHwnd = $sel._hwnd | |
| $script:selectedSlug = if ($sel.Kind -eq 'Script') { $sel.Info } else { $sel.Title } | |
| $script:selectedLabel = "[$($sel.Kind)] $($sel.Title)" | |
| Write-Host '' | |
| Write-Host "=== [$($sel.Kind)] $($sel.Detail) $($sel.Title) ===" -ForegroundColor Cyan | |
| Write-Host '' | |
| # ── Dump ─────────────────────────────────────────────────────────────────────── | |
| function Out-DumpRecords { | |
| param([PSCustomObject[]]$records) | |
| switch ($Format) { | |
| 'json' { $records | ConvertTo-Json -Depth 3 } | |
| 'csv' { $records | ConvertTo-Csv -NoTypeInformation } | |
| 'html' { | |
| if (-not (Test-Path $OutputPath)) { New-Item $OutputPath -ItemType Directory | Out-Null } | |
| # Build file stem from transaction / window title | |
| $rawSlug = ($script:selectedSlug -split '\s+')[0] -replace '[^A-Za-z0-9_-]', '' | |
| if ($rawSlug.Length -gt 32) { $rawSlug = $rawSlug.Substring(0, 32) } | |
| if (-not $rawSlug) { $rawSlug = 'window' } | |
| $stamp = Get-Date -Format 'yyyyMMdd_HHmmss' | |
| $stem = "SapDump_${rawSlug}_${stamp}" | |
| $pngFile = "$stem.png" | |
| $pngPath = Join-Path $OutputPath $pngFile | |
| $htmlPath = Join-Path $OutputPath "$stem.html" | |
| $dims = Get-HwndScreenshot -hwnd $script:selectedHwnd -pngPath $pngPath | |
| $winLeft = $dims.Left | |
| $winTop = $dims.Top | |
| $imgW = $dims.Width | |
| $imgH = $dims.Height | |
| # Display the PNG at logical (CSS) pixels so it matches the real window size on screen. | |
| $scale = $dims.Scale | |
| $dispW = [int]($imgW / $scale) | |
| $dispH = [int]($imgH / $scale) | |
| # Leaf/interactive types only — containers overlap descendants and are excluded | |
| $overlayTypes = [System.Collections.Generic.HashSet[string]]@( | |
| 'GuiButton', 'GuiTextField', 'GuiCTextField', 'GuiPasswordField', | |
| 'GuiComboBox', 'GuiCheckBox', 'GuiRadioButton', 'GuiTab', | |
| 'GuiGridView', 'GuiLabel', 'GuiStatusbar', 'GuiTitlebar', | |
| 'GuiMenubar', 'GuiMenu', 'GuiToolbar', 'GuiShell' | |
| ) | |
| # Build absolutely-positioned <div> overlays (visible, hover-able) | |
| $usr = $records | Where-Object { $_.id -match '/wnd\[\d+\]/usr$' } | Select-Object -First 1 | |
| $usrX = if ($usr) { [double]$usr.screenX } else { 0.0 } | |
| $usrY = if ($usr) { [double]$usr.screenY } else { 0.0 } | |
| # GuiGridView divs are emitted first so they sit behind all other overlays. | |
| # Same-z-index elements stack in DOM source order; earlier = lower. | |
| $gridDivs = [System.Collections.Generic.List[string]]::new() | |
| $overlayDivs = [System.Collections.Generic.List[string]]::new() | |
| foreach ($r in $records) { | |
| if (-not $overlayTypes.Contains([string]$r.type)) { continue } | |
| if (-not $r.PSObject.Properties['screenX'] -or "$($r.screenX)" -eq '') { continue } | |
| $rx = [double]$r.screenX | |
| $ry = [double]$r.screenY | |
| $rw = [double]$r.width | |
| $rh = [double]$r.height | |
| if ($rw -le 0 -or $rh -le 0 -or $rx -lt 0 -or $ry -lt 0) { continue } | |
| $isUsrChild = ($usr -and [string]$r.id -match '/usr/' -and [string]$r.id -notmatch '/wnd\[\d+\]/usr$') | |
| if ($isUsrChild) { | |
| $x1 = ($usrX - $winLeft) + (($rx - $usrX) * $scale) | |
| $y1 = ($usrY - $winTop) + (($ry - $usrY) * $scale) | |
| $sw = [int][math]::Round($rw * $scale) | |
| $sh = [int][math]::Round($rh * $scale) | |
| } | |
| else { | |
| $x1 = $rx - $winLeft | |
| $y1 = $ry - $winTop | |
| $sw = [int][math]::Round($rw) | |
| $sh = [int][math]::Round($rh) | |
| } | |
| if ($sw -le 0 -or $sh -le 0) { continue } | |
| $x1p = [math]::Round(100.0 * $x1 / $imgW, 3) | |
| $y1p = [math]::Round(100.0 * $y1 / $imgH, 3) | |
| $swp = [math]::Round(100.0 * $sw / $imgW, 3) | |
| $shp = [math]::Round(100.0 * $sh / $imgH, 3) | |
| $color = Get-ControlColor $r.type | |
| $info = ConvertTo-HtmlAttr "$($r.id) | $($r.type) | $($r.text)" | |
| $dname = ConvertTo-HtmlAttr "$($r.name)" | |
| $caps = ConvertTo-HtmlAttr (Get-Capabilities $r.type) | |
| $tid = ConvertTo-HtmlAttr $r.id | |
| $zidx = if ([string]$r.type -eq 'GuiGridView') { 1 } else { 2 } | |
| $div = " <div class=`"ov`" style=`"left:${x1p}%;top:${y1p}%;width:${swp}%;height:${shp}%;background:$color;z-index:${zidx}`" data-info=`"$info`" data-name=`"$dname`" data-caps=`"$caps`" title=`"$tid`"></div>" | |
| if ([string]$r.type -eq 'GuiGridView') { $gridDivs.Add($div) } else { $overlayDivs.Add($div) } | |
| } | |
| $overlayDivs.InsertRange(0, $gridDivs) | |
| $htmlTitle = ConvertTo-HtmlAttr $script:selectedLabel | |
| $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' | |
| $overlayHtml = $overlayDivs -join "`n" | |
| $htmlContent = @" | |
| <!DOCTYPE html> | |
| <html><head> | |
| <meta charset="utf-8"> | |
| <title>SAP Dump - $htmlTitle</title> | |
| <style> | |
| body { font-family:monospace; background:#1e1e1e; color:#d4d4d4; margin:1em } | |
| h2 { color:#9cdcfe } | |
| .wrap { position:relative; display:inline-block; line-height:0 } | |
| .ov { position:absolute; box-sizing:border-box; | |
| border:1px solid rgba(255,255,255,0.35); cursor:default } | |
| .ov:hover { border:2px solid #fff; z-index:10 } | |
| #tt { position:fixed; display:none; pointer-events:none; z-index:9999; | |
| background:#252526; border:1px solid #454545; border-radius:6px; | |
| padding:.6em .9em; box-shadow:0 4px 18px rgba(0,0,0,.7); | |
| max-width:460px; min-width:180px } | |
| #tt .tt-name { color:#4ec9b0; font-weight:bold; font-size:1.05em; | |
| margin-bottom:.45em; word-break:break-all } | |
| #tt .tt-caps { display:flex; gap:.5em; margin-bottom:.5em; flex-wrap:wrap } | |
| #tt .tt-cap { font-size:.78em; padding:1px 7px; border-radius:3px; | |
| border:1px solid; font-family:monospace } | |
| #tt .tt-id { color:#569cd6; font-size:.78em; word-break:break-all; | |
| margin-bottom:.2em } | |
| #tt .tt-row { color:#888; font-size:.78em } | |
| .legend { margin-top:.8em; font-size:.8em; display:flex; gap:1.2em; flex-wrap:wrap } | |
| .li { display:flex; align-items:center; gap:.35em } | |
| .sw { display:inline-block; width:.9em; height:.9em; | |
| border:1px solid rgba(255,255,255,.4) } | |
| </style> | |
| </head><body> | |
| <h2>$htmlTitle</h2> | |
| <p style="color:#888;font-size:.8em">$ts · $pngFile · origin ($winLeft,$winTop) · ${imgW}×${imgH}px physical · scale $([math]::Round($scale,3))</p> | |
| <div class="wrap"> | |
| <img src="$pngFile" width="$dispW" height="$dispH"> | |
| $overlayHtml | |
| </div> | |
| <div id="tt"> | |
| <div class="tt-name" id="tt-name"></div> | |
| <div class="tt-caps" id="tt-caps"></div> | |
| <div class="tt-id" id="tt-id"></div> | |
| <div class="tt-row" id="tt-type"></div> | |
| <div class="tt-row" id="tt-text"></div> | |
| </div> | |
| <div class="legend"> | |
| <span class="li"><span class="sw" style="background:rgba(255,140,0,0.6)"></span>GuiButton</span> | |
| <span class="li"><span class="sw" style="background:rgba(30,144,255,0.6)"></span>TextField / ComboBox</span> | |
| <span class="li"><span class="sw" style="background:rgba(50,205,50,0.6)"></span>CheckBox / RadioButton / Tab</span> | |
| <span class="li"><span class="sw" style="background:rgba(148,0,211,0.6)"></span>GridView / Menu / Toolbar</span> | |
| <span class="li"><span class="sw" style="background:rgba(200,200,200,0.5)"></span>Label / StatusBar / Titlebar</span> | |
| </div> | |
| <script> | |
| const tt = document.getElementById('tt'); | |
| const ttName = document.getElementById('tt-name'); | |
| const ttCaps = document.getElementById('tt-caps'); | |
| const ttId = document.getElementById('tt-id'); | |
| const ttType = document.getElementById('tt-type'); | |
| const ttText = document.getElementById('tt-text'); | |
| const capDef = [ | |
| {cap:'press', color:'#ff8c00'}, | |
| {cap:'text', color:'#1e90ff'}, | |
| {cap:'key', color:'#ffd700'}, | |
| {cap:'selected',color:'#32cd32'} | |
| ]; | |
| function showTt(d, mx, my) { | |
| const parts = (d.dataset.info||'').split('|').map(s=>s.trim()); | |
| const caps = (d.dataset.caps||'').split(','); | |
| ttName.textContent = d.dataset.name || ''; | |
| ttCaps.innerHTML = capDef.map(({cap,color}) => { | |
| const on = caps.includes(cap); | |
| const style = on | |
| ? 'border-color:'+color+';color:'+color | |
| : 'border-color:#444;color:#555'; | |
| return '<span class="tt-cap" style="'+style+'">'+(on?'✓':'✗')+' '+cap+'</span>'; | |
| }).join(''); | |
| ttId.textContent = parts[0] || ''; | |
| ttType.textContent = parts[1] || ''; | |
| ttText.textContent = parts[2] ? 'text: '+parts[2] : ''; | |
| tt.style.display = 'block'; | |
| moveTt(mx, my); | |
| } | |
| function moveTt(mx, my) { | |
| const ox = 14, oy = 14; | |
| let x = mx + ox, y = my + oy; | |
| const tw = tt.offsetWidth, th = tt.offsetHeight; | |
| if (x + tw > window.innerWidth - 8) x = mx - tw - ox; | |
| if (y + th > window.innerHeight - 8) y = my - th - oy; | |
| tt.style.left = x + 'px'; | |
| tt.style.top = y + 'px'; | |
| } | |
| document.querySelectorAll('.ov').forEach(d => { | |
| d.addEventListener('mouseenter', e => showTt(d, e.clientX, e.clientY)); | |
| d.addEventListener('mousemove', e => moveTt(e.clientX, e.clientY)); | |
| d.addEventListener('mouseleave', () => { tt.style.display = 'none'; }); | |
| }); | |
| </script> | |
| </body></html> | |
| "@ | |
| $htmlContent | Set-Content -Encoding UTF8 $htmlPath | |
| Write-Host "Saved: $htmlPath" | |
| if (-not $NoOpen) { Start-Process $htmlPath } | |
| } | |
| default { | |
| foreach ($r in $records) { | |
| $indent = ' ' * [int]$r.depth | |
| $line = "$indent$($r.id) | $($r.type) | $($r.name) | $($r.text)" | |
| $extras = @() | |
| if ($r.PSObject.Properties['screenX'] -and $r.screenX -ne '') { | |
| $extras += "[$($r.screenX),$($r.screenY) $($r.width)x$($r.height)]" | |
| } | |
| if ($r.PSObject.Properties['actions'] -and $r.actions -ne '') { | |
| $extras += $r.actions | |
| } | |
| if ($extras) { $line += " $($extras -join ' ')" } | |
| Write-Host $line | |
| } | |
| } | |
| } | |
| } | |
| if ($sel.Kind -eq 'Script') { | |
| try { | |
| $raw = Invoke-Vbs $vbsDump @("$($sel._conn)", "$($sel._ses)", "$($sel._wnd)") | |
| $records = $raw | ForEach-Object { | |
| $p = $_ -split '\|', 10 | |
| [PSCustomObject]@{ | |
| depth = [int]$p[0]; id = $p[1]; type = $p[2]; name = $p[3]; text = $p[4] | |
| screenX = $p[5]; screenY = $p[6]; width = $p[7]; height = $p[8] | |
| actions = if ($p.Count -ge 10) { $p[9] } else { '' } | |
| } | |
| } | |
| Out-DumpRecords $records | |
| } catch { | |
| Write-Error "Script dump failed.`n$_" | |
| exit 1 | |
| } | |
| } else { | |
| # Win32 — recursive tree, depth-first via GetDirectChildren | |
| $records = [System.Collections.Generic.List[PSCustomObject]]::new() | |
| $visited = [System.Collections.Generic.HashSet[IntPtr]]::new() | |
| function Build-Win32Records { | |
| param([IntPtr]$hwnd, [int]$depth, [string]$cls, [string]$title, [int]$ctrlId) | |
| if (-not $visited.Add($hwnd)) { return } | |
| $records.Add([PSCustomObject]@{ | |
| depth = $depth | |
| id = "0x$($hwnd.ToInt64().ToString('X'))" | |
| type = $cls | |
| name = "ctrlid=$ctrlId" | |
| text = $title | |
| }) | |
| foreach ($c in [SapWin32]::GetDirectChildren($hwnd)) { | |
| Build-Win32Records $c.Hwnd ($depth + 1) $c.ClassName $c.Title $c.CtrlId | |
| } | |
| } | |
| Build-Win32Records $sel._hwnd 0 $sel.Info $sel.Title 0 | |
| Out-DumpRecords $records | |
| } |
| #Requires -Version 5.1 | |
| <# | |
| .SYNOPSIS | |
| Probe DPI settings and compare SAP scripting coordinates against | |
| GetWindowRect (logical) and GetWindowRect (physical) for every open | |
| SAP window. Run on the SAP machine; paste full output as feedback. | |
| #> | |
| Add-Type -TypeDefinition @' | |
| using System; | |
| using System.Runtime.InteropServices; | |
| public class SapDpiProbe { | |
| [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr h); | |
| [DllImport("gdi32.dll")] public static extern int GetDeviceCaps(IntPtr h, int i); | |
| [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr h, IntPtr dc); | |
| [StructLayout(LayoutKind.Sequential)] | |
| public struct RECT { public int Left, Top, Right, Bottom; } | |
| [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out RECT r); | |
| [DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr ctx); | |
| } | |
| '@ | |
| Write-Host '' | |
| Write-Host '=== Screen DPI ===' -ForegroundColor Yellow | |
| $dc = [SapDpiProbe]::GetDC([IntPtr]::Zero) | |
| $logDpi = [SapDpiProbe]::GetDeviceCaps($dc, 88) # LOGPIXELSX | |
| $logW = [SapDpiProbe]::GetDeviceCaps($dc, 8) # HORZRES (logical px) | |
| $logH = [SapDpiProbe]::GetDeviceCaps($dc, 10) # VERTRES | |
| $physW = [SapDpiProbe]::GetDeviceCaps($dc, 118) # DESKTOPHORZRES (physical px) | |
| $physH = [SapDpiProbe]::GetDeviceCaps($dc, 117) # DESKTOPVERTRES | |
| [SapDpiProbe]::ReleaseDC([IntPtr]::Zero, $dc) | Out-Null | |
| "LOGPIXELSX (screen DC) : $logDpi (96=100%, 120=125%, 144=150%, 192=200%)" | |
| "Logical resolution : ${logW} x ${logH}" | |
| "Physical resolution : ${physW} x ${physH}" | |
| "Phys/log scale factor : $([math]::Round($physW / $logW, 4))" | |
| Write-Host '' | |
| Write-Host '=== SAP window: scripting coords vs GetWindowRect ===' -ForegroundColor Yellow | |
| # Enumerate open SAP windows via VBScript (avoids TYPE_E_CANTLOADLIBRARY) | |
| $vbs = @' | |
| On Error Resume Next | |
| Dim SapGuiAuto, app, conn, ses, wnd | |
| Set SapGuiAuto = GetObject("SAPGUI") | |
| If Err.Number <> 0 Then WScript.Echo "ERR:" & Err.Description : WScript.Quit 1 | |
| Set app = SapGuiAuto.GetScriptingEngine | |
| For Each conn In app.Children | |
| For Each ses In conn.Children | |
| For Each wnd In ses.Children | |
| WScript.Echo wnd.Handle & "|" & wnd.ScreenLeft & "|" & wnd.ScreenTop & "|" & wnd.Width & "|" & wnd.Height & "|" & wnd.Text | |
| Next | |
| Next | |
| Next | |
| '@ | |
| $tmp = [IO.Path]::GetTempFileName() -replace '\.tmp$', '.vbs' | |
| $vbs | Set-Content -Encoding ASCII $tmp | |
| $lines = @(& cscript //nologo $tmp 2>&1) | |
| Remove-Item $tmp -Force | |
| if (-not $lines) { | |
| Write-Host 'No SAP scripting windows found (SAP not running or scripting disabled).' -ForegroundColor Red | |
| } else { | |
| foreach ($line in $lines) { | |
| if ($line -match '^ERR:') { Write-Host $line -ForegroundColor Red; continue } | |
| $p = "$line" -split '\|', 6 | |
| if ($p.Count -lt 5) { continue } | |
| $hwnd = [IntPtr][long]$p[0] | |
| # GetWindowRect without DPI override (DPI-unaware = logical coordinates) | |
| $rLog = [SapDpiProbe+RECT]::new() | |
| [SapDpiProbe]::GetWindowRect($hwnd, [ref]$rLog) | Out-Null | |
| # GetWindowRect with per-monitor-aware-v2 context (physical coordinates) | |
| $prevCtx = [SapDpiProbe]::SetThreadDpiAwarenessContext([IntPtr]::new(-4)) | |
| $rPhys = [SapDpiProbe+RECT]::new() | |
| [SapDpiProbe]::GetWindowRect($hwnd, [ref]$rPhys) | Out-Null | |
| if ($prevCtx -ne [IntPtr]::Zero) { [SapDpiProbe]::SetThreadDpiAwarenessContext($prevCtx) | Out-Null } | |
| $title = if ($p.Count -ge 6) { $p[5] } else { '' } | |
| $sapL = [int]$p[1]; $sapT = [int]$p[2] | |
| $sapW = [int]$p[3]; $sapH = [int]$p[4] | |
| $logWR = $rLog.Right - $rLog.Left; $logHR = $rLog.Bottom - $rLog.Top | |
| $physWR = $rPhys.Right - $rPhys.Left; $physHR = $rPhys.Bottom - $rPhys.Top | |
| Write-Host "Window : $title" -ForegroundColor Cyan | |
| " SAP ScreenLeft/Top : ($sapL, $sapT) SAP Width/Height : ${sapW} x ${sapH}" | |
| " GetWindowRect LOGICAL : L=$($rLog.Left) T=$($rLog.Top) ${logWR} x ${logHR}" | |
| " GetWindowRect PHYSICAL : L=$($rPhys.Left) T=$($rPhys.Top) ${physWR} x ${physHR}" | |
| " SAP vs log offset : dL=$($sapL - $rLog.Left) dT=$($sapT - $rLog.Top)" | |
| " SAP vs phys offset : dL=$($sapL - $rPhys.Left) dT=$($sapT - $rPhys.Top)" | |
| "" | |
| } | |
| } | |
| Write-Host '=== done ===' -ForegroundColor Yellow |
Improvement 4 — -Format html: screenshot + interactive image map
What it does
-Format html captures the target SAP window as a PNG screenshot (using PrintWindow — works even if the window is partially off-screen or obscured), then writes a self-contained HTML file that overlays a <map> image map on the screenshot. Hovering over any highlighted control region shows its scripting ID, type, and interaction hint in a status bar below the image.
Two files are written per run to the same directory:
SapDump_{slug}_{yyyyMMdd_HHmmss}.png— the screenshotSapDump_{slug}_{yyyyMMdd_HHmmss}.html— the image map page
{slug} comes from the transaction code (Script windows) or window title (Win32 windows). The timestamp prevents collisions without any counter suffix.
Default output directory: $env:TEMP\SapDump (created if absent). Override with -OutputPath.
The HTML opens automatically in the default browser. Suppress with -NoOpen.
Filter by Type — handling nested controls
SAP's scripting tree is deeply nested. Container nodes (GuiUserArea, GuiTabStrip, GuiScrollContainer, GuiContainerShell, etc.) overlap all their descendants. If every control got an <area>, hovering over a button would also match the container behind it.
Only interactive/leaf types generate an <area> element:
| Type | Interaction |
|---|---|
| GuiButton | press |
| GuiTextField / GuiCTextField | read/write text |
| GuiPasswordField | write text |
| GuiComboBox | select key/value |
| GuiCheckBox | toggle Selected |
| GuiRadioButton | select |
| GuiTab | select |
| GuiGridView | read/write cells, select rows |
| GuiLabel / GuiStatusBar | read only |
| GuiTitlebar / GuiMenubar / GuiMenu / GuiToolbar | navigate |
Containers still appear in text/json/csv dumps but are excluded from the image map.
Coordinates
The VBScript dump emits ScreenLeft, ScreenTop, Width, Height (screen-absolute). HTML generation converts to window-relative pixels:
x1 = control.ScreenLeft - window.Left
y1 = control.ScreenTop - window.Top
x2 = x1 + control.Width
y2 = y1 + control.Height
New parameters
-Format html # enables this mode
-OutputPath <dir> # default: $env:TEMP\SapDump
-NoOpen # skip Start-Process after writing
Technical note: Win32 windows
For Win32 windows (timeout popups, SAP Logon), the screenshot is still captured but no <area> elements are generated — Win32 controls have no SAP scripting coordinates.
pwsh -File .\Probe-SapDpi.ps1
=== Screen DPI ===
LOGPIXELSX (screen DC) : 96 (96=100%, 120=125%, 144=150%, 192=200%)
Logical resolution : 2731 x 1440
Physical resolution : 4096 x 2160
Phys/log scale factor : 1.4998
=== SAP window: scripting coords vs GetWindowRect ===
Window : Faktura anzeigen
SAP ScreenLeft/Top : (2431, 691) SAP Width/Height : 1689 x 1302
GetWindowRect LOGICAL : L=1621 T=461 1126 x 868
GetWindowRect PHYSICAL : L=2431 T=691 1689 x 1302
SAP vs log offset : dL=810 dT=230
SAP vs phys offset : dL=0 dT=0
=== done ===
Line |
627 | Write-Error "Script dump failed.`n$_"
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Script dump failed. The variable '$n' cannot be retrieved because it has not been set.
Improvement 3 — Interaction map + screen coordinates
Each control line in the
[Script]dump now includes two additional pieces of information:Screen bounds —
ScreenLeft,ScreenTop,Width,Heightread from the SAP scripting object. Informational only; not used for mouse interaction.Interaction hint — derived from the control's
Type:In
textformat these appear as a suffix:[x,y wxh] action. Injsonandcsvthey are separate fields (screenX,screenY,width,height,actions).The interaction hints are language-agnostic — they are derived from the technical
Typestring, which is always English regardless of the SAP UI language.