VirtualDesktop 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,714 @@
1
+ """Thin, defensive ctypes bridge to the Win32 APIs this library needs.
2
+
3
+ Design rules:
4
+
5
+ * Every handle opened here is closed deterministically (context managers / try-finally).
6
+ * ``ctypes`` calls are checked for failure, and ``GetLastError`` is captured immediately.
7
+ * Optional APIs (per-monitor DPI, ``WM_DPICHANGED`` era functions) are probed at runtime so
8
+ the library keeps working on older Windows builds.
9
+ * Pure helpers (title matching, monitor selection) are kept free of Win32 calls so they can
10
+ be unit tested on any platform.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ctypes
16
+ import os
17
+ import re
18
+ import sys
19
+ from ctypes import wintypes
20
+ from dataclasses import dataclass, field
21
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
22
+
23
+ from ._log import get_logger
24
+ from .errors import UnsupportedPlatformError
25
+
26
+ log = get_logger("winapi")
27
+
28
+ __all__ = [
29
+ "IS_WINDOWS",
30
+ "require_windows",
31
+ "is_admin",
32
+ "is_process_elevated",
33
+ "is_system_session",
34
+ "dpi_awareness_scope",
35
+ "MonitorInfo",
36
+ "enumerate_monitors",
37
+ "virtual_screen_rect",
38
+ "system_metrics_rect",
39
+ "display_device_map",
40
+ "select_virtual_monitor",
41
+ "title_matches",
42
+ "WindowRecord",
43
+ "list_windows",
44
+ "format_hresult",
45
+ "is_owned_window",
46
+ "find_hwnd_by_title",
47
+ "GWL_STYLE",
48
+ "GWL_EXSTYLE",
49
+ "WS_EX_APPWINDOW",
50
+ "WS_EX_TOOLWINDOW",
51
+ ]
52
+
53
+ IS_WINDOWS = sys.platform == "win32"
54
+
55
+ # --------------------------------------------------------------------------------------
56
+ # user32 / kernel32 load
57
+ # --------------------------------------------------------------------------------------
58
+
59
+ if IS_WINDOWS: # pragma: no branch - platform gate
60
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
61
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
62
+ shell32 = ctypes.WinDLL("shell32", use_last_error=True)
63
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
64
+ try: # pragma: no cover - shcore exists on Win8.1+
65
+ shcore = ctypes.WinDLL("shcore", use_last_error=True)
66
+ except OSError: # pragma: no cover
67
+ shcore = None
68
+ try:
69
+ gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
70
+ except OSError: # pragma: no cover
71
+ gdi32 = None
72
+ else: # pragma: no cover - non-Windows import path
73
+ user32 = kernel32 = shell32 = advapi32 = shcore = gdi32 = None
74
+
75
+
76
+ def require_windows(what: str = "this operation") -> None:
77
+ """Raise :class:`UnsupportedPlatformError` when not running on Windows."""
78
+ if not IS_WINDOWS:
79
+ raise UnsupportedPlatformError(
80
+ f"{what} requires Windows; current platform is {sys.platform!r}."
81
+ )
82
+
83
+
84
+ def format_hresult(value: int) -> str:
85
+ """Render an HRESULT/NTSTATUS the way Windows documents it (``0x80070005``)."""
86
+ try:
87
+ return f"0x{value & 0xFFFFFFFF:08X}"
88
+ except TypeError: # pragma: no cover - defensive
89
+ return repr(value)
90
+
91
+
92
+ # --------------------------------------------------------------------------------------
93
+ # Privilege / session state
94
+ # --------------------------------------------------------------------------------------
95
+
96
+
97
+ class _TokenElevation(ctypes.Structure):
98
+ _fields_ = [("TokenIsElevated", wintypes.DWORD)]
99
+
100
+
101
+ def is_process_elevated() -> bool:
102
+ """True when the current process token is elevated (UAC-aware)."""
103
+ if not IS_WINDOWS:
104
+ return False
105
+ TOKEN_QUERY = 0x0008
106
+ TokenElevation = 20
107
+ token = wintypes.HANDLE()
108
+ if not advapi32.OpenProcessToken(
109
+ kernel32.GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)
110
+ ):
111
+ return False
112
+ try:
113
+ info = _TokenElevation()
114
+ returned = wintypes.DWORD()
115
+ ok = advapi32.GetTokenInformation(
116
+ token,
117
+ TokenElevation,
118
+ ctypes.byref(info),
119
+ ctypes.sizeof(info),
120
+ ctypes.byref(returned),
121
+ )
122
+ return bool(ok and info.TokenIsElevated)
123
+ finally:
124
+ kernel32.CloseHandle(token)
125
+
126
+
127
+ def is_admin() -> bool:
128
+ """Alias kept for readability at call sites (checks elevation, not group membership)."""
129
+ return is_process_elevated()
130
+
131
+
132
+ def is_system_session() -> bool:
133
+ """True when the process runs in a non-interactive session (service / task scheduler)."""
134
+ if not IS_WINDOWS:
135
+ return False
136
+ return (os.environ.get("SESSIONNAME") or "").upper().startswith("SERVICES")
137
+
138
+
139
+ # --------------------------------------------------------------------------------------
140
+ # DPI awareness
141
+ # --------------------------------------------------------------------------------------
142
+
143
+ _DPI_AWARENESS_NAMES = {
144
+ -1: "unaware",
145
+ 0: "system-aware",
146
+ 1: "per-monitor-aware",
147
+ 2: "per-monitor-v2",
148
+ 3: "unaware-gdi-scaled",
149
+ }
150
+
151
+
152
+ def _current_dpi_awareness() -> Optional[int]:
153
+ if not IS_WINDOWS or shcore is None:
154
+ return None
155
+ try:
156
+ value = ctypes.c_int()
157
+ if shcore.GetProcessDpiAwareness(None, ctypes.byref(value)) == 0:
158
+ return int(value.value)
159
+ except Exception: # pragma: no cover - defensive
160
+ return None
161
+ return None
162
+
163
+
164
+ class dpi_awareness_scope: # noqa: N801 - context-manager style name
165
+ """Process-wide DPI awareness, restored on exit when it can be.
166
+
167
+ ``PrintWindow`` and ``BitBlt`` return unscaled pixels only when the process is DPI
168
+ aware. Failing to set this is the single most common cause of "my screenshot is
169
+ 125 % too small / offset".
170
+ """
171
+
172
+ def __init__(self, awareness: str = "per_monitor_v2", *, restore: bool = True) -> None:
173
+ self.requested = awareness
174
+ self.restore = restore
175
+ self.previous: Optional[int] = None
176
+ self.applied: Optional[int] = None
177
+
178
+ def _apply(self) -> Optional[int]:
179
+ if not IS_WINDOWS:
180
+ return None
181
+ # Every candidate API is tried by the compatibility layer, which also handles the
182
+ # build-specific failure mode: on Windows 8.1 ``shcore`` exists but answers
183
+ # E_ACCESSDENIED once the process awareness has already been set, so an exception-free
184
+ # HRESULT check is required, not just an AttributeError guard.
185
+ from ._compat import set_process_dpi_awareness
186
+
187
+ requested = self.requested.lower()
188
+ context = -4 if requested in ("per_monitor_v2", "per-monitor-v2", "pmv2") else -3
189
+ outcome = set_process_dpi_awareness(context)
190
+ applied = outcome.get("applied")
191
+ if applied is None:
192
+ log.debug("could not set DPI awareness: %s", outcome.get("attempts"))
193
+ return None
194
+ return 2 if applied in (-3, -4) else 0
195
+
196
+ def __enter__(self) -> "dpi_awareness_scope":
197
+ self.previous = _current_dpi_awareness()
198
+ if self.previous is not None and self.previous >= 2:
199
+ self.applied = self.previous
200
+ return self
201
+ if IS_WINDOWS:
202
+ self.applied = self._apply()
203
+ return self
204
+
205
+ def __exit__(self, exc_type, exc, tb) -> None:
206
+ if not self.restore or self.previous is None or self.applied is None:
207
+ return
208
+ if self.applied == self.previous:
209
+ return
210
+ if shcore is None:
211
+ return
212
+ try:
213
+ shcore.SetProcessDpiAwareness(self.previous)
214
+ except Exception: # pragma: no cover - best effort
215
+ log.debug("could not restore DPI awareness to %s", self.previous)
216
+
217
+
218
+ # --------------------------------------------------------------------------------------
219
+ # Monitors
220
+ # --------------------------------------------------------------------------------------
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class MonitorInfo:
225
+ """One display as seen by ``EnumDisplayMonitors``."""
226
+
227
+ handle: int
228
+ device: str
229
+ monitor: Tuple[int, int, int, int]
230
+ work: Tuple[int, int, int, int]
231
+ primary: bool
232
+ #: PNP device instance id of the adapter driving this monitor, when resolvable.
233
+ pnp_device_id: str = ""
234
+ #: Friendly adapter description (``\\.\DISPLAY2`` -> "MttVDD").
235
+ device_string: str = ""
236
+
237
+ @property
238
+ def width(self) -> int:
239
+ return self.monitor[2] - self.monitor[0]
240
+
241
+ @property
242
+ def height(self) -> int:
243
+ return self.monitor[3] - self.monitor[1]
244
+
245
+ @property
246
+ def index(self) -> int:
247
+ match = re.search(r"DISPLAY(\d+)", self.device or "", re.IGNORECASE)
248
+ return int(match.group(1)) - 1 if match else -1
249
+
250
+ def is_decidedly_virtual(
251
+ self, markers: Sequence[str] = ("MttVDD", "IDD", "Virtual", "MTT")
252
+ ) -> bool:
253
+ """True when this monitor comes from a virtual/indirect display driver.
254
+
255
+ Two independent signals are checked because Windows reports them inconsistently: the
256
+ adapter/monitor description (``"Virtual Display Driver"``) and the device instance id
257
+ (``MONITOR\\MTT1337...``, ``ROOT\\MTTVDD``). Measured on this machine: an IddCx
258
+ display can surface as ``\\\\.\\DISPLAY1`` on the *Intel* adapter with the VDD's EDID as
259
+ its monitor id, so neither signal alone is sufficient.
260
+ """
261
+ haystack = f"{self.pnp_device_id} {self.device_string} {self.device}".upper()
262
+ return any(marker.upper() in haystack for marker in markers)
263
+
264
+ def to_dict(self) -> dict:
265
+ return {
266
+ "device": self.device,
267
+ "monitor": list(self.monitor),
268
+ "work": list(self.work),
269
+ "primary": self.primary,
270
+ "width": self.width,
271
+ "height": self.height,
272
+ "pnp_device_id": self.pnp_device_id,
273
+ "device_string": self.device_string,
274
+ }
275
+
276
+
277
+ class _RECT(ctypes.Structure):
278
+ _fields_ = [
279
+ ("left", wintypes.LONG),
280
+ ("top", wintypes.LONG),
281
+ ("right", wintypes.LONG),
282
+ ("bottom", wintypes.LONG),
283
+ ]
284
+
285
+
286
+ class _MONITORINFOEXW(ctypes.Structure):
287
+ _fields_ = [
288
+ ("cbSize", wintypes.DWORD),
289
+ ("rcMonitor", _RECT),
290
+ ("rcWork", _RECT),
291
+ ("dwFlags", wintypes.DWORD),
292
+ ("szDevice", ctypes.c_wchar * 32),
293
+ ]
294
+
295
+
296
+ if IS_WINDOWS: # pragma: no branch
297
+ _MONITORENUMPROC = ctypes.WINFUNCTYPE(
298
+ wintypes.BOOL,
299
+ wintypes.HMONITOR,
300
+ wintypes.HDC,
301
+ ctypes.POINTER(_RECT),
302
+ wintypes.LPARAM,
303
+ )
304
+ user32.GetMonitorInfoW.argtypes = [wintypes.HMONITOR, ctypes.POINTER(_MONITORINFOEXW)]
305
+ user32.GetMonitorInfoW.restype = wintypes.BOOL
306
+
307
+
308
+ class _DISPLAY_DEVICEW(ctypes.Structure):
309
+ _fields_ = [
310
+ ("cb", wintypes.DWORD),
311
+ ("DeviceName", ctypes.c_wchar * 32),
312
+ ("DeviceString", ctypes.c_wchar * 128),
313
+ ("StateFlags", wintypes.DWORD),
314
+ ("DeviceID", ctypes.c_wchar * 128),
315
+ ("DeviceKey", ctypes.c_wchar * 128),
316
+ ]
317
+
318
+
319
+ if IS_WINDOWS: # pragma: no branch
320
+ user32.EnumDisplayDevicesW.argtypes = [
321
+ ctypes.c_wchar_p,
322
+ wintypes.DWORD,
323
+ ctypes.POINTER(_DISPLAY_DEVICEW),
324
+ wintypes.DWORD,
325
+ ]
326
+ user32.EnumDisplayDevicesW.restype = wintypes.BOOL
327
+
328
+
329
+ def virtual_screen_rect() -> Tuple[int, int, int, int]:
330
+ """Canonical desktop rectangle, in the caller's DPI coordinate space.
331
+
332
+ ``GetSystemMetrics(SM_*VIRTUALSCREEN)`` and ``EnumDisplayMonitors`` are both DPI-virtualised
333
+ for an unaware caller, but they are *not* guaranteed to agree: measured on Windows 11 at
334
+ 125 % scale, an unaware process reports ``1536x864`` from **both**, a per-monitor-aware one
335
+ ``1920x1080`` from **both** — while mixed states (awareness set after one of the two was
336
+ already queried, or a monitor added between the calls) can leave them one scale factor
337
+ apart. A crop computed from one against a shot taken from the other is then silently
338
+ offset or undersized.
339
+
340
+ Preference order:
341
+
342
+ 1. the union of ``EnumDisplayMonitors`` rectangles — self-consistent with every other
343
+ monitor measurement in this library, and correct with negative-coordinate layouts;
344
+ 2. ``GetSystemMetrics`` — only when no window station is available (a session-0 service, or
345
+ a non-interactive agent) so that enumeration returns nothing.
346
+ """
347
+ if not IS_WINDOWS: # pragma: no cover - Windows-only library
348
+ return (0, 0, 0, 0)
349
+ monitors = enumerate_monitors()
350
+ if monitors:
351
+ return (
352
+ min(m.monitor[0] for m in monitors),
353
+ min(m.monitor[1] for m in monitors),
354
+ max(m.monitor[2] for m in monitors),
355
+ max(m.monitor[3] for m in monitors),
356
+ )
357
+ return system_metrics_rect()
358
+
359
+
360
+ def system_metrics_rect() -> Tuple[int, int, int, int]:
361
+ """``GetSystemMetrics`` desktop rectangle — the fallback used when enumeration is empty."""
362
+ SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 76, 77, 78, 79
363
+ if not IS_WINDOWS: # pragma: no cover - Windows-only library
364
+ return (0, 0, 0, 0)
365
+ user32.GetSystemMetrics.argtypes = [ctypes.c_int]
366
+ user32.GetSystemMetrics.restype = ctypes.c_int
367
+ return (
368
+ user32.GetSystemMetrics(SM_XVIRTUALSCREEN),
369
+ user32.GetSystemMetrics(SM_YVIRTUALSCREEN),
370
+ user32.GetSystemMetrics(SM_XVIRTUALSCREEN) + user32.GetSystemMetrics(SM_CXVIRTUALSCREEN),
371
+ user32.GetSystemMetrics(SM_YVIRTUALSCREEN) + user32.GetSystemMetrics(SM_CYVIRTUALSCREEN),
372
+ )
373
+
374
+
375
+ def display_device_map() -> Dict[str, Dict[str, str]]:
376
+ """Map ``\\\\.\\DISPLAYn`` to the PNP id / description of the adapter behind it.
377
+
378
+ The monitor-owning device is the *second* level of ``EnumDisplayDevices`` (the
379
+ "monitor" child of the display adapter), and its parent is what carries the hardware
380
+ id we match against. We therefore report the monitor device's ``DeviceID`` plus the
381
+ adapter's, so a pattern like ``MttVDD`` matches either way.
382
+ """
383
+ if not IS_WINDOWS:
384
+ return {}
385
+ result: Dict[str, Dict[str, str]] = {}
386
+ index = 0
387
+ DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x00000001
388
+ while True:
389
+ adapter = _DISPLAY_DEVICEW()
390
+ adapter.cb = ctypes.sizeof(_DISPLAY_DEVICEW)
391
+ if not user32.EnumDisplayDevicesW(None, index, ctypes.byref(adapter), 0):
392
+ break
393
+ index += 1
394
+ if not adapter.DeviceName:
395
+ continue
396
+ entry = {
397
+ "adapter_string": adapter.DeviceString,
398
+ "adapter_id": adapter.DeviceID,
399
+ "monitor_string": "",
400
+ "monitor_id": "",
401
+ }
402
+ monitor = _DISPLAY_DEVICEW()
403
+ monitor.cb = ctypes.sizeof(_DISPLAY_DEVICEW)
404
+ if user32.EnumDisplayDevicesW(
405
+ adapter.DeviceName, 0, ctypes.byref(monitor), 0
406
+ ) and (monitor.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP):
407
+ entry["monitor_string"] = monitor.DeviceString
408
+ entry["monitor_id"] = monitor.DeviceID
409
+ result[adapter.DeviceName] = entry
410
+ return result
411
+
412
+
413
+ def enumerate_monitors() -> List[MonitorInfo]:
414
+ """Return every display monitor currently attached to the desktop."""
415
+ require_windows("enumerate_monitors")
416
+ devices = display_device_map()
417
+ monitors: List[MonitorInfo] = []
418
+
419
+ def _callback(hmonitor, hdc, lprect, lparam):
420
+ info = _MONITORINFOEXW()
421
+ info.cbSize = ctypes.sizeof(_MONITORINFOEXW)
422
+ if not user32.GetMonitorInfoW(hmonitor, ctypes.byref(info)):
423
+ return True
424
+ device = info.szDevice or ""
425
+ extra = devices.get(device, {})
426
+ monitors.append(
427
+ MonitorInfo(
428
+ handle=int(hmonitor) if hmonitor else 0,
429
+ device=device,
430
+ monitor=(
431
+ int(info.rcMonitor.left),
432
+ int(info.rcMonitor.top),
433
+ int(info.rcMonitor.right),
434
+ int(info.rcMonitor.bottom),
435
+ ),
436
+ work=(
437
+ int(info.rcWork.left),
438
+ int(info.rcWork.top),
439
+ int(info.rcWork.right),
440
+ int(info.rcWork.bottom),
441
+ ),
442
+ primary=bool(info.dwFlags & 1),
443
+ pnp_device_id=extra.get("monitor_id") or extra.get("adapter_id", ""),
444
+ device_string=extra.get("monitor_string") or extra.get("adapter_string", ""),
445
+ )
446
+ )
447
+ return True
448
+
449
+ callback = _MONITORENUMPROC(_callback)
450
+ if not user32.EnumDisplayMonitors(None, None, callback, 0):
451
+ log.debug("EnumDisplayMonitors returned no monitors: %s", ctypes.get_last_error())
452
+ monitors.sort(key=lambda m: (not m.primary, m.monitor[0], m.monitor[1]))
453
+ return monitors
454
+
455
+
456
+ def select_virtual_monitor(
457
+ monitors: Sequence[MonitorInfo],
458
+ *,
459
+ preferred: str = "auto",
460
+ markers: Optional[Sequence[str]] = None,
461
+ exclude_primary: bool = True,
462
+ ) -> Optional[MonitorInfo]:
463
+ """Pick the monitor that a mounted window should live on.
464
+
465
+ ``preferred`` accepts:
466
+
467
+ * ``"auto"`` – first non-primary monitor whose adapter looks virtual, else the first
468
+ non-primary monitor, else ``None`` (never silently mount onto the user's primary
469
+ display unless ``exclude_primary=False``).
470
+ * ``"virtual"`` – like auto but *only* adapter-name/marker matches.
471
+ * ``"primary"`` – the primary monitor (useful for testing without a virtual display).
472
+ * ``"DISPLAY2"`` / ``"\\\\.\\DISPLAY2"`` / ``"2"`` – an explicit device name or 1-based index.
473
+
474
+ ``markers=None`` uses :meth:`MonitorInfo.is_decidedly_virtual`'s own defaults, so the two
475
+ can never drift apart (they did: the default list here omitted ``MTT``, and a monitor the
476
+ method called virtual was rejected by the selector, producing a misleading
477
+ ``DriverNotFoundError``).
478
+ """
479
+ if not monitors:
480
+ return None
481
+ value = (preferred or "auto").strip()
482
+ lowered = value.lower()
483
+
484
+ def looks_virtual(monitor: MonitorInfo) -> bool:
485
+ if markers is None:
486
+ return monitor.is_decidedly_virtual()
487
+ return monitor.is_decidedly_virtual(markers)
488
+
489
+ if lowered in ("primary", "main", "0"):
490
+ return next((m for m in monitors if m.primary), monitors[0])
491
+ if lowered in ("auto", "any"):
492
+ virtual = [m for m in monitors if looks_virtual(m)]
493
+ if virtual:
494
+ return virtual[0]
495
+ non_primary = [m for m in monitors if not m.primary]
496
+ if non_primary:
497
+ return non_primary[0]
498
+ return monitors[0] if not exclude_primary else None
499
+ if lowered in ("virtual", "idd", "marker"):
500
+ return next((m for m in monitors if looks_virtual(m)), None)
501
+
502
+ # Explicit selection.
503
+ if lowered.isdigit():
504
+ index = int(lowered)
505
+ for monitor in monitors:
506
+ if monitor.index == index:
507
+ return monitor
508
+ return None
509
+ needle = value.upper().lstrip("\\").lstrip(".")
510
+ for monitor in monitors:
511
+ if (monitor.device or "").upper().lstrip("\\").lstrip(".") == needle:
512
+ return monitor
513
+ for monitor in monitors:
514
+ if needle in (monitor.device or "").upper() or needle in (monitor.device_string or "").upper():
515
+ return monitor
516
+ return None
517
+
518
+
519
+ # --------------------------------------------------------------------------------------
520
+ # Windows
521
+ # --------------------------------------------------------------------------------------
522
+
523
+
524
+ @dataclass
525
+ class WindowRecord:
526
+ hwnd: int
527
+ title: str
528
+ class_name: str
529
+ pid: int = 0
530
+ process_name: str = ""
531
+ visible: bool = False
532
+ minimized: bool = False
533
+ rect: Tuple[int, int, int, int] = (0, 0, 0, 0)
534
+ owner: int = 0
535
+ tool_window: bool = False
536
+
537
+ def to_dict(self) -> dict:
538
+ return {
539
+ "hwnd": self.hwnd,
540
+ "title": self.title,
541
+ "class_name": self.class_name,
542
+ "pid": self.pid,
543
+ "process_name": self.process_name,
544
+ "visible": self.visible,
545
+ "minimized": self.minimized,
546
+ "rect": list(self.rect),
547
+ }
548
+
549
+
550
+ GWL_STYLE = -16
551
+ GWL_EXSTYLE = -20
552
+ WS_EX_APPWINDOW = 0x00040000
553
+ WS_EX_TOOLWINDOW = 0x00000080
554
+ WS_EX_LAYERED = 0x00080000
555
+ WS_EX_NOREDIRECTIONBITMAP = 0x00200000
556
+
557
+
558
+ #: Characters that make a pattern "obviously a regular expression". ``.`` is deliberately
559
+ #: absent: in window titles it almost always means a literal dot (``"Notepad 1.2"``), and
560
+ #: treating it as "any character" silently widens every match.
561
+ _REGEX_META = frozenset("\\^$[](){}+*?|")
562
+
563
+
564
+ def title_matches(title: str, pattern: str, *, mode: str = "auto") -> bool:
565
+ """Match a window title against a user-supplied pattern (pure helper, unit tested).
566
+
567
+ ``mode`` is one of ``exact``, ``substring``, ``regex`` or ``auto``.
568
+
569
+ ``auto`` (the default) is deliberately conservative, because guessing wrong is
570
+ expensive in both directions:
571
+
572
+ 1. an exact match wins;
573
+ 2. a literal substring match wins;
574
+ 3. an *anchored* regex match is tried (``re.fullmatch``), so ``"Report \\\\d+"`` does not
575
+ need ``^...$``;
576
+ 4. an unanchored regex search is tried **only** when the pattern contains real regex
577
+ syntax (see :data:`_REGEX_META`), so ``"1.2"`` can never match ``"1x2"``.
578
+
579
+ Callers who want real regex semantics regardless should pass ``mode="regex"``.
580
+ """
581
+ if pattern is None:
582
+ return True
583
+ if mode == "exact":
584
+ return title == pattern
585
+ if mode == "substring":
586
+ return pattern in title
587
+ if mode == "regex":
588
+ return re.search(pattern, title) is not None
589
+ if mode != "auto":
590
+ raise ValueError(f"unknown title match mode {mode!r}")
591
+ if pattern == title or pattern in title:
592
+ return True
593
+ try:
594
+ if re.fullmatch(pattern, title):
595
+ return True
596
+ except re.error:
597
+ return False
598
+ if not (_REGEX_META & set(pattern)):
599
+ return False
600
+ try:
601
+ return re.search(pattern, title) is not None
602
+ except re.error:
603
+ return False
604
+
605
+
606
+ if IS_WINDOWS: # pragma: no branch
607
+ _WNDENUMPROC = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
608
+ user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
609
+ user32.GetWindowTextW.argtypes = [wintypes.HWND, ctypes.c_wchar_p, ctypes.c_int]
610
+ user32.GetClassNameW.argtypes = [wintypes.HWND, ctypes.c_wchar_p, ctypes.c_int]
611
+ user32.IsWindowVisible.argtypes = [wintypes.HWND]
612
+ user32.IsIconic.argtypes = [wintypes.HWND]
613
+ user32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
614
+ user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
615
+ user32.GetWindowLongW.restype = wintypes.LONG
616
+
617
+
618
+ _GW_OWNER = 4
619
+
620
+
621
+ def is_owned_window(hwnd: int) -> bool:
622
+ """True when the window has an owner (a dialog / tool window, not a taskbar app)."""
623
+ if not IS_WINDOWS:
624
+ return False
625
+ return bool(user32.GetWindow(hwnd, _GW_OWNER))
626
+
627
+
628
+ def _process_name_for_pid(pid: int, cache: Optional[dict] = None) -> str:
629
+ if not pid:
630
+ return ""
631
+ if cache is not None and pid in cache:
632
+ return cache[pid]
633
+ name = ""
634
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
635
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
636
+ if handle:
637
+ try:
638
+ buf = ctypes.create_unicode_buffer(1024)
639
+ size = wintypes.DWORD(1024)
640
+ if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):
641
+ name = os.path.basename(buf.value)
642
+ finally:
643
+ kernel32.CloseHandle(handle)
644
+ if cache is not None:
645
+ cache[pid] = name
646
+ return name
647
+
648
+
649
+ def list_windows(*, include_hidden: bool = True) -> List[WindowRecord]:
650
+ """Enumerate top-level windows with the metadata needed for selection."""
651
+ require_windows("list_windows")
652
+ records: List[WindowRecord] = []
653
+ pid_cache: Dict[int, str] = {}
654
+
655
+ def _callback(hwnd, lparam):
656
+ length = user32.GetWindowTextLengthW(hwnd)
657
+ buf = ctypes.create_unicode_buffer(length + 1)
658
+ user32.GetWindowTextW(hwnd, buf, length + 1)
659
+ title = buf.value
660
+ cls = ctypes.create_unicode_buffer(256)
661
+ user32.GetClassNameW(hwnd, cls, 256)
662
+ visible = bool(user32.IsWindowVisible(hwnd))
663
+ if not include_hidden and not visible:
664
+ return True
665
+ pid = wintypes.DWORD()
666
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
667
+ rect = _RECT()
668
+ if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
669
+ rect = _RECT(0, 0, 0, 0)
670
+ ex_style = int(user32.GetWindowLongW(hwnd, GWL_EXSTYLE)) & 0xFFFFFFFF
671
+ records.append(
672
+ WindowRecord(
673
+ hwnd=int(hwnd),
674
+ title=title,
675
+ class_name=cls.value,
676
+ pid=int(pid.value),
677
+ process_name=_process_name_for_pid(int(pid.value), pid_cache),
678
+ visible=visible,
679
+ minimized=bool(user32.IsIconic(hwnd)),
680
+ rect=(int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)),
681
+ owner=int(user32.GetWindow(hwnd, _GW_OWNER) or 0),
682
+ tool_window=bool(ex_style & WS_EX_TOOLWINDOW),
683
+ )
684
+ )
685
+ return True
686
+
687
+ callback = _WNDENUMPROC(_callback)
688
+ user32.EnumWindows(callback, 0)
689
+ return records
690
+
691
+
692
+ def find_hwnd_by_title(title: str, *, limit: int = 0) -> int:
693
+ """Best-effort ``FindWindow(None, title)`` used by the CLI ``capture`` command.
694
+
695
+ Returns 0 when there is no *exact* top-level title match, and ``limit`` > 0 makes the
696
+ caller's intent explicit: the first substring match is returned instead.
697
+ """
698
+ require_windows("find_hwnd_by_title")
699
+ hwnd = int(user32.FindWindowW(None, title) or 0)
700
+ if hwnd or not limit:
701
+ return hwnd
702
+ for record in list_windows():
703
+ if title.lower() in (record.title or "").lower():
704
+ return record.hwnd
705
+ return 0
706
+
707
+
708
+ if IS_WINDOWS: # pragma: no branch
709
+ user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(_RECT)]
710
+ user32.GetWindowRect.restype = wintypes.BOOL
711
+ user32.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
712
+ user32.GetWindow.restype = wintypes.HWND
713
+ user32.FindWindowW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p]
714
+ user32.FindWindowW.restype = wintypes.HWND