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,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