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.
- VirtualDesktop/__init__.py +121 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_helper.py +31 -0
- VirtualDesktop/_log.py +85 -0
- VirtualDesktop/_process.py +912 -0
- VirtualDesktop/_sigcheck.ps1 +32 -0
- VirtualDesktop/_sigcheck.py +321 -0
- VirtualDesktop/_winapi.py +714 -0
- VirtualDesktop/capture.py +671 -0
- VirtualDesktop/cli.py +673 -0
- VirtualDesktop/config.py +255 -0
- VirtualDesktop/desktop.py +231 -0
- VirtualDesktop/desktop_capture.py +159 -0
- VirtualDesktop/desktop_session.py +533 -0
- VirtualDesktop/display_setup.py +790 -0
- VirtualDesktop/driver.py +831 -0
- VirtualDesktop/driver_manifest.json +118 -0
- VirtualDesktop/errors.py +211 -0
- VirtualDesktop/manifest.py +310 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +869 -0
- virtualdesktop-1.0.1.dist-info/METADATA +307 -0
- virtualdesktop-1.0.1.dist-info/RECORD +31 -0
- virtualdesktop-1.0.1.dist-info/WHEEL +5 -0
- virtualdesktop-1.0.1.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.0.1.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|