From 608f56f1b03e2c6832578553d7165066b4783cfc Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:02:54 -0400 Subject: [PATCH 1/5] feat: add title screen generation tooling Add local automation for capturing WinUtil in Light and Dark themes and generating the composite title-screen image. --- .github/CODEOWNERS | 5 +- .gitignore | 3 + tools/title-screen/.python-version | 1 + tools/title-screen/README.md | 66 +++ tools/title-screen/automate_title_screen.py | 252 +++++++++++ tools/title-screen/capture_winutil.py | 426 ++++++++++++++++++ tools/title-screen/create_composite.py | 178 ++++++++ tools/title-screen/inspect_winutil.py | 135 ++++++ tools/title-screen/pyproject.toml | 12 + .../test_automate_title_screen.py | 19 + tools/title-screen/test_create_composite.py | 45 ++ tools/title-screen/uv.lock | 141 ++++++ 12 files changed, 1282 insertions(+), 1 deletion(-) create mode 100644 tools/title-screen/.python-version create mode 100644 tools/title-screen/README.md create mode 100644 tools/title-screen/automate_title_screen.py create mode 100644 tools/title-screen/capture_winutil.py create mode 100644 tools/title-screen/create_composite.py create mode 100644 tools/title-screen/inspect_winutil.py create mode 100644 tools/title-screen/pyproject.toml create mode 100644 tools/title-screen/test_automate_title_screen.py create mode 100644 tools/title-screen/test_create_composite.py create mode 100644 tools/title-screen/uv.lock diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ebe925d621..a4267d7b6a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,7 @@ # Seanh1917 (ANGRYxScotsmans) is the docs guy docs/ @ChrisTitusTech @seanh1995 -tools/devdocs-generator.* @ChrisTitusTech @seanh1995 \ No newline at end of file +tools/devdocs-generator.* @ChrisTitusTech @seanh1995 + +# Title screen generation +tools/title-screen/ @ChrisTitusTech @mewclouds diff --git a/.gitignore b/.gitignore index ff0ac607c0..f54dc4f46c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ testResults.xml # general software/os specific desktop.ini .DS_Store +__pycache__/ +*.pyc +.venv/ .vscode/ .idea/ diff --git a/tools/title-screen/.python-version b/tools/title-screen/.python-version new file mode 100644 index 0000000000..24ee5b1be9 --- /dev/null +++ b/tools/title-screen/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/tools/title-screen/README.md b/tools/title-screen/README.md new file mode 100644 index 0000000000..f3fc002e82 --- /dev/null +++ b/tools/title-screen/README.md @@ -0,0 +1,66 @@ +# WinUtil title screen + +This tool generates the Light and Dark composite used as WinUtil's title screen +in the repository README and documentation site. It opens the Tweaks tab, +captures both themes, and combines them into one PNG. The two raw captures are +temporary and are removed when the command finishes. + +## Requirements + +- Windows with an interactive desktop +- [uv](https://docs.astral.sh/uv/) +- WinUtil compiled and running +- An elevated PowerShell terminal + +Run the commands below from `tools/title-screen`. + +## Generate and review a test image + +Start WinUtil from the repository root: + +```powershell +.\Compile.ps1 -Run +``` + +With WinUtil still open, return to this directory in an elevated terminal and +generate a test image: + +```powershell +uv run --locked python automate_title_screen.py --output "$env:TEMP\winutil-title-screen.png" +``` + +Open the resulting PNG and check that: + +- the Tweaks tab is shown, +- the Light theme is on the upper-left side of the diagonal, +- the Dark theme is on the lower-right side, and +- no desktop background or other windows are visible. + +The automation works whether WinUtil starts in Light or Dark mode. It leaves the +window on the Tweaks tab in Light mode. This is integrated into a GitHub action so there's no need to modify the actual title-screen manually. + +## Tests + +The tests cover theme detection and composite image generation without opening +WinUtil: + +```powershell +uv run --locked python -m unittest discover +``` + +## Troubleshooting + +If WinUtil cannot be found, make sure the compiled WPF window is open and that +the terminal is elevated. The script deliberately ignores editors, terminals, +and browser windows that merely contain "WinUtil" in their title. + +If a tab or theme control cannot be found, capture the UI Automation tree while +WinUtil is open: + +```powershell +uv run --locked python inspect_winutil.py "$env:TEMP\winutil-inspect.txt" +``` + +The inspector opens the theme menu before recording its controls. Attach the +text file when reporting a failure. It contains window and control metadata, not +the generated screenshots. diff --git a/tools/title-screen/automate_title_screen.py b/tools/title-screen/automate_title_screen.py new file mode 100644 index 0000000000..68af380530 --- /dev/null +++ b/tools/title-screen/automate_title_screen.py @@ -0,0 +1,252 @@ +"""Generate WinUtil's Light and Dark Tweaks title-screen composite. + +The automation targets a strictly validated WinUtil WPF window, selects the +Tweaks tab, and chooses the explicit Dark and Light menu items rather than +toggling an unknown initial state. Raw captures live in a temporary directory; +only the requested composite output persists. + +Local execution must use the same elevation level as WinUtil. CI may provide +``WINUTIL_HWND`` to bypass desktop discovery after launching a known window. +The current maximized WPF bounds determine the capture dimensions. +""" + +from __future__ import annotations + +import argparse +import tempfile +import time +from pathlib import Path + +from PIL import Image, ImageStat +from pywinauto.application import Application + +from capture_winutil import ( + capture_window, + find_winutil_hwnd, + get_window_capture_size, +) +from create_composite import create_composite + + +CONTROL_TIMEOUT_SECONDS = 10 +MENU_SETTLE_SECONDS = 0.25 +TAB_SETTLE_SECONDS = 1.0 +THEME_SETTLE_SECONDS = 2.0 +WINDOW_SETTLE_SECONDS = 0.5 +THEME_BRIGHTNESS_THRESHOLD = 128 +SUPPORTED_THEMES = {"Dark", "Light"} + + +def is_dark_mode(image: Image.Image) -> bool: + """Return whether the stable center band resembles WinUtil's Dark theme. + + The center band avoids the title-bar icons and outer DWM frame. Current Dark + captures average roughly 30-40 luminance while Light captures are above 230, + leaving a wide margin around the midpoint threshold. + """ + width, height = image.size + sample = image.crop( + ( + width // 4, + height // 2 - 10, + 3 * width // 4, + height // 2 + 10, + ) + ).convert("L") + return ImageStat.Stat(sample).mean[0] < THEME_BRIGHTNESS_THRESHOLD + + +def capture_theme( + hwnd, + width: int, + height: int, + output_path: str | Path, + *, + expected_dark: bool, +) -> Image.Image: + """Capture a theme and reject a stale or unsuccessful theme change.""" + image = capture_window(hwnd, width, height, output_path) + actual_dark = is_dark_mode(image) + if actual_dark != expected_dark: + expected_name = "Dark" if expected_dark else "Light" + actual_name = "Dark" if actual_dark else "Light" + raise RuntimeError( + f"Expected {expected_name} mode after selecting its theme menu item, " + f"but the capture looks {actual_name}." + ) + return image + + +def maximize_window(hwnd) -> tuple[int, int]: + """Maximize WinUtil and return its refreshed physical capture dimensions.""" + app = Application(backend="win32").connect( + handle=hwnd, + timeout=CONTROL_TIMEOUT_SECONDS, + ) + window = app.window(handle=hwnd).wrapper_object() + window.maximize() + time.sleep(WINDOW_SETTLE_SECONDS) + return get_window_capture_size(hwnd) + + +def select_tweaks_tab(hwnd) -> None: + """Select the Tweaks tab through its stable WPF automation identifier.""" + app = Application(backend="uia").connect( + handle=hwnd, + timeout=CONTROL_TIMEOUT_SECONDS, + ) + window = app.window(handle=hwnd) + tweaks_spec = window.child_window( + auto_id="WPFTab2BT", + control_type="Button", + ) + try: + tweaks_spec.wait( + "exists enabled visible ready", + timeout=CONTROL_TIMEOUT_SECONDS, + ) + tweaks_button = tweaks_spec.wrapper_object() + except Exception as exc: + raise RuntimeError( + f"Tweaks button WPFTab2BT was not available in HWND {hex(hwnd)}. " + "Confirm WinUtil is fully loaded and run at the same elevation level." + ) from exc + + window.set_focus() + tweaks_button.click_input() + time.sleep(TAB_SETTLE_SECONDS) + + +def set_theme(hwnd, theme_name: str) -> None: + """Open WinUtil's theme menu and choose an explicit theme menu item.""" + if theme_name not in SUPPORTED_THEMES: + supported = ", ".join(sorted(SUPPORTED_THEMES)) + raise ValueError( + f"Unsupported WinUtil theme {theme_name!r}; expected one of {supported}." + ) + + # WPF can replace automation elements while swapping its resource dictionary, + # so each theme selection starts with a fresh UIA connection and wrapper. + app = Application(backend="uia").connect( + handle=hwnd, + timeout=CONTROL_TIMEOUT_SECONDS, + ) + window = app.window(handle=hwnd) + theme_button_spec = window.child_window( + auto_id="ThemeButton", + control_type="Button", + ) + try: + theme_button_spec.wait( + "exists enabled visible ready", + timeout=CONTROL_TIMEOUT_SECONDS, + ) + theme_button = theme_button_spec.wrapper_object() + except Exception as exc: + raise RuntimeError( + f"ThemeButton was not available in HWND {hex(hwnd)}. Confirm WinUtil " + "is fully loaded and run at the same elevation level." + ) from exc + + # ThemeButton advertises UIA InvokePattern, but its WinUtil handler requires + # real pointer input. click_input sends the same mouse path as a user click. + window.set_focus() + theme_button.click_input() + time.sleep(MENU_SETTLE_SECONDS) + + deadline = time.monotonic() + CONTROL_TIMEOUT_SECONDS + popup_details = [] + while time.monotonic() < deadline: + popup_windows = [ + candidate + for candidate in app.windows(visible_only=True) + if candidate.handle != hwnd + ] + popup_details = [ + f"{popup.window_text()!r} ({popup.class_name()})" + for popup in popup_windows + ] + + # Framework versions differ on whether a WPF menu is attached to the main + # UIA tree or exposed as its own top-level popup, so search both forms. + search_roots = [*popup_windows, window.wrapper_object()] + for root in search_roots: + matching_controls = root.descendants(title=theme_name) + if matching_controls: + matching_controls[0].click_input() + time.sleep(THEME_SETTLE_SECONDS) + return + time.sleep(0.1) + + visible_popups = ", ".join(popup_details) if popup_details else "none" + raise RuntimeError( + f"Theme popup opened, but its {theme_name!r} option was not found in the " + "refreshed WinUtil UIA tree. Run inspect_winutil.py to check whether the " + f"menu changed. Visible same-process popups: {visible_popups}." + ) + + +def generate_title_screen(output_path: str | Path) -> Path: + """Capture both themes and write one final title-screen composite.""" + output_path = Path(output_path) + + print("Locating WinUtil window...") + hwnd, _, _ = find_winutil_hwnd() + + print("Maximizing WinUtil...") + width, height = maximize_window(hwnd) + + print("Selecting Tweaks tab...") + select_tweaks_tab(hwnd) + + print("Selecting Dark theme...") + set_theme(hwnd, "Dark") + + with tempfile.TemporaryDirectory(prefix="winutil-title-screen-") as temp_dir: + capture_directory = Path(temp_dir) + dark_path = capture_directory / "winutil-dark.png" + light_path = capture_directory / "winutil-light.png" + + print("Capturing Dark theme...") + capture_theme( + hwnd, + width, + height, + dark_path, + expected_dark=True, + ) + + print("Selecting Light theme...") + set_theme(hwnd, "Light") + + print("Capturing Light theme...") + capture_theme( + hwnd, + width, + height, + light_path, + expected_dark=False, + ) + + print("Generating title-screen composite...") + create_composite(dark_path, light_path, output_path) + + print(f"Generated title screen: {output_path}") + return output_path + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Capture WinUtil's Light and Dark Tweaks title screen." + ) + parser.add_argument("--output", required=True, type=Path, help="Destination PNG") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + generate_title_screen(args.output) + + +if __name__ == "__main__": + main() diff --git a/tools/title-screen/capture_winutil.py b/tools/title-screen/capture_winutil.py new file mode 100644 index 0000000000..41b4f84c7e --- /dev/null +++ b/tools/title-screen/capture_winutil.py @@ -0,0 +1,426 @@ +"""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: + 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: + 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 "winutil" in title.lower() and "hwndwrapper" in class_name.lower() + + +def _describe_window(hwnd, title: str, class_name: str, width: int, height: int): + 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) + 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 + + 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. + gdi32.SelectObject(memory_dc, previous_bitmap) + gdi32.DeleteObject(bitmap) + gdi32.DeleteDC(memory_dc) + user32.ReleaseDC(hwnd, window_dc) + + # GDI writes BGRA bytes. Declaring that raw layout lets Pillow reorder the + # channels while retaining the alpha byte used by the later compositor. + image = Image.frombytes( + "RGBA", + (width, height), + pixel_buffer.raw, + "raw", + "BGRA", + ) + # 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() diff --git a/tools/title-screen/create_composite.py b/tools/title-screen/create_composite.py new file mode 100644 index 0000000000..f2b41b7d4b --- /dev/null +++ b/tools/title-screen/create_composite.py @@ -0,0 +1,178 @@ +"""Create WinUtil's framed diagonal Light and Dark title-screen image. + +WinUtil captures include a narrow near-black DWM shadow around the actual WPF +window. The compositor detects that shadow from the Dark capture, applies the +same crop to both themes, and blends them with a supersampled diagonal mask. +Only the final composite is written; the cropped and framed theme images remain +in memory. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from PIL import Image, ImageDraw + + +BORDER_COLOR = (180, 180, 180, 255) +BORDER_WIDTH = 2 +# DWM shadow pixels are nearly black. WinUtil's actual Dark theme remains above +# this threshold at the window edges used by the bounds scan. +BRIGHTNESS_THRESHOLD = 20 +LARGE_SHADOW_MARGIN = 30 +MASK_SCALE = 4 +# These endpoints keep the split clear of the left tab labels near the top and +# the right-side action controls near the bottom of the Tweaks layout. +TOP_SPLIT = 0.16 +BOTTOM_SPLIT = 0.86 + + +def _contains_content(pixel) -> bool: + return any(channel > BRIGHTNESS_THRESHOLD for channel in pixel[:3]) + + +def _find_content_bounds(image: Image.Image) -> tuple[int, int, int, int]: + """Return the non-shadow bounds detected from a Dark theme capture. + + Every edge coordinate is checked because Windows 11 shadows can be narrower + than ten pixels. Sampling at a fixed interval could skip the first content + pixel and trim real UI from the final image. + """ + width, height = image.size + pixels = image.load() + + left = next( + ( + x + for x in range(width) + if any(_contains_content(pixels[x, y]) for y in range(height)) + ), + 0, + ) + right = next( + ( + x + for x in range(width - 1, -1, -1) + if any(_contains_content(pixels[x, y]) for y in range(height)) + ), + width - 1, + ) + top = next( + ( + y + for y in range(height) + if any(_contains_content(pixels[x, y]) for x in range(width)) + ), + 0, + ) + bottom = next( + ( + y + for y in range(height - 1, -1, -1) + if any(_contains_content(pixels[x, y]) for x in range(width)) + ), + height - 1, + ) + + right_margin = width - right - 1 + bottom_margin = height - bottom - 1 + margins = (left, top, right_margin, bottom_margin) + if any(margin > LARGE_SHADOW_MARGIN for margin in margins): + print( + "Warning: unusually large shadow margins detected " + f"(left={left}, top={top}, right={right_margin}, " + f"bottom={bottom_margin}). Input images may already be cropped or " + "framed." + ) + + return left, top, right + 1, bottom + 1 + + +def _apply_frame(image: Image.Image) -> Image.Image: + framed = image.copy() + width, height = framed.size + draw = ImageDraw.Draw(framed) + for inset in range(BORDER_WIDTH): + draw.rectangle( + [inset, inset, width - 1 - inset, height - 1 - inset], + outline=BORDER_COLOR, + ) + return framed + + +def create_composite( + dark_path: str | Path, + light_path: str | Path, + output_path: str | Path, +) -> Path: + """Create one framed diagonal composite from matching theme captures.""" + dark_path = Path(dark_path) + light_path = Path(light_path) + output_path = Path(output_path) + + with Image.open(dark_path) as dark_source: + dark_image = dark_source.convert("RGBA") + with Image.open(light_path) as light_source: + light_image = light_source.convert("RGBA") + + if dark_image.size != light_image.size: + raise ValueError( + "Image dimensions do not match: " + f"Dark is {dark_image.size}, Light is {light_image.size}" + ) + + crop_box = _find_content_bounds(dark_image) + print(f"Content crop box (stripping DWM shadow): {crop_box}") + + dark_framed = _apply_frame(dark_image.crop(crop_box)) + light_framed = _apply_frame(light_image.crop(crop_box)) + width, height = dark_framed.size + + # Drawing the mask at four times the target resolution and downsampling it + # with Lanczos produces a smooth diagonal without softening either UI image. + mask_width = width * MASK_SCALE + mask_height = height * MASK_SCALE + mask_high_resolution = Image.new("L", (mask_width, mask_height), 0) + draw_mask = ImageDraw.Draw(mask_high_resolution) + draw_mask.polygon( + [ + (0, 0), + (TOP_SPLIT * mask_width, 0), + (BOTTOM_SPLIT * mask_width, mask_height), + (0, mask_height), + ], + fill=255, + ) + mask = mask_high_resolution.resize( + (width, height), + resample=Image.Resampling.LANCZOS, + ) + + # A white mask selects Light, so Light occupies the left side and Dark the + # right. Reapplying the frame removes any mask antialiasing at the outer edge. + composite = Image.composite(light_framed, dark_framed, mask) + composite = _apply_frame(composite) + composite.save(output_path, "PNG") + + print(f"Generated title-screen composite: {output_path} ({width}x{height})") + return output_path + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create WinUtil's diagonal Light and Dark title-screen image." + ) + parser.add_argument("dark", type=Path, help="Raw Dark theme PNG") + parser.add_argument("light", type=Path, help="Raw Light theme PNG") + parser.add_argument("output", type=Path, help="Destination composite PNG") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + create_composite(args.dark, args.light, args.output) + + +if __name__ == "__main__": + main() diff --git a/tools/title-screen/inspect_winutil.py b/tools/title-screen/inspect_winutil.py new file mode 100644 index 0000000000..8fd9f83cfc --- /dev/null +++ b/tools/title-screen/inspect_winutil.py @@ -0,0 +1,135 @@ +"""Write WinUtil window and UI Automation diagnostics to an explicit path. + +The regular tree does not contain WPF popup-menu entries until the popup is +open. This inspector therefore records the desktop and main window first, then +clicks ``ThemeButton`` and records both the refreshed window tree and any +same-process popup windows. It must run at the same integrity level as WinUtil. +""" + +from __future__ import annotations + +import argparse +import contextlib +import time +from pathlib import Path + +from pywinauto import Desktop +from pywinauto.application import Application + +from capture_winutil import find_winutil_hwnd + + +def dump_tree(element, depth: int = 0, max_depth: int = 6) -> None: + """Print a bounded UI Automation subtree without failing on stale elements.""" + if depth > max_depth: + return + + indent = " " * depth + try: + control_type = element.element_info.control_type + name = element.element_info.name or "" + automation_id = element.element_info.automation_id or "" + class_name = element.element_info.class_name or "" + print( + f"{indent}[{control_type}] name={name!r:40s} " + f"auto_id={automation_id!r:30s} class={class_name!r}" + ) + except Exception as exc: + print(f"{indent}") + return + + try: + children = element.children() + except Exception as exc: + print(f"{indent}") + return + for child in children: + dump_tree(child, depth + 1, max_depth) + + +def inspect_winutil() -> None: + """Print desktop, WinUtil, and transient theme-menu diagnostics.""" + print("=== All visible top-level windows (win32 backend) ===") + for window in Desktop(backend="win32").windows(): + try: + if window.is_visible(): + print( + f" title={window.window_text()!r:50s} " + f"class={window.class_name()!r}" + ) + except Exception as exc: + print(f" ") + + print("\n=== Looking for WinUtil (shared strict lookup) ===") + winutil_hwnd, _, _ = find_winutil_hwnd() + + print("\n=== Control tree via UIA backend ===") + try: + app = Application(backend="uia").connect(handle=winutil_hwnd) + window = app.window(handle=winutil_hwnd) + dump_tree(window) + + print("\n=== Opening theme popup and dumping its UIA tree ===") + theme_button = window.child_window( + auto_id="ThemeButton", + control_type="Button", + ).wrapper_object() + window.set_focus() + theme_button.click_input() + time.sleep(0.5) + + print("\n=== Main WinUtil UIA tree with theme popup open ===") + dump_tree(window) + + # WPF may expose the menu as a separate top-level popup or attach it to + # the main tree depending on framework and rendering state. Record both. + popup_windows = [ + candidate + for candidate in app.windows(visible_only=True) + if candidate.handle != winutil_hwnd + ] + if not popup_windows: + print("No separate same-process popup window was found.") + for popup in popup_windows: + print( + f"Popup HWND={hex(popup.handle)} " + f"title={popup.window_text()!r} class={popup.class_name()!r}" + ) + dump_tree(popup) + except Exception as exc: + raise RuntimeError( + "UI Automation inspection failed. Run at the same elevation level " + "as WinUtil." + ) from exc + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Write WinUtil UI Automation diagnostics." + ) + parser.add_argument("output", type=Path, help="Destination text file") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + failure = None + # Preserve diagnostics even when discovery or UIA access fails, then return a + # non-zero exit so local callers and CI do not mistake the dump for success. + with args.output.open("w", encoding="utf-8") as output_file: + with contextlib.redirect_stdout(output_file): + try: + inspect_winutil() + except Exception as exc: + print(f"\nInspection failed: {exc}") + if exc.__cause__: + print(f"Cause: {exc.__cause__}") + failure = exc + + print(f"Diagnostics written to {args.output}") + if failure: + raise failure + + +if __name__ == "__main__": + main() diff --git a/tools/title-screen/pyproject.toml b/tools/title-screen/pyproject.toml new file mode 100644 index 0000000000..0e55dc4d21 --- /dev/null +++ b/tools/title-screen/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "winutil-title-screen" +version = "0.1.0" +description = "Generate WinUtil's Light and Dark title-screen composite" +requires-python = ">=3.13" +dependencies = [ + "pillow", + "pywinauto", +] + +[tool.uv] +package = false diff --git a/tools/title-screen/test_automate_title_screen.py b/tools/title-screen/test_automate_title_screen.py new file mode 100644 index 0000000000..5737a92ad4 --- /dev/null +++ b/tools/title-screen/test_automate_title_screen.py @@ -0,0 +1,19 @@ +"""Non-GUI tests for title-screen automation validation helpers.""" + +import unittest + +from PIL import Image + +from automate_title_screen import is_dark_mode + + +class AutomateTitleScreenTests(unittest.TestCase): + def test_classifies_dark_and_light_captures(self): + dark_image = Image.new("RGB", (100, 100), (35, 35, 35)) + light_image = Image.new("RGB", (100, 100), (240, 240, 240)) + + self.assertTrue(is_dark_mode(dark_image)) + self.assertFalse(is_dark_mode(light_image)) + +if __name__ == "__main__": + unittest.main() diff --git a/tools/title-screen/test_create_composite.py b/tools/title-screen/test_create_composite.py new file mode 100644 index 0000000000..68d16f3337 --- /dev/null +++ b/tools/title-screen/test_create_composite.py @@ -0,0 +1,45 @@ +"""Smoke tests for the WinUtil title-screen compositor.""" + +import tempfile +import unittest +from pathlib import Path + +from PIL import Image, ImageDraw + +from create_composite import BORDER_COLOR, create_composite + + +class CreateCompositeTests(unittest.TestCase): + def test_creates_only_requested_composite(self): + with tempfile.TemporaryDirectory(prefix="winutil-composite-test-") as temp_dir: + work_dir = Path(temp_dir) + dark_path = work_dir / "dark.png" + light_path = work_dir / "light.png" + output_path = work_dir / "title-screen.png" + + self._create_capture(dark_path, (32, 34, 36, 255)) + self._create_capture(light_path, (235, 237, 239, 255)) + + result = create_composite(dark_path, light_path, output_path) + + self.assertEqual(result, output_path) + self.assertEqual( + {path.name for path in work_dir.glob("*.png")}, + {"dark.png", "light.png", "title-screen.png"}, + ) + with Image.open(output_path) as composite: + self.assertEqual(composite.size, (64, 40)) + self.assertEqual(composite.getpixel((0, 0)), BORDER_COLOR) + self.assertEqual(composite.getpixel((5, 20)), (235, 237, 239, 255)) + self.assertEqual(composite.getpixel((58, 20)), (32, 34, 36, 255)) + + @staticmethod + def _create_capture(path: Path, content_color: tuple[int, int, int, int]): + image = Image.new("RGBA", (70, 46), (0, 0, 0, 255)) + draw = ImageDraw.Draw(image) + draw.rectangle((3, 3, 66, 42), fill=content_color) + image.save(path, "PNG") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/title-screen/uv.lock b/tools/title-screen/uv.lock new file mode 100644 index 0000000000..a9badc0b1e --- /dev/null +++ b/tools/title-screen/uv.lock @@ -0,0 +1,141 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "comtypes" +version = "1.4.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/2a/65274c13327f637ec13af8d39f2cf579d9ebe7a0e683696b5f05236d2805/comtypes-1.4.16.tar.gz", hash = "sha256:cd66d1add01265cface4df51ba1e31cd1657e04463c281c802e737e79e1ba93c", size = 260252, upload-time = "2026-03-02T23:11:42.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/7c/0eb685107290b6221c03c46d39214a4e42a124189691cb83ae3228257f46/comtypes-1.4.16-py3-none-any.whl", hash = "sha256:e18d85179ff12955524c5a8c3bc09cb3c0d890f1da4d7123d14244c7b78f84c8", size = 296230, upload-time = "2026-03-02T23:11:41.049Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywinauto" +version = "0.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comtypes", marker = "sys_platform == 'win32'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/85/cc65e3b64e7473cc86c07f0b5c415d509402c03ef19dadc39c583835eb5f/pywinauto-0.6.9.tar.gz", hash = "sha256:94d710bfa796df245250f952ffa65d97233d9807bcd42e052b71b81af469b6de", size = 434734, upload-time = "2025-01-06T11:30:01.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/46/f2283648e0c237af451dc50ebc42121abfd198d12391fd9ad4df1c6b97d2/pywinauto-0.6.9-py2.py3-none-any.whl", hash = "sha256:5924b3072864a1d730c5546bbeb17cf4063ba518b618dbc5e43c18276c7c9356", size = 363041, upload-time = "2025-01-06T11:29:55.586Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "winutil-title-screen" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pillow" }, + { name = "pywinauto" }, +] + +[package.metadata] +requires-dist = [ + { name = "pillow" }, + { name = "pywinauto" }, +] From b590860d238cddd9b78fcee2afe4acee25253901 Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:18:34 -0400 Subject: [PATCH 2/5] 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. --- .github/workflows/generate-title-screen.yaml | 207 +++++++++++++++++++ tools/title-screen/README.md | 11 +- 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/generate-title-screen.yaml diff --git a/.github/workflows/generate-title-screen.yaml b/.github/workflows/generate-title-screen.yaml new file mode 100644 index 0000000000..eaacbeb990 --- /dev/null +++ b/.github/workflows/generate-title-screen.yaml @@ -0,0 +1,207 @@ +name: Generate WinUtil title screen + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: winutil-title-screen + cancel-in-progress: true + +jobs: + generate: + runs-on: windows-latest + timeout-minutes: 15 + env: + WINUTIL_CAPTURE_WIDTH: "1920" + WINUTIL_CAPTURE_HEIGHT: "1080" + + defaults: + run: + shell: pwsh + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: main + + - name: Install uv and Python + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.0" + python-version: "3.13" + enable-cache: false + + - name: Set display resolution + run: | + # Set-DisplayResolution is provided by Windows PowerShell's ServerCore + # module, so this step intentionally calls powershell.exe from pwsh. + & powershell.exe -NoLogo -NoProfile -Command @' + Set-DisplayResolution ` + -Width $env:WINUTIL_CAPTURE_WIDTH ` + -Height $env:WINUTIL_CAPTURE_HEIGHT ` + -Force + '@ + + if ($LASTEXITCODE -ne 0) { + throw "Failed to set the runner display resolution." + } + + Add-Type @" + using System.Runtime.InteropServices; + + public static class ResolutionCheck { + [DllImport("user32.dll")] + public static extern int GetSystemMetrics(int index); + } + "@ + + $actualWidth = [ResolutionCheck]::GetSystemMetrics(0) + $actualHeight = [ResolutionCheck]::GetSystemMetrics(1) + if ( + $actualWidth -ne [int]$env:WINUTIL_CAPTURE_WIDTH -or + $actualHeight -ne [int]$env:WINUTIL_CAPTURE_HEIGHT + ) { + throw ( + "The hosted runner rejected the requested resolution. " + + "Requested: $env:WINUTIL_CAPTURE_WIDTH" + + "x$env:WINUTIL_CAPTURE_HEIGHT; " + + "actual: ${actualWidth}x${actualHeight}." + ) + } + + - name: Report display environment + run: | + Add-Type @" + using System.Runtime.InteropServices; + + public static class DisplayInfo { + [DllImport("user32.dll")] + public static extern int GetSystemMetrics(int index); + } + "@ + + $width = [DisplayInfo]::GetSystemMetrics(0) + $height = [DisplayInfo]::GetSystemMetrics(1) + $sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId + + Write-Host "Resolution: ${width}x${height}" + Write-Host "Session ID: $sessionId" + Write-Host "User: $env:USERNAME" + + - name: Compile WinUtil + run: | + Set-ExecutionPolicy Bypass -Scope Process -Force + ./Compile.ps1 + + - name: Launch WinUtil + run: | + $scriptPath = Join-Path $env:GITHUB_WORKSPACE "winutil.ps1" + $command = "& '$scriptPath'" + $bytes = [Text.Encoding]::Unicode.GetBytes($command) + $encodedCommand = [Convert]::ToBase64String($bytes) + + # Hide the console host while leaving the WPF window available. + $process = Start-Process powershell.exe -ArgumentList @( + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-EncodedCommand", $encodedCommand + ) -WindowStyle Hidden -PassThru + + "WINUTIL_HOST_PID=$($process.Id)" | + Out-File $env:GITHUB_ENV -Append -Encoding utf8 + + $deadline = (Get-Date).AddSeconds(90) + do { + Start-Sleep -Seconds 2 + # A versioned title excludes the unversioned console host. + $window = Get-Process | + Where-Object { $_.MainWindowTitle -match '^WinUtil\s+\d' } | + Select-Object -First 1 + } until ($window -or (Get-Date) -ge $deadline) + + if (-not $window) { + throw "WinUtil did not expose a window within 90 seconds." + } + + Write-Host "WinUtil HWND: $($window.MainWindowHandle)" + Write-Host "WinUtil title: $($window.MainWindowTitle)" + + # The exact HWND avoids desktop-enumeration ambiguity. Python validates + # the title and WPF class before using it. + "WINUTIL_HWND=$($window.MainWindowHandle)" | + Out-File $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Generate title screen + working-directory: tools/title-screen + run: | + & uv run --locked python automate_title_screen.py ` + --output ..\..\docs\src\assets\branding\title-screen.png 2>&1 | + Tee-Object -FilePath "$env:RUNNER_TEMP\title-screen-capture.log" + + if ($LASTEXITCODE -ne 0) { + throw "Title-screen automation exited with code $LASTEXITCODE." + } + + - name: Create title-screen update pull request + id: cpr + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.AUTO_MERGE }} + add-paths: docs/src/assets/branding/title-screen.png + base: main + branch: title-screen-update + delete-branch: true + commit-message: "docs: update WinUtil title screen" + title: "docs: update WinUtil title screen" + body: | + Regenerates the WinUtil Light and Dark title-screen composite from the current main branch. + + Source workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + labels: | + automated + documentation + skip-changelog + + - name: Report pull request + if: steps.cpr.outputs.pull-request-url + env: + PR_OPERATION: ${{ steps.cpr.outputs.pull-request-operation }} + PR_URL: ${{ steps.cpr.outputs.pull-request-url }} + run: | + Write-Host "Pull request $env:PR_OPERATION`: $env:PR_URL" + + - name: Inspect UI Automation on failure + if: failure() + continue-on-error: true + working-directory: tools/title-screen + run: | + uv run --locked python inspect_winutil.py ` + "$env:RUNNER_TEMP\winutil-title-screen-inspect.txt" + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: winutil-title-screen-failure-${{ github.run_number }} + path: | + docs/src/assets/branding/title-screen.png + ${{ runner.temp }}/title-screen-capture.log + ${{ runner.temp }}/winutil-title-screen-inspect.txt + if-no-files-found: warn + retention-days: 14 + + - name: Close WinUtil + if: always() + run: | + if ($env:WINUTIL_HOST_PID) { + Stop-Process ` + -Id ([int]$env:WINUTIL_HOST_PID) ` + -Force ` + -ErrorAction SilentlyContinue + } diff --git a/tools/title-screen/README.md b/tools/title-screen/README.md index f3fc002e82..e1ae7662d8 100644 --- a/tools/title-screen/README.md +++ b/tools/title-screen/README.md @@ -37,7 +37,16 @@ Open the resulting PNG and check that: - no desktop background or other windows are visible. The automation works whether WinUtil starts in Light or Dark mode. It leaves the -window on the Tweaks tab in Light mode. This is integrated into a GitHub action so there's no need to modify the actual title-screen manually. +window on the Tweaks tab in Light mode. + +## Automation + +The title screen is updated through a manually triggered GitHub Actions workflow. +When the generated image changes, the workflow opens or updates a pull request +for review. It does not merge the pull request automatically. + +Failed runs upload the capture log, UI Automation inspection, and available image +as diagnostic artifacts for 14 days. ## Tests From 3b4598c6c87dfbbf728928a725b2de3afd20542f Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:22:33 -0400 Subject: [PATCH 3/5] docs: update AGENTS.md and SPEC.md with title-screen generation details --- AGENTS.md | 1 + SPEC.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 43aada50e8..786315d133 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,7 @@ If a check cannot be run, say exactly why and what residual risk remains. See SP - Treat local `winutil.ps1` changes as disposable compile output. - Never stage or commit `winutil.ps1`, `binary/`, or anything else ignored by the root `.gitignore` or `docs/.gitignore` — read those files rather than assuming. `docs/public/` is tracked source for static assets, not generated output. +- `docs/src/assets/branding/title-screen.png` is a tracked generated asset. Do not edit it manually. Update `tools/title-screen/` or run the title-screen workflow. - Do not remove `.gitignore` rules that keep generated artifacts out of Git. - Before finishing, check `git status --short` and separate your changes from pre-existing user changes. - Do not revert user changes unless explicitly asked. diff --git a/SPEC.md b/SPEC.md index 3dfe853f10..1364975738 100644 --- a/SPEC.md +++ b/SPEC.md @@ -30,6 +30,7 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is - `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows. - `pester/`: Pester tests for config and function checks. - `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings. +- `tools/title-screen/`: uv project that captures WinUtil's Light and Dark themes and generates the title-screen composite. - `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`. - `winutil.ps1`: ignored generated build artifact. @@ -97,6 +98,7 @@ Because the final script is concatenated, code cannot rely on runtime module imp - `docs/src/content/docs/code-reference/tweaks/` and `.../features/` are auto-generated by `tools/devdocs-generator.ps1` from `config/tweaks.json`/`config/feature.json` and the relevant PowerShell function files. Other pages under `code-reference/` (e.g. `architecture.mdx`) are hand-written and untouched by the generator. - Sidebar entries in `docs/astro.config.mjs` must match actual page slugs under `docs/src/content/docs/`. - `docs/public/` is tracked source for static assets (favicons, etc.), not generated output. Generated/ignored paths are listed in `docs/.gitignore` (`dist/`, `.astro/`, `node_modules/`, local env files). +- `docs/src/assets/branding/title-screen.png` is a tracked generated image used by the repository README and docs homepage. Its raw Light and Dark captures are temporary. - `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`) containerize the site's npm tooling; see AGENTS.md's Dependency Installs, Builds, And Dev Servers for why and how agents must use them instead of running npm on the host. ## Testing And CI @@ -106,6 +108,7 @@ Because the final script is concatenated, code cannot rely on runtime module imp - Pester 5.8.0 runs the suite under `pester/*.Tests.ps1`. GitHub Actions (`unittests.yaml`) installs Pester 5.8.0 fresh and runs with `-CI`, which produces `testResults.xml` and exits non-zero on failure. - GitHub Actions also runs PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1` on every push. - The generated `winutil.ps1` may appear locally after compile. It remains ignored build output (see root `.gitignore`) and must not be committed. +- The manually triggered title-screen workflow compiles WinUtil from `main` and opens an image-only pull request when the generated composite changes. These pull requests require manual review. Failed runs retain diagnostics for 14 days. ## Release Artifact From 61d3db4522189a732cc1367057fd1e37b4f5fff2 Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:54:49 -0400 Subject: [PATCH 4/5] fix: address review comments Tested image is correct, SHA-256 hash is identical to the previously verified output. --- .github/workflows/generate-title-screen.yaml | 4 ++-- AGENTS.md | 2 +- tools/title-screen/capture_winutil.py | 9 +++++++-- tools/title-screen/create_composite.py | 2 ++ 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/generate-title-screen.yaml b/.github/workflows/generate-title-screen.yaml index eaacbeb990..a24c03dafc 100644 --- a/.github/workflows/generate-title-screen.yaml +++ b/.github/workflows/generate-title-screen.yaml @@ -4,8 +4,7 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read concurrency: group: winutil-title-screen @@ -28,6 +27,7 @@ jobs: uses: actions/checkout@v7 with: ref: main + persist-credentials: false - name: Install uv and Python uses: astral-sh/setup-uv@v9.0.0 diff --git a/AGENTS.md b/AGENTS.md index 786315d133..57b83d6152 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Given the current wave of npm/pnpm/yarn supply-chain worms (malicious postinstal - Treat any `postinstall`/`preinstall` lifecycle script in a new dependency as worth flagging to the user before installing — summarize what it does. - Don't put real secrets anywhere under `docs/`. `docs/.dockerignore` only trims what `docker build` copies into the image — it does not affect the `docker compose` bind mount, which exposes the entire `docs/` directory (including any `.env` file) inside the container for every dev/build/preview command (see the next bullet). There is no "keep it out unless mounted" middle ground here. - The container mounts `docs/` as a volume, so file edits on the host are reflected inside the container immediately — no rebuild needed for normal code changes, only when `docs/package.json`/`docs/package-lock.json` change (see the rebuild-and-drop-volume steps above). -- This Docker requirement is specific to `docs/`. The rest of the repo is PowerShell (`Compile.ps1`, Pester, Script Analyzer) and runs directly on the host per Section 1. +- This Docker requirement is specific to `docs/`. PowerShell tooling runs directly on the host per Section 1. The Python project under `tools/title-screen/` runs with uv as documented in its README. ## 3. Source Of Truth diff --git a/tools/title-screen/capture_winutil.py b/tools/title-screen/capture_winutil.py index 41b4f84c7e..8616be7acb 100644 --- a/tools/title-screen/capture_winutil.py +++ b/tools/title-screen/capture_winutil.py @@ -162,6 +162,7 @@ class BITMAPINFO(ctypes.Structure): 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) @@ -169,16 +170,19 @@ def _window_text(hwnd) -> str: 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( @@ -388,8 +392,8 @@ def capture_window( gdi32.DeleteDC(memory_dc) user32.ReleaseDC(hwnd, window_dc) - # GDI writes BGRA bytes. Declaring that raw layout lets Pillow reorder the - # channels while retaining the alpha byte used by the later compositor. + # 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), @@ -397,6 +401,7 @@ def capture_window( "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() diff --git a/tools/title-screen/create_composite.py b/tools/title-screen/create_composite.py index f2b41b7d4b..b2df78aa56 100644 --- a/tools/title-screen/create_composite.py +++ b/tools/title-screen/create_composite.py @@ -29,6 +29,7 @@ def _contains_content(pixel) -> bool: + """Return whether a pixel is brighter than the near-black DWM shadow.""" return any(channel > BRIGHTNESS_THRESHOLD for channel in pixel[:3]) @@ -90,6 +91,7 @@ def _find_content_bounds(image: Image.Image) -> tuple[int, int, int, int]: def _apply_frame(image: Image.Image) -> Image.Image: + """Return a copy with the title-screen border drawn inside its bounds.""" framed = image.copy() width, height = framed.size draw = ImageDraw.Draw(framed) From 47a1a6c6a5fbda20aacabfc765e81282cfde190a Mon Sep 17 00:00:00 2001 From: Omar <90123670+mewclouds@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:09:06 -0400 Subject: [PATCH 5/5] fix: deselect bitmap before reading capture pixels --- tools/title-screen/capture_winutil.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/title-screen/capture_winutil.py b/tools/title-screen/capture_winutil.py index 8616be7acb..a20aa42153 100644 --- a/tools/title-screen/capture_winutil.py +++ b/tools/title-screen/capture_winutil.py @@ -341,6 +341,7 @@ def capture_window( # 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) @@ -371,6 +372,10 @@ def capture_window( 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, @@ -387,7 +392,8 @@ def capture_window( finally: # GDI handles are process-global and are not reclaimed promptly by Python. # Always release them, including when PrintWindow or GetDIBits fails. - gdi32.SelectObject(memory_dc, previous_bitmap) + if bitmap_selected: + gdi32.SelectObject(memory_dc, previous_bitmap) gdi32.DeleteObject(bitmap) gdi32.DeleteDC(memory_dc) user32.ReleaseDC(hwnd, window_dc)