Last active
April 1, 2026 15:49
-
-
Save cprima/c8a85d6b6d2708cd2edc62dd5f7dfbc2 to your computer and use it in GitHub Desktop.
Interactive SAP GUI window browser that dumps the full control tree of a chosen window.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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 |
Author
cprima
commented
Apr 1, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment