VirtualDesktop 1.6.5__py3-none-win_amd64.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 +152 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_desktop_runtime.py +465 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_enumerate.py +97 -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/_vendor/FFMPEG-LICENSE.txt +23 -0
- VirtualDesktop/_vendor/ffmpeg.exe +4 -0
- VirtualDesktop/_winapi.py +739 -0
- VirtualDesktop/agent.py +403 -0
- VirtualDesktop/capture.py +676 -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 +848 -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/record.py +701 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +1240 -0
- VirtualDesktop/workspace.py +1274 -0
- virtualdesktop-1.6.5.dist-info/METADATA +846 -0
- virtualdesktop-1.6.5.dist-info/RECORD +38 -0
- virtualdesktop-1.6.5.dist-info/WHEEL +5 -0
- virtualdesktop-1.6.5.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.6.5.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.6.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Windows version detection and API-availability fallbacks.
|
|
2
|
+
|
|
3
|
+
Everything in this project has to behave identically on Windows 10 (1607 through 22H2) and
|
|
4
|
+
Windows 11 (21H2 through 24H2+). The APIs involved did not all arrive at once, and a few of
|
|
5
|
+
them changed *behaviour* rather than availability:
|
|
6
|
+
|
|
7
|
+
=========================== ========================== ==================================
|
|
8
|
+
API / behaviour First available Fallback used here
|
|
9
|
+
=========================== ========================== ==================================
|
|
10
|
+
``SetProcessDpiAwareness`` Win 8.1 (shcore) ``SetProcessDPIAware`` (Vista+)
|
|
11
|
+
``SetProcessDpiAwareness`` — *and* check the HRESULT: on Win 8.1
|
|
12
|
+
``Context`` (pmv2) Win 10 1703 it fails with ``E_ACCESSDENIED``
|
|
13
|
+
once awareness is already set
|
|
14
|
+
``GetDpiForWindow`` Win 10 1607 ``GetDpiForMonitor`` then the
|
|
15
|
+
monitor DC's ``LOGPIXELSX``
|
|
16
|
+
``GetDpiForMonitor`` Win 8.1 (shcore) the monitor DC's ``LOGPIXELSX``
|
|
17
|
+
``DwmGetWindowAttribute`` Vista (dwmapi) ``GetWindowRect`` fallback
|
|
18
|
+
``PrintWindow`` + ``PW_RENDERFULLCONTENT`` plain ``PrintWindow``, ``WM_PRINT``,
|
|
19
|
+
``PW_RENDERFULLCONTENT`` Win 8.1 ``BitBlt``
|
|
20
|
+
``ShellExecuteExW`` runas Vista —
|
|
21
|
+
``CreateDesktopW`` Win 2000 —
|
|
22
|
+
=========================== ========================== ==================================
|
|
23
|
+
|
|
24
|
+
The rule for callers: ask this module, never ``platform.version()`` directly, and never assume
|
|
25
|
+
a *newer* build implies a capability — Windows 10 and Windows 11 both ship builds in which an
|
|
26
|
+
optional API is present but returns a failure code.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import ctypes
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from ctypes import wintypes
|
|
35
|
+
from typing import Optional, Tuple
|
|
36
|
+
|
|
37
|
+
from ._log import get_logger
|
|
38
|
+
|
|
39
|
+
log = get_logger("compat")
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"WINDOWS_10_1607",
|
|
43
|
+
"WINDOWS_10_1703",
|
|
44
|
+
"WINDOWS_10_1809",
|
|
45
|
+
"WINDOWS_11_21H2",
|
|
46
|
+
"windows_build",
|
|
47
|
+
"windows_release",
|
|
48
|
+
"is_windows_11",
|
|
49
|
+
"at_least",
|
|
50
|
+
"describe_windows",
|
|
51
|
+
"supports_pmv2",
|
|
52
|
+
"set_process_dpi_awareness",
|
|
53
|
+
"get_process_dpi_awareness",
|
|
54
|
+
"dpi_awareness_name",
|
|
55
|
+
"monitor_dpi_legacy",
|
|
56
|
+
"window_dpi_compat",
|
|
57
|
+
"IDDCX_MIN_BUILD",
|
|
58
|
+
"iddcx_support",
|
|
59
|
+
"windows_compatibility_report",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
#: Build thresholds. Windows 10 and Windows 11 share the same major version (10.0), so the
|
|
63
|
+
#: build number is the only reliable discriminator — ``platform.release()`` is "10" on both.
|
|
64
|
+
WINDOWS_10_1607 = 14393
|
|
65
|
+
WINDOWS_10_1703 = 15063
|
|
66
|
+
WINDOWS_10_1809 = 17763
|
|
67
|
+
WINDOWS_11_21H2 = 22000
|
|
68
|
+
|
|
69
|
+
#: ``RtlGetVersion`` is used instead of ``GetVersionExW`` because the latter lies unless the
|
|
70
|
+
#: executable carries a compatibility manifest — a detail that differs between Python builds.
|
|
71
|
+
_RTL_OSVERSIONINFOW_SIZE = 6 * 4 + 128 * 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _OSVERSIONINFOW(ctypes.Structure):
|
|
75
|
+
_fields_ = [
|
|
76
|
+
("dwOSVersionInfoSize", wintypes.DWORD),
|
|
77
|
+
("dwMajorVersion", wintypes.DWORD),
|
|
78
|
+
("dwMinorVersion", wintypes.DWORD),
|
|
79
|
+
("dwBuildNumber", wintypes.DWORD),
|
|
80
|
+
("dwPlatformId", wintypes.DWORD),
|
|
81
|
+
("szCSDVersion", ctypes.c_wchar * 128),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _rtl_version() -> Optional[Tuple[int, int, int]]:
|
|
86
|
+
try:
|
|
87
|
+
ntdll = ctypes.WinDLL("ntdll", use_last_error=True)
|
|
88
|
+
info = _OSVERSIONINFOW()
|
|
89
|
+
info.dwOSVersionInfoSize = ctypes.sizeof(_OSVERSIONINFOW)
|
|
90
|
+
if ntdll.RtlGetVersion(ctypes.byref(info)) != 0:
|
|
91
|
+
return None
|
|
92
|
+
return int(info.dwMajorVersion), int(info.dwMinorVersion), int(info.dwBuildNumber)
|
|
93
|
+
except Exception: # pragma: no cover - non-Windows / blocked ntdll
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def windows_build() -> int:
|
|
98
|
+
"""The OS build number (``22000``+ on Windows 11, ``10240``..``19045`` on Windows 10)."""
|
|
99
|
+
version = _rtl_version()
|
|
100
|
+
if version is not None:
|
|
101
|
+
return version[2]
|
|
102
|
+
# ``sys.getwindowsversion()`` is the documented Python accessor and needs no ctypes.
|
|
103
|
+
try:
|
|
104
|
+
return int(sys.getwindowsversion().build)
|
|
105
|
+
except Exception: # pragma: no cover - non-Windows
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def windows_release() -> str:
|
|
110
|
+
"""``"11"``, ``"10"``, ``"8.1"`` … — derived from the build, not from ``platform``."""
|
|
111
|
+
build = windows_build()
|
|
112
|
+
if build >= WINDOWS_11_21H2:
|
|
113
|
+
return "11"
|
|
114
|
+
if build >= 10240:
|
|
115
|
+
return "10"
|
|
116
|
+
version = _rtl_version()
|
|
117
|
+
if version is None:
|
|
118
|
+
return "unknown"
|
|
119
|
+
return f"{version[0]}.{version[1]}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_windows_11() -> bool:
|
|
123
|
+
return windows_build() >= WINDOWS_11_21H2
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def at_least(build: int) -> bool:
|
|
127
|
+
return windows_build() >= build
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def describe_windows() -> str:
|
|
131
|
+
build = windows_build()
|
|
132
|
+
return f"Windows {windows_release()} (build {build or 'unknown'})"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------------------
|
|
136
|
+
# DPI awareness
|
|
137
|
+
# --------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
#: ``DPI_AWARENESS_CONTEXT`` pseudo-handles. They are only understood by
|
|
140
|
+
#: ``SetProcessDpiAwarenessContext``, and passing them to the older APIs fails.
|
|
141
|
+
DPI_AWARENESS_CONTEXT_UNAWARE = -1
|
|
142
|
+
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = -2
|
|
143
|
+
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = -3
|
|
144
|
+
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
|
|
145
|
+
DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED = -5
|
|
146
|
+
|
|
147
|
+
_SHCORE_PROCESS_DPI_AWARENESS = {
|
|
148
|
+
-1: 0, # unaware
|
|
149
|
+
-2: 1, # system aware
|
|
150
|
+
-3: 2, # per monitor
|
|
151
|
+
-4: 2, # per monitor v2 is only expressible through the context API
|
|
152
|
+
}
|
|
153
|
+
_PROCESS_DPI_AWARENESS_NAMES = {0: "unaware", 1: "system-aware", 2: "per-monitor-aware"}
|
|
154
|
+
E_ACCESSDENIED = -2147024891 # 0x80070005, returned as a signed HRESULT by shcore
|
|
155
|
+
|
|
156
|
+
_user32 = None
|
|
157
|
+
_shcore = None
|
|
158
|
+
|
|
159
|
+
#: Whether ``SetProcessDpiAwarenessContext`` exists. ``ctypes`` does **not** expose a
|
|
160
|
+
#: missing export as ``AttributeError`` — it raises ``OSError`` on the first *access* — so
|
|
161
|
+
#: ``hasattr`` alone would answer "yes" on Windows 8.1 and then crash on the call. The probe
|
|
162
|
+
#: below resolves the symbol once, at import time, and records the truth.
|
|
163
|
+
_HAS_CONTEXT_API = False
|
|
164
|
+
_HAS_AWARENESS_API = False
|
|
165
|
+
if sys.platform == "win32": # pragma: no branch
|
|
166
|
+
try:
|
|
167
|
+
_user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
168
|
+
try:
|
|
169
|
+
_user32.SetProcessDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
|
170
|
+
_user32.SetProcessDpiAwarenessContext.restype = wintypes.BOOL
|
|
171
|
+
_HAS_CONTEXT_API = True
|
|
172
|
+
except OSError: # pragma: no cover - Windows 8.1 / 7
|
|
173
|
+
_HAS_CONTEXT_API = False
|
|
174
|
+
try:
|
|
175
|
+
_user32.GetThreadDpiAwarenessContext.argtypes = []
|
|
176
|
+
_user32.GetThreadDpiAwarenessContext.restype = ctypes.c_void_p
|
|
177
|
+
_HAS_CONTEXT_API = True
|
|
178
|
+
except OSError: # pragma: no cover - pre-1607
|
|
179
|
+
_HAS_CONTEXT_API = False
|
|
180
|
+
try:
|
|
181
|
+
_user32.GetAwarenessFromDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
|
182
|
+
_user32.GetAwarenessFromDpiAwarenessContext.restype = ctypes.c_int
|
|
183
|
+
_HAS_AWARENESS_API = True
|
|
184
|
+
except OSError: # pragma: no cover - pre-1607
|
|
185
|
+
_HAS_AWARENESS_API = False
|
|
186
|
+
_user32.SetProcessDPIAware.argtypes = []
|
|
187
|
+
_user32.SetProcessDPIAware.restype = wintypes.BOOL
|
|
188
|
+
except Exception: # pragma: no cover - very old user32
|
|
189
|
+
_user32 = None
|
|
190
|
+
try:
|
|
191
|
+
_shcore = ctypes.WinDLL("shcore", use_last_error=True)
|
|
192
|
+
_shcore.SetProcessDpiAwareness.argtypes = [ctypes.c_int]
|
|
193
|
+
_shcore.SetProcessDpiAwareness.restype = ctypes.c_long
|
|
194
|
+
_shcore.GetProcessDpiAwareness.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)]
|
|
195
|
+
_shcore.GetProcessDpiAwareness.restype = ctypes.c_long
|
|
196
|
+
except Exception: # pragma: no cover - Windows 8.0 / 7
|
|
197
|
+
_shcore = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def supports_pmv2() -> bool:
|
|
201
|
+
"""True when ``SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)`` can be called."""
|
|
202
|
+
return _HAS_CONTEXT_API
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def set_process_dpi_awareness(awareness: int = DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) -> dict:
|
|
206
|
+
"""Become DPI aware, trying every API the running build offers, in order.
|
|
207
|
+
|
|
208
|
+
Returns ``{"applied": <context|None>, "api": <name|None>, "attempts": [...]}``. The
|
|
209
|
+
``attempts`` list is kept so a caller can log *why* a fallback was taken — the interesting
|
|
210
|
+
case is Windows 8.1, where ``shcore`` exists but returns ``E_ACCESSDENIED`` once the
|
|
211
|
+
process awareness has already been set by an earlier call.
|
|
212
|
+
"""
|
|
213
|
+
result: dict = {"applied": None, "api": None, "attempts": [], "build": windows_build()}
|
|
214
|
+
if sys.platform != "win32": # pragma: no cover - Windows-only library
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
# (1) the modern context API (Win 10 1703+). Fails on older builds by absence.
|
|
218
|
+
if supports_pmv2():
|
|
219
|
+
try:
|
|
220
|
+
if _user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(awareness)):
|
|
221
|
+
result.update(applied=awareness, api="SetProcessDpiAwarenessContext")
|
|
222
|
+
result["attempts"].append({"api": "context", "ok": True})
|
|
223
|
+
return result
|
|
224
|
+
result["attempts"].append(
|
|
225
|
+
{"api": "context", "ok": False, "win32_error": ctypes.get_last_error()}
|
|
226
|
+
)
|
|
227
|
+
except Exception as exc: # noqa: BLE001 - never fatal, always fall through
|
|
228
|
+
result["attempts"].append({"api": "context", "ok": False, "error": str(exc)[:120]})
|
|
229
|
+
else:
|
|
230
|
+
result["attempts"].append({"api": "context", "ok": False, "reason": "not exported"})
|
|
231
|
+
|
|
232
|
+
# (2) per-monitor awareness through shcore (Win 8.1+).
|
|
233
|
+
mapped = _SHCORE_PROCESS_DPI_AWARENESS.get(awareness)
|
|
234
|
+
if _shcore is not None and mapped is not None:
|
|
235
|
+
try:
|
|
236
|
+
code = int(_shcore.SetProcessDpiAwareness(mapped))
|
|
237
|
+
if code == 0:
|
|
238
|
+
result.update(applied=awareness, api="SetProcessDpiAwareness")
|
|
239
|
+
result["attempts"].append({"api": "shcore", "ok": True})
|
|
240
|
+
return result
|
|
241
|
+
result["attempts"].append(
|
|
242
|
+
{
|
|
243
|
+
"api": "shcore",
|
|
244
|
+
"ok": False,
|
|
245
|
+
"hresult": hex(code & 0xFFFFFFFF),
|
|
246
|
+
"reason": "already-set" if code == E_ACCESSDENIED else "error",
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
except Exception as exc: # noqa: BLE001
|
|
250
|
+
result["attempts"].append({"api": "shcore", "ok": False, "error": str(exc)[:120]})
|
|
251
|
+
else:
|
|
252
|
+
result["attempts"].append({"api": "shcore", "ok": False, "reason": "unavailable"})
|
|
253
|
+
|
|
254
|
+
# (3) the Vista-era call, which is unconditional and always worth attempting.
|
|
255
|
+
try:
|
|
256
|
+
if _user32 is not None and _user32.SetProcessDPIAware():
|
|
257
|
+
result.update(applied=DPI_AWARENESS_CONTEXT_SYSTEM_AWARE, api="SetProcessDPIAware")
|
|
258
|
+
result["attempts"].append({"api": "legacy", "ok": True})
|
|
259
|
+
return result
|
|
260
|
+
result["attempts"].append(
|
|
261
|
+
{"api": "legacy", "ok": False, "win32_error": ctypes.get_last_error()}
|
|
262
|
+
)
|
|
263
|
+
except Exception as exc: # noqa: BLE001
|
|
264
|
+
result["attempts"].append({"api": "legacy", "ok": False, "error": str(exc)[:120]})
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def get_process_dpi_awareness() -> Optional[str]:
|
|
269
|
+
"""``"per-monitor-v2"``, ``"per-monitor-aware"``, ``"system-aware"``, ``"unaware"``…
|
|
270
|
+
|
|
271
|
+
The subtlety that costs a crash if ignored: ``GetThreadDpiAwarenessContext`` does **not**
|
|
272
|
+
reliably return one of the documented negative pseudo-handles. Measured on Windows 11
|
|
273
|
+
22631 (Python 3.11, unelevated): it returned ``0x6010`` — a real handle. Interpreting that
|
|
274
|
+
as a ``DPI_AWARENESS_CONTEXT_*`` constant and de-referencing it is an access violation, so
|
|
275
|
+
the value is only ever *compared*, never read, and the documented
|
|
276
|
+
``GetAwarenessFromDpiAwarenessContext`` supplies the awareness level.
|
|
277
|
+
|
|
278
|
+
``GetAwarenessFromDpiAwarenessContext`` cannot distinguish per-monitor-v1 from v2 (both
|
|
279
|
+
report ``DPI_AWARENESS_PER_MONITOR_AWARE``), so the v2 pseudo-handle is probed once by
|
|
280
|
+
*calling* with it and recording the resulting context — a comparison, never a dereference.
|
|
281
|
+
"""
|
|
282
|
+
if sys.platform != "win32": # pragma: no cover
|
|
283
|
+
return None
|
|
284
|
+
if _user32 is not None and _HAS_CONTEXT_API and _HAS_AWARENESS_API:
|
|
285
|
+
try:
|
|
286
|
+
context = int(_user32.GetThreadDpiAwarenessContext() or 0)
|
|
287
|
+
if context:
|
|
288
|
+
awareness = int(
|
|
289
|
+
_user32.GetAwarenessFromDpiAwarenessContext(ctypes.c_void_p(context))
|
|
290
|
+
)
|
|
291
|
+
if awareness >= 0 and awareness != 3: # 3 = DPI_AWARENESS_INVALID
|
|
292
|
+
name = _PROCESS_DPI_AWARENESS_NAMES.get(awareness, f"unknown({awareness})")
|
|
293
|
+
if awareness == 2 and _per_monitor_v2_active(context):
|
|
294
|
+
return "per-monitor-v2"
|
|
295
|
+
return name
|
|
296
|
+
except Exception: # pragma: no cover - defensive
|
|
297
|
+
pass
|
|
298
|
+
if _shcore is not None:
|
|
299
|
+
try:
|
|
300
|
+
value = ctypes.c_int()
|
|
301
|
+
if _shcore.GetProcessDpiAwareness(None, ctypes.byref(value)) == 0:
|
|
302
|
+
return _PROCESS_DPI_AWARENESS_NAMES.get(
|
|
303
|
+
int(value.value), f"unknown({value.value})"
|
|
304
|
+
)
|
|
305
|
+
except Exception: # pragma: no cover - defensive
|
|
306
|
+
pass
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _per_monitor_v2_active(context: int) -> bool:
|
|
311
|
+
"""True when ``context`` is the per-monitor-v2 context.
|
|
312
|
+
|
|
313
|
+
Tested by *calling* ``SetThreadDpiAwarenessContext`` with the v2 pseudo-handle: it returns
|
|
314
|
+
the previous context, so ``previous == current-desired`` iff this thread already holds v2.
|
|
315
|
+
That is a comparison of two opaque handles — never a de-reference, which is what makes it
|
|
316
|
+
safe on the builds where the handle is real rather than a pseudo-handle.
|
|
317
|
+
"""
|
|
318
|
+
if _user32 is None or not _HAS_CONTEXT_API: # pragma: no cover - pre-1703
|
|
319
|
+
return False
|
|
320
|
+
try:
|
|
321
|
+
_user32.SetThreadDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
|
322
|
+
_user32.SetThreadDpiAwarenessContext.restype = ctypes.c_void_p
|
|
323
|
+
previous = int(
|
|
324
|
+
_user32.SetThreadDpiAwarenessContext(
|
|
325
|
+
ctypes.c_void_p(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
|
|
326
|
+
)
|
|
327
|
+
or 0
|
|
328
|
+
)
|
|
329
|
+
if previous:
|
|
330
|
+
# Put the thread back exactly as it was.
|
|
331
|
+
_user32.SetThreadDpiAwarenessContext(ctypes.c_void_p(previous))
|
|
332
|
+
now = int(_user32.GetThreadDpiAwarenessContext() or 0)
|
|
333
|
+
return bool(now) and now == previous
|
|
334
|
+
except Exception: # pragma: no cover - defensive
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def dpi_awareness_name() -> str:
|
|
339
|
+
return get_process_dpi_awareness() or "unknown"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --------------------------------------------------------------------------------------
|
|
343
|
+
# Per-monitor DPI, the pre-1607 way
|
|
344
|
+
# --------------------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def window_dpi_compat(hwnd: int, *, fallback: int = 96) -> int:
|
|
348
|
+
"""Effective DPI of ``hwnd``, using whichever accessor the running build provides.
|
|
349
|
+
|
|
350
|
+
1. ``GetDpiForWindow`` — Win 10 1607+, and the only one that accounts for a per-monitor-v2
|
|
351
|
+
window that has been dragged between monitors;
|
|
352
|
+
2. ``GetDpiForMonitor(MDT_EFFECTIVE_DPI)`` — Win 8.1+, resolved through
|
|
353
|
+
``MonitorFromWindow(MONITOR_DEFAULTTONEAREST)``;
|
|
354
|
+
3. the monitor DC's ``LOGPIXELSX`` — available on every supported build.
|
|
355
|
+
"""
|
|
356
|
+
if sys.platform != "win32" or not hwnd: # pragma: no cover - Windows-only library
|
|
357
|
+
return fallback
|
|
358
|
+
get_dpi_for_window = None
|
|
359
|
+
monitor_from_window = None
|
|
360
|
+
if _user32 is not None:
|
|
361
|
+
try:
|
|
362
|
+
get_dpi_for_window = _user32.GetDpiForWindow
|
|
363
|
+
except (AttributeError, OSError): # pragma: no cover - pre-1607
|
|
364
|
+
get_dpi_for_window = None
|
|
365
|
+
if get_dpi_for_window is not None:
|
|
366
|
+
try: # a plain callable (a test double) legitimately has no argtypes
|
|
367
|
+
get_dpi_for_window.argtypes = [wintypes.HWND]
|
|
368
|
+
get_dpi_for_window.restype = wintypes.UINT
|
|
369
|
+
except (AttributeError, TypeError):
|
|
370
|
+
pass
|
|
371
|
+
try:
|
|
372
|
+
monitor_from_window = _user32.MonitorFromWindow
|
|
373
|
+
except (AttributeError, OSError): # pragma: no cover - pre-2000
|
|
374
|
+
monitor_from_window = None
|
|
375
|
+
if monitor_from_window is not None:
|
|
376
|
+
try:
|
|
377
|
+
monitor_from_window.argtypes = [wintypes.HWND, wintypes.DWORD]
|
|
378
|
+
monitor_from_window.restype = wintypes.HMONITOR
|
|
379
|
+
except (AttributeError, TypeError):
|
|
380
|
+
pass
|
|
381
|
+
if get_dpi_for_window is not None:
|
|
382
|
+
try:
|
|
383
|
+
value = int(get_dpi_for_window(wintypes.HWND(hwnd)))
|
|
384
|
+
if value:
|
|
385
|
+
return value
|
|
386
|
+
except Exception: # noqa: BLE001 - the call itself failed, try the next accessor
|
|
387
|
+
pass
|
|
388
|
+
monitor = 0
|
|
389
|
+
if monitor_from_window is not None:
|
|
390
|
+
try:
|
|
391
|
+
monitor = int(monitor_from_window(wintypes.HWND(hwnd), 2) or 0)
|
|
392
|
+
except Exception: # noqa: BLE001
|
|
393
|
+
monitor = 0
|
|
394
|
+
if monitor and _shcore is not None:
|
|
395
|
+
try:
|
|
396
|
+
get_dpi_for_monitor = _shcore.GetDpiForMonitor
|
|
397
|
+
get_dpi_for_monitor.argtypes = [
|
|
398
|
+
wintypes.HMONITOR,
|
|
399
|
+
ctypes.c_int,
|
|
400
|
+
ctypes.POINTER(wintypes.UINT),
|
|
401
|
+
ctypes.POINTER(wintypes.UINT),
|
|
402
|
+
]
|
|
403
|
+
x, y = wintypes.UINT(), wintypes.UINT()
|
|
404
|
+
if get_dpi_for_monitor(
|
|
405
|
+
wintypes.HMONITOR(monitor), 0, ctypes.byref(x), ctypes.byref(y)
|
|
406
|
+
) == 0 and int(x.value):
|
|
407
|
+
return int(x.value)
|
|
408
|
+
except Exception: # noqa: BLE001 - shcore missing (Windows 8.0) or call failed
|
|
409
|
+
pass
|
|
410
|
+
if monitor:
|
|
411
|
+
value = monitor_dpi_legacy(monitor, fallback=fallback)
|
|
412
|
+
if value:
|
|
413
|
+
return value
|
|
414
|
+
return fallback
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def monitor_dpi_legacy(hmonitor: int, *, fallback: int = 96) -> Optional[int]:
|
|
418
|
+
"""Effective DPI of ``hmonitor`` using only APIs present since Windows 2000.
|
|
419
|
+
|
|
420
|
+
``GetDpiForWindow`` (1607+) and ``GetDpiForMonitor`` (8.1+) are the modern accessors, but
|
|
421
|
+
neither exists on every supported build. The device context of a monitor reports
|
|
422
|
+
``LOGPIXELSX``, and Windows scales that value with the monitor's DPI setting, so it is a
|
|
423
|
+
usable last resort. The DC must be released or GDI leaks one handle per call.
|
|
424
|
+
"""
|
|
425
|
+
if sys.platform != "win32" or not hmonitor: # pragma: no cover - Windows-only library
|
|
426
|
+
return None
|
|
427
|
+
LOGPIXELSX = 88
|
|
428
|
+
try:
|
|
429
|
+
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
|
|
430
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
431
|
+
user32.GetDC.argtypes = [wintypes.HWND]
|
|
432
|
+
user32.GetDC.restype = wintypes.HDC
|
|
433
|
+
user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
|
|
434
|
+
user32.ReleaseDC.restype = ctypes.c_int
|
|
435
|
+
gdi32.GetDeviceCaps.argtypes = [wintypes.HDC, ctypes.c_int]
|
|
436
|
+
gdi32.GetDeviceCaps.restype = ctypes.c_int
|
|
437
|
+
# A monitor handle is a valid HDC source for GDI; ``GetDC`` accepts it directly.
|
|
438
|
+
dc = user32.GetDC(wintypes.HWND(hmonitor))
|
|
439
|
+
if not dc:
|
|
440
|
+
return None
|
|
441
|
+
try:
|
|
442
|
+
value = int(gdi32.GetDeviceCaps(dc, LOGPIXELSX))
|
|
443
|
+
finally:
|
|
444
|
+
user32.ReleaseDC(wintypes.HWND(hmonitor), dc)
|
|
445
|
+
return value or fallback
|
|
446
|
+
except Exception: # pragma: no cover - defensive
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# --------------------------------------------------------------------------------------
|
|
451
|
+
# Report
|
|
452
|
+
# --------------------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _file_version_dll(path: str) -> Optional[str]:
|
|
456
|
+
"""The ``FileVersion`` resource of a system DLL, or ``None``.
|
|
457
|
+
|
|
458
|
+
Read with ``GetFileVersionInfoSizeW``/``GetFileVersionInfoW`` instead of loading the DLL:
|
|
459
|
+
probing by ``LoadLibrary`` would pull a display-driver framework into the process for no
|
|
460
|
+
reason, and on a locked-down build it can fail for unrelated policy reasons.
|
|
461
|
+
"""
|
|
462
|
+
if sys.platform != "win32" or not os.path.isfile(path): # pragma: no cover - Windows only
|
|
463
|
+
return None
|
|
464
|
+
try:
|
|
465
|
+
version = ctypes.WinDLL("version", use_last_error=True)
|
|
466
|
+
version.GetFileVersionInfoSizeW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(wintypes.DWORD)]
|
|
467
|
+
version.GetFileVersionInfoSizeW.restype = wintypes.DWORD
|
|
468
|
+
version.GetFileVersionInfoW.argtypes = [
|
|
469
|
+
ctypes.c_wchar_p, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p
|
|
470
|
+
]
|
|
471
|
+
version.GetFileVersionInfoW.restype = wintypes.BOOL
|
|
472
|
+
version.VerQueryValueW.argtypes = [
|
|
473
|
+
ctypes.c_void_p, ctypes.c_wchar_p, ctypes.POINTER(ctypes.c_void_p),
|
|
474
|
+
ctypes.POINTER(wintypes.UINT),
|
|
475
|
+
]
|
|
476
|
+
version.VerQueryValueW.restype = wintypes.BOOL
|
|
477
|
+
handle = wintypes.DWORD()
|
|
478
|
+
size = version.GetFileVersionInfoSizeW(path, ctypes.byref(handle))
|
|
479
|
+
if not size:
|
|
480
|
+
return None
|
|
481
|
+
buffer = ctypes.create_string_buffer(size)
|
|
482
|
+
if not version.GetFileVersionInfoW(path, 0, size, buffer):
|
|
483
|
+
return None
|
|
484
|
+
pointer = ctypes.c_void_p()
|
|
485
|
+
length = wintypes.UINT()
|
|
486
|
+
if not version.VerQueryValueW(buffer, "\\", ctypes.byref(pointer), ctypes.byref(length)):
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
class _FixedFileInfo(ctypes.Structure):
|
|
490
|
+
_fields_ = [
|
|
491
|
+
("dwSignature", wintypes.DWORD),
|
|
492
|
+
("dwStrucVersion", wintypes.DWORD),
|
|
493
|
+
("dwFileVersionMS", wintypes.DWORD),
|
|
494
|
+
("dwFileVersionLS", wintypes.DWORD),
|
|
495
|
+
]
|
|
496
|
+
|
|
497
|
+
info = ctypes.cast(pointer, ctypes.POINTER(_FixedFileInfo)).contents
|
|
498
|
+
ms, ls = int(info.dwFileVersionMS), int(info.dwFileVersionLS)
|
|
499
|
+
return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}"
|
|
500
|
+
except Exception: # pragma: no cover - defensive
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
#: The virtual display driver is an IddCx (indirect display) driver. ``IddCx0102`` in its INF
|
|
505
|
+
#: means the UMDF extension interface version 1.2, which ships with Windows 10 1809 (build
|
|
506
|
+
#: 17763). Windows 10 1709 and earlier have no IddCx at all, so no indirect display driver can
|
|
507
|
+
#: load there — that is a hard platform floor, not a tunable.
|
|
508
|
+
IDDCX_MIN_BUILD = WINDOWS_10_1809
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def iddcx_support() -> dict:
|
|
512
|
+
"""Whether this build can host an IddCx indirect display driver, and why.
|
|
513
|
+
|
|
514
|
+
Two independent signals, because either can be missing on its own:
|
|
515
|
+
|
|
516
|
+
* the build number (``IddCx`` appeared in Windows 10 1703 and the 1.2 interface used by the
|
|
517
|
+
driver's INF in 1809);
|
|
518
|
+
* the ``IddCx.dll`` file version actually present in ``System32``.
|
|
519
|
+
"""
|
|
520
|
+
system_root = os.environ.get("SystemRoot", r"C:\Windows")
|
|
521
|
+
candidates = [
|
|
522
|
+
os.path.join(system_root, "System32", "drivers", "UMDF", "IddCx.dll"),
|
|
523
|
+
os.path.join(system_root, "System32", "IddCx.dll"),
|
|
524
|
+
]
|
|
525
|
+
found = next((path for path in candidates if os.path.isfile(path)), None)
|
|
526
|
+
version = _file_version_dll(found) if found else None
|
|
527
|
+
return {
|
|
528
|
+
"build": windows_build(),
|
|
529
|
+
"min_build": IDDCX_MIN_BUILD,
|
|
530
|
+
"build_ok": windows_build() >= IDDCX_MIN_BUILD,
|
|
531
|
+
"dll_present": bool(found),
|
|
532
|
+
"dll_path": found,
|
|
533
|
+
"dll_version": version,
|
|
534
|
+
"supported": windows_build() >= IDDCX_MIN_BUILD,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def windows_compatibility_report() -> dict:
|
|
539
|
+
"""Everything a bug report needs: build, awareness and which optional APIs are present."""
|
|
540
|
+
report: dict = {
|
|
541
|
+
"release": windows_release(),
|
|
542
|
+
"build": windows_build(),
|
|
543
|
+
"is_windows_11": is_windows_11(),
|
|
544
|
+
"described": describe_windows(),
|
|
545
|
+
"python": sys.version.split()[0],
|
|
546
|
+
"dpi_awareness": get_process_dpi_awareness(),
|
|
547
|
+
"thresholds": {
|
|
548
|
+
"windows_10_1607": WINDOWS_10_1607,
|
|
549
|
+
"windows_10_1703": WINDOWS_10_1703,
|
|
550
|
+
"windows_10_1809": WINDOWS_10_1809,
|
|
551
|
+
"windows_11_21h2": WINDOWS_11_21H2,
|
|
552
|
+
},
|
|
553
|
+
"capabilities": {},
|
|
554
|
+
}
|
|
555
|
+
if sys.platform != "win32": # pragma: no cover
|
|
556
|
+
return report
|
|
557
|
+
checks = {
|
|
558
|
+
"SetProcessDpiAwarenessContext": ("user32", "SetProcessDpiAwarenessContext"),
|
|
559
|
+
"SetProcessDpiAwareness": ("shcore", "SetProcessDpiAwareness"),
|
|
560
|
+
"GetDpiForWindow": ("user32", "GetDpiForWindow"),
|
|
561
|
+
"GetDpiForMonitor": ("shcore", "GetDpiForMonitor"),
|
|
562
|
+
"DwmGetWindowAttribute": ("dwmapi", "DwmGetWindowAttribute"),
|
|
563
|
+
"PrintWindow": ("user32", "PrintWindow"),
|
|
564
|
+
"ShellExecuteExW": ("shell32", "ShellExecuteExW"),
|
|
565
|
+
"CreateDesktopW": ("user32", "CreateDesktopW"),
|
|
566
|
+
}
|
|
567
|
+
for label, (library, symbol) in checks.items():
|
|
568
|
+
try:
|
|
569
|
+
module = ctypes.WinDLL(library, use_last_error=True)
|
|
570
|
+
# Accessing a missing export raises OSError, not AttributeError, so probe by
|
|
571
|
+
# resolving the symbol rather than by ``hasattr``.
|
|
572
|
+
getattr(module, symbol)
|
|
573
|
+
report["capabilities"][label] = True
|
|
574
|
+
except Exception: # noqa: BLE001
|
|
575
|
+
report["capabilities"][label] = False
|
|
576
|
+
return report
|