mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
Automate WinUtil title screen generation (#4965)
* 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
This commit is contained in:
@@ -4,3 +4,6 @@
|
|||||||
# Seanh1917 (ANGRYxScotsmans) is the docs guy
|
# Seanh1917 (ANGRYxScotsmans) is the docs guy
|
||||||
docs/ @ChrisTitusTech @seanh1995
|
docs/ @ChrisTitusTech @seanh1995
|
||||||
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
|
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
|
||||||
|
|
||||||
|
# Title screen generation
|
||||||
|
tools/title-screen/ @ChrisTitusTech @mewclouds
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
name: Generate WinUtil title screen
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
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
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- 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
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ testResults.xml
|
|||||||
# general software/os specific
|
# general software/os specific
|
||||||
desktop.ini
|
desktop.ini
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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).
|
- 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
|
## 3. Source Of Truth
|
||||||
|
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Before finishing, check `git status --short` and separate your changes from pre-existing user changes.
|
||||||
- Do not revert user changes unless explicitly asked.
|
- Do not revert user changes unless explicitly asked.
|
||||||
|
|||||||
@@ -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.
|
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
||||||
- `pester/`: Pester tests for config and function checks.
|
- `pester/`: Pester tests for config and function checks.
|
||||||
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
|
- `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`.
|
- `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`.
|
||||||
- `winutil.ps1`: ignored generated build artifact.
|
- `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.
|
- `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/`.
|
- 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/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.
|
- `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
|
## 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.
|
- 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.
|
- 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 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
|
## Release Artifact
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""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 whether a pixel is brighter than the near-black DWM shadow."""
|
||||||
|
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:
|
||||||
|
"""Return a copy with the title-screen border drawn inside its bounds."""
|
||||||
|
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()
|
||||||
@@ -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}<element inspection failed: {exc}>")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
children = element.children()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"{indent}<child enumeration failed: {exc}>")
|
||||||
|
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" <window inspection failed: {exc}>")
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Generated
+141
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user