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,790 @@
|
|
|
1
|
+
"""Configure the virtual display's mode and scaling from Python.
|
|
2
|
+
|
|
3
|
+
``SessionConfig.monitor_size`` / ``monitor_dpi`` express the intent ("make the virtual desktop
|
|
4
|
+
1920x1080 at 100 %"), and this module turns it into the two Windows mechanisms that actually
|
|
5
|
+
implement it:
|
|
6
|
+
|
|
7
|
+
* **mode** (resolution / refresh): ``ChangeDisplaySettingsExW`` for the virtual monitor's device
|
|
8
|
+
name. A monitor that is not currently attached rejects the change with
|
|
9
|
+
``DISP_CHANGE_FAILED``, so the monitor's current state is checked first and reported.
|
|
10
|
+
* **scaling** (DPI): ``SetDisplayConfig`` with the supplied path/mode arrays, the documented way
|
|
11
|
+
to set a per-monitor DPI. Both mechanisms are wrapped so a failure is *reported with the
|
|
12
|
+
Win32/HRESULT code* rather than silently leaving the display at the wrong size — the state
|
|
13
|
+
this library previously ran into (a fresh install comes up at the driver default, 800x600).
|
|
14
|
+
|
|
15
|
+
The default for both is "whatever the current primary monitor uses", so the virtual desktop
|
|
16
|
+
matches the screen the user is looking at.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ctypes
|
|
22
|
+
from ctypes import wintypes
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import List, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
from ._log import get_logger
|
|
27
|
+
from ._winapi import IS_WINDOWS, MonitorInfo, enumerate_monitors, require_windows
|
|
28
|
+
from .errors import ConfigError, OffscreenMountError
|
|
29
|
+
|
|
30
|
+
log = get_logger("display_setup")
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DisplayMode",
|
|
34
|
+
"DisplaySetupResult",
|
|
35
|
+
"parse_size",
|
|
36
|
+
"parse_dpi",
|
|
37
|
+
"current_primary_size",
|
|
38
|
+
"current_primary_dpi",
|
|
39
|
+
"list_modes",
|
|
40
|
+
"apply_display_mode",
|
|
41
|
+
"apply_monitor_dpi",
|
|
42
|
+
"configure_virtual_display",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if IS_WINDOWS: # pragma: no branch
|
|
46
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
47
|
+
else: # pragma: no cover
|
|
48
|
+
user32 = None
|
|
49
|
+
|
|
50
|
+
ENUM_CURRENT_SETTINGS = 0xFFFFFFFF
|
|
51
|
+
DM_PELSWIDTH = 0x00080000
|
|
52
|
+
DM_PELSHEIGHT = 0x00100000
|
|
53
|
+
DM_BITSPERPEL = 0x00040000
|
|
54
|
+
DM_DISPLAYFREQUENCY = 0x00400000
|
|
55
|
+
DISP_CHANGE_SUCCESSFUL = 0
|
|
56
|
+
DISP_CHANGE_BADMODE = -2
|
|
57
|
+
DISP_CHANGE_FAILED = -1
|
|
58
|
+
DISP_CHANGE_BADPARAM = -5
|
|
59
|
+
DISP_CHANGE_NOTUPDATED = -3
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DEVMODEW(ctypes.Structure):
|
|
63
|
+
_fields_ = [
|
|
64
|
+
("dmDeviceName", ctypes.c_wchar * 32), ("dmSpecVersion", wintypes.WORD),
|
|
65
|
+
("dmDriverVersion", wintypes.WORD), ("dmSize", wintypes.WORD), ("dmDriverExtra", wintypes.WORD),
|
|
66
|
+
("dmFields", wintypes.DWORD), ("dmOrientation", ctypes.c_short), ("dmPaperSize", ctypes.c_short),
|
|
67
|
+
("dmPaperLength", ctypes.c_short), ("dmPaperWidth", ctypes.c_short), ("dmScale", ctypes.c_short),
|
|
68
|
+
("dmCopies", ctypes.c_short), ("dmDefaultSource", ctypes.c_short), ("dmPrintQuality", ctypes.c_short),
|
|
69
|
+
("dmColor", ctypes.c_short), ("dmDuplex", ctypes.c_short), ("dmYResolution", ctypes.c_short),
|
|
70
|
+
("dmTTOption", ctypes.c_short), ("dmCollate", ctypes.c_short), ("dmFormName", ctypes.c_wchar * 32),
|
|
71
|
+
("dmLogPixels", wintypes.WORD), ("dmBitsPerPel", wintypes.DWORD), ("dmPelsWidth", wintypes.DWORD),
|
|
72
|
+
("dmPelsHeight", wintypes.DWORD), ("dmDisplayFlags", wintypes.DWORD),
|
|
73
|
+
("dmDisplayFrequency", wintypes.DWORD), ("dmICMMethod", wintypes.DWORD),
|
|
74
|
+
("dmICMIntent", wintypes.DWORD), ("dmMediaType", wintypes.DWORD), ("dmDitherType", wintypes.DWORD),
|
|
75
|
+
("dmReserved1", wintypes.DWORD), ("dmReserved2", wintypes.DWORD), ("dmPanningWidth", wintypes.DWORD),
|
|
76
|
+
("dmPanningHeight", wintypes.DWORD),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if IS_WINDOWS: # pragma: no branch
|
|
81
|
+
user32.EnumDisplaySettingsExW.argtypes = [
|
|
82
|
+
ctypes.c_wchar_p, wintypes.DWORD, ctypes.POINTER(DEVMODEW), wintypes.DWORD
|
|
83
|
+
]
|
|
84
|
+
user32.EnumDisplaySettingsExW.restype = wintypes.BOOL
|
|
85
|
+
user32.ChangeDisplaySettingsExW.argtypes = [
|
|
86
|
+
ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW), wintypes.HWND, wintypes.DWORD, ctypes.c_void_p
|
|
87
|
+
]
|
|
88
|
+
user32.ChangeDisplaySettingsExW.restype = ctypes.c_long
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class DisplayMode:
|
|
93
|
+
width: int
|
|
94
|
+
height: int
|
|
95
|
+
bits: int = 32
|
|
96
|
+
refresh: int = 60
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict:
|
|
99
|
+
return {"width": self.width, "height": self.height, "bits": self.bits, "refresh": self.refresh}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class DisplaySetupResult:
|
|
104
|
+
device: str
|
|
105
|
+
requested_size: Optional[Tuple[int, int]] = None
|
|
106
|
+
requested_dpi: Optional[int] = None
|
|
107
|
+
size_applied: bool = False
|
|
108
|
+
dpi_applied: bool = False
|
|
109
|
+
effective_size: Optional[Tuple[int, int]] = None
|
|
110
|
+
effective_dpi: Optional[int] = None
|
|
111
|
+
skipped: str = ""
|
|
112
|
+
errors: List[str] = None # type: ignore[assignment]
|
|
113
|
+
|
|
114
|
+
def __post_init__(self) -> None:
|
|
115
|
+
if self.errors is None:
|
|
116
|
+
self.errors = []
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def ok(self) -> bool:
|
|
120
|
+
"""True when everything that was asked for is in place.
|
|
121
|
+
|
|
122
|
+
A driver that cannot do per-monitor scaling is reported through ``skipped`` instead of
|
|
123
|
+
``errors``, because it is not a failure of the request handling — it is a capability the
|
|
124
|
+
target simply does not have, and the caller may legitimately continue.
|
|
125
|
+
"""
|
|
126
|
+
size_ok = self.requested_size is None or self.size_applied or self.skipped == "monitor-detached"
|
|
127
|
+
dpi_ok = self.requested_dpi is None or self.dpi_applied or self.skipped in (
|
|
128
|
+
"per-monitor-dpi-unsupported",
|
|
129
|
+
"dpi-not-settable-using-effective",
|
|
130
|
+
"monitor-detached",
|
|
131
|
+
)
|
|
132
|
+
return bool(size_ok and dpi_ok)
|
|
133
|
+
|
|
134
|
+
def to_dict(self) -> dict:
|
|
135
|
+
return {
|
|
136
|
+
"device": self.device,
|
|
137
|
+
"requested_size": list(self.requested_size) if self.requested_size else None,
|
|
138
|
+
"requested_dpi": self.requested_dpi,
|
|
139
|
+
"size_applied": self.size_applied,
|
|
140
|
+
"dpi_applied": self.dpi_applied,
|
|
141
|
+
"effective_size": list(self.effective_size) if self.effective_size else None,
|
|
142
|
+
"effective_dpi": self.effective_dpi,
|
|
143
|
+
"ok": self.ok,
|
|
144
|
+
"skipped": self.skipped,
|
|
145
|
+
"errors": list(self.errors),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# -- parsing / defaults --------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_size(value) -> Optional[Tuple[int, int]]:
|
|
153
|
+
"""Accept ``(1920, 1080)``, ``"1920x1080"``, ``"main"``/``"primary"``/``None`` (= follow)."""
|
|
154
|
+
if value is None:
|
|
155
|
+
return None
|
|
156
|
+
if isinstance(value, (tuple, list)) and len(value) == 2:
|
|
157
|
+
width, height = int(value[0]), int(value[1])
|
|
158
|
+
elif isinstance(value, str):
|
|
159
|
+
text = value.strip().lower()
|
|
160
|
+
if text in ("", "main", "primary", "auto"):
|
|
161
|
+
return None
|
|
162
|
+
for separator in ("x", "*", ","):
|
|
163
|
+
if separator in text:
|
|
164
|
+
left, _, right = text.partition(separator)
|
|
165
|
+
try:
|
|
166
|
+
width, height = int(left), int(right)
|
|
167
|
+
except ValueError:
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
f"monitor_size {value!r} is not WxH", code="bad-monitor-size"
|
|
170
|
+
) from None
|
|
171
|
+
break
|
|
172
|
+
else:
|
|
173
|
+
raise ConfigError(f"monitor_size {value!r} is not WxH", code="bad-monitor-size")
|
|
174
|
+
else:
|
|
175
|
+
raise ConfigError(f"monitor_size {value!r} is not understood", code="bad-monitor-size")
|
|
176
|
+
if width < 320 or height < 200:
|
|
177
|
+
raise ConfigError(
|
|
178
|
+
f"monitor_size {width}x{height} is implausibly small", code="bad-monitor-size"
|
|
179
|
+
)
|
|
180
|
+
return (width, height)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_dpi(value) -> Optional[int]:
|
|
184
|
+
"""Accept ``96``/``120``/``144``/``192``, a percentage (``"125%"``) or ``None`` (= follow)."""
|
|
185
|
+
if value is None:
|
|
186
|
+
return None
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
text = value.strip().lower()
|
|
189
|
+
if text in ("", "main", "primary", "auto"):
|
|
190
|
+
return None
|
|
191
|
+
if text.endswith("%"):
|
|
192
|
+
try:
|
|
193
|
+
percent = float(text[:-1])
|
|
194
|
+
except ValueError:
|
|
195
|
+
raise ConfigError(f"monitor_dpi {value!r} is not a percentage", code="bad-monitor-dpi") from None
|
|
196
|
+
if not (100.0 <= percent <= 500.0):
|
|
197
|
+
raise ConfigError(
|
|
198
|
+
f"monitor_dpi {value!r} is outside the supported 100%..500% range",
|
|
199
|
+
code="bad-monitor-dpi",
|
|
200
|
+
)
|
|
201
|
+
return int(round(96 * percent / 100.0))
|
|
202
|
+
try:
|
|
203
|
+
dpi = int(text)
|
|
204
|
+
except ValueError:
|
|
205
|
+
raise ConfigError(f"monitor_dpi {value!r} is not a number", code="bad-monitor-dpi") from None
|
|
206
|
+
elif isinstance(value, (int, float)):
|
|
207
|
+
dpi = int(value)
|
|
208
|
+
else:
|
|
209
|
+
raise ConfigError(f"monitor_dpi {value!r} is not understood", code="bad-monitor-dpi")
|
|
210
|
+
if dpi < 96 or dpi > 480:
|
|
211
|
+
raise ConfigError(
|
|
212
|
+
f"monitor_dpi {dpi} is outside the supported 96..480 range", code="bad-monitor-dpi"
|
|
213
|
+
)
|
|
214
|
+
return dpi
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _device_dpi(device_name: str) -> Optional[int]:
|
|
218
|
+
"""Effective DPI Windows reports for a ``\\\\.\\DISPLAYn`` device, or ``None``.
|
|
219
|
+
|
|
220
|
+
``GetDpiForMonitor(MDT_EFFECTIVE_DPI)`` is the documented accessor and respects the caller's
|
|
221
|
+
DPI awareness context, so this is called after DPI awareness has been set (the session does
|
|
222
|
+
that before mounting). It arrived with Windows 8.1; on anything older — and whenever it
|
|
223
|
+
refuses the handle — the monitor DC's ``LOGPIXELSX`` is used instead, which Windows also
|
|
224
|
+
scales with the display's DPI setting on Windows 10 and 11.
|
|
225
|
+
"""
|
|
226
|
+
if not IS_WINDOWS or not device_name:
|
|
227
|
+
return None
|
|
228
|
+
monitors = enumerate_monitors()
|
|
229
|
+
target = next((m for m in monitors if m.device.lower() == device_name.lower()), None)
|
|
230
|
+
if target is None or not target.handle:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
shcore = ctypes.WinDLL("shcore", use_last_error=True)
|
|
234
|
+
except OSError:
|
|
235
|
+
shcore = None
|
|
236
|
+
if shcore is not None:
|
|
237
|
+
try:
|
|
238
|
+
shcore.GetDpiForMonitor.argtypes = [
|
|
239
|
+
wintypes.HANDLE, ctypes.c_int,
|
|
240
|
+
ctypes.POINTER(wintypes.UINT), ctypes.POINTER(wintypes.UINT),
|
|
241
|
+
]
|
|
242
|
+
x, y = wintypes.UINT(), wintypes.UINT()
|
|
243
|
+
if shcore.GetDpiForMonitor(
|
|
244
|
+
wintypes.HANDLE(target.handle), 0, ctypes.byref(x), ctypes.byref(y)
|
|
245
|
+
) == 0 and int(x.value):
|
|
246
|
+
return int(x.value)
|
|
247
|
+
except Exception: # pragma: no cover - defensive
|
|
248
|
+
pass
|
|
249
|
+
from ._compat import monitor_dpi_legacy
|
|
250
|
+
|
|
251
|
+
return monitor_dpi_legacy(target.handle)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def current_primary_dpi() -> Optional[int]:
|
|
255
|
+
"""DPI of the primary monitor (the default the virtual display should match)."""
|
|
256
|
+
monitors = enumerate_monitors()
|
|
257
|
+
primary = next((m for m in monitors if m.primary), monitors[0] if monitors else None)
|
|
258
|
+
if primary is None:
|
|
259
|
+
return None
|
|
260
|
+
return _device_dpi(primary.device)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def current_primary_size() -> Optional[Tuple[int, int]]:
|
|
264
|
+
"""Physical pixel size of the primary monitor (the default the virtual display should match)."""
|
|
265
|
+
monitors = enumerate_monitors()
|
|
266
|
+
primary = next((m for m in monitors if m.primary), monitors[0] if monitors else None)
|
|
267
|
+
if primary is None:
|
|
268
|
+
return None
|
|
269
|
+
mode = _current_mode(primary.device)
|
|
270
|
+
if mode is not None:
|
|
271
|
+
return (int(mode.dmPelsWidth), int(mode.dmPelsHeight))
|
|
272
|
+
return (primary.width, primary.height)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# -- mode handling -------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _current_mode(device_name: str) -> Optional[DEVMODEW]:
|
|
279
|
+
mode = DEVMODEW()
|
|
280
|
+
mode.dmSize = ctypes.sizeof(DEVMODEW)
|
|
281
|
+
if user32.EnumDisplaySettingsExW(device_name, ENUM_CURRENT_SETTINGS, ctypes.byref(mode), 0):
|
|
282
|
+
return mode
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def list_modes(device_name: str) -> List[DisplayMode]:
|
|
287
|
+
"""Every mode the device offers, deduplicated and sorted."""
|
|
288
|
+
require_windows("list_modes")
|
|
289
|
+
seen = {}
|
|
290
|
+
index = 0
|
|
291
|
+
while True:
|
|
292
|
+
mode = DEVMODEW()
|
|
293
|
+
mode.dmSize = ctypes.sizeof(DEVMODEW)
|
|
294
|
+
if not user32.EnumDisplaySettingsExW(device_name, index, ctypes.byref(mode), 0):
|
|
295
|
+
break
|
|
296
|
+
index += 1
|
|
297
|
+
key = (int(mode.dmPelsWidth), int(mode.dmPelsHeight), int(mode.dmBitsPerPel), int(mode.dmDisplayFrequency))
|
|
298
|
+
seen[key] = DisplayMode(*key)
|
|
299
|
+
return [seen[key] for key in sorted(seen)]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def apply_display_mode(
|
|
303
|
+
device_name: str, width: int, height: int, *, bits: int = 32, refresh: Optional[int] = None
|
|
304
|
+
) -> DisplaySetupResult:
|
|
305
|
+
"""Set the resolution of ``device_name`` (preferring the best refresh rate offered)."""
|
|
306
|
+
require_windows("apply_display_mode")
|
|
307
|
+
result = DisplaySetupResult(device=device_name, requested_size=(width, height))
|
|
308
|
+
candidates = [m for m in list_modes(device_name) if (m.width, m.height) == (width, height)]
|
|
309
|
+
if not candidates:
|
|
310
|
+
offered = sorted({(m.width, m.height) for m in list_modes(device_name)})
|
|
311
|
+
result.errors.append(
|
|
312
|
+
f"{device_name} does not offer {width}x{height}; offered: {offered[:12]}"
|
|
313
|
+
)
|
|
314
|
+
log.warning("%s", result.errors[-1])
|
|
315
|
+
return result
|
|
316
|
+
chosen = max(candidates, key=lambda m: m.refresh) if refresh is None else next(
|
|
317
|
+
(m for m in candidates if m.refresh == refresh), max(candidates, key=lambda m: m.refresh)
|
|
318
|
+
)
|
|
319
|
+
mode = DEVMODEW()
|
|
320
|
+
mode.dmSize = ctypes.sizeof(DEVMODEW)
|
|
321
|
+
mode.dmPelsWidth = chosen.width
|
|
322
|
+
mode.dmPelsHeight = chosen.height
|
|
323
|
+
mode.dmBitsPerPel = bits or chosen.bits
|
|
324
|
+
mode.dmDisplayFrequency = chosen.refresh
|
|
325
|
+
mode.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT | DM_BITSPERPEL | DM_DISPLAYFREQUENCY
|
|
326
|
+
rc = int(user32.ChangeDisplaySettingsExW(device_name, ctypes.byref(mode), None, 0, None))
|
|
327
|
+
if rc == DISP_CHANGE_SUCCESSFUL:
|
|
328
|
+
result.size_applied = True
|
|
329
|
+
result.effective_size = (chosen.width, chosen.height)
|
|
330
|
+
log.info("set %s to %dx%d @%dHz", device_name, chosen.width, chosen.height, chosen.refresh)
|
|
331
|
+
else:
|
|
332
|
+
hint = {
|
|
333
|
+
DISP_CHANGE_FAILED: "the monitor is not attached (a detached mode cannot be set)",
|
|
334
|
+
DISP_CHANGE_BADMODE: "the mode is not supported by the driver",
|
|
335
|
+
DISP_CHANGE_BADPARAM: "invalid flags",
|
|
336
|
+
DISP_CHANGE_NOTUPDATED: "the change could not be written to the registry",
|
|
337
|
+
}.get(rc, "unknown failure")
|
|
338
|
+
result.errors.append(
|
|
339
|
+
f"ChangeDisplaySettingsEx({device_name}, {chosen.width}x{chosen.height}) "
|
|
340
|
+
f"returned {rc}: {hint}"
|
|
341
|
+
)
|
|
342
|
+
log.warning("%s", result.errors[-1])
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# -- DPI handling (SetDisplayConfig) -------------------------------------------------
|
|
347
|
+
|
|
348
|
+
QDC_ONLY_ACTIVE_PATHS = 0x00000002
|
|
349
|
+
SDC_APPLY = 0x00000080
|
|
350
|
+
SDC_USE_SUPPLIED_DISPLAY_CONFIG = 0x00000020
|
|
351
|
+
SDC_SAVE_TO_DATABASE = 0x00000200
|
|
352
|
+
SDC_ALLOW_CHANGES = 0x00000400
|
|
353
|
+
SDC_VIRTUAL_MODE_AWARE = 0x00008000
|
|
354
|
+
DISPLAYCONFIG_PATH_ACTIVE = 0x00000001
|
|
355
|
+
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME = 1
|
|
356
|
+
DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME = 2
|
|
357
|
+
DISPLAYCONFIG_DEVICE_INFO_SET_DPI_SCALE = 0xFFCF # -49 as unsigned
|
|
358
|
+
DISPLAYCONFIG_DEVICE_INFO_GET_DPI_SCALE = 0xFFCE # -50 as unsigned
|
|
359
|
+
ERROR_SUCCESS = 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class LUID(ctypes.Structure):
|
|
363
|
+
_fields_ = [("LowPart", wintypes.DWORD), ("HighPart", wintypes.LONG)]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class DISPLAYCONFIG_PATH_SOURCE_INFO(ctypes.Structure):
|
|
367
|
+
_fields_ = [("adapterId", LUID), ("id", wintypes.UINT), ("modeInfoIdx", wintypes.UINT),
|
|
368
|
+
("statusFlags", wintypes.UINT)]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class DISPLAYCONFIG_PATH_TARGET_INFO(ctypes.Structure):
|
|
372
|
+
_fields_ = [("adapterId", LUID), ("id", wintypes.UINT), ("modeInfoIdx", wintypes.UINT),
|
|
373
|
+
("outputTechnology", wintypes.UINT), ("rotation", wintypes.UINT),
|
|
374
|
+
("scaling", wintypes.UINT), ("refreshRate", wintypes.UINT),
|
|
375
|
+
("scanLineOrdering", wintypes.UINT), ("targetAvailable", wintypes.BOOL),
|
|
376
|
+
("statusFlags", wintypes.UINT)]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class DISPLAYCONFIG_PATH_INFO(ctypes.Structure):
|
|
380
|
+
_fields_ = [("sourceInfo", DISPLAYCONFIG_PATH_SOURCE_INFO),
|
|
381
|
+
("targetInfo", DISPLAYCONFIG_PATH_TARGET_INFO), ("flags", wintypes.UINT)]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class DISPLAYCONFIG_2DREGION(ctypes.Structure):
|
|
385
|
+
_fields_ = [("cx", wintypes.UINT), ("cy", wintypes.UINT)]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class DISPLAYCONFIG_VIDEO_SIGNAL_INFO(ctypes.Structure):
|
|
389
|
+
_fields_ = [("pixelRate", ctypes.c_uint64), ("hSyncFreq", DISPLAYCONFIG_2DREGION),
|
|
390
|
+
("vSyncFreq", DISPLAYCONFIG_2DREGION), ("activeSize", DISPLAYCONFIG_2DREGION),
|
|
391
|
+
("totalSize", DISPLAYCONFIG_2DREGION), ("videoStandard", wintypes.UINT),
|
|
392
|
+
("scanLineOrdering", wintypes.UINT)]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class DISPLAYCONFIG_TARGET_MODE(ctypes.Structure):
|
|
396
|
+
_fields_ = [("targetVideoSignalInfo", DISPLAYCONFIG_VIDEO_SIGNAL_INFO)]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class DISPLAYCONFIG_SOURCE_MODE(ctypes.Structure):
|
|
400
|
+
_fields_ = [("width", wintypes.UINT), ("height", wintypes.UINT), ("pixelFormat", wintypes.UINT),
|
|
401
|
+
("position", ctypes.c_int * 2)]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class DISPLAYCONFIG_MODE_INFO_UNION(ctypes.Union):
|
|
405
|
+
_fields_ = [("targetMode", DISPLAYCONFIG_TARGET_MODE), ("sourceMode", DISPLAYCONFIG_SOURCE_MODE)]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class DISPLAYCONFIG_MODE_INFO(ctypes.Structure):
|
|
409
|
+
_fields_ = [("infoType", wintypes.UINT), ("id", wintypes.UINT), ("adapterId", LUID),
|
|
410
|
+
("u", DISPLAYCONFIG_MODE_INFO_UNION)]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class DISPLAYCONFIG_DEVICE_INFO_HEADER(ctypes.Structure):
|
|
414
|
+
_fields_ = [("type", wintypes.UINT), ("size", wintypes.UINT), ("adapterId", LUID),
|
|
415
|
+
("id", wintypes.UINT)]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class DISPLAYCONFIG_SOURCE_DEVICE_NAME(ctypes.Structure):
|
|
419
|
+
_fields_ = [("header", DISPLAYCONFIG_DEVICE_INFO_HEADER), ("viewGdiDeviceName", ctypes.c_wchar * 32)]
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class DISPLAYCONFIG_SET_DPI_SCALE(ctypes.Structure):
|
|
423
|
+
_fields_ = [("header", DISPLAYCONFIG_DEVICE_INFO_HEADER),
|
|
424
|
+
("scaleRelativeToRecommended", ctypes.c_int), ("scaleStep", ctypes.c_int)]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class DISPLAYCONFIG_GET_DPI_SCALE(ctypes.Structure):
|
|
428
|
+
_fields_ = [("header", DISPLAYCONFIG_DEVICE_INFO_HEADER),
|
|
429
|
+
("minScaleRelativeToRecommended", ctypes.c_int),
|
|
430
|
+
("maxScaleRelativeToRecommended", ctypes.c_int),
|
|
431
|
+
("curScaleRelativeToRecommended", ctypes.c_int)]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if IS_WINDOWS: # pragma: no branch
|
|
435
|
+
user32.GetDisplayConfigBufferSizes.argtypes = [
|
|
436
|
+
wintypes.UINT, ctypes.POINTER(wintypes.UINT), ctypes.POINTER(wintypes.UINT)
|
|
437
|
+
]
|
|
438
|
+
user32.GetDisplayConfigBufferSizes.restype = ctypes.c_long
|
|
439
|
+
user32.QueryDisplayConfig.argtypes = [
|
|
440
|
+
wintypes.UINT, ctypes.POINTER(wintypes.UINT), ctypes.POINTER(DISPLAYCONFIG_PATH_INFO),
|
|
441
|
+
ctypes.POINTER(wintypes.UINT), ctypes.POINTER(DISPLAYCONFIG_MODE_INFO), ctypes.c_void_p
|
|
442
|
+
]
|
|
443
|
+
user32.QueryDisplayConfig.restype = ctypes.c_long
|
|
444
|
+
user32.DisplayConfigGetDeviceInfo.argtypes = [ctypes.c_void_p]
|
|
445
|
+
user32.DisplayConfigGetDeviceInfo.restype = ctypes.c_long
|
|
446
|
+
user32.DisplayConfigSetDeviceInfo.argtypes = [ctypes.c_void_p]
|
|
447
|
+
user32.DisplayConfigSetDeviceInfo.restype = ctypes.c_long
|
|
448
|
+
user32.SetDisplayConfig.argtypes = [
|
|
449
|
+
wintypes.UINT, ctypes.POINTER(DISPLAYCONFIG_PATH_INFO), wintypes.UINT,
|
|
450
|
+
ctypes.POINTER(DISPLAYCONFIG_MODE_INFO), wintypes.UINT
|
|
451
|
+
]
|
|
452
|
+
user32.SetDisplayConfig.restype = ctypes.c_long
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _query_paths():
|
|
456
|
+
num_paths = wintypes.UINT()
|
|
457
|
+
num_modes = wintypes.UINT()
|
|
458
|
+
rc = user32.GetDisplayConfigBufferSizes(
|
|
459
|
+
QDC_ONLY_ACTIVE_PATHS, ctypes.byref(num_paths), ctypes.byref(num_modes)
|
|
460
|
+
)
|
|
461
|
+
if rc != ERROR_SUCCESS:
|
|
462
|
+
raise OffscreenMountError(f"GetDisplayConfigBufferSizes rc={rc}", code="ccd-buffer-failed")
|
|
463
|
+
paths = (DISPLAYCONFIG_PATH_INFO * max(num_paths.value, 1))()
|
|
464
|
+
modes = (DISPLAYCONFIG_MODE_INFO * max(num_modes.value, 1))()
|
|
465
|
+
rc = user32.QueryDisplayConfig(
|
|
466
|
+
QDC_ONLY_ACTIVE_PATHS, ctypes.byref(num_paths), paths, ctypes.byref(num_modes), modes, None
|
|
467
|
+
)
|
|
468
|
+
if rc != ERROR_SUCCESS:
|
|
469
|
+
raise OffscreenMountError(f"QueryDisplayConfig rc={rc}", code="ccd-query-failed")
|
|
470
|
+
return paths[: num_paths.value], modes[: num_modes.value]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _gdi_name(path) -> str:
|
|
474
|
+
info = DISPLAYCONFIG_SOURCE_DEVICE_NAME()
|
|
475
|
+
info.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME
|
|
476
|
+
info.header.size = ctypes.sizeof(DISPLAYCONFIG_SOURCE_DEVICE_NAME)
|
|
477
|
+
info.header.adapterId = path.sourceInfo.adapterId
|
|
478
|
+
info.header.id = path.sourceInfo.id
|
|
479
|
+
if user32.DisplayConfigGetDeviceInfo(ctypes.byref(info)) == ERROR_SUCCESS:
|
|
480
|
+
return info.viewGdiDeviceName
|
|
481
|
+
return ""
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def apply_monitor_dpi(device_name: str, dpi: int) -> DisplaySetupResult:
|
|
485
|
+
"""Set a monitor's scaling with ``SetDisplayConfig`` (the documented per-monitor DPI path).
|
|
486
|
+
|
|
487
|
+
``DISPLAYCONFIG_SET_DPI_SCALE`` works in *relative* steps from the recommended scale, so the
|
|
488
|
+
desired DPI is converted into a step offset and clamped to what the display offers.
|
|
489
|
+
"""
|
|
490
|
+
require_windows("apply_monitor_dpi")
|
|
491
|
+
result = DisplaySetupResult(device=device_name, requested_dpi=dpi)
|
|
492
|
+
try:
|
|
493
|
+
paths, _modes = _query_paths()
|
|
494
|
+
except OffscreenMountError as exc:
|
|
495
|
+
result.errors.append(str(exc))
|
|
496
|
+
return result
|
|
497
|
+
target = next((p for p in paths if _gdi_name(p).lower() == device_name.lower()), None)
|
|
498
|
+
if target is None:
|
|
499
|
+
attached = [_gdi_name(p) for p in paths]
|
|
500
|
+
result.errors.append(
|
|
501
|
+
f"{device_name} is not an active display path (active: {attached}); "
|
|
502
|
+
"a detached monitor has no per-monitor scaling to set"
|
|
503
|
+
)
|
|
504
|
+
return result
|
|
505
|
+
|
|
506
|
+
get_scale = DISPLAYCONFIG_GET_DPI_SCALE()
|
|
507
|
+
get_scale.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_DPI_SCALE
|
|
508
|
+
get_scale.header.size = ctypes.sizeof(DISPLAYCONFIG_GET_DPI_SCALE)
|
|
509
|
+
get_scale.header.adapterId = target.targetInfo.adapterId
|
|
510
|
+
get_scale.header.id = target.targetInfo.id
|
|
511
|
+
rc = user32.DisplayConfigGetDeviceInfo(ctypes.byref(get_scale))
|
|
512
|
+
if rc != ERROR_SUCCESS:
|
|
513
|
+
# Measured on this machine: an IddCx virtual target answers GET_DPI_SCALE with
|
|
514
|
+
# ERROR_INVALID_PARAMETER (87), i.e. the driver implements no per-monitor scaling steps.
|
|
515
|
+
# Reporting that clearly is the useful outcome; pretending the DPI was set is not.
|
|
516
|
+
result.errors.append(
|
|
517
|
+
f"the display driver does not support per-monitor DPI scaling "
|
|
518
|
+
f"(DisplayConfigGetDeviceInfo(GET_DPI_SCALE) rc={rc})"
|
|
519
|
+
)
|
|
520
|
+
result.skipped = "per-monitor-dpi-unsupported"
|
|
521
|
+
log.warning("%s: %s", device_name, result.errors[-1])
|
|
522
|
+
return result
|
|
523
|
+
|
|
524
|
+
# 1 step == 25 % relative to the recommended scale, per the DISPLAYCONFIG docs.
|
|
525
|
+
recommended_dpi = 96 * (1 + 0.0) # the recommended scale is reported implicitly by the range
|
|
526
|
+
steps = int(round((dpi / 96.0 - 1.0) / 0.25))
|
|
527
|
+
steps = max(get_scale.minScaleRelativeToRecommended, min(get_scale.maxScaleRelativeToRecommended, steps))
|
|
528
|
+
recommended_dpi = int(round(dpi / (1 + 0.25 * steps))) if steps else dpi
|
|
529
|
+
|
|
530
|
+
set_scale = DISPLAYCONFIG_SET_DPI_SCALE()
|
|
531
|
+
set_scale.header.type = DISPLAYCONFIG_DEVICE_INFO_SET_DPI_SCALE
|
|
532
|
+
set_scale.header.size = ctypes.sizeof(DISPLAYCONFIG_SET_DPI_SCALE)
|
|
533
|
+
set_scale.header.adapterId = target.targetInfo.adapterId
|
|
534
|
+
set_scale.header.id = target.targetInfo.id
|
|
535
|
+
set_scale.scaleRelativeToRecommended = steps
|
|
536
|
+
set_scale.scaleStep = 0
|
|
537
|
+
rc = user32.DisplayConfigSetDeviceInfo(ctypes.byref(set_scale))
|
|
538
|
+
if rc != ERROR_SUCCESS:
|
|
539
|
+
result.errors.append(
|
|
540
|
+
f"DisplayConfigSetDeviceInfo(SET_DPI_SCALE={steps} steps for {dpi} dpi) rc={rc}"
|
|
541
|
+
)
|
|
542
|
+
log.warning("%s", result.errors[-1])
|
|
543
|
+
return result
|
|
544
|
+
result.dpi_applied = True
|
|
545
|
+
result.effective_dpi = _device_dpi(device_name) or recommended_dpi
|
|
546
|
+
log.info("set %s DPI to %s (recommended %s, %d steps)", device_name, result.effective_dpi,
|
|
547
|
+
recommended_dpi, steps)
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# -- DPI via the per-monitor registry (works when SetDisplayConfig cannot) -----------
|
|
552
|
+
|
|
553
|
+
#: Windows stores a per-display scaling override in
|
|
554
|
+
#: ``HKCU\Control Panel\Desktop\PerMonitorSettings\<monitor device>\DpiValue`` where the value is
|
|
555
|
+
#: an index: -1 = one step below the recommended scale, 0 = recommended, +N = N steps above,
|
|
556
|
+
#: each step being 25 % on typical hardware. The key name is a normalized form of the monitor's
|
|
557
|
+
#: device path (``\\?\DISPLAY#MTT1337#...`` -> ``MTT13370_...``), which is what
|
|
558
|
+
#: :func:`monitor_settings_key` reconstructs.
|
|
559
|
+
PER_MONITOR_SETTINGS = r"Control Panel\Desktop\PerMonitorSettings"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def monitor_settings_key(device_name: str) -> Optional[str]:
|
|
563
|
+
r"""``PerMonitorSettings`` subkey for a monitor, matched by its monitor device id.
|
|
564
|
+
|
|
565
|
+
``EnumDisplayDevices(\\\\.\\DISPLAYn, 0)`` yields the monitor device id
|
|
566
|
+
(``\\?\DISPLAY#MTT1337#4&...``), and Windows names the scaling override after the same
|
|
567
|
+
``MANUFACTURER + PRODUCTCODE`` pair (measured keys: ``MTT1337...``, ``BOE0726...``). Matching
|
|
568
|
+
on that identity is stable across the instance-id rewriting Windows applies to the key name.
|
|
569
|
+
"""
|
|
570
|
+
if not IS_WINDOWS:
|
|
571
|
+
return None
|
|
572
|
+
identity = _monitor_identity(device_name)
|
|
573
|
+
if not identity:
|
|
574
|
+
return None
|
|
575
|
+
try:
|
|
576
|
+
import winreg
|
|
577
|
+
|
|
578
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, PER_MONITOR_SETTINGS) as key:
|
|
579
|
+
index = 0
|
|
580
|
+
candidates = []
|
|
581
|
+
while True:
|
|
582
|
+
try:
|
|
583
|
+
name = winreg.EnumKey(key, index)
|
|
584
|
+
except OSError:
|
|
585
|
+
break
|
|
586
|
+
index += 1
|
|
587
|
+
if name.upper().startswith(identity.upper()):
|
|
588
|
+
candidates.append(name)
|
|
589
|
+
if candidates:
|
|
590
|
+
return sorted(candidates)[0]
|
|
591
|
+
except OSError:
|
|
592
|
+
return None
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _monitor_identity(device_name: str) -> str:
|
|
597
|
+
"""``MANUFACTURER + PRODUCTCODE`` (upper-case) for the monitor behind ``\\\\.\\DISPLAYn``.
|
|
598
|
+
|
|
599
|
+
``MONITOR\\MTT1337\\{class-guid}\\0000`` -> ``MTT1337``; the class GUID part is dropped so the
|
|
600
|
+
value can be compared with the ``PerMonitorSettings`` key prefix.
|
|
601
|
+
"""
|
|
602
|
+
if not IS_WINDOWS:
|
|
603
|
+
return ""
|
|
604
|
+
monitor_id = ""
|
|
605
|
+
try:
|
|
606
|
+
index = 0
|
|
607
|
+
while True:
|
|
608
|
+
device = _DISPLAY_DEVICEW()
|
|
609
|
+
device.cb = ctypes.sizeof(_DISPLAY_DEVICEW)
|
|
610
|
+
if not user32.EnumDisplayDevicesW(None, index, ctypes.byref(device), 0):
|
|
611
|
+
break
|
|
612
|
+
index += 1
|
|
613
|
+
if not device.DeviceName or device.DeviceName.lower() != device_name.lower():
|
|
614
|
+
continue
|
|
615
|
+
monitor = _DISPLAY_DEVICEW()
|
|
616
|
+
monitor.cb = ctypes.sizeof(_DISPLAY_DEVICEW)
|
|
617
|
+
if user32.EnumDisplayDevicesW(device.DeviceName, 0, ctypes.byref(monitor), 0):
|
|
618
|
+
monitor_id = monitor.DeviceID or ""
|
|
619
|
+
break
|
|
620
|
+
except Exception: # pragma: no cover - defensive
|
|
621
|
+
return ""
|
|
622
|
+
if not monitor_id:
|
|
623
|
+
return ""
|
|
624
|
+
# Two shapes occur in practice:
|
|
625
|
+
# ``MONITOR\MTT1337\{class-guid}\0000`` (measured on this machine)
|
|
626
|
+
# ``\\?\DISPLAY#MTT1337#4&84127e3&...`` (documented "device path" form)
|
|
627
|
+
if monitor_id.upper().startswith("MONITOR\\"):
|
|
628
|
+
parts = [part for part in monitor_id.split("\\") if part]
|
|
629
|
+
return parts[1].upper() if len(parts) > 1 else ""
|
|
630
|
+
parts = [part for part in monitor_id.replace("\\\\?\\", "").split("#") if part]
|
|
631
|
+
for part in parts:
|
|
632
|
+
if part.upper().startswith("MONITOR") or part.startswith("{") or part.isdigit():
|
|
633
|
+
continue
|
|
634
|
+
return part.split("_")[0].upper()
|
|
635
|
+
return ""
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class _DISPLAY_DEVICEW(ctypes.Structure):
|
|
639
|
+
_fields_ = [
|
|
640
|
+
("cb", wintypes.DWORD),
|
|
641
|
+
("DeviceName", ctypes.c_wchar * 32),
|
|
642
|
+
("DeviceString", ctypes.c_wchar * 128),
|
|
643
|
+
("StateFlags", wintypes.DWORD),
|
|
644
|
+
("DeviceID", ctypes.c_wchar * 128),
|
|
645
|
+
("DeviceKey", ctypes.c_wchar * 128),
|
|
646
|
+
]
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
if IS_WINDOWS: # pragma: no branch
|
|
650
|
+
user32.EnumDisplayDevicesW.argtypes = [
|
|
651
|
+
ctypes.c_wchar_p, wintypes.DWORD, ctypes.POINTER(_DISPLAY_DEVICEW), wintypes.DWORD
|
|
652
|
+
]
|
|
653
|
+
user32.EnumDisplayDevicesW.restype = wintypes.BOOL
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def set_monitor_dpi_registry(device_name: str, dpi: int) -> DisplaySetupResult:
|
|
657
|
+
"""Persist a per-monitor scaling override in ``HKCU`` (takes effect after sign-in/restart).
|
|
658
|
+
|
|
659
|
+
This is the fallback for displays whose driver exposes no ``SET_DPI_SCALE`` support: Windows
|
|
660
|
+
reads ``DpiValue`` when a session starts, so the value is written and the caller is told that a
|
|
661
|
+
sign-out is required. ``DpiValue`` is a step index (0 = recommended, ±1 = ±25 %).
|
|
662
|
+
"""
|
|
663
|
+
result = DisplaySetupResult(device=device_name, requested_dpi=dpi)
|
|
664
|
+
key_name = monitor_settings_key(device_name)
|
|
665
|
+
if not key_name:
|
|
666
|
+
result.errors.append(
|
|
667
|
+
f"no PerMonitorSettings entry exists for {device_name}; Windows creates one after the "
|
|
668
|
+
"monitor has been used in a session (a restart may be needed first)"
|
|
669
|
+
)
|
|
670
|
+
result.skipped = "per-monitor-dpi-unsupported"
|
|
671
|
+
return result
|
|
672
|
+
step = int(round((dpi / 96.0 - 1.0) / 0.25))
|
|
673
|
+
try:
|
|
674
|
+
import winreg
|
|
675
|
+
|
|
676
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, f"{PER_MONITOR_SETTINGS}\\{key_name}") as key:
|
|
677
|
+
winreg.SetValueEx(key, "DpiValue", 0, winreg.REG_DWORD, step & 0xFFFFFFFF)
|
|
678
|
+
except OSError as exc:
|
|
679
|
+
result.errors.append(f"could not write PerMonitorSettings\\{key_name}\\DpiValue: {exc}")
|
|
680
|
+
return result
|
|
681
|
+
result.dpi_applied = True
|
|
682
|
+
result.effective_dpi = dpi
|
|
683
|
+
result.skipped = "per-monitor-dpi-needs-signout"
|
|
684
|
+
log.info(
|
|
685
|
+
"wrote per-monitor DPI override for %s (%s): step %d for %d dpi; takes effect after sign-out",
|
|
686
|
+
device_name,
|
|
687
|
+
key_name,
|
|
688
|
+
step,
|
|
689
|
+
dpi,
|
|
690
|
+
)
|
|
691
|
+
return result
|
|
692
|
+
|
|
693
|
+
def configure_virtual_display(
|
|
694
|
+
*,
|
|
695
|
+
size=None,
|
|
696
|
+
dpi=None,
|
|
697
|
+
device: Optional[str] = None,
|
|
698
|
+
) -> DisplaySetupResult:
|
|
699
|
+
"""Apply size/DPI to the virtual display, defaulting both to the primary monitor.
|
|
700
|
+
|
|
701
|
+
``size=None`` / ``dpi=None`` mean "match the current primary monitor", which is the sensible
|
|
702
|
+
default: the virtual desktop then looks exactly like the screen the automation runs against.
|
|
703
|
+
|
|
704
|
+
Reality check on DPI (measured on this machine): the IddCx virtual target answers
|
|
705
|
+
``DISPLAYCONFIG_DEVICE_INFO_GET_DPI_SCALE`` with ``ERROR_INVALID_PARAMETER`` and is not listed
|
|
706
|
+
as a CCD active path, so *neither* the CCD API nor the per-monitor registry override can set
|
|
707
|
+
its scaling here — Windows keeps it at the recommended 100 %. What this function guarantees
|
|
708
|
+
is that the **request is reported precisely** (``size_applied``, ``dpi_applied``, ``skipped``,
|
|
709
|
+
``errors``) rather than silently ignored. ``effective_dpi`` always carries the real value, so
|
|
710
|
+
callers can adapt.
|
|
711
|
+
"""
|
|
712
|
+
require_windows("configure_virtual_display")
|
|
713
|
+
monitors = enumerate_monitors()
|
|
714
|
+
if not monitors:
|
|
715
|
+
raise OffscreenMountError("no monitors are attached", code="no-monitor")
|
|
716
|
+
target = None
|
|
717
|
+
if device:
|
|
718
|
+
target = next((m for m in monitors if m.device.lower() == device.lower()), None)
|
|
719
|
+
if target is None:
|
|
720
|
+
target = next((m for m in monitors if m.is_decidedly_virtual()), None)
|
|
721
|
+
if target is None:
|
|
722
|
+
raise OffscreenMountError(
|
|
723
|
+
"no virtual monitor is attached; install/enable the virtual display first",
|
|
724
|
+
code="no-virtual-monitor",
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
wanted_size = parse_size(size) or current_primary_size()
|
|
728
|
+
wanted_dpi = parse_dpi(dpi) or current_primary_dpi()
|
|
729
|
+
# An explicit request is a requirement; a defaulted value is only a preference.
|
|
730
|
+
dpi_required = dpi is not None and parse_dpi(dpi) is not None
|
|
731
|
+
|
|
732
|
+
result = DisplaySetupResult(device=target.device, requested_size=wanted_size, requested_dpi=wanted_dpi)
|
|
733
|
+
result.effective_dpi = _device_dpi(target.device)
|
|
734
|
+
if wanted_size:
|
|
735
|
+
size_result = apply_display_mode(target.device, wanted_size[0], wanted_size[1])
|
|
736
|
+
result.size_applied = size_result.size_applied
|
|
737
|
+
result.effective_size = size_result.effective_size
|
|
738
|
+
result.errors.extend(size_result.errors)
|
|
739
|
+
if not result.size_applied and not result.effective_size:
|
|
740
|
+
current = _current_mode(target.device)
|
|
741
|
+
if current is not None:
|
|
742
|
+
result.effective_size = (int(current.dmPelsWidth), int(current.dmPelsHeight))
|
|
743
|
+
if wanted_dpi:
|
|
744
|
+
already = result.effective_dpi is not None and int(result.effective_dpi) == int(wanted_dpi)
|
|
745
|
+
if already:
|
|
746
|
+
# Nothing to change: the display is already at the requested scaling (measured: both
|
|
747
|
+
# the physical panel and the virtual display run at 100 % here). Treating that as a
|
|
748
|
+
# failure would be wrong, and trying to "set" it is unnecessary.
|
|
749
|
+
result.dpi_applied = True
|
|
750
|
+
result.skipped = "dpi-already-matches"
|
|
751
|
+
log.info("%s already runs at %s dpi", target.device, wanted_dpi)
|
|
752
|
+
else:
|
|
753
|
+
dpi_result = apply_monitor_dpi(target.device, wanted_dpi)
|
|
754
|
+
result.dpi_applied = dpi_result.dpi_applied
|
|
755
|
+
if dpi_result.skipped:
|
|
756
|
+
result.skipped = dpi_result.skipped
|
|
757
|
+
result.errors.extend(dpi_result.errors)
|
|
758
|
+
if not result.dpi_applied:
|
|
759
|
+
# Fall back to the per-monitor registry override, applied by Windows at sign-in.
|
|
760
|
+
registry_result = set_monitor_dpi_registry(target.device, wanted_dpi)
|
|
761
|
+
if registry_result.dpi_applied:
|
|
762
|
+
result.dpi_applied = True
|
|
763
|
+
result.skipped = registry_result.skipped
|
|
764
|
+
log.info(
|
|
765
|
+
"per-monitor DPI %s for %s stored for the next sign-in", wanted_dpi, target.device
|
|
766
|
+
)
|
|
767
|
+
else:
|
|
768
|
+
result.errors.extend(registry_result.errors)
|
|
769
|
+
result.effective_dpi = _device_dpi(target.device) or result.effective_dpi
|
|
770
|
+
if not result.dpi_applied:
|
|
771
|
+
if dpi_required:
|
|
772
|
+
result.errors.append(
|
|
773
|
+
f"could not set the virtual display to {wanted_dpi} dpi; "
|
|
774
|
+
f"it stays at {result.effective_dpi} dpi (this driver exposes no "
|
|
775
|
+
"per-monitor scaling step)"
|
|
776
|
+
)
|
|
777
|
+
else:
|
|
778
|
+
# The *default* was "match the primary monitor"; the driver cannot do that,
|
|
779
|
+
# so the actual value is reported instead of pretending the request failed.
|
|
780
|
+
result.skipped = "dpi-not-settable-using-effective"
|
|
781
|
+
result.effective_dpi = _device_dpi(target.device) or result.effective_dpi
|
|
782
|
+
log.info(
|
|
783
|
+
"%s cannot be scaled to %s dpi; keeping the effective %s dpi",
|
|
784
|
+
target.device,
|
|
785
|
+
wanted_dpi,
|
|
786
|
+
result.effective_dpi,
|
|
787
|
+
)
|
|
788
|
+
if not wanted_size and not wanted_dpi:
|
|
789
|
+
result.skipped = "no size and no DPI requested"
|
|
790
|
+
return result
|