Skip to content

Instantly share code, notes, and snippets.

@cprima
Last active April 1, 2026 15:49
Show Gist options
  • Select an option

  • Save cprima/c8a85d6b6d2708cd2edc62dd5f7dfbc2 to your computer and use it in GitHub Desktop.

Select an option

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.
<#
.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 '&','&amp;' -replace '"','&quot;' -replace '<','&lt;' -replace '>','&gt;'
}
# ── 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 &middot; $pngFile &middot; origin ($winLeft,$winTop) &middot; ${imgW}&times;${imgH}px physical &middot; 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?'&#10003;':'&#10007;')+' '+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
@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

Which windows appear as [Win32] after deduplication

After HWND-based deduplication removes all windows already covered by the SAP scripting API, two categories of Win32-only windows remain:

SAP Logon (#32770, title "SAP Logon 770") — the connection manager application where SAP systems are configured and sessions are launched. It has no scripting session; it is the launcher, not a session.

Session-timeout popups (#32770, title "SAP GUI for Windows 770") — native dialogs raised by SAP Logon when a backend session has been terminated due to inactivity. Two variants exist: a reconnect prompt (button Yes/No) and a detailed error dialog (button OK). Both are invisible to the SAP scripting API.

@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

Improvement 1 — Output formats (text / json / csv)

A -Format parameter has been added with three values:

pwsh -File Dump-SapWindow.ps1                  # text (default)
pwsh -File Dump-SapWindow.ps1 -Format json
pwsh -File Dump-SapWindow.ps1 -Format csv

How it works:

The VBScript dumper now emits one structured pipe-delimited record per control instead of pre-indented text:

depth|id|type|name|text

PowerShell parses these records and renders them according to -Format:

  • text — reconstructs the indented tree view (same appearance as before)
  • json — array of objects via ConvertTo-Json
  • csv — RFC 4180 with header row via ConvertTo-Csv

The [Win32] dump follows the same record structure (depth=0 for the root window, depth=1 for children), so all three formats apply to both window kinds consistently.

@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

Improvement 2 — Recursive Win32 dump with depth

The [Win32] dump previously listed only direct children in a flat list with no nesting information. This was sufficient for simple dialogs but missed nested control groups.

Fix: replaced EnumChildWindows (which recurses internally but returns all descendants flat) with GetWindow(GW_CHILD) + GetWindow(GW_HWNDNEXT) to enumerate only direct children at each level. A recursive PowerShell function Build-Win32Records then walks the tree depth-first, tracking depth explicitly.

The result is a proper tree — the same structure and record format (depth|id|type|name|text) as the [Script] dump, so -Format json and -Format csv apply equally to both window kinds.

@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

Improvement 3 — Interaction map + screen coordinates

Each control line in the [Script] dump now includes two additional pieces of information:

Screen boundsScreenLeft, ScreenTop, Width, Height read from the SAP scripting object. Informational only; not used for mouse interaction.

Interaction hint — derived from the control's Type:

Type Hint
GuiButton press
GuiTextField, GuiCTextField read/write text
GuiPasswordField write text
GuiComboBox select key/value
GuiCheckBox toggle Selected
GuiRadioButton select
GuiTab, GuiTabStrip select
GuiGridView read/write cells, select rows
GuiLabel, GuiStatusBar read only

In text format these appear as a suffix: [x,y wxh] action. In json and csv they are separate fields (screenX, screenY, width, height, actions).

The interaction hints are language-agnostic — they are derived from the technical Type string, which is always English regardless of the SAP UI language.

@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

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 screenshot
  • SapDump_{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.

@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

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 ===

@cprima
Copy link
Copy Markdown
Author

cprima commented Apr 1, 2026

Line |
 627 |          Write-Error "Script dump failed.`n$_"
     |          ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Script dump failed. The variable '$n' cannot be retrieved because it has not been set.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment