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/window.py
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
"""Window mounting: move a target window onto the virtual display and hide its traces.
|
|
2
|
+
|
|
3
|
+
What "mount" means concretely:
|
|
4
|
+
|
|
5
|
+
1. The window is restored from ``SW_MINIMIZE``/``SW_MAXIMIZE`` to a normal state (a
|
|
6
|
+
maximized window would snap back to the old monitor the moment it is restored).
|
|
7
|
+
2. Its extended style is rewritten from ``WS_EX_APPWINDOW`` to ``WS_EX_TOOLWINDOW``, which
|
|
8
|
+
is the only reliable way to remove a *taskbar button* and remove the window from
|
|
9
|
+
Alt+Tab. ``SetWindowPos(SWP_FRAMECHANGED)`` applies the frame change, plus an optional
|
|
10
|
+
hide/show cycle that makes the shell refresh its (sometimes cached) taskbar entry.
|
|
11
|
+
3. The window is moved to the virtual monitor's rectangle.
|
|
12
|
+
|
|
13
|
+
Every step is reversible: :meth:`WindowMounter.unmount` restores the exact original
|
|
14
|
+
rectangle, styles and minimized state, and unmount is idempotent.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ctypes
|
|
20
|
+
import time
|
|
21
|
+
from ctypes import wintypes
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from ._log import get_logger
|
|
26
|
+
from ._winapi import (
|
|
27
|
+
GWL_EXSTYLE,
|
|
28
|
+
GWL_STYLE,
|
|
29
|
+
IS_WINDOWS,
|
|
30
|
+
WS_EX_APPWINDOW,
|
|
31
|
+
WS_EX_TOOLWINDOW,
|
|
32
|
+
MonitorInfo,
|
|
33
|
+
WindowRecord,
|
|
34
|
+
is_owned_window,
|
|
35
|
+
list_windows,
|
|
36
|
+
require_windows,
|
|
37
|
+
title_matches,
|
|
38
|
+
)
|
|
39
|
+
from .errors import AmbiguousWindowError, WindowMountError, WindowNotFoundError
|
|
40
|
+
|
|
41
|
+
log = get_logger("window")
|
|
42
|
+
|
|
43
|
+
__all__ = ["Selection", "WindowSnapshot", "WindowMounter", "find_window", "window_diagnostics"]
|
|
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
|
+
SW_HIDE = 0
|
|
51
|
+
SW_SHOWNORMAL = 1
|
|
52
|
+
SW_SHOWNA = 8
|
|
53
|
+
SWP_NOSIZE = 0x0001
|
|
54
|
+
SWP_NOMOVE = 0x0002
|
|
55
|
+
SWP_NOZORDER = 0x0004
|
|
56
|
+
SWP_NOACTIVATE = 0x0010
|
|
57
|
+
SWP_FRAMECHANGED = 0x0020
|
|
58
|
+
HWND_TOP = 0
|
|
59
|
+
HWND_NOTOPMOST = -2
|
|
60
|
+
|
|
61
|
+
if IS_WINDOWS: # pragma: no branch
|
|
62
|
+
user32.MoveWindow.argtypes = [
|
|
63
|
+
wintypes.HWND,
|
|
64
|
+
ctypes.c_int,
|
|
65
|
+
ctypes.c_int,
|
|
66
|
+
ctypes.c_int,
|
|
67
|
+
ctypes.c_int,
|
|
68
|
+
wintypes.BOOL,
|
|
69
|
+
]
|
|
70
|
+
user32.MoveWindow.restype = wintypes.BOOL
|
|
71
|
+
user32.SetWindowPos.argtypes = [
|
|
72
|
+
wintypes.HWND,
|
|
73
|
+
wintypes.HWND,
|
|
74
|
+
ctypes.c_int,
|
|
75
|
+
ctypes.c_int,
|
|
76
|
+
ctypes.c_int,
|
|
77
|
+
ctypes.c_int,
|
|
78
|
+
wintypes.UINT,
|
|
79
|
+
]
|
|
80
|
+
user32.SetWindowPos.restype = wintypes.BOOL
|
|
81
|
+
user32.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.LONG]
|
|
82
|
+
user32.SetWindowLongW.restype = wintypes.LONG
|
|
83
|
+
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.c_void_p]
|
|
84
|
+
user32.GetWindowRect.restype = wintypes.BOOL
|
|
85
|
+
user32.GetClientRect.argtypes = [wintypes.HWND, ctypes.c_void_p]
|
|
86
|
+
user32.GetClientRect.restype = wintypes.BOOL
|
|
87
|
+
user32.IsWindow.argtypes = [wintypes.HWND]
|
|
88
|
+
user32.IsWindow.restype = wintypes.BOOL
|
|
89
|
+
user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
90
|
+
user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
91
|
+
user32.ShowWindow.restype = wintypes.BOOL
|
|
92
|
+
user32.IsIconic.argtypes = [wintypes.HWND]
|
|
93
|
+
user32.IsIconic.restype = wintypes.BOOL
|
|
94
|
+
user32.IsZoomed.argtypes = [wintypes.HWND]
|
|
95
|
+
user32.IsZoomed.restype = wintypes.BOOL
|
|
96
|
+
user32.BringWindowToTop.argtypes = [wintypes.HWND]
|
|
97
|
+
user32.BringWindowToTop.restype = wintypes.BOOL
|
|
98
|
+
user32.SetForegroundWindow.argtypes = [wintypes.HWND]
|
|
99
|
+
user32.SetForegroundWindow.restype = wintypes.BOOL
|
|
100
|
+
user32.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
101
|
+
user32.GetWindowLongW.restype = wintypes.LONG
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _RECT(ctypes.Structure):
|
|
105
|
+
_fields_ = [
|
|
106
|
+
("left", wintypes.LONG),
|
|
107
|
+
("top", wintypes.LONG),
|
|
108
|
+
("right", wintypes.LONG),
|
|
109
|
+
("bottom", wintypes.LONG),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _POINT(ctypes.Structure):
|
|
114
|
+
_fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Selection:
|
|
119
|
+
"""Criteria used to pick exactly one window."""
|
|
120
|
+
|
|
121
|
+
title: Optional[str] = None
|
|
122
|
+
class_name: Optional[str] = None
|
|
123
|
+
process_name: Optional[str] = None
|
|
124
|
+
pid: Optional[int] = None
|
|
125
|
+
title_mode: str = "auto"
|
|
126
|
+
match_case: bool = False
|
|
127
|
+
require_visible: bool = True
|
|
128
|
+
include_tool_windows: bool = False
|
|
129
|
+
include_owned: bool = True
|
|
130
|
+
|
|
131
|
+
def describe(self) -> str:
|
|
132
|
+
parts = []
|
|
133
|
+
if self.title:
|
|
134
|
+
parts.append(f"title~{self.title!r} ({self.title_mode})")
|
|
135
|
+
if self.class_name:
|
|
136
|
+
parts.append(f"class={self.class_name!r}")
|
|
137
|
+
if self.process_name:
|
|
138
|
+
parts.append(f"process={self.process_name!r}")
|
|
139
|
+
if self.pid:
|
|
140
|
+
parts.append(f"pid={self.pid}")
|
|
141
|
+
return ", ".join(parts) or "<no criteria>"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _matches(record: WindowRecord, selection: Selection) -> bool:
|
|
145
|
+
if selection.pid and record.pid != selection.pid:
|
|
146
|
+
return False
|
|
147
|
+
if selection.process_name:
|
|
148
|
+
wanted = selection.process_name if selection.match_case else selection.process_name.lower()
|
|
149
|
+
actual = record.process_name if selection.match_case else record.process_name.lower()
|
|
150
|
+
if wanted not in actual:
|
|
151
|
+
return False
|
|
152
|
+
if selection.class_name:
|
|
153
|
+
wanted = selection.class_name if selection.match_case else selection.class_name.lower()
|
|
154
|
+
actual = record.class_name if selection.match_case else record.class_name.lower()
|
|
155
|
+
if wanted != actual:
|
|
156
|
+
return False
|
|
157
|
+
if selection.title:
|
|
158
|
+
if selection.match_case:
|
|
159
|
+
return title_matches(record.title, selection.title, mode=selection.title_mode)
|
|
160
|
+
return title_matches(record.title.lower(), selection.title.lower(), mode=selection.title_mode)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _candidates(selection: Selection) -> List[WindowRecord]:
|
|
165
|
+
records = list_windows()
|
|
166
|
+
found: List[WindowRecord] = []
|
|
167
|
+
for record in records:
|
|
168
|
+
if not record.title and not selection.class_name:
|
|
169
|
+
continue
|
|
170
|
+
if selection.require_visible and not record.visible:
|
|
171
|
+
continue
|
|
172
|
+
if not selection.include_tool_windows and record.tool_window:
|
|
173
|
+
continue
|
|
174
|
+
if not selection.include_owned and record.owner:
|
|
175
|
+
continue
|
|
176
|
+
if _matches(record, selection):
|
|
177
|
+
found.append(record)
|
|
178
|
+
return found
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def find_window(
|
|
182
|
+
*,
|
|
183
|
+
title: Optional[str] = None,
|
|
184
|
+
class_name: Optional[str] = None,
|
|
185
|
+
process_name: Optional[str] = None,
|
|
186
|
+
pid: Optional[int] = None,
|
|
187
|
+
title_mode: str = "auto",
|
|
188
|
+
match_case: bool = False,
|
|
189
|
+
require_visible: bool = True,
|
|
190
|
+
include_tool_windows: bool = False,
|
|
191
|
+
include_owned: bool = True,
|
|
192
|
+
timeout: float = 0.0,
|
|
193
|
+
interval: float = 0.5,
|
|
194
|
+
allow_multiple: bool = False,
|
|
195
|
+
) -> WindowRecord:
|
|
196
|
+
"""Locate exactly one window, waiting up to ``timeout`` seconds for it to appear."""
|
|
197
|
+
require_windows("find_window")
|
|
198
|
+
selection = Selection(
|
|
199
|
+
title=title,
|
|
200
|
+
class_name=class_name,
|
|
201
|
+
process_name=process_name,
|
|
202
|
+
pid=pid,
|
|
203
|
+
title_mode=title_mode,
|
|
204
|
+
match_case=match_case,
|
|
205
|
+
require_visible=require_visible,
|
|
206
|
+
include_tool_windows=include_tool_windows,
|
|
207
|
+
include_owned=include_owned,
|
|
208
|
+
)
|
|
209
|
+
deadline = time.monotonic() + max(timeout, 0.0)
|
|
210
|
+
while True:
|
|
211
|
+
found = _candidates(selection)
|
|
212
|
+
if len(found) == 1:
|
|
213
|
+
return found[0]
|
|
214
|
+
if len(found) > 1:
|
|
215
|
+
if allow_multiple:
|
|
216
|
+
return found[0]
|
|
217
|
+
raise AmbiguousWindowError(
|
|
218
|
+
f"{len(found)} windows match {selection.describe()}",
|
|
219
|
+
details={"candidates": [r.to_dict() for r in found[:20]]},
|
|
220
|
+
code="window-ambiguous",
|
|
221
|
+
)
|
|
222
|
+
if time.monotonic() >= deadline:
|
|
223
|
+
raise WindowNotFoundError(
|
|
224
|
+
f"no window matched {selection.describe()}",
|
|
225
|
+
details={"waited_seconds": timeout, "windows": _summaries()},
|
|
226
|
+
hint="Use VirtualDesktop.list_windows() to inspect candidates.",
|
|
227
|
+
)
|
|
228
|
+
time.sleep(interval)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _summaries(limit: int = 25) -> List[dict]:
|
|
232
|
+
try:
|
|
233
|
+
return [
|
|
234
|
+
{"title": r.title, "class": r.class_name, "process": r.process_name, "pid": r.pid}
|
|
235
|
+
for r in list_windows()
|
|
236
|
+
if r.title
|
|
237
|
+
][:limit]
|
|
238
|
+
except Exception: # pragma: no cover - diagnostics must never raise
|
|
239
|
+
return []
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class WindowSnapshot:
|
|
244
|
+
"""Everything needed to put a window back exactly as it was."""
|
|
245
|
+
|
|
246
|
+
hwnd: int
|
|
247
|
+
rect: Tuple[int, int, int, int]
|
|
248
|
+
style: int
|
|
249
|
+
ex_style: int
|
|
250
|
+
was_minimized: bool = False
|
|
251
|
+
was_maximized: bool = False
|
|
252
|
+
was_visible: bool = True
|
|
253
|
+
mounted: bool = False
|
|
254
|
+
#: True when the window was hidden by parking it outside every monitor.
|
|
255
|
+
offscreen: bool = False
|
|
256
|
+
mounted_at: float = 0.0
|
|
257
|
+
target_monitor: Optional[Tuple[int, int, int, int]] = None
|
|
258
|
+
toolwindow_applied: bool = False
|
|
259
|
+
hide_taskbar_trace: bool = False
|
|
260
|
+
#: Window rectangle observed right after the move (the app may have adjusted it).
|
|
261
|
+
mounted_rect: Optional[Tuple[int, int, int, int]] = None
|
|
262
|
+
#: DPI of the window when :attr:`rect` was captured (restores across DPI changes).
|
|
263
|
+
dpi: int = 96
|
|
264
|
+
#: Monitor rectangle the window lived on before mounting (for a DPI-safe restore).
|
|
265
|
+
origin_monitor: Optional[Tuple[int, int, int, int]] = None
|
|
266
|
+
#: Window offset inside :attr:`origin_monitor`, in that monitor's coordinate space.
|
|
267
|
+
origin_offset: Optional[Tuple[int, int]] = None
|
|
268
|
+
warnings: List[str] = field(default_factory=list)
|
|
269
|
+
|
|
270
|
+
def to_dict(self) -> dict:
|
|
271
|
+
return {
|
|
272
|
+
"hwnd": self.hwnd,
|
|
273
|
+
"rect": list(self.rect),
|
|
274
|
+
"style": hex(self.style & 0xFFFFFFFF),
|
|
275
|
+
"ex_style": hex(self.ex_style & 0xFFFFFFFF),
|
|
276
|
+
"was_minimized": self.was_minimized,
|
|
277
|
+
"was_maximized": self.was_maximized,
|
|
278
|
+
"was_visible": self.was_visible,
|
|
279
|
+
"mounted": self.mounted,
|
|
280
|
+
"offscreen": self.offscreen,
|
|
281
|
+
"target_monitor": list(self.target_monitor) if self.target_monitor else None,
|
|
282
|
+
"toolwindow_applied": self.toolwindow_applied,
|
|
283
|
+
"mounted_rect": list(self.mounted_rect) if self.mounted_rect else None,
|
|
284
|
+
"warnings": list(self.warnings),
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _get_rect(hwnd: int) -> Tuple[int, int, int, int]:
|
|
289
|
+
rect = _RECT()
|
|
290
|
+
if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
|
291
|
+
raise WindowMountError(
|
|
292
|
+
f"GetWindowRect(0x{hwnd:X}) failed with Win32 error {ctypes.get_last_error()}",
|
|
293
|
+
code="get-window-rect-failed",
|
|
294
|
+
)
|
|
295
|
+
return (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def window_diagnostics(hwnd: int) -> dict:
|
|
299
|
+
"""Snapshot a window's geometry/style for reports and bug reports."""
|
|
300
|
+
if not IS_WINDOWS:
|
|
301
|
+
return {}
|
|
302
|
+
if not user32.IsWindow(hwnd):
|
|
303
|
+
return {"hwnd": hwnd, "valid": False}
|
|
304
|
+
rect = _RECT()
|
|
305
|
+
client = _RECT()
|
|
306
|
+
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
|
307
|
+
user32.GetClientRect(hwnd, ctypes.byref(client))
|
|
308
|
+
return {
|
|
309
|
+
"hwnd": hwnd,
|
|
310
|
+
"valid": True,
|
|
311
|
+
"rect": [rect.left, rect.top, rect.right, rect.bottom],
|
|
312
|
+
"client": [client.left, client.top, client.right, client.bottom],
|
|
313
|
+
"style": hex(int(user32.GetWindowLongW(hwnd, GWL_STYLE)) & 0xFFFFFFFF),
|
|
314
|
+
"ex_style": hex(int(user32.GetWindowLongW(hwnd, GWL_EXSTYLE)) & 0xFFFFFFFF),
|
|
315
|
+
"visible": bool(user32.IsWindowVisible(hwnd)),
|
|
316
|
+
"minimized": bool(user32.IsIconic(hwnd)),
|
|
317
|
+
"maximized": bool(user32.IsZoomed(hwnd)),
|
|
318
|
+
"owned": is_owned_window(hwnd),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def window_dpi(hwnd: int) -> int:
|
|
325
|
+
"""Effective DPI of a window (96 = 100 %).
|
|
326
|
+
|
|
327
|
+
Delegates to the compatibility layer, which knows the per-build fallback chain
|
|
328
|
+
(``GetDpiForWindow`` on Win 10 1607+, then ``GetDpiForMonitor`` on Win 8.1+, then the
|
|
329
|
+
monitor DC's ``LOGPIXELSX``). Returning 96 unconditionally on an older build would
|
|
330
|
+
silently mis-scale every restored rectangle.
|
|
331
|
+
"""
|
|
332
|
+
if not IS_WINDOWS: # pragma: no cover - Windows-only library
|
|
333
|
+
return 96
|
|
334
|
+
from ._compat import window_dpi_compat
|
|
335
|
+
|
|
336
|
+
return window_dpi_compat(hwnd)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def scale_rect(rect, numerator: int, denominator: int):
|
|
340
|
+
"""Scale a rectangle between DPI-aware coordinate spaces."""
|
|
341
|
+
if not numerator or not denominator or numerator == denominator:
|
|
342
|
+
return tuple(rect)
|
|
343
|
+
factor = numerator / denominator
|
|
344
|
+
return tuple(int(round(value * factor)) for value in rect)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
#: Hide by parking the window *outside* every monitor, in the desktop's own coordinate space.
|
|
348
|
+
#: ``PrintWindow`` paints the window into a caller-supplied DC regardless of where the window
|
|
349
|
+
#: sits (or whether it is covered), so an off-screen window is still fully capturable — this is
|
|
350
|
+
#: the technique used by production WeChat automation and validated by
|
|
351
|
+
#: :func:`tools/offscreen_probe.py`.
|
|
352
|
+
OFFSCREEN_MARGIN = 200
|
|
353
|
+
|
|
354
|
+
#: How far outside the desktop to try before giving up (some apps/appcompat shims clamp the
|
|
355
|
+
#: position back into the monitor; the probe measured that a plain ``SetWindowPos`` does not).
|
|
356
|
+
OFFSCREEN_PROBES = (-200, -2000, -10000, -30000)
|
|
357
|
+
|
|
358
|
+
#: Hide mode names, ordered by preference: the virtual display first, off-screen as
|
|
359
|
+
#: fallback, plus ``auto`` which tries them in that order and reports what it used.
|
|
360
|
+
HIDE_MODES = ("virtual_screen", "offscreen", "auto")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def virtual_desktop_rect() -> Tuple[int, int, int, int]:
|
|
364
|
+
"""Bounding rectangle of the whole desktop, including negative-coordinate monitors.
|
|
365
|
+
|
|
366
|
+
Delegates to :func:`VirtualDesktop._winapi.virtual_screen_rect`, which prefers the union of
|
|
367
|
+
the enumerated monitors and only falls back to ``GetSystemMetrics`` when enumeration is
|
|
368
|
+
empty. That matters on Windows 10/11: the two accessors disagree by the monitor scale
|
|
369
|
+
factor for a DPI-unaware caller, and this rectangle is used both to test "is the window off
|
|
370
|
+
screen?" and to crop desktop captures.
|
|
371
|
+
"""
|
|
372
|
+
if not IS_WINDOWS: # pragma: no cover - Windows-only library
|
|
373
|
+
return (0, 0, 0, 0)
|
|
374
|
+
from ._winapi import virtual_screen_rect
|
|
375
|
+
|
|
376
|
+
return virtual_screen_rect()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def rect_on_screen(rect, *, desktop: Optional[Tuple[int, int, int, int]] = None, tolerance: int = 0) -> bool:
|
|
380
|
+
"""True when any part of ``rect`` overlaps the desktop (i.e. the user could see it).
|
|
381
|
+
|
|
382
|
+
The *desktop* includes virtual monitors, so a window mounted on a virtual display is
|
|
383
|
+
``on_screen=True`` here. Use :func:`rect_on_physical_screen` for the stronger claim "the
|
|
384
|
+
user cannot see it at all".
|
|
385
|
+
"""
|
|
386
|
+
if rect is None:
|
|
387
|
+
return False
|
|
388
|
+
left, top, right, bottom = desktop or virtual_desktop_rect()
|
|
389
|
+
return not (
|
|
390
|
+
rect[2] <= left + tolerance
|
|
391
|
+
or rect[0] >= right - tolerance
|
|
392
|
+
or rect[3] <= top + tolerance
|
|
393
|
+
or rect[1] >= bottom - tolerance
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def rect_on_physical_screen(rect, tolerance: int = 0) -> bool:
|
|
398
|
+
"""True when ``rect`` overlaps a monitor the user is actually looking at.
|
|
399
|
+
|
|
400
|
+
Virtual (IddCx) monitors are excluded, which is what makes "mounted on the virtual display"
|
|
401
|
+
a genuinely hidden state instead of a technically-on-desktop one.
|
|
402
|
+
"""
|
|
403
|
+
if rect is None:
|
|
404
|
+
return False
|
|
405
|
+
from ._winapi import enumerate_monitors
|
|
406
|
+
|
|
407
|
+
for monitor in enumerate_monitors():
|
|
408
|
+
if monitor.is_decidedly_virtual():
|
|
409
|
+
continue
|
|
410
|
+
left, top, right, bottom = monitor.monitor
|
|
411
|
+
if not (
|
|
412
|
+
rect[2] <= left + tolerance
|
|
413
|
+
or rect[0] >= right - tolerance
|
|
414
|
+
or rect[3] <= top + tolerance
|
|
415
|
+
or rect[1] >= bottom - tolerance
|
|
416
|
+
):
|
|
417
|
+
return True
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def offscreen_target(hwnd: int, *, margin: int = OFFSCREEN_MARGIN) -> Tuple[int, int]:
|
|
422
|
+
desktop = virtual_desktop_rect()
|
|
423
|
+
# Negative space is unbounded in practice; place the window entirely left of and above the
|
|
424
|
+
# desktop so the taskbar, Alt+Tab preview and screenshot tools cannot show it.
|
|
425
|
+
return (desktop[0] - margin, desktop[1] - margin)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def rects_equivalent(
|
|
429
|
+
observed,
|
|
430
|
+
original,
|
|
431
|
+
*,
|
|
432
|
+
original_dpi: int = 96,
|
|
433
|
+
observed_dpi: Optional[int] = None,
|
|
434
|
+
tolerance: int = 2,
|
|
435
|
+
) -> bool:
|
|
436
|
+
"""Compare two window rectangles, accounting for a DPI change between them.
|
|
437
|
+
|
|
438
|
+
A window moved between monitors with different scaling factors legitimately reports
|
|
439
|
+
different pixel dimensions: 583 px at 120 DPI is 466 px at 96 DPI. Treating that as a
|
|
440
|
+
failed restore is wrong, so the expected physical size is scaled before comparing, and only
|
|
441
|
+
the origin must match exactly.
|
|
442
|
+
"""
|
|
443
|
+
if observed is None or original is None:
|
|
444
|
+
return False
|
|
445
|
+
# Pixel-identical rectangles are always a valid restore, whatever DPI is reported now.
|
|
446
|
+
if tuple(observed) == tuple(original):
|
|
447
|
+
return True
|
|
448
|
+
current = observed_dpi if observed_dpi is not None else original_dpi
|
|
449
|
+
factor = (current / original_dpi) if original_dpi else 1.0
|
|
450
|
+
expected_width = (original[2] - original[0]) * factor
|
|
451
|
+
expected_height = (original[3] - original[1]) * factor
|
|
452
|
+
size_tolerance = max(tolerance, int(round(max(expected_width, expected_height) * 0.02)))
|
|
453
|
+
return (
|
|
454
|
+
abs(observed[0] - original[0]) <= tolerance
|
|
455
|
+
and abs(observed[1] - original[1]) <= tolerance
|
|
456
|
+
and abs((observed[2] - observed[0]) - expected_width) <= size_tolerance
|
|
457
|
+
and abs((observed[3] - observed[1]) - expected_height) <= size_tolerance
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class WindowMounter:
|
|
462
|
+
"""Moves windows onto a monitor and restores them afterwards."""
|
|
463
|
+
|
|
464
|
+
def __init__(
|
|
465
|
+
self,
|
|
466
|
+
*,
|
|
467
|
+
hide_taskbar_trace: bool = True,
|
|
468
|
+
activate_on_mount: bool = False,
|
|
469
|
+
taskbar_refresh: bool = True,
|
|
470
|
+
keep_size: bool = False,
|
|
471
|
+
) -> None:
|
|
472
|
+
self.hide_taskbar_trace = hide_taskbar_trace
|
|
473
|
+
self.activate_on_mount = activate_on_mount
|
|
474
|
+
self.taskbar_refresh = taskbar_refresh
|
|
475
|
+
#: Default for :meth:`mount`: move without resizing (safer for custom-rendered apps).
|
|
476
|
+
self.keep_size = keep_size
|
|
477
|
+
self._snapshots: Dict[int, WindowSnapshot] = {}
|
|
478
|
+
|
|
479
|
+
# -- state ----------------------------------------------------------------------
|
|
480
|
+
@property
|
|
481
|
+
def mounted_windows(self) -> List[int]:
|
|
482
|
+
return [hwnd for hwnd, snap in self._snapshots.items() if snap.mounted]
|
|
483
|
+
|
|
484
|
+
def snapshot(self, hwnd: int) -> WindowSnapshot:
|
|
485
|
+
if hwnd in self._snapshots and self._snapshots[hwnd].mounted:
|
|
486
|
+
return self._snapshots[hwnd]
|
|
487
|
+
require_windows("WindowMounter")
|
|
488
|
+
if not user32.IsWindow(hwnd):
|
|
489
|
+
raise WindowMountError(f"0x{hwnd:X} is not a window", code="invalid-hwnd")
|
|
490
|
+
snap = WindowSnapshot(
|
|
491
|
+
hwnd=hwnd,
|
|
492
|
+
rect=_get_rect(hwnd),
|
|
493
|
+
dpi=window_dpi(hwnd),
|
|
494
|
+
style=int(user32.GetWindowLongW(hwnd, GWL_STYLE)) & 0xFFFFFFFF,
|
|
495
|
+
ex_style=int(user32.GetWindowLongW(hwnd, GWL_EXSTYLE)) & 0xFFFFFFFF,
|
|
496
|
+
was_minimized=bool(user32.IsIconic(hwnd)),
|
|
497
|
+
was_maximized=bool(user32.IsZoomed(hwnd)),
|
|
498
|
+
was_visible=bool(user32.IsWindowVisible(hwnd)),
|
|
499
|
+
)
|
|
500
|
+
self._snapshots[hwnd] = snap
|
|
501
|
+
return snap
|
|
502
|
+
|
|
503
|
+
# -- mount ----------------------------------------------------------------------
|
|
504
|
+
def mount(
|
|
505
|
+
self,
|
|
506
|
+
hwnd: int,
|
|
507
|
+
monitor: MonitorInfo,
|
|
508
|
+
*,
|
|
509
|
+
padding: int = 0,
|
|
510
|
+
verify: bool = True,
|
|
511
|
+
placement_timeout: float = 3.0,
|
|
512
|
+
keep_size: Optional[bool] = None,
|
|
513
|
+
) -> WindowSnapshot:
|
|
514
|
+
"""Move ``hwnd`` onto ``monitor`` and hide its taskbar/Alt+Tab traces.
|
|
515
|
+
|
|
516
|
+
``keep_size=True`` moves the window **without resizing it**, which is what
|
|
517
|
+
production WeChat automation does. It matters for custom-rendered apps: WeChat's
|
|
518
|
+
login window ignores a resize request and snaps to the *physical* panel's size
|
|
519
|
+
(measured: asked for 1920x1080 on the virtual display, ended up 1536x864 = the
|
|
520
|
+
physical panel), after which a captured frame no longer matches the window. The
|
|
521
|
+
geometry actually achieved is always recorded on the snapshot, so a mismatch is
|
|
522
|
+
visible to the caller instead of silently accepted.
|
|
523
|
+
"""
|
|
524
|
+
require_windows("WindowMounter.mount")
|
|
525
|
+
if not user32.IsWindow(hwnd):
|
|
526
|
+
raise WindowMountError(
|
|
527
|
+
f"cannot mount 0x{hwnd:X}: the window no longer exists", code="invalid-hwnd"
|
|
528
|
+
)
|
|
529
|
+
snap = self.snapshot(hwnd)
|
|
530
|
+
keep_size = self.keep_size if keep_size is None else keep_size
|
|
531
|
+
left, top, right, bottom = monitor.monitor
|
|
532
|
+
if keep_size:
|
|
533
|
+
width = snap.rect[2] - snap.rect[0]
|
|
534
|
+
height = snap.rect[3] - snap.rect[1]
|
|
535
|
+
else:
|
|
536
|
+
width = (right - left) - 2 * padding
|
|
537
|
+
height = (bottom - top) - 2 * padding
|
|
538
|
+
if width <= 0 or height <= 0: # pragma: no cover - defensive
|
|
539
|
+
raise WindowMountError(
|
|
540
|
+
f"virtual monitor {monitor.device} has a degenerate size {monitor.monitor}",
|
|
541
|
+
code="degenerate-monitor",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# (1) leave minimized / maximized state behind
|
|
545
|
+
if snap.was_minimized:
|
|
546
|
+
self._show(hwnd, SW_SHOWNORMAL)
|
|
547
|
+
time.sleep(0.1)
|
|
548
|
+
if snap.was_maximized:
|
|
549
|
+
self._show(hwnd, SW_SHOWNORMAL)
|
|
550
|
+
time.sleep(0.1)
|
|
551
|
+
|
|
552
|
+
# (2) hide taskbar / Alt+Tab traces
|
|
553
|
+
if self.hide_taskbar_trace:
|
|
554
|
+
self._apply_toolwindow(snap)
|
|
555
|
+
|
|
556
|
+
# (3) move it
|
|
557
|
+
if not user32.MoveWindow(hwnd, left + padding, top + padding, width, height, True):
|
|
558
|
+
error = ctypes.get_last_error()
|
|
559
|
+
raise WindowMountError(
|
|
560
|
+
f"MoveWindow(0x{hwnd:X}) to {monitor.device} failed with Win32 error {error}",
|
|
561
|
+
code="move-window-failed",
|
|
562
|
+
details={"win32_error": error, "target": list(monitor.monitor)},
|
|
563
|
+
hint="Windows of a *more privileged* process cannot be moved (UIPI).",
|
|
564
|
+
)
|
|
565
|
+
user32.SetWindowPos(
|
|
566
|
+
hwnd,
|
|
567
|
+
HWND_TOP if self.activate_on_mount else 0,
|
|
568
|
+
0,
|
|
569
|
+
0,
|
|
570
|
+
0,
|
|
571
|
+
0,
|
|
572
|
+
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED,
|
|
573
|
+
)
|
|
574
|
+
if self.activate_on_mount:
|
|
575
|
+
user32.BringWindowToTop(hwnd)
|
|
576
|
+
|
|
577
|
+
snap.mounted = True
|
|
578
|
+
snap.mounted_at = time.time()
|
|
579
|
+
snap.target_monitor = tuple(monitor.monitor)
|
|
580
|
+
snap.mounted_rect = _get_rect(hwnd)
|
|
581
|
+
# Remember where the window started *inside* the monitor it lived on. Restoring by
|
|
582
|
+
# absolute screen coordinates is not DPI-safe: when the window is moved back, Windows
|
|
583
|
+
# re-applies the 125 % layout and the origin drifts (measured: (14,64) instead of
|
|
584
|
+
# (18,80)). Storing the monitor-local offset lets the restore path add it back in the
|
|
585
|
+
# coordinate space that is valid at that moment.
|
|
586
|
+
if snap.origin_monitor is None:
|
|
587
|
+
snap.origin_monitor = tuple(monitor.monitor)
|
|
588
|
+
snap.origin_offset = (
|
|
589
|
+
snap.rect[0] - monitor.monitor[0],
|
|
590
|
+
snap.rect[1] - monitor.monitor[1],
|
|
591
|
+
)
|
|
592
|
+
if verify and not self.wait_until_placed(hwnd, timeout=placement_timeout):
|
|
593
|
+
actual = _get_rect(hwnd)
|
|
594
|
+
snap.warnings.append(f"window did not settle on {monitor.device}: rect={actual}")
|
|
595
|
+
log.warning(
|
|
596
|
+
"window 0x%X is at %s, expected inside %s",
|
|
597
|
+
hwnd,
|
|
598
|
+
actual,
|
|
599
|
+
monitor.monitor,
|
|
600
|
+
)
|
|
601
|
+
log.info(
|
|
602
|
+
"mounted 0x%X on %s (%dx%d+%d+%d)",
|
|
603
|
+
hwnd,
|
|
604
|
+
monitor.device,
|
|
605
|
+
width,
|
|
606
|
+
height,
|
|
607
|
+
left,
|
|
608
|
+
top,
|
|
609
|
+
)
|
|
610
|
+
return snap
|
|
611
|
+
|
|
612
|
+
# -- off-screen parking (fallback hide mode) -------------------------------------
|
|
613
|
+
def mount_offscreen(
|
|
614
|
+
self,
|
|
615
|
+
hwnd: int,
|
|
616
|
+
*,
|
|
617
|
+
margin: int = OFFSCREEN_MARGIN,
|
|
618
|
+
verify: bool = True,
|
|
619
|
+
placement_timeout: float = 2.0,
|
|
620
|
+
) -> WindowSnapshot:
|
|
621
|
+
"""Park ``hwnd`` fully outside every monitor instead of moving it to a display.
|
|
622
|
+
|
|
623
|
+
Use when no virtual display is available: the window is kept alive (never minimized,
|
|
624
|
+
so ``PrintWindow`` keeps working) while being invisible to the user — no taskbar entry,
|
|
625
|
+
no Alt+Tab preview, and nothing on screen. Position is verified against the whole
|
|
626
|
+
desktop rectangle, and progressively larger offsets are tried because some
|
|
627
|
+
applications clamp a move back into the work area.
|
|
628
|
+
"""
|
|
629
|
+
require_windows("WindowMounter.mount_offscreen")
|
|
630
|
+
if not user32.IsWindow(hwnd):
|
|
631
|
+
raise WindowMountError(
|
|
632
|
+
f"cannot park 0x{hwnd:X}: the window no longer exists", code="invalid-hwnd"
|
|
633
|
+
)
|
|
634
|
+
snap = self.snapshot(hwnd)
|
|
635
|
+
width = snap.rect[2] - snap.rect[0]
|
|
636
|
+
height = snap.rect[3] - snap.rect[1]
|
|
637
|
+
if width <= 0 or height <= 0: # pragma: no cover - defensive
|
|
638
|
+
raise WindowMountError(
|
|
639
|
+
f"window 0x{hwnd:X} has a degenerate size {width}x{height}",
|
|
640
|
+
code="degenerate-window",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# A minimized window cannot be captured, so leave the minimized state first.
|
|
644
|
+
if snap.was_minimized:
|
|
645
|
+
self._show(hwnd, SW_SHOWNORMAL)
|
|
646
|
+
time.sleep(0.1)
|
|
647
|
+
if snap.was_maximized:
|
|
648
|
+
self._show(hwnd, SW_SHOWNORMAL)
|
|
649
|
+
time.sleep(0.1)
|
|
650
|
+
|
|
651
|
+
if self.hide_taskbar_trace:
|
|
652
|
+
self._apply_toolwindow(snap)
|
|
653
|
+
|
|
654
|
+
desktop = virtual_desktop_rect()
|
|
655
|
+
x, y = offscreen_target(hwnd, margin=margin)
|
|
656
|
+
placed = False
|
|
657
|
+
for attempt in range(4):
|
|
658
|
+
if not user32.SetWindowPos(hwnd, 0, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE):
|
|
659
|
+
log.debug("SetWindowPos failed while parking 0x%X: %s", hwnd, ctypes.get_last_error())
|
|
660
|
+
time.sleep(0.08)
|
|
661
|
+
rect = _get_rect(hwnd)
|
|
662
|
+
if not rect_on_screen(rect, desktop=desktop):
|
|
663
|
+
placed = True
|
|
664
|
+
break
|
|
665
|
+
# The app clamped the move: push it further out and try again.
|
|
666
|
+
x, y = offscreen_target(hwnd, margin=abs(x - desktop[0]) * 5 or margin * 5)
|
|
667
|
+
log.debug("parking attempt %d: observed %s still on screen, retrying at (%d,%d)", attempt + 1, rect, x, y)
|
|
668
|
+
|
|
669
|
+
snap.mounted = True
|
|
670
|
+
snap.mounted_at = time.time()
|
|
671
|
+
snap.mounted_rect = _get_rect(hwnd)
|
|
672
|
+
snap.offscreen = True
|
|
673
|
+
snap.target_monitor = None
|
|
674
|
+
if snap.origin_monitor is None:
|
|
675
|
+
# Remember enough to restore exactly, in a DPI-safe way.
|
|
676
|
+
snap.origin_monitor = desktop
|
|
677
|
+
snap.origin_offset = (snap.rect[0] - desktop[0], snap.rect[1] - desktop[1])
|
|
678
|
+
if not placed:
|
|
679
|
+
snap.warnings.append(
|
|
680
|
+
f"the window still overlaps the desktop after parking: rect={snap.mounted_rect}"
|
|
681
|
+
)
|
|
682
|
+
log.warning(
|
|
683
|
+
"window 0x%X could not be parked off-screen (rect=%s)", hwnd, snap.mounted_rect
|
|
684
|
+
)
|
|
685
|
+
elif verify:
|
|
686
|
+
deadline = time.monotonic() + placement_timeout
|
|
687
|
+
while time.monotonic() < deadline:
|
|
688
|
+
if not rect_on_screen(_get_rect(hwnd), desktop=desktop):
|
|
689
|
+
break
|
|
690
|
+
time.sleep(0.1)
|
|
691
|
+
log.info("parked 0x%X off-screen at (%d,%d) size %dx%d", hwnd, x, y, width, height)
|
|
692
|
+
return snap
|
|
693
|
+
|
|
694
|
+
def _show(self, hwnd: int, command: int) -> None:
|
|
695
|
+
user32.ShowWindow(hwnd, command)
|
|
696
|
+
user32.SetWindowPos(
|
|
697
|
+
hwnd, 0, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def _apply_toolwindow(self, snap: WindowSnapshot) -> None:
|
|
701
|
+
hwnd = snap.hwnd
|
|
702
|
+
desired = (snap.ex_style & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW
|
|
703
|
+
if desired == snap.ex_style:
|
|
704
|
+
snap.toolwindow_applied = True
|
|
705
|
+
return
|
|
706
|
+
if self._set_ex_style(hwnd, desired):
|
|
707
|
+
snap.toolwindow_applied = True
|
|
708
|
+
user32.SetWindowPos(
|
|
709
|
+
hwnd, 0, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED
|
|
710
|
+
)
|
|
711
|
+
if self.taskbar_refresh:
|
|
712
|
+
# The shell caches taskbar entries; a hidden->visible cycle forces a refresh.
|
|
713
|
+
user32.ShowWindow(hwnd, SW_HIDE)
|
|
714
|
+
user32.ShowWindow(hwnd, SW_SHOWNA)
|
|
715
|
+
snap.hide_taskbar_trace = True
|
|
716
|
+
else:
|
|
717
|
+
error = ctypes.get_last_error()
|
|
718
|
+
snap.warnings.append(
|
|
719
|
+
f"SetWindowLong(GWL_EXSTYLE) failed with Win32 error {error}; the window "
|
|
720
|
+
"may keep its taskbar button"
|
|
721
|
+
)
|
|
722
|
+
log.warning(
|
|
723
|
+
"could not convert 0x%X to a tool window (Win32 error %s); continuing",
|
|
724
|
+
hwnd,
|
|
725
|
+
error,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
@staticmethod
|
|
729
|
+
def _set_ex_style(hwnd: int, value: int) -> bool:
|
|
730
|
+
ctypes.set_last_error(0)
|
|
731
|
+
previous = user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ctypes.c_long(value))
|
|
732
|
+
error = ctypes.get_last_error()
|
|
733
|
+
return bool(previous) or error == 0
|
|
734
|
+
|
|
735
|
+
def wait_until_placed(self, hwnd: int, *, timeout: float = 3.0, tolerance: int = 2) -> bool:
|
|
736
|
+
"""Wait until the window rect sits inside the requested monitor rect.
|
|
737
|
+
|
|
738
|
+
Size is not compared: with ``keep_size=True`` the window deliberately keeps its own
|
|
739
|
+
size, so the only requirement is that its **origin** lands on the target monitor and
|
|
740
|
+
that at least part of it stays inside. Comparing the width here produced a misleading
|
|
741
|
+
"window did not settle" warning for a perfectly placed window.
|
|
742
|
+
"""
|
|
743
|
+
snap = self._snapshots.get(hwnd)
|
|
744
|
+
target = snap.target_monitor if snap else None
|
|
745
|
+
if not target:
|
|
746
|
+
return True
|
|
747
|
+
deadline = time.monotonic() + timeout
|
|
748
|
+
while True:
|
|
749
|
+
rect = _get_rect(hwnd)
|
|
750
|
+
inside_x = target[0] - tolerance <= rect[0] <= target[2] + tolerance
|
|
751
|
+
inside_y = target[1] - tolerance <= rect[1] <= target[3] + tolerance
|
|
752
|
+
if inside_x and inside_y:
|
|
753
|
+
return True
|
|
754
|
+
if time.monotonic() >= deadline:
|
|
755
|
+
return False
|
|
756
|
+
time.sleep(0.1)
|
|
757
|
+
|
|
758
|
+
# -- unmount --------------------------------------------------------------------
|
|
759
|
+
def unmount(self, hwnd: Optional[int] = None) -> List[WindowSnapshot]:
|
|
760
|
+
"""Restore one window (or all mounted windows). Idempotent and never raises."""
|
|
761
|
+
results: List[WindowSnapshot] = []
|
|
762
|
+
targets = [hwnd] if hwnd else list(self._snapshots)
|
|
763
|
+
for target in targets:
|
|
764
|
+
snap = self._snapshots.get(target)
|
|
765
|
+
if snap is None:
|
|
766
|
+
continue
|
|
767
|
+
results.append(self._restore(snap))
|
|
768
|
+
if hwnd is None:
|
|
769
|
+
for snap in results:
|
|
770
|
+
self._snapshots.pop(snap.hwnd, None)
|
|
771
|
+
return results
|
|
772
|
+
|
|
773
|
+
def _restore(self, snap: WindowSnapshot) -> WindowSnapshot:
|
|
774
|
+
hwnd = snap.hwnd
|
|
775
|
+
if not snap.mounted:
|
|
776
|
+
return snap
|
|
777
|
+
if not user32.IsWindow(hwnd):
|
|
778
|
+
log.warning("window 0x%X disappeared before it could be restored", hwnd)
|
|
779
|
+
snap.mounted = False
|
|
780
|
+
snap.warnings.append("window no longer exists")
|
|
781
|
+
return snap
|
|
782
|
+
|
|
783
|
+
# Restore the extended style first so the taskbar entry comes back before the move.
|
|
784
|
+
if snap.toolwindow_applied:
|
|
785
|
+
if not self._set_ex_style(hwnd, snap.ex_style):
|
|
786
|
+
snap.warnings.append(
|
|
787
|
+
f"could not restore the original extended style (Win32 error {ctypes.get_last_error()})"
|
|
788
|
+
)
|
|
789
|
+
user32.SetWindowPos(
|
|
790
|
+
hwnd, 0, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
# Restoring the exact original geometry is DPI-sensitive, and the conversions involved
|
|
794
|
+
# are not symmetric: the same window reports DPI 120 on one monitor and 96 on the other,
|
|
795
|
+
# and *both* the rectangle and the coordinates passed to SetWindowPos are interpreted in
|
|
796
|
+
# whichever space is current (measured: a naive restore of 583 px produced 747 px, then
|
|
797
|
+
# 934 px / 598 px with one-directional corrections). Rather than guess the semantics,
|
|
798
|
+
# the restore is closed-loop: move, measure, and correct the size by the observed error
|
|
799
|
+
# ratio until the rectangle matches the original (or three attempts have been made).
|
|
800
|
+
current_dpi = window_dpi(hwnd)
|
|
801
|
+
original_dpi = snap.dpi or current_dpi
|
|
802
|
+
original_width = max(snap.rect[2] - snap.rect[0], 1)
|
|
803
|
+
original_height = max(snap.rect[3] - snap.rect[1], 1)
|
|
804
|
+
if snap.origin_monitor and snap.origin_offset:
|
|
805
|
+
left = snap.origin_monitor[0] + snap.origin_offset[0]
|
|
806
|
+
top = snap.origin_monitor[1] + snap.origin_offset[1]
|
|
807
|
+
else: # pragma: no cover - mount always records the origin monitor
|
|
808
|
+
left, top = snap.rect[0], snap.rect[1]
|
|
809
|
+
width = int(round(original_width * original_dpi / current_dpi))
|
|
810
|
+
height = int(round(original_height * original_dpi / current_dpi))
|
|
811
|
+
flags = SWP_NOZORDER | SWP_NOACTIVATE
|
|
812
|
+
|
|
813
|
+
for attempt in range(3):
|
|
814
|
+
ok = user32.SetWindowPos(hwnd, 0, left, top, width, height, flags)
|
|
815
|
+
if not ok:
|
|
816
|
+
ok = bool(user32.MoveWindow(hwnd, left, top, width, height, True))
|
|
817
|
+
if not ok:
|
|
818
|
+
snap.warnings.append(
|
|
819
|
+
f"could not move the window back to {snap.rect} "
|
|
820
|
+
f"(Win32 error {ctypes.get_last_error()})"
|
|
821
|
+
)
|
|
822
|
+
log.warning("failed to restore 0x%X to %s", hwnd, snap.rect)
|
|
823
|
+
break
|
|
824
|
+
time.sleep(0.08)
|
|
825
|
+
observed = _get_rect(hwnd)
|
|
826
|
+
observed_width = max(observed[2] - observed[0], 1)
|
|
827
|
+
observed_height = max(observed[3] - observed[1], 1)
|
|
828
|
+
width_ok = abs(observed_width - original_width) <= max(2, original_width * 0.01)
|
|
829
|
+
height_ok = abs(observed_height - original_height) <= max(2, original_height * 0.01)
|
|
830
|
+
position_ok = abs(observed[0] - left) <= 2 and abs(observed[1] - top) <= 2
|
|
831
|
+
if width_ok and height_ok and position_ok:
|
|
832
|
+
break
|
|
833
|
+
# Correct both the size and the position from the measured error.
|
|
834
|
+
width = max(int(round(width * original_width / observed_width)), 1)
|
|
835
|
+
height = max(int(round(height * original_height / observed_height)), 1)
|
|
836
|
+
left += snap.rect[0] - observed[0] if not snap.origin_monitor else left - observed[0]
|
|
837
|
+
top += snap.rect[1] - observed[1] if not snap.origin_monitor else top - observed[1]
|
|
838
|
+
log.debug(
|
|
839
|
+
"restore attempt %d: observed %s, retrying with %dx%d at (%d,%d)",
|
|
840
|
+
attempt + 1, observed, width, height, left, top,
|
|
841
|
+
)
|
|
842
|
+
else:
|
|
843
|
+
snap.warnings.append(
|
|
844
|
+
f"window geometry after restore ({observed}) differs from the original "
|
|
845
|
+
f"{snap.rect}; the app enforces its own size"
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
if snap.was_maximized:
|
|
849
|
+
self._show(hwnd, 3) # SW_MAXIMIZE
|
|
850
|
+
elif snap.was_minimized:
|
|
851
|
+
self._show(hwnd, 6) # SW_MINIMIZE
|
|
852
|
+
elif snap.was_visible:
|
|
853
|
+
self._show(hwnd, SW_SHOWNA)
|
|
854
|
+
else:
|
|
855
|
+
self._show(hwnd, SW_HIDE)
|
|
856
|
+
|
|
857
|
+
snap.mounted = False
|
|
858
|
+
log.info("restored 0x%X to %s", hwnd, snap.rect)
|
|
859
|
+
return snap
|
|
860
|
+
|
|
861
|
+
def restore_styles_only(self, hwnd: int) -> bool:
|
|
862
|
+
"""Undo the taskbar hiding without moving the window (used by ``unmount_window``)."""
|
|
863
|
+
snap = self._snapshots.get(hwnd)
|
|
864
|
+
if snap is None or not snap.toolwindow_applied:
|
|
865
|
+
return False
|
|
866
|
+
ok = self._set_ex_style(hwnd, snap.ex_style)
|
|
867
|
+
user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED)
|
|
868
|
+
snap.toolwindow_applied = not ok
|
|
869
|
+
return ok
|