VirtualDesktop 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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