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,255 @@
1
+ """Configuration objects and environment-variable defaults.
2
+
3
+ Two explicitly separated concerns, because they have different lifetimes:
4
+
5
+ * :class:`DriverConfig` 鈥?machine-scoped, expensive, privileged (installing a driver).
6
+ * :class:`SessionConfig` 鈥?per-session, cheap, unprivileged (mounting a window).
7
+
8
+ Every field can be supplied in code *or* through an ``VIRTUALDESKTOP_*`` environment
9
+ variable, which is what makes the library usable from scheduled tasks, CI jobs and
10
+ container-like wrappers where editing code is not an option.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass, field, replace
17
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
18
+
19
+ from .errors import ConfigError
20
+ from .manifest import Manifest, load_manifest
21
+ from .window import HIDE_MODES
22
+
23
+ __all__ = [
24
+ "DriverConfig",
25
+ "SessionConfig",
26
+ "env",
27
+ "env_bool",
28
+ "env_float",
29
+ "env_int",
30
+ "env_list",
31
+ "DRIVER_POLICIES",
32
+ ]
33
+
34
+ DRIVER_POLICIES = ("auto", "never", "always")
35
+ _TRUTHY = {"1", "true", "yes", "on", "y", "t"}
36
+ _FALSY = {"0", "false", "no", "off", "n", "f"}
37
+
38
+
39
+ def env(name: str, default: Optional[str] = None) -> Optional[str]:
40
+ value = os.environ.get(f"VIRTUALDESKTOP_{name}")
41
+ return default if value is None or value == "" else value
42
+
43
+
44
+ def env_bool(name: str, default: bool = False) -> bool:
45
+ value = env(name)
46
+ if value is None:
47
+ return default
48
+ lowered = value.strip().lower()
49
+ if lowered in _TRUTHY:
50
+ return True
51
+ if lowered in _FALSY:
52
+ return False
53
+ raise ConfigError(
54
+ f"VIRTUALDESKTOP_{name}={value!r} is not a boolean", code="bad-env-value"
55
+ )
56
+
57
+
58
+ def env_int(name: str, default: int) -> int:
59
+ value = env(name)
60
+ if value is None:
61
+ return default
62
+ try:
63
+ return int(value)
64
+ except ValueError:
65
+ raise ConfigError(
66
+ f"VIRTUALDESKTOP_{name}={value!r} is not an integer", code="bad-env-value"
67
+ ) from None
68
+
69
+
70
+ def env_float(name: str, default: float) -> float:
71
+ value = env(name)
72
+ if value is None:
73
+ return default
74
+ try:
75
+ return float(value)
76
+ except ValueError:
77
+ raise ConfigError(
78
+ f"VIRTUALDESKTOP_{name}={value!r} is not a number", code="bad-env-value"
79
+ ) from None
80
+
81
+
82
+ def env_list(name: str, default: Sequence[str] = ()) -> Tuple[str, ...]:
83
+ """Parse a comma-separated environment variable into a tuple of stripped items.
84
+
85
+ Empty items are dropped, so ``VIRTUALDESKTOP_MONITOR_TAGS="a, ,b"`` yields ``("a", "b")``
86
+ and an unset variable yields ``default``. Note the value of the *variable* is split —
87
+ calling ``env(...).split`` would split the function object and raise ``AttributeError``.
88
+ """
89
+ value = env(name)
90
+ if value is None:
91
+ return tuple(default)
92
+ return tuple(item.strip() for item in value.split(",") if item.strip())
93
+
94
+
95
+ #: Internal alias kept short for the dataclass field factories.
96
+ _split_env = env_list
97
+
98
+
99
+ @dataclass
100
+ class DriverConfig:
101
+ """Everything the bootstrap needs; machine-scoped and privileged."""
102
+
103
+ #: ``auto`` - install on demand during ``mount()`` (default, one UAC prompt once)
104
+ #: ``never`` - never install; raise a clear error when the device is missing
105
+ #: ``always`` - re-run the install flow on every ``ensure()`` (upgrade / repair)
106
+ policy: str = field(default_factory=lambda: env("DRIVER_POLICY", "auto") or "auto")
107
+ strategy: str = field(default_factory=lambda: env("DRIVER_STRATEGY", "auto") or "auto")
108
+ driver: Optional[str] = field(default_factory=lambda: env("DRIVER"))
109
+ cache_dir: Optional[str] = field(default_factory=lambda: env("CACHE_DIR"))
110
+ manifest_path: Optional[str] = field(default_factory=lambda: env("MANIFEST"))
111
+ signature_policy: str = field(
112
+ default_factory=lambda: env("SIGNATURE_POLICY", "warn") or "warn"
113
+ )
114
+ allow_scoop: bool = field(default_factory=lambda: env_bool("ALLOW_SCOOP", False))
115
+ install_timeout: float = field(default_factory=lambda: env_float("INSTALL_TIMEOUT", 600.0))
116
+ download_timeout: float = field(default_factory=lambda: env_float("DOWNLOAD_TIMEOUT", 300.0))
117
+ #: Extra substrings that identify the virtual monitor (forks/custom EDIDs).
118
+ monitor_tags: Tuple[str, ...] = field(
119
+ default_factory=lambda: _split_env("MONITOR_TAGS")
120
+ )
121
+ #: Optional path to a custom ``vdd_settings.xml`` copied next to the INF.
122
+ settings_file: Optional[str] = field(default_factory=lambda: env("DRIVER_SETTINGS"))
123
+
124
+ def __post_init__(self) -> None:
125
+ if self.policy not in DRIVER_POLICIES:
126
+ raise ConfigError(
127
+ f"driver policy {self.policy!r} must be one of {', '.join(DRIVER_POLICIES)}",
128
+ code="bad-driver-policy",
129
+ )
130
+ self.monitor_tags = tuple(self.monitor_tags)
131
+
132
+ def manifest(self) -> Manifest:
133
+ return load_manifest(self.manifest_path)
134
+
135
+ def installer(self, **overrides: Any):
136
+ """Build a :class:`~VirtualDesktop.driver.DriverInstaller` from this config."""
137
+ from .driver import DriverInstaller
138
+
139
+ kwargs: Dict[str, Any] = {
140
+ "manifest": self.manifest(),
141
+ "cache_dir": self.cache_dir,
142
+ "driver": self.driver,
143
+ "strategy": self.strategy,
144
+ "signature_policy": self.signature_policy,
145
+ "allow_scoop": self.allow_scoop,
146
+ "install_timeout": self.install_timeout,
147
+ "download_timeout": self.download_timeout,
148
+ "monitor_tags": self.monitor_tags,
149
+ }
150
+ kwargs.update(overrides)
151
+ return DriverInstaller(**kwargs)
152
+
153
+
154
+ @dataclass
155
+ class SessionConfig:
156
+ """Per-session behaviour: which monitor, which window, how to capture."""
157
+
158
+ #: ``auto`` | ``virtual`` | ``primary`` | ``DISPLAY2`` | ``\\\\.\\DISPLAY2`` | ``1``
159
+ monitor: str = field(default_factory=lambda: env("MONITOR", "auto") or "auto")
160
+ #: ``auto`` (marker match) | ``virtual`` | ``primary`` | explicit monitor name.
161
+ monitor_markers: Tuple[str, ...] = field(
162
+ default_factory=lambda: env_list("MONITOR_MARKERS", ("MttVDD", "IDD", "Virtual"))
163
+ )
164
+ #: Window selection.
165
+ target_window_title: Optional[str] = None
166
+ window_class: Optional[str] = field(default_factory=lambda: env("WINDOW_CLASS"))
167
+ process_name: Optional[str] = field(default_factory=lambda: env("WINDOW_PROCESS"))
168
+ window_pid: Optional[int] = None
169
+ title_mode: str = field(default_factory=lambda: env("TITLE_MODE", "auto") or "auto")
170
+ match_case: bool = field(default_factory=lambda: env_bool("MATCH_CASE", False))
171
+ window_timeout: float = field(default_factory=lambda: env_float("WINDOW_TIMEOUT", 10.0))
172
+ #: Hide taskbar button + Alt+Tab entry while mounted.
173
+ hide_taskbar_trace: bool = field(default_factory=lambda: env_bool("HIDE_TASKBAR_TRACE", True))
174
+ #: Bind the calling thread to ``WinSta0\Default`` before doing anything else.
175
+ bind_desktop: bool = field(default_factory=lambda: env_bool("BIND_DESKTOP", True))
176
+ #: How the window is hidden:
177
+ #: ``virtual_screen`` (default) - move it onto the virtual display;
178
+ #: ``offscreen`` - park it outside every monitor (no driver needed);
179
+ #: ``auto`` - try ``virtual_screen``, fall back to ``offscreen``.
180
+ hide_mode: str = field(default_factory=lambda: (env("HIDE_MODE", "virtual_screen") or "virtual_screen").strip().lower())
181
+ #: Padding (pixels) applied inside the virtual monitor rect.
182
+ padding: int = field(default_factory=lambda: env_int("PADDING", 0))
183
+ #: Move the window without resizing it. Safer for custom-rendered apps: WeChat's
184
+ #: login window ignores a resize and snaps back to the physical panel's size.
185
+ keep_window_size: bool = field(default_factory=lambda: env_bool("KEEP_WINDOW_SIZE", False))
186
+ #: Capture defaults.
187
+ capture_client_only: bool = field(default_factory=lambda: env_bool("CAPTURE_CLIENT_ONLY", True))
188
+ capture_methods: Optional[Tuple[str, ...]] = field(
189
+ default_factory=lambda: env_list("CAPTURE_METHODS") or None
190
+ )
191
+ capture_validate: bool = field(default_factory=lambda: env_bool("CAPTURE_VALIDATE", True))
192
+ capture_variance_threshold: float = field(
193
+ default_factory=lambda: env_float("CAPTURE_VARIANCE", 0.05)
194
+ )
195
+ capture_dominant_threshold: float = field(
196
+ default_factory=lambda: env_float("CAPTURE_DOMINANT", 0.999)
197
+ )
198
+ #: Restore the window automatically when the context manager exits.
199
+ restore_on_exit: bool = field(default_factory=lambda: env_bool("RESTORE_ON_EXIT", True))
200
+
201
+ def __post_init__(self) -> None:
202
+ valid = ("auto", "virtual", "primary", "any")
203
+ lowered = (self.monitor or "auto").strip().lower()
204
+ if lowered not in valid and not (
205
+ lowered.isdigit() or lowered.startswith("\\\\.\\") or lowered.startswith("display")
206
+ ):
207
+ raise ConfigError(
208
+ f"monitor selector {self.monitor!r} is not understood",
209
+ code="bad-monitor-selector",
210
+ hint="Use auto, virtual, primary, a DISPLAY name, or a 1-based index.",
211
+ )
212
+ if (self.hide_mode or "virtual_screen").strip().lower() not in HIDE_MODES:
213
+ raise ConfigError(
214
+ f"hide_mode {self.hide_mode!r} is not one of {', '.join(HIDE_MODES)}",
215
+ code="bad-hide-mode",
216
+ hint="virtual_screen = move onto the virtual display, offscreen = park outside "
217
+ "every monitor, auto = try the first then fall back.",
218
+ )
219
+ if self.title_mode not in ("auto", "exact", "substring", "regex"):
220
+ raise ConfigError(
221
+ f"title_mode {self.title_mode!r} must be auto, exact, substring or regex",
222
+ code="bad-title-mode",
223
+ )
224
+ if self.capture_methods is not None:
225
+ self.capture_methods = tuple(self.capture_methods)
226
+
227
+ def with_title(self, title: Optional[str]) -> "SessionConfig":
228
+ return replace(self, target_window_title=title)
229
+
230
+
231
+ def capture_engine_from(config: SessionConfig, **overrides: Any):
232
+ """Build a :class:`~VirtualDesktop.capture.CaptureEngine` from a session config."""
233
+ from .capture import CaptureEngine
234
+
235
+ kwargs: Dict[str, Any] = {
236
+ "client_only": config.capture_client_only,
237
+ "validate": config.capture_validate,
238
+ "variance_threshold": config.capture_variance_threshold,
239
+ "dominant_threshold": config.capture_dominant_threshold,
240
+ }
241
+ if config.capture_methods:
242
+ kwargs["methods"] = config.capture_methods
243
+ kwargs.update(overrides)
244
+ return CaptureEngine(**kwargs)
245
+
246
+
247
+ def mounter_from(config: SessionConfig, **overrides: Any):
248
+ from .window import WindowMounter
249
+
250
+ kwargs: Dict[str, Any] = {
251
+ "hide_taskbar_trace": config.hide_taskbar_trace,
252
+ "keep_size": config.keep_window_size,
253
+ }
254
+ kwargs.update(overrides)
255
+ return WindowMounter(**kwargs)
@@ -0,0 +1,231 @@
1
+ """Desktop binding (:func:`ensure_default_desktop`).
2
+
3
+ Windows gives every process a *window station* (``WinSta0`` for interactive sessions) and
4
+ a *desktop* inside it (``Default``, ``Winlogon``, ``Screensaver``). Windows live on a
5
+ desktop, and a process can only see/enumerate windows that live on **its own** desktop.
6
+
7
+ Processes started interactively normally inherit ``WinSta0\\Default`` already, so this
8
+ module is a *guard*, not a requirement. It matters when the library runs under Task
9
+ Scheduler, a service wrapper, or a custom launcher: there, the thread may end up on
10
+ ``WinSta0\\Default`` only by luck.
11
+
12
+ The call is best-effort by design. ``SetThreadDesktop`` fails with
13
+ ``ERROR_BUSY`` (170) when the calling thread has already created windows or installed
14
+ hooks — a condition that is *not* recoverable in-place and must never abort a session,
15
+ because being unable to re-bind usually means we are already where we want to be.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ctypes
21
+ from ctypes import wintypes
22
+ from dataclasses import dataclass
23
+ from typing import Optional
24
+
25
+ from ._log import get_logger
26
+ from ._winapi import IS_WINDOWS, require_windows
27
+ from .errors import DesktopBindError
28
+
29
+ log = get_logger("desktop")
30
+
31
+ __all__ = ["DesktopBinding", "ensure_default_desktop", "current_desktop_name"]
32
+
33
+ if IS_WINDOWS: # pragma: no branch
34
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
35
+ else: # pragma: no cover
36
+ user32 = None
37
+
38
+ DESKTOP_READOBJECTS = 0x0001
39
+ DESKTOP_CREATEWINDOW = 0x0002
40
+ DESKTOP_ENUMERATE = 0x0040
41
+ DESKTOP_WRITEOBJECTS = 0x0080
42
+ DESKTOP_SWITCHDESKTOP = 0x0100
43
+ DESKTOP_READ_CONTROL = 0x00020000
44
+ _GENERIC_DESKTOP_RIGHTS = (
45
+ DESKTOP_READOBJECTS
46
+ | DESKTOP_CREATEWINDOW
47
+ | DESKTOP_ENUMERATE
48
+ | DESKTOP_WRITEOBJECTS
49
+ | DESKTOP_SWITCHDESKTOP
50
+ | DESKTOP_READ_CONTROL
51
+ )
52
+ _ERROR_BUSY = 170
53
+ _ERROR_ACCESS_DENIED = 5
54
+ _ERROR_INVALID_HANDLE = 6
55
+ _ERROR_INSUFFICIENT_BUFFER = 122
56
+
57
+
58
+ @dataclass
59
+ class DesktopBinding:
60
+ """Outcome of :func:`ensure_default_desktop`."""
61
+
62
+ bound: bool = False
63
+ already_on_default: bool = False
64
+ desktop_name: str = ""
65
+ skipped_reason: str = ""
66
+ handle: int = 0
67
+ error_code: int = 0
68
+
69
+ def to_dict(self) -> dict:
70
+ return {
71
+ "bound": self.bound,
72
+ "already_on_default": self.already_on_default,
73
+ "desktop_name": self.desktop_name,
74
+ "skipped_reason": self.skipped_reason,
75
+ "handle": self.handle,
76
+ "error_code": self.error_code,
77
+ }
78
+
79
+
80
+ if IS_WINDOWS: # pragma: no branch
81
+ user32.OpenInputDesktop.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
82
+ user32.OpenInputDesktop.restype = wintypes.HANDLE
83
+ user32.GetThreadDesktop.argtypes = [wintypes.DWORD]
84
+ user32.GetThreadDesktop.restype = wintypes.HANDLE
85
+ user32.SetThreadDesktop.argtypes = [wintypes.HANDLE]
86
+ user32.SetThreadDesktop.restype = wintypes.BOOL
87
+ user32.CloseDesktop.argtypes = [wintypes.HANDLE]
88
+ user32.CloseDesktop.restype = wintypes.BOOL
89
+ user32.GetUserObjectInformationW.argtypes = [
90
+ wintypes.HANDLE,
91
+ ctypes.c_int,
92
+ ctypes.c_void_p,
93
+ wintypes.DWORD,
94
+ ctypes.POINTER(wintypes.DWORD),
95
+ ]
96
+ user32.GetUserObjectInformationW.restype = wintypes.BOOL
97
+
98
+ _UOI_NAME = 2
99
+
100
+
101
+ def _desktop_name(handle: int) -> str:
102
+ """Query ``UOI_NAME``. Two-step sizing is mandatory here.
103
+
104
+ Passing ``NULL`` for the buffer does **not** return the required size for
105
+ ``GetUserObjectInformation`` (it fails with ``ERROR_INSUFFICIENT_BUFFER`` and leaves
106
+ the size variable untouched), so a single oversized call with a retry is used instead.
107
+ """
108
+ if not IS_WINDOWS or not handle:
109
+ return ""
110
+ for capacity in (64, 512):
111
+ needed = wintypes.DWORD()
112
+ buffer = ctypes.create_unicode_buffer(capacity)
113
+ ok = user32.GetUserObjectInformationW(
114
+ handle,
115
+ _UOI_NAME,
116
+ ctypes.cast(buffer, ctypes.c_void_p),
117
+ capacity * ctypes.sizeof(ctypes.c_wchar),
118
+ ctypes.byref(needed),
119
+ )
120
+ if ok:
121
+ return buffer.value
122
+ if ctypes.get_last_error() != _ERROR_INSUFFICIENT_BUFFER:
123
+ return ""
124
+ return ""
125
+
126
+
127
+ def current_desktop_name() -> str:
128
+ """Name of the desktop the calling thread is attached to (``""`` when unknown)."""
129
+ if not IS_WINDOWS:
130
+ return ""
131
+ handle = user32.GetThreadDesktop(_current_thread_id())
132
+ if not handle:
133
+ return ""
134
+ return _desktop_name(handle)
135
+
136
+
137
+ def _current_thread_id() -> int:
138
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
139
+ return int(kernel32.GetCurrentThreadId())
140
+
141
+
142
+ def ensure_default_desktop(
143
+ *,
144
+ name: str = "Default",
145
+ strict: bool = False,
146
+ close_handle_on_exit: bool = False,
147
+ ) -> DesktopBinding:
148
+ """Make sure the calling thread is bound to ``WinSta0\\<name>``.
149
+
150
+ Returns a :class:`DesktopBinding` describing what happened. With ``strict=True`` a
151
+ failure raises :class:`DesktopBindError` instead of being reported.
152
+ """
153
+ binding = DesktopBinding()
154
+ if not IS_WINDOWS:
155
+ binding.skipped_reason = "not-windows"
156
+ return binding
157
+
158
+ current = user32.GetThreadDesktop(_current_thread_id())
159
+ current_name = _desktop_name(current) if current else ""
160
+ binding.desktop_name = current_name or name
161
+ if current_name.lower() == name.lower():
162
+ binding.already_on_default = True
163
+ binding.bound = True
164
+ binding.skipped_reason = "already-bound"
165
+ log.debug("thread is already bound to the %r desktop", current_name)
166
+ return binding
167
+
168
+ handle = user32.OpenDesktopW(name, 0, False, _GENERIC_DESKTOP_RIGHTS)
169
+ if not handle:
170
+ # Fall back to the documented OpenInputDesktop path when OpenDesktop is refused.
171
+ handle = user32.OpenInputDesktop(0, False, _GENERIC_DESKTOP_RIGHTS)
172
+ if not handle:
173
+ error = ctypes.get_last_error()
174
+ binding.error_code = error
175
+ binding.skipped_reason = f"open-desktop-failed:{error}"
176
+ message = (
177
+ f"could not open the {name!r} desktop (Win32 error {error}); the invocation "
178
+ "desktop was left unchanged"
179
+ )
180
+ if strict:
181
+ raise DesktopBindError(
182
+ message,
183
+ details={"win32_error": error},
184
+ hint="Run the process in the interactive session (it usually already has access).",
185
+ )
186
+ log.warning(message)
187
+ return binding
188
+
189
+ binding.handle = int(handle)
190
+ if not user32.SetThreadDesktop(handle):
191
+ error = ctypes.get_last_error()
192
+ binding.error_code = error
193
+ binding.skipped_reason = f"set-thread-desktop-failed:{error}"
194
+ message = (
195
+ f"SetThreadDesktop({name!r}) failed with Win32 error {error}. "
196
+ "This is expected when the thread already owns windows or hooks."
197
+ )
198
+ if error == _ERROR_BUSY:
199
+ log.info("%s Continuing on the current desktop, which is the normal outcome.", message)
200
+ binding.bound = True
201
+ binding.skipped_reason = "thread-busy-continuing"
202
+ else:
203
+ if strict:
204
+ raise DesktopBindError(
205
+ message,
206
+ details={"win32_error": error},
207
+ hint="Bind the desktop before creating any window or installing hooks.",
208
+ )
209
+ log.warning(message)
210
+ if not close_handle_on_exit:
211
+ user32.CloseDesktop(handle)
212
+ binding.handle = 0
213
+ return binding
214
+
215
+ binding.bound = True
216
+ binding.desktop_name = name
217
+ log.info("bound the calling thread to the %r desktop", name)
218
+ if not close_handle_on_exit:
219
+ user32.CloseDesktop(handle)
220
+ binding.handle = 0
221
+ return binding
222
+
223
+
224
+ if IS_WINDOWS: # pragma: no branch
225
+ user32.OpenDesktopW.argtypes = [
226
+ ctypes.c_wchar_p,
227
+ wintypes.DWORD,
228
+ wintypes.BOOL,
229
+ wintypes.DWORD,
230
+ ]
231
+ user32.OpenDesktopW.restype = wintypes.HANDLE
@@ -0,0 +1,159 @@
1
+ """Grab a whole monitor (by its device name) or the whole virtual desktop, in physical pixels.
2
+
3
+ ``PIL.ImageGrab.grab(bbox=...)`` is the reliable path for a *virtual* monitor: its rectangle can
4
+ sit entirely outside the primary screen (the virtual display lives at x=1920 here), and
5
+ ``ImageGrab`` with ``all_screens=True`` covers the whole virtual desktop. ``BitBlt`` from the
6
+ desktop DC is used as a fallback and to capture an arbitrary rectangle.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ctypes
12
+ from ctypes import wintypes
13
+ from dataclasses import dataclass
14
+ from typing import Optional, Tuple
15
+
16
+ from ._log import get_logger
17
+ from ._winapi import IS_WINDOWS, MonitorInfo, enumerate_monitors, require_windows
18
+ from .capture import _MemoryBitmap
19
+ from .errors import CaptureError
20
+
21
+ log = get_logger("desktop_capture")
22
+
23
+ __all__ = ["DesktopCaptureResult", "capture_rect", "capture_monitor", "capture_all_screens"]
24
+
25
+ if IS_WINDOWS: # pragma: no branch
26
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
27
+ gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
28
+ else: # pragma: no cover
29
+ user32 = gdi32 = None
30
+
31
+ SRCCOPY = 0x00CC0020
32
+ CAPTUREBLT = 0x40000000
33
+ SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 76, 77, 78, 79
34
+
35
+ if IS_WINDOWS: # pragma: no branch
36
+ # Explicit prototypes: without them ctypes treats the DC handle returned by ``GetDC`` as a
37
+ # plain ``int`` and ``BitBlt`` raises "int too long to convert" on 64-bit builds (measured).
38
+ user32.GetDC.argtypes = [wintypes.HWND]
39
+ user32.GetDC.restype = wintypes.HDC
40
+ user32.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
41
+ user32.ReleaseDC.restype = ctypes.c_int
42
+ user32.GetSystemMetrics.argtypes = [ctypes.c_int]
43
+ user32.GetSystemMetrics.restype = ctypes.c_int
44
+ gdi32.BitBlt.argtypes = [
45
+ wintypes.HDC, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
46
+ wintypes.HDC, ctypes.c_int, ctypes.c_int, wintypes.DWORD,
47
+ ]
48
+ gdi32.BitBlt.restype = wintypes.BOOL
49
+
50
+
51
+ @dataclass
52
+ class DesktopCaptureResult:
53
+ image: object
54
+ rect: Tuple[int, int, int, int]
55
+ method: str
56
+
57
+ @property
58
+ def size(self) -> Tuple[int, int]:
59
+ return (self.image.width, self.image.height) # type: ignore[attr-defined]
60
+
61
+ def save(self, path) -> str:
62
+ target = str(path)
63
+ self.image.save(target) # type: ignore[attr-defined]
64
+ return target
65
+
66
+ def to_dict(self) -> dict:
67
+ return {"rect": list(self.rect), "size": list(self.size), "method": self.method}
68
+
69
+
70
+ def virtual_desktop_rect() -> Tuple[int, int, int, int]:
71
+ """Bounding rectangle of the whole desktop, including negative-coordinate monitors.
72
+
73
+ Uses the library-wide canonical accessor so this rectangle always agrees with the monitor
74
+ rectangles used elsewhere. ``GetSystemMetrics`` alone disagrees with
75
+ ``EnumDisplayMonitors`` by the monitor scale factor for a DPI-unaware caller on Windows 10
76
+ and 11 — and a rectangle that disagrees with the one used to *take* the shot produces a
77
+ silently offset crop.
78
+ """
79
+ require_windows("virtual_desktop_rect")
80
+ from ._winapi import virtual_screen_rect
81
+
82
+ return virtual_screen_rect()
83
+
84
+
85
+ def _grab_rect_pil(rect: Tuple[int, int, int, int]):
86
+ from PIL import ImageGrab
87
+
88
+ left, top, right, bottom = rect
89
+ if right <= left or bottom <= top:
90
+ raise CaptureError(f"degenerate rectangle {rect}", code="degenerate-rect")
91
+ # ImageGrab works in physical pixels when the caller is DPI aware, which it must be.
92
+ return ImageGrab.grab(bbox=(left, top, right, bottom), all_screens=True).convert("RGB")
93
+
94
+
95
+ def _grab_rect_bitblt(rect: Tuple[int, int, int, int]):
96
+ left, top, right, bottom = rect
97
+ width, height = right - left, bottom - top
98
+ if width <= 0 or height <= 0:
99
+ raise CaptureError(f"degenerate rectangle {rect}", code="degenerate-rect")
100
+ bitmap = _MemoryBitmap(width, height)
101
+ try:
102
+ screen = user32.GetDC(None)
103
+ if not screen:
104
+ raise CaptureError("GetDC(NULL) failed", code="getdc-failed")
105
+ try:
106
+ ok = gdi32.BitBlt(bitmap.dc, 0, 0, width, height, screen, left, top, SRCCOPY | CAPTUREBLT)
107
+ finally:
108
+ user32.ReleaseDC(None, screen)
109
+ if not ok:
110
+ raise CaptureError(
111
+ f"BitBlt of {rect} failed (Win32 error {ctypes.get_last_error()})", code="bitblt-failed"
112
+ )
113
+ return bitmap.to_image()
114
+ finally:
115
+ bitmap.close()
116
+
117
+
118
+ def capture_rect(rect: Tuple[int, int, int, int], *, prefer: str = "pil") -> DesktopCaptureResult:
119
+ """Capture an arbitrary screen rectangle, with a working fallback for each method."""
120
+ require_windows("capture_rect")
121
+ errors = []
122
+ order = ("pil", "bitblt") if prefer == "pil" else ("bitblt", "pil")
123
+ for method in order:
124
+ try:
125
+ image = _grab_rect_pil(rect) if method == "pil" else _grab_rect_bitblt(rect)
126
+ log.debug("captured %s with %s -> %s", rect, method, image.size)
127
+ return DesktopCaptureResult(image=image, rect=tuple(rect), method=method)
128
+ except Exception as exc: # noqa: BLE001 - try the next backend
129
+ errors.append(f"{method}: {exc}")
130
+ log.debug("capture backend %s failed for %s: %s", method, rect, exc)
131
+ raise CaptureError(
132
+ f"could not capture {rect}: {'; '.join(errors)}",
133
+ code="desktop-capture-failed",
134
+ hint="The rectangle may lie outside every monitor, or DPI awareness is not set.",
135
+ )
136
+
137
+
138
+ def capture_monitor(
139
+ monitor: Optional[MonitorInfo] = None, *, device: Optional[str] = None, prefer: str = "pil"
140
+ ) -> DesktopCaptureResult:
141
+ """Capture one monitor (the virtual display by default) using its own rectangle."""
142
+ monitors = enumerate_monitors()
143
+ if monitor is None:
144
+ if device:
145
+ monitor = next((m for m in monitors if m.device.lower() == device.lower()), None)
146
+ if monitor is None:
147
+ raise CaptureError(f"no monitor named {device!r}", code="monitor-not-found")
148
+ else:
149
+ monitor = next((m for m in monitors if m.is_decidedly_virtual()), None) or (
150
+ monitors[0] if monitors else None
151
+ )
152
+ if monitor is None:
153
+ raise CaptureError("no monitors are attached", code="no-monitor")
154
+ return capture_rect(tuple(monitor.monitor), prefer=prefer)
155
+
156
+
157
+ def capture_all_screens(*, prefer: str = "pil") -> DesktopCaptureResult:
158
+ """Capture the whole virtual desktop (every monitor, including the virtual one)."""
159
+ return capture_rect(virtual_desktop_rect(), prefer=prefer)