mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
* feat: add title screen generation tooling Add local automation for capturing WinUtil in Light and Dark themes and generating the composite title-screen image. * ci: automate title screen updates Add a manual Windows workflow that compiles WinUtil, generates the Light and Dark composite, and opens an image-only pull request. * docs: update AGENTS.md and SPEC.md with title-screen generation details * fix: address review comments Tested image is correct, SHA-256 hash is identical to the previously verified output. * fix: deselect bitmap before reading capture pixels
438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""Locate and capture the WinUtil WPF window through Win32 APIs.
|
|
|
|
The module identifies WinUtil by both its title and WPF ``HwndWrapper`` class so
|
|
an editor or terminal containing the word "winutil" cannot be captured by
|
|
mistake. It opts into physical-pixel DPI coordinates before reading bounds, then
|
|
uses ``PrintWindow`` and a 32-bit GDI bitmap to capture the WPF surface without
|
|
including the surrounding desktop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ctypes
|
|
import os
|
|
from ctypes import wintypes
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
|
|
GENERIC_ALL = 0x10000000
|
|
PW_RENDERFULLCONTENT = 2
|
|
DIB_RGB_COLORS = 0
|
|
BI_RGB = 0
|
|
DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
|
|
|
|
|
# Win32 otherwise virtualizes coordinates for DPI-unaware Python processes. The
|
|
# older shcore call supports systems where Per-Monitor V2 is unavailable.
|
|
try:
|
|
ctypes.windll.user32.SetProcessDpiAwarenessContext(-4)
|
|
except Exception:
|
|
try:
|
|
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
user32 = ctypes.windll.user32
|
|
dwmapi = ctypes.windll.dwmapi
|
|
gdi32 = ctypes.windll.gdi32
|
|
kernel32 = ctypes.windll.kernel32
|
|
|
|
# ctypes assumes integer arguments and return values unless signatures are
|
|
# declared. Explicit handle types prevent silent 32-bit truncation on 64-bit
|
|
# Windows, which can otherwise produce invalid windows or GDI crashes.
|
|
user32.OpenInputDesktop.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
user32.OpenInputDesktop.restype = wintypes.HDESK
|
|
user32.OpenDesktopW.argtypes = [
|
|
wintypes.LPCWSTR,
|
|
wintypes.DWORD,
|
|
wintypes.BOOL,
|
|
wintypes.DWORD,
|
|
]
|
|
user32.OpenDesktopW.restype = wintypes.HDESK
|
|
user32.CloseDesktop.argtypes = [wintypes.HDESK]
|
|
user32.CloseDesktop.restype = wintypes.BOOL
|
|
|
|
WINDOW_ENUM_CALLBACK = ctypes.WINFUNCTYPE(
|
|
wintypes.BOOL,
|
|
wintypes.HWND,
|
|
wintypes.LPARAM,
|
|
)
|
|
user32.EnumDesktopWindows.argtypes = [
|
|
wintypes.HDESK,
|
|
WINDOW_ENUM_CALLBACK,
|
|
wintypes.LPARAM,
|
|
]
|
|
user32.EnumDesktopWindows.restype = wintypes.BOOL
|
|
user32.EnumWindows.argtypes = [WINDOW_ENUM_CALLBACK, wintypes.LPARAM]
|
|
user32.EnumWindows.restype = wintypes.BOOL
|
|
user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
user32.IsWindowVisible.restype = wintypes.BOOL
|
|
user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
|
|
user32.GetWindowTextLengthW.restype = ctypes.c_int
|
|
user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
user32.GetWindowTextW.restype = ctypes.c_int
|
|
user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
user32.GetClassNameW.restype = ctypes.c_int
|
|
|
|
|
|
class RECT(ctypes.Structure):
|
|
"""Win32 rectangle used by both User32 and DWM bounds APIs."""
|
|
|
|
_fields_ = [
|
|
("left", ctypes.c_long),
|
|
("top", ctypes.c_long),
|
|
("right", ctypes.c_long),
|
|
("bottom", ctypes.c_long),
|
|
]
|
|
|
|
|
|
dwmapi.DwmGetWindowAttribute.argtypes = [
|
|
wintypes.HWND,
|
|
wintypes.DWORD,
|
|
ctypes.c_void_p,
|
|
wintypes.DWORD,
|
|
]
|
|
dwmapi.DwmGetWindowAttribute.restype = wintypes.DWORD
|
|
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(RECT)]
|
|
user32.GetWindowRect.restype = wintypes.BOOL
|
|
user32.GetDpiForWindow.argtypes = [wintypes.HWND]
|
|
user32.GetDpiForWindow.restype = wintypes.UINT
|
|
user32.GetWindowDC.argtypes = [wintypes.HWND]
|
|
user32.GetWindowDC.restype = wintypes.HDC
|
|
user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
|
|
user32.ReleaseDC.restype = ctypes.c_int
|
|
user32.PrintWindow.argtypes = [wintypes.HWND, wintypes.HDC, wintypes.UINT]
|
|
user32.PrintWindow.restype = wintypes.BOOL
|
|
|
|
gdi32.CreateCompatibleDC.argtypes = [wintypes.HDC]
|
|
gdi32.CreateCompatibleDC.restype = wintypes.HDC
|
|
gdi32.CreateCompatibleBitmap.argtypes = [wintypes.HDC, ctypes.c_int, ctypes.c_int]
|
|
gdi32.CreateCompatibleBitmap.restype = wintypes.HBITMAP
|
|
gdi32.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
|
|
gdi32.SelectObject.restype = wintypes.HGDIOBJ
|
|
gdi32.DeleteDC.argtypes = [wintypes.HDC]
|
|
gdi32.DeleteDC.restype = wintypes.BOOL
|
|
gdi32.DeleteObject.argtypes = [wintypes.HGDIOBJ]
|
|
gdi32.DeleteObject.restype = wintypes.BOOL
|
|
kernel32.GetLastError.argtypes = []
|
|
kernel32.GetLastError.restype = wintypes.DWORD
|
|
|
|
|
|
class BITMAPINFOHEADER(ctypes.Structure):
|
|
"""Header describing the top-down, uncompressed bitmap requested from GDI."""
|
|
|
|
_fields_ = [
|
|
("biSize", wintypes.DWORD),
|
|
("biWidth", ctypes.c_long),
|
|
("biHeight", ctypes.c_long),
|
|
("biPlanes", wintypes.WORD),
|
|
("biBitCount", wintypes.WORD),
|
|
("biCompression", wintypes.DWORD),
|
|
("biSizeImage", wintypes.DWORD),
|
|
("biXPelsPerMeter", ctypes.c_long),
|
|
("biYPelsPerMeter", ctypes.c_long),
|
|
("biClrUsed", wintypes.DWORD),
|
|
("biClrImportant", wintypes.DWORD),
|
|
]
|
|
|
|
|
|
class BITMAPINFO(ctypes.Structure):
|
|
"""GDI bitmap metadata with correctly aligned unused RGB color entries."""
|
|
|
|
_fields_ = [
|
|
("bmiHeader", BITMAPINFOHEADER),
|
|
("bmiColors", wintypes.DWORD * 3),
|
|
]
|
|
|
|
|
|
gdi32.GetDIBits.argtypes = [
|
|
wintypes.HDC,
|
|
wintypes.HBITMAP,
|
|
wintypes.UINT,
|
|
wintypes.UINT,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(BITMAPINFO),
|
|
wintypes.UINT,
|
|
]
|
|
gdi32.GetDIBits.restype = ctypes.c_int
|
|
|
|
|
|
def _window_text(hwnd) -> str:
|
|
"""Return the title of a top-level window."""
|
|
length = user32.GetWindowTextLengthW(hwnd)
|
|
buffer = ctypes.create_unicode_buffer(length + 1)
|
|
user32.GetWindowTextW(hwnd, buffer, length + 1)
|
|
return buffer.value
|
|
|
|
|
|
def _window_class(hwnd) -> str:
|
|
"""Return the Win32 class name of a top-level window."""
|
|
buffer = ctypes.create_unicode_buffer(256)
|
|
user32.GetClassNameW(hwnd, buffer, len(buffer))
|
|
return buffer.value
|
|
|
|
|
|
def _is_winutil_window(title: str, class_name: str) -> bool:
|
|
"""Return whether a title and class identify the WinUtil WPF window."""
|
|
return "winutil" in title.lower() and "hwndwrapper" in class_name.lower()
|
|
|
|
|
|
def _describe_window(hwnd, title: str, class_name: str, width: int, height: int):
|
|
"""Print the window identity, DPI scale, and physical capture size."""
|
|
dpi = user32.GetDpiForWindow(hwnd)
|
|
scale = dpi / 96.0 if dpi > 0 else 1.0
|
|
print(
|
|
f"Located HWND: {hex(hwnd)} | Title: {title!r} | Class: {class_name!r} "
|
|
f"| DPI: {dpi} ({scale:.2f}x) | Capture Size: {width}x{height}"
|
|
)
|
|
|
|
|
|
def find_winutil_hwnd():
|
|
"""Return the validated WinUtil HWND and physical capture dimensions.
|
|
|
|
``WINUTIL_HWND`` is an optional CI optimization, not a trust boundary: its
|
|
title and WPF class are validated exactly like a discovered window.
|
|
"""
|
|
explicit_hwnd = os.environ.get("WINUTIL_HWND")
|
|
if explicit_hwnd:
|
|
try:
|
|
hwnd = int(explicit_hwnd, 0)
|
|
except ValueError as exc:
|
|
raise RuntimeError(
|
|
f"WINUTIL_HWND is not a valid window handle: {explicit_hwnd!r}"
|
|
) from exc
|
|
|
|
title = _window_text(hwnd)
|
|
class_name = _window_class(hwnd)
|
|
if not _is_winutil_window(title, class_name):
|
|
raise RuntimeError(
|
|
f"WINUTIL_HWND {hex(hwnd)} is not the WinUtil WPF window: "
|
|
f"title={title!r}, class={class_name!r}"
|
|
)
|
|
|
|
width, height = get_window_capture_size(hwnd)
|
|
_describe_window(hwnd, title, class_name, width, height)
|
|
return hwnd, width, height
|
|
|
|
found_windows = {}
|
|
|
|
def collect_window(hwnd, _):
|
|
if not user32.IsWindowVisible(hwnd):
|
|
return True
|
|
|
|
rect = RECT()
|
|
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
|
return True
|
|
width = rect.right - rect.left
|
|
height = rect.bottom - rect.top
|
|
if width < 100 or height < 100:
|
|
return True
|
|
|
|
title = _window_text(hwnd)
|
|
class_name = _window_class(hwnd)
|
|
if _is_winutil_window(title, class_name):
|
|
found_windows[hwnd] = (hwnd, title, class_name, width, height)
|
|
return True
|
|
|
|
callback = WINDOW_ENUM_CALLBACK(collect_window)
|
|
user32.EnumWindows(callback, 0)
|
|
|
|
# Hosted runners can expose the GUI on the Default desktop while the calling
|
|
# process sees another input desktop. Enumerating both finds that window
|
|
# without moving this thread between desktops.
|
|
desktop_handles = [
|
|
user32.OpenInputDesktop(0, False, GENERIC_ALL),
|
|
user32.OpenDesktopW("Default", 0, False, GENERIC_ALL),
|
|
]
|
|
for desktop_handle in desktop_handles:
|
|
if not desktop_handle:
|
|
continue
|
|
try:
|
|
user32.EnumDesktopWindows(desktop_handle, callback, 0)
|
|
finally:
|
|
user32.CloseDesktop(desktop_handle)
|
|
|
|
if not found_windows:
|
|
raise RuntimeError(
|
|
"Could not find a visible WinUtil WPF window. Confirm WinUtil is open "
|
|
"and run this command at the same elevation level."
|
|
)
|
|
|
|
windows = sorted(
|
|
found_windows.values(),
|
|
key=lambda candidate: candidate[3] * candidate[4],
|
|
reverse=True,
|
|
)
|
|
if len(windows) > 1:
|
|
print(
|
|
f"Warning: found {len(windows)} WinUtil windows; using the largest "
|
|
"WPF window."
|
|
)
|
|
|
|
hwnd, title, class_name, _, _ = windows[0]
|
|
width, height = get_window_capture_size(hwnd)
|
|
_describe_window(hwnd, title, class_name, width, height)
|
|
return hwnd, width, height
|
|
|
|
|
|
def get_window_capture_size(hwnd) -> tuple[int, int]:
|
|
"""Return the current physical-pixel capture size for a window.
|
|
|
|
DWM extended-frame bounds exclude invisible shadow padding and are preferred
|
|
when available. Because DPI awareness is enabled during import, neither the
|
|
DWM nor User32 result should be scaled again.
|
|
"""
|
|
window_rect = RECT()
|
|
if not user32.GetWindowRect(hwnd, ctypes.byref(window_rect)):
|
|
raise RuntimeError(f"GetWindowRect failed for HWND {hex(hwnd)}")
|
|
|
|
window_width = window_rect.right - window_rect.left
|
|
window_height = window_rect.bottom - window_rect.top
|
|
|
|
frame_rect = RECT()
|
|
frame_result = dwmapi.DwmGetWindowAttribute(
|
|
hwnd,
|
|
DWMWA_EXTENDED_FRAME_BOUNDS,
|
|
ctypes.byref(frame_rect),
|
|
ctypes.sizeof(frame_rect),
|
|
)
|
|
frame_width = frame_rect.right - frame_rect.left
|
|
frame_height = frame_rect.bottom - frame_rect.top
|
|
if frame_result == 0 and frame_width > 0 and frame_height > 0:
|
|
return frame_width, frame_height
|
|
if window_width > 0 and window_height > 0:
|
|
return window_width, window_height
|
|
raise RuntimeError(f"Window {hex(hwnd)} has invalid bounds")
|
|
|
|
|
|
def capture_window(
|
|
hwnd,
|
|
width: int,
|
|
height: int,
|
|
output_path: str | Path,
|
|
) -> Image.Image:
|
|
"""Capture an HWND to a validated PNG and return the Pillow image.
|
|
|
|
The supplied dimensions must match the window's current physical bounds.
|
|
``PW_RENDERFULLCONTENT`` is attempted first because WPF may render portions
|
|
outside the visible desktop; ordinary ``PrintWindow`` is retained as a
|
|
compatibility fallback.
|
|
"""
|
|
output_path = Path(output_path)
|
|
window_dc = user32.GetWindowDC(hwnd)
|
|
if not window_dc:
|
|
raise RuntimeError("GetWindowDC failed. Run at the same elevation as WinUtil.")
|
|
|
|
memory_dc = gdi32.CreateCompatibleDC(window_dc)
|
|
if not memory_dc:
|
|
user32.ReleaseDC(hwnd, window_dc)
|
|
raise RuntimeError("CreateCompatibleDC failed")
|
|
|
|
bitmap = gdi32.CreateCompatibleBitmap(window_dc, width, height)
|
|
if not bitmap:
|
|
gdi32.DeleteDC(memory_dc)
|
|
user32.ReleaseDC(hwnd, window_dc)
|
|
raise RuntimeError("CreateCompatibleBitmap failed")
|
|
|
|
# A compatible memory DC receives the pixels rendered by PrintWindow. The
|
|
# previously selected GDI object must be restored before deleting the bitmap.
|
|
previous_bitmap = gdi32.SelectObject(memory_dc, bitmap)
|
|
bitmap_selected = True
|
|
buffer_size = width * height * 4
|
|
pixel_buffer = ctypes.create_string_buffer(buffer_size)
|
|
|
|
try:
|
|
success = user32.PrintWindow(hwnd, memory_dc, PW_RENDERFULLCONTENT)
|
|
if not success:
|
|
extended_error = kernel32.GetLastError()
|
|
print(
|
|
f"PrintWindow(2) failed with error {extended_error}; retrying "
|
|
"without PW_RENDERFULLCONTENT."
|
|
)
|
|
success = user32.PrintWindow(hwnd, memory_dc, 0)
|
|
if not success:
|
|
fallback_error = kernel32.GetLastError()
|
|
raise RuntimeError(
|
|
"PrintWindow failed in both modes. Run at the same elevation "
|
|
f"as WinUtil. Errors: extended={extended_error}, "
|
|
f"fallback={fallback_error}."
|
|
)
|
|
|
|
bitmap_info = BITMAPINFO()
|
|
bitmap_info.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
|
|
bitmap_info.bmiHeader.biWidth = width
|
|
# Negative height requests a top-down DIB. Positive-height DIBs are stored
|
|
# bottom-up and would produce a vertically flipped PNG.
|
|
bitmap_info.bmiHeader.biHeight = -height
|
|
bitmap_info.bmiHeader.biPlanes = 1
|
|
bitmap_info.bmiHeader.biBitCount = 32
|
|
bitmap_info.bmiHeader.biCompression = BI_RGB
|
|
|
|
# GetDIBits requires the source bitmap not to be selected into a DC.
|
|
gdi32.SelectObject(memory_dc, previous_bitmap)
|
|
bitmap_selected = False
|
|
|
|
scan_lines = gdi32.GetDIBits(
|
|
memory_dc,
|
|
bitmap,
|
|
0,
|
|
height,
|
|
pixel_buffer,
|
|
ctypes.byref(bitmap_info),
|
|
DIB_RGB_COLORS,
|
|
)
|
|
if scan_lines != height:
|
|
raise RuntimeError(
|
|
f"GetDIBits returned {scan_lines} of {height} expected scan lines."
|
|
)
|
|
finally:
|
|
# GDI handles are process-global and are not reclaimed promptly by Python.
|
|
# Always release them, including when PrintWindow or GetDIBits fails.
|
|
if bitmap_selected:
|
|
gdi32.SelectObject(memory_dc, previous_bitmap)
|
|
gdi32.DeleteObject(bitmap)
|
|
gdi32.DeleteDC(memory_dc)
|
|
user32.ReleaseDC(hwnd, window_dc)
|
|
|
|
# GDI writes BGR color bytes, but BI_RGB does not define the fourth byte as
|
|
# alpha. Decode the channel order, then make the window capture fully opaque.
|
|
image = Image.frombytes(
|
|
"RGBA",
|
|
(width, height),
|
|
pixel_buffer.raw,
|
|
"raw",
|
|
"BGRA",
|
|
)
|
|
image.putalpha(255)
|
|
# PrintWindow can report success while returning a blank surface across an
|
|
# integrity-level boundary, so reject all-black and all-white captures.
|
|
extrema = image.getextrema()
|
|
if all(channel[1] == 0 for channel in extrema[:3]):
|
|
raise ValueError("Captured image is completely black")
|
|
if all(channel[0] == 255 for channel in extrema[:3]):
|
|
raise ValueError("Captured image is completely white")
|
|
|
|
image.save(output_path, "PNG")
|
|
print(f"Verified and saved: {output_path} ({width}x{height})")
|
|
return image
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Capture the visible WinUtil window.")
|
|
parser.add_argument("output", type=Path, help="Destination PNG path")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = _parse_args()
|
|
hwnd, width, height = find_winutil_hwnd()
|
|
capture_window(hwnd, width, height, args.output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|