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
VirtualDesktop/config.py
ADDED
|
@@ -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)
|