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/cli.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""``VirtualDesktop`` command line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes are stable so that scripts and CI jobs can branch on them (see
|
|
4
|
+
:data:`VirtualDesktop.errors.EXIT_CODES`).
|
|
5
|
+
|
|
6
|
+
::
|
|
7
|
+
|
|
8
|
+
VirtualDesktop check # read-only environment report
|
|
9
|
+
VirtualDesktop install-driver --dry-run # print exactly what would be executed
|
|
10
|
+
VirtualDesktop install-driver # install (raises one UAC prompt)
|
|
11
|
+
VirtualDesktop uninstall-driver
|
|
12
|
+
VirtualDesktop list-windows # find the exact title to target
|
|
13
|
+
VirtualDesktop mount "Notepad" --out shot.png
|
|
14
|
+
VirtualDesktop capture "Notepad" --out shot.png --unmount
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from . import __version__
|
|
27
|
+
from ._log import enable_debug_logging
|
|
28
|
+
from ._process import helper_command, run_command
|
|
29
|
+
from ._winapi import IS_WINDOWS, enumerate_monitors, is_admin, list_windows
|
|
30
|
+
from .config import DriverConfig, SessionConfig
|
|
31
|
+
from .driver import DriverInstaller, install_from_helper
|
|
32
|
+
from .errors import EXIT_CODES, OffscreenMountError, WindowNotFoundError
|
|
33
|
+
from .session import OffscreenSession, probe_environment
|
|
34
|
+
from .window import find_window
|
|
35
|
+
|
|
36
|
+
__all__ = ["main", "build_parser"]
|
|
37
|
+
|
|
38
|
+
_INTERNAL = "_elevated-install"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print(payload: Any, *, as_json: bool) -> None:
|
|
42
|
+
if as_json:
|
|
43
|
+
print(json.dumps(payload, indent=2, sort_keys=True, default=str))
|
|
44
|
+
return
|
|
45
|
+
if isinstance(payload, str):
|
|
46
|
+
print(payload)
|
|
47
|
+
else:
|
|
48
|
+
print(json.dumps(payload, indent=2, sort_keys=True, default=str))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _exit_code_for(exc: Exception) -> int:
|
|
52
|
+
code = getattr(exc, "code", "")
|
|
53
|
+
if code == "driver-not-found":
|
|
54
|
+
return EXIT_CODES["driver-missing"]
|
|
55
|
+
if code in ("elevation-required", "elevation-denied"):
|
|
56
|
+
return EXIT_CODES["elevation-required"]
|
|
57
|
+
if code in ("window-not-found", "window-ambiguous"):
|
|
58
|
+
return EXIT_CODES["window-not-found"]
|
|
59
|
+
if code and str(code).startswith("capture"):
|
|
60
|
+
return EXIT_CODES["capture-failed"]
|
|
61
|
+
if code == "unsupported-platform":
|
|
62
|
+
return EXIT_CODES["unsupported-platform"]
|
|
63
|
+
return EXIT_CODES["error"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _configure_console() -> None:
|
|
67
|
+
"""Make console output survivable.
|
|
68
|
+
|
|
69
|
+
Two Windows-specific hazards this neutralises:
|
|
70
|
+
|
|
71
|
+
* a legacy code page (cp936, cp1252, ...) that cannot encode CJK/emoji window titles
|
|
72
|
+
would raise ``UnicodeEncodeError`` mid-report;
|
|
73
|
+
* the console disappearing (piped through ``head``, or a closed window) turns the next
|
|
74
|
+
``print`` into ``BrokenPipeError``.
|
|
75
|
+
"""
|
|
76
|
+
for stream in (sys.stdout, sys.stderr):
|
|
77
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
78
|
+
if reconfigure is None:
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
82
|
+
except (ValueError, OSError): # pragma: no cover - very old/odd streams
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
87
|
+
parser = argparse.ArgumentParser(
|
|
88
|
+
prog="VirtualDesktop",
|
|
89
|
+
description="Mount a window on a virtual display, capture it, and put it back.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument("--version", action="version", version=f"VirtualDesktop {__version__}")
|
|
92
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
93
|
+
parser.add_argument("--debug", action="store_true", help="verbose library logging")
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--log-file", help="also append logs to this file (rotating, 1 MB x 2)"
|
|
96
|
+
)
|
|
97
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
98
|
+
|
|
99
|
+
# -- check ----------------------------------------------------------------------
|
|
100
|
+
check = sub.add_parser("check", help="read-only environment and driver report")
|
|
101
|
+
check.add_argument("--window", type=int, help="also report on this HWND")
|
|
102
|
+
check.add_argument("--capture-probe", action="store_true", help="try every capture method")
|
|
103
|
+
|
|
104
|
+
# -- driver ---------------------------------------------------------------------
|
|
105
|
+
for name, help_text in (
|
|
106
|
+
("install-driver", "download, verify and install the virtual display driver"),
|
|
107
|
+
("uninstall-driver", "remove the virtual display device (and optionally the files)"),
|
|
108
|
+
):
|
|
109
|
+
sub_parser = sub.add_parser(name, help=help_text)
|
|
110
|
+
sub_parser.add_argument("--driver", help="manifest driver key (default: manifest default)")
|
|
111
|
+
sub_parser.add_argument(
|
|
112
|
+
"--strategy",
|
|
113
|
+
default="auto",
|
|
114
|
+
choices=["auto", "nefcon", "nefcon-manual", "pnputil", "scoop"],
|
|
115
|
+
)
|
|
116
|
+
sub_parser.add_argument(
|
|
117
|
+
"--signature-policy", default="warn", choices=["warn", "strict", "off"]
|
|
118
|
+
)
|
|
119
|
+
sub_parser.add_argument("--cache-dir", help="override the artifact cache directory")
|
|
120
|
+
sub_parser.add_argument("--timeout", type=float, default=600.0)
|
|
121
|
+
if name == "install-driver":
|
|
122
|
+
sub_parser.add_argument(
|
|
123
|
+
"--dry-run",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="verify downloads and print the privileged command without installing",
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
sub_parser.add_argument("--purge-files", action="store_true", help="delete cached driver files")
|
|
129
|
+
sub_parser.add_argument(
|
|
130
|
+
"--purge-driver-store", action="store_true", help="also purge the driver store package"
|
|
131
|
+
)
|
|
132
|
+
fetch = sub.add_parser("fetch-driver", help="download and verify driver artifacts only")
|
|
133
|
+
fetch.add_argument("--driver")
|
|
134
|
+
fetch.add_argument("--cache-dir")
|
|
135
|
+
|
|
136
|
+
# -- windows --------------------------------------------------------------------
|
|
137
|
+
listing = sub.add_parser("list-windows", help="list top-level windows for targeting")
|
|
138
|
+
listing.add_argument("--all", action="store_true", help="include hidden and tool windows")
|
|
139
|
+
listing.add_argument("--filter", help="only titles containing this substring")
|
|
140
|
+
|
|
141
|
+
# -- mount / capture ------------------------------------------------------------
|
|
142
|
+
for name in ("mount", "capture"):
|
|
143
|
+
sub_parser = sub.add_parser(name, help=f"{name} a window on the virtual display")
|
|
144
|
+
sub_parser.add_argument("title", nargs="?", help="window title (exact, substring or regex)")
|
|
145
|
+
sub_parser.add_argument("--class", dest="window_class", help="window class name")
|
|
146
|
+
sub_parser.add_argument("--process", dest="process_name", help="executable name filter")
|
|
147
|
+
sub_parser.add_argument("--pid", type=int, help="owning process id")
|
|
148
|
+
sub_parser.add_argument(
|
|
149
|
+
"--title-mode", default="auto", choices=["auto", "exact", "substring", "regex"]
|
|
150
|
+
)
|
|
151
|
+
sub_parser.add_argument("--monitor", default=None, help="auto | virtual | primary | DISPLAY2 | 1")
|
|
152
|
+
sub_parser.add_argument(
|
|
153
|
+
"--hide-mode",
|
|
154
|
+
default=None,
|
|
155
|
+
choices=["virtual_screen", "offscreen", "auto"],
|
|
156
|
+
help="virtual_screen = move onto the virtual display (default), "
|
|
157
|
+
"offscreen = park outside every monitor, auto = try the first then fall back",
|
|
158
|
+
)
|
|
159
|
+
sub_parser.add_argument(
|
|
160
|
+
"--virtual-size",
|
|
161
|
+
default=None,
|
|
162
|
+
help="resize the virtual desktop before mounting, e.g. 1920x1080 or 'main' (default: main)",
|
|
163
|
+
)
|
|
164
|
+
sub_parser.add_argument(
|
|
165
|
+
"--virtual-dpi",
|
|
166
|
+
default=None,
|
|
167
|
+
help="virtual desktop DPI, e.g. 120, '125%' or 'main' (default: main)",
|
|
168
|
+
)
|
|
169
|
+
sub_parser.add_argument(
|
|
170
|
+
"--keep-window-size",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="move the window without resizing it (safer for custom-rendered apps)",
|
|
173
|
+
)
|
|
174
|
+
sub_parser.add_argument("--timeout", type=float, default=10.0, help="window wait timeout")
|
|
175
|
+
sub_parser.add_argument("--padding", type=int, default=0)
|
|
176
|
+
sub_parser.add_argument("--keep-visible", action="store_true", help="show the window after mounting")
|
|
177
|
+
sub_parser.add_argument(
|
|
178
|
+
"--no-driver-install", action="store_true", help="never install a driver; fail instead"
|
|
179
|
+
)
|
|
180
|
+
sub_parser.add_argument("--out", help="write the captured PNG here")
|
|
181
|
+
sub_parser.add_argument("--client-only", action="store_true", default=None)
|
|
182
|
+
sub_parser.add_argument(
|
|
183
|
+
"--methods", help="comma-separated capture methods (default: all, in order)"
|
|
184
|
+
)
|
|
185
|
+
sub_parser.add_argument("--unmount", action="store_true", help="restore the window when done")
|
|
186
|
+
sub_parser.add_argument(
|
|
187
|
+
"--ui-tree", metavar="DIR", help="also export the window's UI tree into DIR"
|
|
188
|
+
)
|
|
189
|
+
sub_parser.add_argument(
|
|
190
|
+
"--desktop-shot", metavar="PNG", help="also save a shot of the whole virtual desktop"
|
|
191
|
+
)
|
|
192
|
+
if name == "capture":
|
|
193
|
+
sub_parser.add_argument(
|
|
194
|
+
"--no-mount", action="store_true", help="capture in place, without moving the window"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# -- internal (elevated child) --------------------------------------------------
|
|
198
|
+
internal = sub.add_parser(_INTERNAL, help=argparse.SUPPRESS)
|
|
199
|
+
internal.add_argument("--response-pipe", required=True)
|
|
200
|
+
internal.add_argument("--cache-dir")
|
|
201
|
+
internal.add_argument("--driver")
|
|
202
|
+
internal.add_argument("--strategy", default="auto")
|
|
203
|
+
internal.add_argument("--signature-policy", default="warn")
|
|
204
|
+
internal.add_argument("--install-timeout", type=float, default=600.0)
|
|
205
|
+
internal.add_argument("--log-dir")
|
|
206
|
+
internal.add_argument(
|
|
207
|
+
"--uninstall", action="store_true", help="privileged child: remove the device instead of adding it"
|
|
208
|
+
)
|
|
209
|
+
internal.add_argument("--purge-files", action="store_true")
|
|
210
|
+
internal.add_argument("--purge-driver-store", action="store_true")
|
|
211
|
+
internal.add_argument(
|
|
212
|
+
"--no-elevate",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="privileged child: already elevated, never request elevation again",
|
|
215
|
+
)
|
|
216
|
+
return parser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _driver_config_from(args: argparse.Namespace) -> DriverConfig:
|
|
220
|
+
config = DriverConfig()
|
|
221
|
+
updates: Dict[str, Any] = {}
|
|
222
|
+
for attribute, arg in (
|
|
223
|
+
("driver", "driver"),
|
|
224
|
+
("strategy", "strategy"),
|
|
225
|
+
("signature_policy", "signature_policy"),
|
|
226
|
+
("cache_dir", "cache_dir"),
|
|
227
|
+
):
|
|
228
|
+
value = getattr(args, arg, None)
|
|
229
|
+
if value:
|
|
230
|
+
updates[attribute] = value
|
|
231
|
+
if getattr(args, "timeout", None):
|
|
232
|
+
updates["install_timeout"] = args.timeout
|
|
233
|
+
return type(config)(**{**config.__dict__, **updates}) if updates else config
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _session_from(args: argparse.Namespace) -> OffscreenSession:
|
|
237
|
+
session_config = SessionConfig()
|
|
238
|
+
updates: Dict[str, Any] = {}
|
|
239
|
+
if getattr(args, "monitor", None):
|
|
240
|
+
updates["monitor"] = args.monitor
|
|
241
|
+
if getattr(args, "hide_mode", None):
|
|
242
|
+
updates["hide_mode"] = args.hide_mode
|
|
243
|
+
if getattr(args, "keep_window_size", False):
|
|
244
|
+
updates["keep_window_size"] = True
|
|
245
|
+
if getattr(args, "timeout", None):
|
|
246
|
+
updates["window_timeout"] = args.timeout
|
|
247
|
+
if getattr(args, "title_mode", None):
|
|
248
|
+
updates["title_mode"] = args.title_mode
|
|
249
|
+
if getattr(args, "padding", None) is not None:
|
|
250
|
+
updates["padding"] = args.padding
|
|
251
|
+
if getattr(args, "client_only", None) is not None:
|
|
252
|
+
updates["capture_client_only"] = bool(args.client_only)
|
|
253
|
+
if getattr(args, "methods", None):
|
|
254
|
+
updates["capture_methods"] = tuple(m.strip() for m in args.methods.split(",") if m.strip())
|
|
255
|
+
if updates:
|
|
256
|
+
session_config = type(session_config)(**{**session_config.__dict__, **updates})
|
|
257
|
+
return OffscreenSession(
|
|
258
|
+
target_window_title=getattr(args, "title", None),
|
|
259
|
+
window_class=getattr(args, "window_class", None),
|
|
260
|
+
process_name=getattr(args, "process_name", None),
|
|
261
|
+
window_pid=getattr(args, "pid", None),
|
|
262
|
+
driver=_driver_config_from(args),
|
|
263
|
+
config=session_config,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
268
|
+
report = probe_environment(
|
|
269
|
+
driver_config=_driver_config_from(args),
|
|
270
|
+
window=getattr(args, "window", None),
|
|
271
|
+
capture_probe=getattr(args, "capture_probe", False),
|
|
272
|
+
)
|
|
273
|
+
if args.json:
|
|
274
|
+
_print(report, as_json=True)
|
|
275
|
+
return EXIT_CODES["ok"]
|
|
276
|
+
print(f"platform : {'windows' if report['platform']['windows'] else 'NOT windows'}")
|
|
277
|
+
compat = report.get("compatibility") or {}
|
|
278
|
+
iddcx = report.get("iddcx") or {}
|
|
279
|
+
if compat:
|
|
280
|
+
print(f"windows : {compat.get('described')} (dpi awareness: {compat.get('dpi_awareness')})")
|
|
281
|
+
if iddcx:
|
|
282
|
+
print(
|
|
283
|
+
f"iddcx : {'supported' if iddcx.get('supported') else 'UNSUPPORTED'}"
|
|
284
|
+
f" (min build {iddcx.get('min_build')}, dll {iddcx.get('dll_version') or 'absent'})"
|
|
285
|
+
)
|
|
286
|
+
print(f"session type : {report['platform']['session']}")
|
|
287
|
+
print(f"elevated : {report['elevated']}")
|
|
288
|
+
print(f"desktop : {report['desktop'] or '<unknown>'}")
|
|
289
|
+
print(f"monitors : {len(report['monitors'])}")
|
|
290
|
+
for monitor in report["monitors"]:
|
|
291
|
+
marker = " <- primary" if monitor["primary"] else ""
|
|
292
|
+
print(
|
|
293
|
+
f" {monitor['device']:<18} {monitor['width']}x{monitor['height']}"
|
|
294
|
+
f"+{monitor['monitor'][0]}+{monitor['monitor'][1]} {monitor['device_string']}{marker}"
|
|
295
|
+
)
|
|
296
|
+
driver = report.get("driver") or {}
|
|
297
|
+
print(f"driver device : {'present' if driver.get('device_present') else 'MISSING'} ({driver.get('driver')})")
|
|
298
|
+
if driver.get("error"):
|
|
299
|
+
print(f"driver error : {driver['error']}")
|
|
300
|
+
if driver.get("cache"):
|
|
301
|
+
cache = driver["cache"]
|
|
302
|
+
print(f"cache dir : {cache.get('cache_dir')}")
|
|
303
|
+
print(
|
|
304
|
+
f"artifact : {cache.get('artifact')} "
|
|
305
|
+
f"({'cached' if cache.get('artifact_cached') else cache.get('artifact_reason')})"
|
|
306
|
+
)
|
|
307
|
+
if report.get("window"):
|
|
308
|
+
print(f"window : {report['window']}")
|
|
309
|
+
if report.get("capture"):
|
|
310
|
+
for entry in report["capture"].get("methods", []):
|
|
311
|
+
print(f"capture {entry['method']:<18}: {'ok' if entry.get('ok') else entry.get('error')}")
|
|
312
|
+
print(f"verdict : {report['verdict']}")
|
|
313
|
+
return EXIT_CODES["ok"]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _cmd_fetch_driver(args: argparse.Namespace) -> int:
|
|
317
|
+
config = _driver_config_from(args)
|
|
318
|
+
installer = config.installer()
|
|
319
|
+
bundle = installer.bundles.prepare_driver(
|
|
320
|
+
installer.spec, settings_file=config.settings_file or None
|
|
321
|
+
)
|
|
322
|
+
tool = installer.bundles.tool(installer.manifest, "nefcon")
|
|
323
|
+
payload = {
|
|
324
|
+
"driver": installer.spec.name,
|
|
325
|
+
"version": installer.spec.version,
|
|
326
|
+
"inf": str(bundle.inf_path),
|
|
327
|
+
"nefcon": str(tool),
|
|
328
|
+
"cache_dir": str(installer.cache_dir),
|
|
329
|
+
"verified": True,
|
|
330
|
+
}
|
|
331
|
+
if args.json:
|
|
332
|
+
_print(payload, as_json=True)
|
|
333
|
+
else:
|
|
334
|
+
for key, value in payload.items():
|
|
335
|
+
print(f"{key:<10}: {value}")
|
|
336
|
+
return EXIT_CODES["ok"]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _cmd_install_driver(args: argparse.Namespace) -> int:
|
|
340
|
+
config = _driver_config_from(args)
|
|
341
|
+
installer = config.installer()
|
|
342
|
+
if getattr(args, "dry_run", False):
|
|
343
|
+
bundle = installer.bundles.prepare_driver(
|
|
344
|
+
installer.spec, settings_file=config.settings_file or None
|
|
345
|
+
)
|
|
346
|
+
nefcon = installer.bundles.tool(installer.manifest, "nefcon")
|
|
347
|
+
commands = [
|
|
348
|
+
[str(nefcon), "install", str(bundle.inf_path), hw, "--no-duplicates"]
|
|
349
|
+
for hw in installer.spec.all_hardware_ids
|
|
350
|
+
]
|
|
351
|
+
payload = {
|
|
352
|
+
"dry_run": True,
|
|
353
|
+
"device_present": installer.is_present(),
|
|
354
|
+
"elevated": is_admin(),
|
|
355
|
+
"inf": str(bundle.inf_path),
|
|
356
|
+
"commands": commands,
|
|
357
|
+
"helper": helper_command(
|
|
358
|
+
"--cache-dir",
|
|
359
|
+
str(installer.cache_dir),
|
|
360
|
+
"--strategy",
|
|
361
|
+
config.strategy,
|
|
362
|
+
"--signature-policy",
|
|
363
|
+
config.signature_policy,
|
|
364
|
+
),
|
|
365
|
+
"signature_policy": config.signature_policy,
|
|
366
|
+
}
|
|
367
|
+
if args.json:
|
|
368
|
+
_print(payload, as_json=True)
|
|
369
|
+
else:
|
|
370
|
+
print("dry run: nothing was installed")
|
|
371
|
+
print(f" device present : {payload['device_present']}")
|
|
372
|
+
print(f" process elevated: {payload['elevated']}")
|
|
373
|
+
for command in commands:
|
|
374
|
+
print(" would run : " + " ".join(f'"{c}"' if " " in c else c for c in command))
|
|
375
|
+
print(" helper (if UAC needed):")
|
|
376
|
+
print(" " + " ".join(f'"{c}"' if " " in c else c for c in payload["helper"]))
|
|
377
|
+
return EXIT_CODES["ok"]
|
|
378
|
+
|
|
379
|
+
policy = config.policy
|
|
380
|
+
try:
|
|
381
|
+
if policy == "always" or not installer.is_present():
|
|
382
|
+
report = installer.ensure(allow_install=True)
|
|
383
|
+
else:
|
|
384
|
+
report = installer.detect()
|
|
385
|
+
report.skipped_reason = "already-installed"
|
|
386
|
+
except OffscreenMountError as exc:
|
|
387
|
+
payload = {"ok": False, "error": exc.to_dict()}
|
|
388
|
+
_print(payload, as_json=args.json)
|
|
389
|
+
if not args.json:
|
|
390
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
391
|
+
return _exit_code_for(exc)
|
|
392
|
+
_print({"ok": True, **report.to_dict()}, as_json=args.json)
|
|
393
|
+
if not args.json:
|
|
394
|
+
print(
|
|
395
|
+
f"driver {report.driver} {report.version}: "
|
|
396
|
+
f"{'present' if report.device_present else 'installed, monitor pending'}"
|
|
397
|
+
f"{' (reboot required)' if report.requires_reboot else ''}"
|
|
398
|
+
)
|
|
399
|
+
return EXIT_CODES["ok"]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _cmd_uninstall_driver(args: argparse.Namespace) -> int:
|
|
403
|
+
config = _driver_config_from(args)
|
|
404
|
+
installer = config.installer()
|
|
405
|
+
report = installer.uninstall(
|
|
406
|
+
purge_files=getattr(args, "purge_files", False),
|
|
407
|
+
purge_driver_store=getattr(args, "purge_driver_store", False),
|
|
408
|
+
)
|
|
409
|
+
_print({"ok": report.ok, **report.to_dict()}, as_json=args.json)
|
|
410
|
+
return EXIT_CODES["ok"]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _cmd_list_windows(args: argparse.Namespace) -> int:
|
|
414
|
+
records = list_windows()
|
|
415
|
+
needle = (args.filter or "").lower()
|
|
416
|
+
rows: List[dict] = []
|
|
417
|
+
for record in records:
|
|
418
|
+
if not args.all and (not record.visible or not record.title or record.tool_window):
|
|
419
|
+
continue
|
|
420
|
+
if needle and needle not in record.title.lower():
|
|
421
|
+
continue
|
|
422
|
+
rows.append(
|
|
423
|
+
{
|
|
424
|
+
"hwnd": hex(record.hwnd),
|
|
425
|
+
"title": record.title,
|
|
426
|
+
"class": record.class_name,
|
|
427
|
+
"process": record.process_name,
|
|
428
|
+
"pid": record.pid,
|
|
429
|
+
"rect": list(record.rect),
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
if args.json:
|
|
433
|
+
_print(rows, as_json=True)
|
|
434
|
+
return EXIT_CODES["ok"]
|
|
435
|
+
if not rows:
|
|
436
|
+
print("no matching windows")
|
|
437
|
+
return EXIT_CODES["ok"]
|
|
438
|
+
print(f"{'hwnd':<12} {'pid':<7} {'process':<24} title")
|
|
439
|
+
for row in rows:
|
|
440
|
+
print(f"{row['hwnd']:<12} {row['pid']:<7} {row['process'][:23]:<24} {row['title'][:70]}")
|
|
441
|
+
return EXIT_CODES["ok"]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _export_ui_tree(hwnd: int, out_dir: str) -> Dict[str, Any]:
|
|
445
|
+
"""Export a window's UI tree (UI Automation + native children) into ``out_dir``.
|
|
446
|
+
|
|
447
|
+
The exporter ships in ``tools/ui_tree_export.py`` because it needs the PowerShell UIA helper
|
|
448
|
+
(``tools/uia_dump.ps1``); when that helper is not present the call degrades to a clear error
|
|
449
|
+
instead of failing the capture that already succeeded.
|
|
450
|
+
"""
|
|
451
|
+
if not hwnd:
|
|
452
|
+
return {"error": "no window handle available"}
|
|
453
|
+
import importlib.util
|
|
454
|
+
|
|
455
|
+
candidates = [
|
|
456
|
+
Path(__file__).resolve().parent.parent / "tools" / "ui_tree_export.py",
|
|
457
|
+
Path.cwd() / "tools" / "ui_tree_export.py",
|
|
458
|
+
]
|
|
459
|
+
for candidate in candidates:
|
|
460
|
+
if not candidate.is_file():
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
spec = importlib.util.spec_from_file_location("_offscreen_ui_tree_export", candidate)
|
|
464
|
+
if spec is None or spec.loader is None:
|
|
465
|
+
continue
|
|
466
|
+
module = importlib.util.module_from_spec(spec)
|
|
467
|
+
sys.modules[spec.name] = module
|
|
468
|
+
spec.loader.exec_module(module)
|
|
469
|
+
tools_dir = str(candidate.parent)
|
|
470
|
+
if tools_dir not in sys.path:
|
|
471
|
+
sys.path.insert(0, tools_dir)
|
|
472
|
+
return module.export_hwnd(hwnd, out_dir, label="window")
|
|
473
|
+
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
|
474
|
+
return {"error": f"{type(exc).__name__}: {exc}"[:300]}
|
|
475
|
+
return {
|
|
476
|
+
"error": "ui_tree_export.py was not found next to the library or in ./tools; "
|
|
477
|
+
"run `python tools/ui_tree_export.py --hwnd 0x… --out-dir DIR` instead"
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _cmd_mount_or_capture(args: argparse.Namespace, *, capture: bool) -> int:
|
|
482
|
+
session = _session_from(args)
|
|
483
|
+
install_driver = not getattr(args, "no_driver_install", False)
|
|
484
|
+
do_mount = True
|
|
485
|
+
if capture and getattr(args, "no_mount", False):
|
|
486
|
+
do_mount = False
|
|
487
|
+
|
|
488
|
+
# Optional virtual-desktop preparation (size / DPI), done before the window is mounted so the
|
|
489
|
+
# target monitor already has its final geometry.
|
|
490
|
+
display_setup = None
|
|
491
|
+
if getattr(args, "virtual_size", None) or getattr(args, "virtual_dpi", None):
|
|
492
|
+
from .display_setup import configure_virtual_display, parse_dpi, parse_size
|
|
493
|
+
|
|
494
|
+
# "main"/None must be passed through as None so the helper applies the documented default
|
|
495
|
+
# (follow the primary monitor) instead of being misread as "no change requested".
|
|
496
|
+
display_setup = configure_virtual_display(
|
|
497
|
+
size=parse_size(args.virtual_size) if args.virtual_size not in (None, "main") else "main",
|
|
498
|
+
dpi=parse_dpi(args.virtual_dpi) if args.virtual_dpi not in (None, "main") else "main",
|
|
499
|
+
)
|
|
500
|
+
log = __import__("VirtualDesktop._log", fromlist=["get_logger"]).get_logger("cli")
|
|
501
|
+
log.info("virtual display setup: %s", display_setup.to_dict())
|
|
502
|
+
if not args.json:
|
|
503
|
+
print(
|
|
504
|
+
f"virtual display: {display_setup.effective_size} @ {display_setup.effective_dpi} dpi "
|
|
505
|
+
f"(size_applied={display_setup.size_applied}, dpi_applied={display_setup.dpi_applied}"
|
|
506
|
+
+ (f", skipped={display_setup.skipped}" if display_setup.skipped else "")
|
|
507
|
+
+ ")"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
if do_mount:
|
|
512
|
+
session.mount(
|
|
513
|
+
install_driver=install_driver,
|
|
514
|
+
visible=True if getattr(args, "keep_visible", False) else None,
|
|
515
|
+
)
|
|
516
|
+
if capture:
|
|
517
|
+
if not do_mount:
|
|
518
|
+
# Capture in place: resolve the window without moving it.
|
|
519
|
+
record = find_window(
|
|
520
|
+
title=session.config.target_window_title,
|
|
521
|
+
class_name=session.config.window_class,
|
|
522
|
+
process_name=session.config.process_name,
|
|
523
|
+
pid=session.config.window_pid,
|
|
524
|
+
timeout=session.config.window_timeout,
|
|
525
|
+
)
|
|
526
|
+
from .capture import DEFAULT_METHODS, CaptureEngine
|
|
527
|
+
|
|
528
|
+
engine = CaptureEngine(
|
|
529
|
+
client_only=session.config.capture_client_only,
|
|
530
|
+
validate=session.config.capture_validate,
|
|
531
|
+
methods=session.config.capture_methods or DEFAULT_METHODS,
|
|
532
|
+
)
|
|
533
|
+
frame = engine.capture(record.hwnd, save_to=args.out)
|
|
534
|
+
else:
|
|
535
|
+
frame = session.capture(save_to=args.out)
|
|
536
|
+
payload = {
|
|
537
|
+
"ok": True,
|
|
538
|
+
"saved": args.out,
|
|
539
|
+
"method": frame.method,
|
|
540
|
+
"size": list(frame.size),
|
|
541
|
+
"analysis": frame.analysis.to_dict() if frame.analysis else None,
|
|
542
|
+
"window": session.report().get("window"),
|
|
543
|
+
"monitor": session.report().get("monitor"),
|
|
544
|
+
"hide_mode_used": session.hide_mode_used,
|
|
545
|
+
"display_setup": display_setup.to_dict() if display_setup else None,
|
|
546
|
+
}
|
|
547
|
+
# Optional artifacts: a whole-desktop shot and the window's UI tree.
|
|
548
|
+
if getattr(args, "desktop_shot", None):
|
|
549
|
+
from .desktop_capture import capture_all_screens, capture_monitor
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
shot = capture_monitor() if session.monitor else capture_all_screens()
|
|
553
|
+
shot.save(args.desktop_shot)
|
|
554
|
+
payload["desktop_shot"] = shot.to_dict() | {"file": args.desktop_shot}
|
|
555
|
+
except Exception as exc: # noqa: BLE001 - the capture itself already succeeded
|
|
556
|
+
payload["desktop_shot"] = {"error": str(exc)[:300]}
|
|
557
|
+
if getattr(args, "ui_tree", None):
|
|
558
|
+
payload["ui_tree"] = _export_ui_tree(
|
|
559
|
+
(session.hwnd or 0) if do_mount else record.hwnd, args.ui_tree
|
|
560
|
+
)
|
|
561
|
+
_print(payload, as_json=args.json)
|
|
562
|
+
if not args.json:
|
|
563
|
+
print(
|
|
564
|
+
f"captured {frame.size[0]}x{frame.size[1]} via {frame.method}"
|
|
565
|
+
+ (f" -> {args.out}" if args.out else "")
|
|
566
|
+
)
|
|
567
|
+
if payload.get("desktop_shot", {}).get("file"):
|
|
568
|
+
print(f"desktop shot -> {payload['desktop_shot']['file']}")
|
|
569
|
+
if payload.get("ui_tree"):
|
|
570
|
+
tree = payload["ui_tree"]
|
|
571
|
+
print(
|
|
572
|
+
f"ui tree -> {tree.get('uia_count')} UIA element(s), "
|
|
573
|
+
f"{tree.get('native_children')} native child window(s) in {args.ui_tree}"
|
|
574
|
+
)
|
|
575
|
+
else:
|
|
576
|
+
_print({"ok": True, **session.report()}, as_json=args.json)
|
|
577
|
+
except OffscreenMountError as exc:
|
|
578
|
+
_print({"ok": False, "error": exc.to_dict()}, as_json=args.json)
|
|
579
|
+
if not args.json:
|
|
580
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
581
|
+
return _exit_code_for(exc)
|
|
582
|
+
finally:
|
|
583
|
+
if getattr(args, "unmount", False) or (capture and do_mount and not args.json):
|
|
584
|
+
session.unmount()
|
|
585
|
+
return EXIT_CODES["ok"]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _write_helper_trace(payload: Dict[str, Any]) -> None:
|
|
589
|
+
"""Best-effort breadcrumb from the elevated child.
|
|
590
|
+
|
|
591
|
+
Set ``VIRTUALDESKTOP_HELPER_TRACE`` to a file path to have the child record that it started
|
|
592
|
+
and what it reported, *independently of the response pipe*. Debugging an elevated helper
|
|
593
|
+
is otherwise blind: its stdout goes nowhere and the parent may never see a pipe message.
|
|
594
|
+
"""
|
|
595
|
+
path = os.environ.get("VIRTUALDESKTOP_HELPER_TRACE")
|
|
596
|
+
if not path:
|
|
597
|
+
return
|
|
598
|
+
try:
|
|
599
|
+
import time as _time
|
|
600
|
+
|
|
601
|
+
line = json.dumps(
|
|
602
|
+
{
|
|
603
|
+
"ts": _time.time(),
|
|
604
|
+
"pid": os.getpid(),
|
|
605
|
+
"elevated": is_admin(),
|
|
606
|
+
"argv": sys.argv,
|
|
607
|
+
**payload,
|
|
608
|
+
},
|
|
609
|
+
default=str,
|
|
610
|
+
)
|
|
611
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
612
|
+
handle.write(line + "\n")
|
|
613
|
+
except OSError: # pragma: no cover - tracing must never break the install
|
|
614
|
+
pass
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
618
|
+
_configure_console()
|
|
619
|
+
parser = build_parser()
|
|
620
|
+
args = parser.parse_args(argv)
|
|
621
|
+
if args.debug or os.environ.get("VIRTUALDESKTOP_DEBUG"):
|
|
622
|
+
enable_debug_logging(log_file=args.log_file)
|
|
623
|
+
elif args.log_file:
|
|
624
|
+
enable_debug_logging(level=20, stream=None, log_file=args.log_file)
|
|
625
|
+
|
|
626
|
+
if not IS_WINDOWS and args.command != "check":
|
|
627
|
+
print("VirtualDesktop only runs on Windows.", file=sys.stderr)
|
|
628
|
+
return EXIT_CODES["unsupported-platform"]
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
if args.command == _INTERNAL:
|
|
632
|
+
_write_helper_trace({"event": "helper-start", "cache_dir": args.cache_dir})
|
|
633
|
+
return install_from_helper(
|
|
634
|
+
cache_dir=args.cache_dir,
|
|
635
|
+
strategy=args.strategy,
|
|
636
|
+
signature_policy=args.signature_policy,
|
|
637
|
+
driver=args.driver,
|
|
638
|
+
install_timeout=args.install_timeout,
|
|
639
|
+
log_dir=args.log_dir,
|
|
640
|
+
response_pipe=args.response_pipe,
|
|
641
|
+
uninstall=getattr(args, "uninstall", False),
|
|
642
|
+
purge_files=getattr(args, "purge_files", False),
|
|
643
|
+
purge_driver_store=getattr(args, "purge_driver_store", False),
|
|
644
|
+
elevate=not getattr(args, "no_elevate", False),
|
|
645
|
+
)
|
|
646
|
+
if args.command == "check":
|
|
647
|
+
return _cmd_check(args)
|
|
648
|
+
if args.command == "fetch-driver":
|
|
649
|
+
return _cmd_fetch_driver(args)
|
|
650
|
+
if args.command == "install-driver":
|
|
651
|
+
return _cmd_install_driver(args)
|
|
652
|
+
if args.command == "uninstall-driver":
|
|
653
|
+
return _cmd_uninstall_driver(args)
|
|
654
|
+
if args.command == "list-windows":
|
|
655
|
+
return _cmd_list_windows(args)
|
|
656
|
+
if args.command == "mount":
|
|
657
|
+
return _cmd_mount_or_capture(args, capture=False)
|
|
658
|
+
if args.command == "capture":
|
|
659
|
+
return _cmd_mount_or_capture(args, capture=True)
|
|
660
|
+
parser.error(f"unknown command {args.command!r}")
|
|
661
|
+
return EXIT_CODES["usage"]
|
|
662
|
+
except KeyboardInterrupt: # pragma: no cover - interactive only
|
|
663
|
+
print("interrupted", file=sys.stderr)
|
|
664
|
+
return 130
|
|
665
|
+
except OffscreenMountError as exc:
|
|
666
|
+
_print({"ok": False, "error": exc.to_dict()}, as_json=getattr(args, "json", False))
|
|
667
|
+
if not getattr(args, "json", False):
|
|
668
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
669
|
+
return _exit_code_for(exc)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
if __name__ == "__main__": # pragma: no cover
|
|
673
|
+
raise SystemExit(main())
|