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/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())