VirtualDesktop 1.6.5__py3-none-win_amd64.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,152 @@
1
+ """``VirtualDesktop`` 鈥?an offscreen window workstation for Windows.
2
+
3
+ The public surface is deliberately small:
4
+
5
+ * :class:`~VirtualDesktop.session.OffscreenSession` 鈥?mount, capture, restore.
6
+ * :func:`~VirtualDesktop.session.probe_environment` 鈥?diagnostics without side effects.
7
+ * :class:`~VirtualDesktop.driver.DriverInstaller` 鈥?explicit driver control.
8
+ * :mod:`VirtualDesktop.errors` 鈥?every exception, each with a ``code`` and a ``hint``.
9
+
10
+ Typical use::
11
+
12
+ from VirtualDesktop import OffscreenSession
13
+
14
+ with OffscreenSession(target_window_title="WeChat") as session:
15
+ session.mount()
16
+ frame = session.capture() # PIL image
17
+ frame.image.save("wechat.png")
18
+
19
+ Nothing is installed until ``mount()`` actually needs a virtual display, and nothing is
20
+ moved until a target window has been found, so a failed call leaves the machine as it was.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from ._log import enable_debug_logging, get_logger
26
+ from ._winapi import (
27
+ IS_WINDOWS,
28
+ MonitorInfo,
29
+ enumerate_monitors,
30
+ is_admin,
31
+ list_windows,
32
+ select_virtual_monitor,
33
+ )
34
+ from .capture import DEFAULT_METHODS, CaptureEngine, Frame, analyze_image
35
+ from .config import DriverConfig, SessionConfig
36
+ from .desktop import DesktopBinding, current_desktop_name, ensure_default_desktop
37
+ from .driver import DriverInstaller, InstallReport, virtual_display_present
38
+ from .errors import (
39
+ AmbiguousWindowError,
40
+ CaptureError,
41
+ ConfigError,
42
+ DesktopBindError,
43
+ DownloadError,
44
+ DriverInstallError,
45
+ DriverNotFoundError,
46
+ DriverVerificationError,
47
+ ElevationDenied,
48
+ ElevationRequired,
49
+ ExtractionError,
50
+ IntegrityError,
51
+ MissingDependencyError,
52
+ OffscreenMountError,
53
+ SessionStateError,
54
+ UnsupportedPlatformError,
55
+ WindowMountError,
56
+ WindowNotFoundError,
57
+ )
58
+ from .manifest import load_manifest
59
+ from .session import OffscreenSession, probe_environment
60
+ from .desktop_session import DesktopProcess, VirtualDesktop, current_desktop_name
61
+ from .window import WindowMounter, WindowRecord, find_window, window_diagnostics
62
+ from .workspace import SHARED, VirtualWorkspace, WorkspaceWindow
63
+ from .agent import AgentProcess, AgentSession
64
+ from .display_setup import configure_virtual_display, current_primary_dpi, current_primary_size
65
+ from .record import (
66
+ GIF_FORMATS,
67
+ IMAGE_FORMATS,
68
+ VIDEO_FORMATS,
69
+ Recorder,
70
+ RecordingReport,
71
+ record_stream,
72
+ supported_formats,
73
+ )
74
+
75
+ __version__ = "1.6.4"
76
+
77
+ __all__ = [
78
+ "__version__",
79
+ # session
80
+ "OffscreenSession",
81
+ "probe_environment",
82
+ # drivers
83
+ "DriverInstaller",
84
+ "InstallReport",
85
+ "virtual_display_present",
86
+ "load_manifest",
87
+ # windows and displays
88
+ "WindowMounter",
89
+ "WindowRecord",
90
+ "find_window",
91
+ "window_diagnostics",
92
+ "enumerate_monitors",
93
+ "select_virtual_monitor",
94
+ "MonitorInfo",
95
+ "list_windows",
96
+ "ensure_default_desktop",
97
+ "current_desktop_name",
98
+ "DesktopBinding",
99
+ # private virtual desktop (no admin, no driver)
100
+ "VirtualDesktop",
101
+ "DesktopProcess",
102
+ # multi-window virtual-display workspace (externally controllable)
103
+ "VirtualWorkspace",
104
+ "WorkspaceWindow",
105
+ "SHARED",
106
+ # in-desktop agent (for isolated workspaces / private desktops)
107
+ "AgentSession",
108
+ "AgentProcess",
109
+ # virtual display geometry
110
+ "configure_virtual_display",
111
+ "current_primary_size",
112
+ "current_primary_dpi",
113
+ # capture
114
+ "CaptureEngine",
115
+ "Frame",
116
+ "analyze_image",
117
+ "DEFAULT_METHODS",
118
+ # recording
119
+ "Recorder",
120
+ "RecordingReport",
121
+ "record_stream",
122
+ "supported_formats",
123
+ "VIDEO_FORMATS",
124
+ "GIF_FORMATS",
125
+ "IMAGE_FORMATS",
126
+ # configuration
127
+ "DriverConfig",
128
+ "SessionConfig",
129
+ # platform helpers
130
+ "IS_WINDOWS",
131
+ "is_admin",
132
+ "enable_debug_logging",
133
+ # errors
134
+ "OffscreenMountError",
135
+ "UnsupportedPlatformError",
136
+ "MissingDependencyError",
137
+ "ConfigError",
138
+ "IntegrityError",
139
+ "DownloadError",
140
+ "ExtractionError",
141
+ "ElevationRequired",
142
+ "ElevationDenied",
143
+ "DriverInstallError",
144
+ "DriverNotFoundError",
145
+ "DriverVerificationError",
146
+ "DesktopBindError",
147
+ "WindowNotFoundError",
148
+ "AmbiguousWindowError",
149
+ "WindowMountError",
150
+ "CaptureError",
151
+ "SessionStateError",
152
+ ]
@@ -0,0 +1,10 @@
1
+ """``python -m VirtualDesktop`` entry point (also used by the elevated helper)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,369 @@
1
+ """Managed on-disk layout for the extracted driver package and the nefcon tool.
2
+
3
+ ```
4
+ <cache>/ (per-user, see VIRTUALDESKTOP_CACHE_DIR)
5
+ VirtualDisplayDriver-x86.Driver.Only.zip verified artifact
6
+ x64/nefconw.exe + x64/nefconc.exe extracted tool tree
7
+ driver/vdd_mtt/ extracted driver package
8
+ .VirtualDesktop.json install marker (version + digest)
9
+ VirtualDisplayDriver/MttVDD.inf
10
+ VirtualDisplayDriver/vdd_settings.xml
11
+ ```
12
+
13
+ The marker file records the digest the tree was extracted from, so a manifest bump (new
14
+ driver release) automatically invalidates and re-extracts the tree instead of silently
15
+ running a stale driver.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import shutil
23
+ import sys
24
+ import time
25
+ import uuid
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
29
+
30
+ from ._download import default_cache_dir, download_artifact, extract_archive, sha256_of
31
+ from ._log import get_logger
32
+ from .errors import ConfigError, ExtractionError, IntegrityError
33
+ from .manifest import Artifact, DriverSpec, Manifest, ToolSpec
34
+
35
+ log = get_logger("bundle")
36
+
37
+ __all__ = [
38
+ "MARKER_NAME",
39
+ "DriverBundle",
40
+ "BundleManager",
41
+ "driver_spec_for_arch",
42
+ "tool_executable",
43
+ ]
44
+
45
+ MARKER_NAME = ".VirtualDesktop.json"
46
+ _MARKER_SCHEMA = 1
47
+
48
+
49
+ def machine_arch() -> str:
50
+ """Return ``x64``, ``arm64`` or ``x86`` for the machine class."""
51
+ import platform
52
+
53
+ machine = (platform.machine() or "").lower()
54
+ if machine in ("arm64", "aarch64"):
55
+ return "arm64"
56
+ if machine in ("x86", "i386", "i686"):
57
+ return "x86"
58
+ # PROCESSOR_ARCHITEW6432 tells a 32-bit Python on 64-bit Windows the real arch.
59
+ env = (os.environ.get("PROCESSOR_ARCHITEW6432") or "").lower()
60
+ if env in ("amd64", "x86"):
61
+ return "x64"
62
+ return "x64"
63
+
64
+
65
+ def driver_spec_for_arch(manifest: Manifest, name: Optional[str] = None) -> DriverSpec:
66
+ """Pick the driver variant matching the current machine architecture."""
67
+ base = manifest.driver(name)
68
+ if machine_arch() != "arm64":
69
+ return base
70
+ arm = manifest.drivers.get(f"{base.name}_arm64")
71
+ if arm is None:
72
+ log.warning("no ARM64 variant of %s in the manifest; using the default build", base.name)
73
+ return base
74
+ log.info("ARM64 Windows detected; using driver variant %s", arm.name)
75
+ return arm
76
+
77
+
78
+ def tool_executable(spec: ToolSpec, root: Path) -> Path:
79
+ """Resolve a tool executable inside its extracted tree, tolerating arch differences.
80
+
81
+ ``nefcon`` ships ``x64/``, ``x86/`` and ``ARM64/`` trees. The manifest points at the
82
+ x64 build because that is what runs on both x64 and (under emulation) ARM64 Windows,
83
+ but if the declared path is missing we fall back to any sibling build rather than
84
+ failing with a bare "file not found".
85
+ """
86
+ declared = root / Path(spec.executable)
87
+ if declared.is_file():
88
+ return declared
89
+ name = Path(spec.executable).name
90
+ candidates = sorted(root.rglob(name))
91
+ if candidates:
92
+ log.debug("using %s instead of the declared %s", candidates[0], declared)
93
+ return candidates[0]
94
+ raise ConfigError(
95
+ f"{name} was not found under {root}",
96
+ code="tool-executable-missing",
97
+ details={"declared": str(declared)},
98
+ )
99
+
100
+
101
+ def _read_marker(path: Path) -> Optional[Dict[str, Any]]:
102
+ try:
103
+ data = json.loads(path.read_text(encoding="utf-8"))
104
+ except (OSError, json.JSONDecodeError):
105
+ return None
106
+ if not isinstance(data, dict) or data.get("schema") != _MARKER_SCHEMA:
107
+ return None
108
+ return data
109
+
110
+
111
+ def _write_marker(path: Path, payload: Dict[str, Any]) -> None:
112
+ tmp = path.with_suffix(path.suffix + f".{uuid.uuid4().hex[:8]}.tmp")
113
+ tmp.parent.mkdir(parents=True, exist_ok=True)
114
+ tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
115
+ os.replace(tmp, path)
116
+
117
+
118
+ @dataclass
119
+ class DriverBundle:
120
+ """A verified, extracted driver package ready to be handed to the installer."""
121
+
122
+ spec: DriverSpec
123
+ root: Path
124
+ inf_path: Path
125
+ settings_path: Optional[Path] = None
126
+ from_cache: bool = False
127
+
128
+ @property
129
+ def driver_dir(self) -> Path:
130
+ return self.inf_path.parent
131
+
132
+ def files(self) -> List[Path]:
133
+ return sorted(p for p in self.driver_dir.iterdir() if p.is_file())
134
+
135
+ def to_dict(self) -> dict:
136
+ return {
137
+ "driver": self.spec.name,
138
+ "version": self.spec.version,
139
+ "root": str(self.root),
140
+ "inf": str(self.inf_path),
141
+ "settings": str(self.settings_path) if self.settings_path else None,
142
+ "from_cache": self.from_cache,
143
+ }
144
+
145
+
146
+ class BundleManager:
147
+ """Prepares (and caches) the driver tree and tool trees under one cache root."""
148
+
149
+ def __init__(self, cache_dir: Optional[os.PathLike] = None):
150
+ self.cache_dir = Path(cache_dir) if cache_dir else default_cache_dir()
151
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
152
+
153
+ def _mirrors(self) -> Tuple[str, ...]:
154
+ """Extra download URL prefixes for this environment.
155
+
156
+ ``VIRTUALDESKTOP_GITHUB_MIRRORS`` is the escape hatch for networks where github.com is slow
157
+ or blocked and the built-in accelerators in the manifest are not enough (an internal
158
+ mirror, a corporate proxy, a different accelerator). Read once per download rather than
159
+ cached so a long-lived process picks up a change. Each entry is either a full URL or a
160
+ prefix ending in ``/`` or ``?``; either way the digest is verified afterwards.
161
+ """
162
+ raw = os.environ.get("VIRTUALDESKTOP_GITHUB_MIRRORS", "")
163
+ return tuple(item.strip() for item in raw.split(",") if item.strip())
164
+
165
+ # -- driver ---------------------------------------------------------------------
166
+ @property
167
+ def driver_root(self) -> Path:
168
+ return self.cache_dir / "driver"
169
+
170
+ def prepare_driver(
171
+ self,
172
+ spec: DriverSpec,
173
+ *,
174
+ settings_file: Optional[os.PathLike] = None,
175
+ force: bool = False,
176
+ progress=None,
177
+ ) -> DriverBundle:
178
+ """Download (if needed), verify, extract and return the driver package."""
179
+ target = self.driver_root / spec.name
180
+ inf_path = target / Path(spec.inf_member)
181
+ marker = target / MARKER_NAME
182
+ existing = _read_marker(marker) if not force else None
183
+ if existing and existing.get("artifact_sha256") == spec.artifact.sha256 and inf_path.is_file():
184
+ log.debug("driver bundle %s already extracted from %s", spec.name, existing.get("version"))
185
+ settings = target / Path(spec.settings_member) if spec.settings_member else None
186
+ return DriverBundle(
187
+ spec=spec,
188
+ root=target,
189
+ inf_path=inf_path,
190
+ settings_path=settings if settings and settings.is_file() else None,
191
+ from_cache=True,
192
+ )
193
+
194
+ archive = download_artifact(
195
+ spec.artifact,
196
+ cache_dir=self.cache_dir,
197
+ progress=progress,
198
+ mirror_extra=self._mirrors(),
199
+ )
200
+ # Defence in depth: re-verify the artifact at the bundle boundary, so even a
201
+ # swapped-out downloader (or a file dropped into the cache between the two calls)
202
+ # cannot turn into an unverified driver tree.
203
+ actual = sha256_of(archive)
204
+ if actual != spec.artifact.sha256:
205
+ from ._download import _quarantine
206
+
207
+ _quarantine(archive, reason="bundle digest mismatch", dest_dir=self.cache_dir / "quarantine")
208
+ raise IntegrityError(
209
+ f"{archive.name} does not match the pinned SHA-256 at the bundle boundary",
210
+ code="digest-mismatch",
211
+ details={"expected": spec.artifact.sha256, "actual": actual},
212
+ )
213
+ staging = self.driver_root / f".{spec.name}.staging.{uuid.uuid4().hex[:8]}"
214
+ if staging.exists(): # pragma: no cover - defensive
215
+ shutil.rmtree(staging, ignore_errors=True)
216
+ try:
217
+ extract_archive(
218
+ archive,
219
+ staging,
220
+ strip_components=spec.artifact.strip_components,
221
+ members=spec.artifact.members,
222
+ )
223
+ if not (staging / Path(spec.inf_member)).is_file():
224
+ raise ExtractionError(
225
+ f"extracted driver tree does not contain {spec.inf_member}",
226
+ code="driver-inf-missing",
227
+ )
228
+ if settings_file is not None:
229
+ self._install_settings(staging, spec, settings_file)
230
+ if target.exists():
231
+ shutil.rmtree(target, ignore_errors=True)
232
+ target.parent.mkdir(parents=True, exist_ok=True)
233
+ os.replace(staging, target)
234
+ finally:
235
+ if staging.exists(): # pragma: no cover - cleanup on failure
236
+ shutil.rmtree(staging, ignore_errors=True)
237
+
238
+ _write_marker(
239
+ target / MARKER_NAME,
240
+ {
241
+ "schema": _MARKER_SCHEMA,
242
+ "driver": spec.name,
243
+ "version": spec.version,
244
+ "artifact_url": spec.artifact.url,
245
+ "artifact_sha256": spec.artifact.sha256,
246
+ "installed_at": time.time(),
247
+ "inf": spec.inf_member,
248
+ },
249
+ )
250
+ settings = target / Path(spec.settings_member) if spec.settings_member else None
251
+ log.info("prepared driver bundle %s at %s", spec.name, target)
252
+ return DriverBundle(
253
+ spec=spec,
254
+ root=target,
255
+ inf_path=target / Path(spec.inf_member),
256
+ settings_path=settings if settings and settings.is_file() else None,
257
+ )
258
+
259
+ @staticmethod
260
+ def _install_settings(staging: Path, spec: DriverSpec, settings_file: os.PathLike) -> None:
261
+ if not spec.settings_member:
262
+ raise ConfigError(
263
+ f"driver {spec.name} does not declare a settings file", code="no-settings-member"
264
+ )
265
+ destination = staging / Path(spec.settings_member)
266
+ destination.parent.mkdir(parents=True, exist_ok=True)
267
+ shutil.copyfile(settings_file, destination)
268
+ log.info("installed custom driver settings from %s", settings_file)
269
+
270
+ def cached_driver(self, spec: DriverSpec) -> Optional[DriverBundle]:
271
+ """Return the prepared bundle without touching the network, if it is intact."""
272
+ target = self.driver_root / spec.name
273
+ inf_path = target / Path(spec.inf_member)
274
+ marker = _read_marker(target / MARKER_NAME)
275
+ if marker and marker.get("artifact_sha256") == spec.artifact.sha256 and inf_path.is_file():
276
+ settings = target / Path(spec.settings_member) if spec.settings_member else None
277
+ return DriverBundle(
278
+ spec=spec,
279
+ root=target,
280
+ inf_path=inf_path,
281
+ settings_path=settings if settings and settings.is_file() else None,
282
+ from_cache=True,
283
+ )
284
+ return None
285
+
286
+ def remove_driver_tree(self, spec: DriverSpec) -> bool:
287
+ """Delete the extracted driver tree (used by ``uninstall-driver --purge-files``)."""
288
+ target = self.driver_root / spec.name
289
+ marker = target / MARKER_NAME
290
+ if not target.exists() or _read_marker(marker) is None:
291
+ log.debug("refusing to delete %s: no valid marker file", target)
292
+ return False
293
+ shutil.rmtree(target, ignore_errors=True)
294
+ log.info("removed extracted driver tree %s", target)
295
+ return True
296
+
297
+ # -- tools ----------------------------------------------------------------------
298
+ def tool(self, manifest: Manifest, name: str, *, progress=None) -> Path:
299
+ """Download (if needed), verify, extract a tool and return its executable path."""
300
+ spec = manifest.tool(name)
301
+ root = self.cache_dir / spec.name
302
+ marker = root / MARKER_NAME
303
+ existing = _read_marker(marker)
304
+ if existing and existing.get("artifact_sha256") == spec.artifact.sha256:
305
+ try:
306
+ return tool_executable(spec, root)
307
+ except ConfigError:
308
+ log.warning("tool tree %s is incomplete; re-extracting", root)
309
+ archive = download_artifact(
310
+ spec.artifact,
311
+ cache_dir=self.cache_dir,
312
+ progress=progress,
313
+ mirror_extra=self._mirrors(),
314
+ )
315
+ actual = sha256_of(archive)
316
+ if actual != spec.artifact.sha256:
317
+ from ._download import _quarantine
318
+
319
+ _quarantine(archive, reason="tool digest mismatch", dest_dir=self.cache_dir / "quarantine")
320
+ raise IntegrityError(
321
+ f"{archive.name} does not match the pinned SHA-256 at the bundle boundary",
322
+ code="digest-mismatch",
323
+ details={"expected": spec.artifact.sha256, "actual": actual},
324
+ )
325
+ staging = self.cache_dir / f".{spec.name}.staging.{uuid.uuid4().hex[:8]}"
326
+ try:
327
+ extract_archive(
328
+ archive,
329
+ staging,
330
+ strip_components=spec.artifact.strip_components,
331
+ members=spec.artifact.members,
332
+ )
333
+ if root.exists():
334
+ shutil.rmtree(root, ignore_errors=True)
335
+ os.replace(staging, root)
336
+ finally:
337
+ if staging.exists(): # pragma: no cover - cleanup on failure
338
+ shutil.rmtree(staging, ignore_errors=True)
339
+ _write_marker(
340
+ root / MARKER_NAME,
341
+ {
342
+ "schema": _MARKER_SCHEMA,
343
+ "tool": spec.name,
344
+ "version": spec.version,
345
+ "artifact_url": spec.artifact.url,
346
+ "artifact_sha256": spec.artifact.sha256,
347
+ "installed_at": time.time(),
348
+ },
349
+ )
350
+ return tool_executable(spec, root)
351
+
352
+ def cached_tool(self, manifest: Manifest, name: str) -> Optional[Path]:
353
+ spec = manifest.tool(name)
354
+ root = self.cache_dir / spec.name
355
+ marker = _read_marker(root / MARKER_NAME)
356
+ if marker and marker.get("artifact_sha256") == spec.artifact.sha256:
357
+ try:
358
+ return tool_executable(spec, root)
359
+ except ConfigError:
360
+ return None
361
+ return None
362
+
363
+ def tools_integrity(self, manifest: Manifest) -> Dict[str, Optional[str]]:
364
+ """Report which cached tool trees are usable (used by ``check``)."""
365
+ result: Dict[str, Optional[str]] = {}
366
+ for name in manifest.tools:
367
+ path = self.cached_tool(manifest, name)
368
+ result[name] = str(path) if path else None
369
+ return result