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.
- VirtualDesktop/__init__.py +152 -0
- VirtualDesktop/__main__.py +10 -0
- VirtualDesktop/_bundle.py +369 -0
- VirtualDesktop/_compat.py +576 -0
- VirtualDesktop/_desktop_runtime.py +465 -0
- VirtualDesktop/_devices.py +335 -0
- VirtualDesktop/_download.py +462 -0
- VirtualDesktop/_enumerate.py +97 -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/_vendor/FFMPEG-LICENSE.txt +23 -0
- VirtualDesktop/_vendor/ffmpeg.exe +4 -0
- VirtualDesktop/_winapi.py +739 -0
- VirtualDesktop/agent.py +403 -0
- VirtualDesktop/capture.py +676 -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 +848 -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/record.py +701 -0
- VirtualDesktop/session.py +514 -0
- VirtualDesktop/window.py +1240 -0
- VirtualDesktop/workspace.py +1274 -0
- virtualdesktop-1.6.5.dist-info/METADATA +846 -0
- virtualdesktop-1.6.5.dist-info/RECORD +38 -0
- virtualdesktop-1.6.5.dist-info/WHEEL +5 -0
- virtualdesktop-1.6.5.dist-info/entry_points.txt +2 -0
- virtualdesktop-1.6.5.dist-info/licenses/LICENSE +21 -0
- virtualdesktop-1.6.5.dist-info/top_level.txt +1 -0
|
@@ -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,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
|