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/driver.py
ADDED
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
"""Driver bootstrap: detect, verify, install and remove the virtual display driver.
|
|
2
|
+
|
|
3
|
+
The flow is deliberately *two-phase*, because installing a display driver is a
|
|
4
|
+
machine-wide privileged operation:
|
|
5
|
+
|
|
6
|
+
1. **Unprivileged phase** (this module, first call): work out whether the device already
|
|
7
|
+
exists. If it does, nothing else happens — the common case costs a single SetupAPI
|
|
8
|
+
enumeration.
|
|
9
|
+
2. **Privileged phase** (``--internal-elevated`` child): download + digest-verify the
|
|
10
|
+
package, verify its Authenticode signature, then create the ROOT device node and bind
|
|
11
|
+
the driver with ``nefconw``.
|
|
12
|
+
|
|
13
|
+
If the process is already elevated, phase 2 runs in-process; otherwise it is executed
|
|
14
|
+
through UAC via :func:`~VirtualDesktop._process.elevate_and_run_helper`, and the caller
|
|
15
|
+
gets the child's structured result back over a private named pipe.
|
|
16
|
+
|
|
17
|
+
Nothing here shells out with ``shell=True``, nothing is installed without a SHA-256 match
|
|
18
|
+
against the manifest, and a driver that fails signature verification is reported instead
|
|
19
|
+
of being installed silently (``signature_policy="warn"``) or refused
|
|
20
|
+
(``signature_policy="strict"``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
30
|
+
|
|
31
|
+
from ._bundle import BundleManager, driver_spec_for_arch, machine_arch
|
|
32
|
+
from ._devices import device_status_summary, find_devices_by_hardware_id
|
|
33
|
+
from ._download import cache_status, default_cache_dir
|
|
34
|
+
from ._log import get_logger
|
|
35
|
+
from ._process import (
|
|
36
|
+
CommandResult,
|
|
37
|
+
default_log_dir,
|
|
38
|
+
elevate_and_run_helper,
|
|
39
|
+
run_command,
|
|
40
|
+
tail_file,
|
|
41
|
+
write_response,
|
|
42
|
+
)
|
|
43
|
+
from ._sigcheck import assert_driver_trusted
|
|
44
|
+
from ._winapi import IS_WINDOWS, enumerate_monitors, is_admin, is_system_session, require_windows
|
|
45
|
+
from .errors import (
|
|
46
|
+
ConfigError,
|
|
47
|
+
DriverInstallError,
|
|
48
|
+
DriverNotFoundError,
|
|
49
|
+
ElevationDenied,
|
|
50
|
+
ElevationRequired,
|
|
51
|
+
OffscreenMountError,
|
|
52
|
+
)
|
|
53
|
+
from .manifest import Manifest, load_manifest
|
|
54
|
+
|
|
55
|
+
log = get_logger("driver")
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"STRATEGIES",
|
|
59
|
+
"VIRTUAL_DISPLAY_MARKERS",
|
|
60
|
+
"InstallReport",
|
|
61
|
+
"DriverInstaller",
|
|
62
|
+
"virtual_display_present",
|
|
63
|
+
"wait_for_virtual_display",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
#: Marker substrings that identify our virtual display among all display devices.
|
|
67
|
+
VIRTUAL_DISPLAY_MARKERS = ("MttVDD", "VID_1B0F", "IddSampleDriver", "ROOT\\IDD")
|
|
68
|
+
STRATEGIES = ("auto", "nefcon", "nefcon-manual", "pnputil", "scoop")
|
|
69
|
+
_REBOOT_REQUIRED = 3010
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def virtual_display_present(
|
|
73
|
+
markers: Sequence[str] = VIRTUAL_DISPLAY_MARKERS, *, extra_markers: Sequence[str] = ()
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""True when a virtual display device is registered (installed), present or not.
|
|
76
|
+
|
|
77
|
+
This is a *device* check (SetupAPI), which is what matters: a driver can be installed
|
|
78
|
+
and enabled while its monitor is temporarily absent, and vice versa.
|
|
79
|
+
"""
|
|
80
|
+
if not IS_WINDOWS:
|
|
81
|
+
return False
|
|
82
|
+
summary = device_status_summary([*markers, *extra_markers])
|
|
83
|
+
return bool(summary["present"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def wait_for_virtual_display(
|
|
87
|
+
timeout: float = 20.0,
|
|
88
|
+
*,
|
|
89
|
+
interval: float = 1.0,
|
|
90
|
+
markers: Sequence[str] = VIRTUAL_DISPLAY_MARKERS,
|
|
91
|
+
extra_markers: Sequence[str] = (),
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Poll for the monitor to show up after installation (drivers come up asynchronously)."""
|
|
94
|
+
deadline = time.monotonic() + timeout
|
|
95
|
+
while True:
|
|
96
|
+
if IS_WINDOWS:
|
|
97
|
+
monitors = enumerate_monitors()
|
|
98
|
+
if any(
|
|
99
|
+
any(
|
|
100
|
+
m.upper() in f"{mon.device_string} {mon.pnp_device_id}".upper()
|
|
101
|
+
for m in [*markers, *extra_markers]
|
|
102
|
+
)
|
|
103
|
+
for mon in monitors
|
|
104
|
+
):
|
|
105
|
+
return True
|
|
106
|
+
if virtual_display_present(markers, extra_markers=extra_markers):
|
|
107
|
+
# Device is registered; the monitor itself may need a few more seconds.
|
|
108
|
+
if IS_WINDOWS and time.monotonic() < deadline:
|
|
109
|
+
monitors = enumerate_monitors()
|
|
110
|
+
if len(monitors) > 1:
|
|
111
|
+
return True
|
|
112
|
+
else:
|
|
113
|
+
return True
|
|
114
|
+
if time.monotonic() >= deadline:
|
|
115
|
+
return virtual_display_present(markers, extra_markers=extra_markers)
|
|
116
|
+
time.sleep(interval)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class InstallReport:
|
|
121
|
+
"""Structured outcome of a bootstrap attempt (JSON-serialisable)."""
|
|
122
|
+
|
|
123
|
+
action: str = "detect"
|
|
124
|
+
ok: bool = False
|
|
125
|
+
device_present: bool = False
|
|
126
|
+
driver: str = ""
|
|
127
|
+
version: str = ""
|
|
128
|
+
strategy_used: str = ""
|
|
129
|
+
elevated: bool = False
|
|
130
|
+
requires_reboot: bool = False
|
|
131
|
+
skipped_reason: str = ""
|
|
132
|
+
signature_findings: List[str] = field(default_factory=list)
|
|
133
|
+
commands: List[dict] = field(default_factory=list)
|
|
134
|
+
cache: Dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
message: str = ""
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> dict:
|
|
138
|
+
return {
|
|
139
|
+
"action": self.action,
|
|
140
|
+
"ok": self.ok,
|
|
141
|
+
"device_present": self.device_present,
|
|
142
|
+
"driver": self.driver,
|
|
143
|
+
"version": self.version,
|
|
144
|
+
"strategy_used": self.strategy_used,
|
|
145
|
+
"elevated": self.elevated,
|
|
146
|
+
"requires_reboot": self.requires_reboot,
|
|
147
|
+
"skipped_reason": self.skipped_reason,
|
|
148
|
+
"signature_findings": list(self.signature_findings),
|
|
149
|
+
"commands": list(self.commands),
|
|
150
|
+
"cache": dict(self.cache),
|
|
151
|
+
"message": self.message,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class DriverInstaller:
|
|
156
|
+
"""Owns every decision about the virtual display driver."""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
*,
|
|
161
|
+
manifest: Optional[Manifest] = None,
|
|
162
|
+
cache_dir: Optional[os.PathLike] = None,
|
|
163
|
+
driver: Optional[str] = None,
|
|
164
|
+
strategy: str = "auto",
|
|
165
|
+
signature_policy: str = "warn",
|
|
166
|
+
allow_scoop: bool = False,
|
|
167
|
+
install_timeout: float = 600.0,
|
|
168
|
+
download_timeout: float = 300.0,
|
|
169
|
+
monitor_tags: Sequence[str] = (),
|
|
170
|
+
) -> None:
|
|
171
|
+
if strategy not in STRATEGIES:
|
|
172
|
+
raise ConfigError(
|
|
173
|
+
f"unknown strategy {strategy!r}; expected one of {', '.join(STRATEGIES)}",
|
|
174
|
+
code="bad-strategy",
|
|
175
|
+
)
|
|
176
|
+
if signature_policy not in ("warn", "strict", "off"):
|
|
177
|
+
raise ConfigError(
|
|
178
|
+
f"unknown signature_policy {signature_policy!r}", code="bad-signature-policy"
|
|
179
|
+
)
|
|
180
|
+
self.manifest = manifest or load_manifest()
|
|
181
|
+
self.cache_dir = Path(cache_dir) if cache_dir else default_cache_dir()
|
|
182
|
+
self.spec = driver_spec_for_arch(self.manifest, driver)
|
|
183
|
+
self.strategy = strategy
|
|
184
|
+
self.signature_policy = signature_policy
|
|
185
|
+
self.allow_scoop = allow_scoop
|
|
186
|
+
self.install_timeout = install_timeout
|
|
187
|
+
self.download_timeout = download_timeout
|
|
188
|
+
self.monitor_tags = tuple(monitor_tags)
|
|
189
|
+
self.bundles = BundleManager(self.cache_dir)
|
|
190
|
+
self.log_dir = default_log_dir(self.cache_dir)
|
|
191
|
+
|
|
192
|
+
# -- detection -------------------------------------------------------------------
|
|
193
|
+
@property
|
|
194
|
+
def markers(self) -> tuple:
|
|
195
|
+
return tuple(VIRTUAL_DISPLAY_MARKERS) + tuple(self.spec.device_match) + self.monitor_tags
|
|
196
|
+
|
|
197
|
+
def is_present(self) -> bool:
|
|
198
|
+
return virtual_display_present(extra_markers=self.markers)
|
|
199
|
+
|
|
200
|
+
def devices(self) -> List[dict]:
|
|
201
|
+
return device_status_summary(self.markers)["devices"]
|
|
202
|
+
|
|
203
|
+
def detect(self) -> InstallReport:
|
|
204
|
+
present = self.is_present()
|
|
205
|
+
return InstallReport(
|
|
206
|
+
action="detect",
|
|
207
|
+
ok=present,
|
|
208
|
+
device_present=present,
|
|
209
|
+
driver=self.spec.name,
|
|
210
|
+
version=self.spec.version,
|
|
211
|
+
skipped_reason="" if present else "device-absent",
|
|
212
|
+
cache=self.cache_report(),
|
|
213
|
+
message=(
|
|
214
|
+
"virtual display device is registered"
|
|
215
|
+
if present
|
|
216
|
+
else "virtual display device is not installed yet"
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def cache_report(self) -> Dict[str, Any]:
|
|
221
|
+
artifact = self.spec.artifact
|
|
222
|
+
status = cache_status(artifact, self.cache_dir)
|
|
223
|
+
return {
|
|
224
|
+
"cache_dir": str(self.cache_dir),
|
|
225
|
+
"artifact": artifact.filename,
|
|
226
|
+
"artifact_url": artifact.url,
|
|
227
|
+
"artifact_cached": status.ok,
|
|
228
|
+
"artifact_reason": status.reason,
|
|
229
|
+
"driver_tree": bool(self.bundles.cached_driver(self.spec)),
|
|
230
|
+
"tools": self.bundles.tools_integrity(self.manifest),
|
|
231
|
+
"log_dir": str(self.log_dir),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# -- installation ----------------------------------------------------------------
|
|
235
|
+
def ensure(self, *, allow_install: bool = True, timeout: Optional[float] = None) -> InstallReport:
|
|
236
|
+
"""Install the driver when it is missing, going through UAC when needed."""
|
|
237
|
+
present = self.is_present()
|
|
238
|
+
if present:
|
|
239
|
+
report = self.detect()
|
|
240
|
+
report.action = "ensure"
|
|
241
|
+
report.skipped_reason = "already-installed"
|
|
242
|
+
report.message = "virtual display device already present; nothing to install"
|
|
243
|
+
log.info(report.message)
|
|
244
|
+
return report
|
|
245
|
+
if not allow_install:
|
|
246
|
+
raise DriverNotFoundError(
|
|
247
|
+
"no virtual display device is installed and driver installation is disabled "
|
|
248
|
+
"(driver_policy='never')",
|
|
249
|
+
details={"driver": self.spec.name, "markers": list(self.markers)},
|
|
250
|
+
)
|
|
251
|
+
if is_admin():
|
|
252
|
+
log.info("process is elevated; installing the driver in-process")
|
|
253
|
+
report = self.install(timeout=timeout)
|
|
254
|
+
report.elevated = True
|
|
255
|
+
return report
|
|
256
|
+
if is_system_session():
|
|
257
|
+
raise ElevationRequired(
|
|
258
|
+
"driver installation is required but the process runs in a non-interactive "
|
|
259
|
+
"session, so a UAC prompt cannot be shown",
|
|
260
|
+
hint="Pre-install the driver once from an elevated terminal: "
|
|
261
|
+
"`VirtualDesktop install-driver`.",
|
|
262
|
+
)
|
|
263
|
+
return self._install_elevated(timeout=timeout)
|
|
264
|
+
|
|
265
|
+
def _announce(self, action: str) -> None:
|
|
266
|
+
"""Tell the operator that a UAC prompt is imminent.
|
|
267
|
+
|
|
268
|
+
The elevated call blocks on the helper's response pipe, so a silent console looks like a
|
|
269
|
+
hang while the consent dialog waits for a click. Printing first makes the interaction
|
|
270
|
+
obvious (and is the difference between "it hung" and "I missed the prompt").
|
|
271
|
+
"""
|
|
272
|
+
print(
|
|
273
|
+
f"[VirtualDesktop] {action} requires administrator rights - "
|
|
274
|
+
"approve the UAC prompt (press Enter on it) to continue ...",
|
|
275
|
+
flush=True,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def _helper_env() -> Dict[str, str]:
|
|
280
|
+
"""Extra environment the elevated helper needs, and nothing more.
|
|
281
|
+
|
|
282
|
+
The helper builds its own minimal environment; these are the values it legitimately
|
|
283
|
+
needs from us: which manifest to trust, and whether the operator asked for debug logs.
|
|
284
|
+
"""
|
|
285
|
+
passthrough = ("VIRTUALDESKTOP_MANIFEST", "VIRTUALDESKTOP_DEBUG", "VIRTUALDESKTOP_HELPER_TRACE")
|
|
286
|
+
return {name: os.environ[name] for name in passthrough if os.environ.get(name)}
|
|
287
|
+
|
|
288
|
+
def _install_elevated(self, *, timeout: Optional[float] = None) -> InstallReport:
|
|
289
|
+
"""Run the privileged phase through UAC and translate the child's report."""
|
|
290
|
+
log.info("driver is missing; requesting elevation to install %s", self.spec.name)
|
|
291
|
+
self._announce(f"installing driver {self.spec.name}")
|
|
292
|
+
log_dir = str(self.log_dir)
|
|
293
|
+
try:
|
|
294
|
+
result = elevate_and_run_helper(
|
|
295
|
+
[
|
|
296
|
+
"--cache-dir",
|
|
297
|
+
str(self.cache_dir),
|
|
298
|
+
"--strategy",
|
|
299
|
+
self.strategy,
|
|
300
|
+
"--signature-policy",
|
|
301
|
+
self.signature_policy,
|
|
302
|
+
"--driver",
|
|
303
|
+
self.spec.name,
|
|
304
|
+
"--install-timeout",
|
|
305
|
+
str(self.install_timeout),
|
|
306
|
+
"--log-dir",
|
|
307
|
+
log_dir,
|
|
308
|
+
],
|
|
309
|
+
timeout=timeout or (self.install_timeout + 300.0),
|
|
310
|
+
log_dir=log_dir,
|
|
311
|
+
extra_env=self._helper_env(),
|
|
312
|
+
)
|
|
313
|
+
except ElevationDenied:
|
|
314
|
+
raise
|
|
315
|
+
if result.timed_out:
|
|
316
|
+
raise DriverInstallError(
|
|
317
|
+
"the elevated installer did not report a result before the timeout",
|
|
318
|
+
hint=f"Check {self.log_dir} for the elevated log.",
|
|
319
|
+
code="driver-install-timeout",
|
|
320
|
+
)
|
|
321
|
+
response = result.response or {}
|
|
322
|
+
report = InstallReport(
|
|
323
|
+
action="install",
|
|
324
|
+
ok=bool(response.get("ok")),
|
|
325
|
+
device_present=bool(response.get("device_present")),
|
|
326
|
+
driver=str(response.get("driver", self.spec.name)),
|
|
327
|
+
version=str(response.get("version", self.spec.version)),
|
|
328
|
+
strategy_used=str(response.get("strategy_used", "")),
|
|
329
|
+
elevated=True,
|
|
330
|
+
requires_reboot=bool(response.get("requires_reboot")),
|
|
331
|
+
signature_findings=list(response.get("signature_findings") or []),
|
|
332
|
+
commands=list(response.get("commands") or []),
|
|
333
|
+
cache=self.cache_report(),
|
|
334
|
+
message=str(response.get("message", "")),
|
|
335
|
+
)
|
|
336
|
+
if not report.ok:
|
|
337
|
+
detail = response.get("error") or report.message or "unknown failure"
|
|
338
|
+
raise DriverInstallError(
|
|
339
|
+
f"the elevated driver installation failed: {detail}",
|
|
340
|
+
attempts=[c.get("argv", []) for c in report.commands],
|
|
341
|
+
hint=f"Full log: {self.log_dir}",
|
|
342
|
+
details={"response": response},
|
|
343
|
+
)
|
|
344
|
+
log.info("elevated installation succeeded (%s)", report.strategy_used)
|
|
345
|
+
return report
|
|
346
|
+
|
|
347
|
+
def install(self, *, timeout: Optional[float] = None) -> InstallReport:
|
|
348
|
+
"""Privileged phase: download, verify, extract and install. Must run elevated."""
|
|
349
|
+
require_windows("driver installation")
|
|
350
|
+
report = self.detect()
|
|
351
|
+
report.action = "install"
|
|
352
|
+
report.elevated = is_admin()
|
|
353
|
+
if report.device_present:
|
|
354
|
+
report.ok = True
|
|
355
|
+
report.skipped_reason = "already-installed"
|
|
356
|
+
report.message = "virtual display device already present"
|
|
357
|
+
return report
|
|
358
|
+
|
|
359
|
+
bundle = self.bundles.prepare_driver(self.spec, progress=self._progress_logger())
|
|
360
|
+
report.driver = self.spec.name
|
|
361
|
+
report.version = self.spec.version
|
|
362
|
+
report.signature_findings = assert_driver_trusted(
|
|
363
|
+
bundle.driver_dir,
|
|
364
|
+
self.spec.signer_thumbprints,
|
|
365
|
+
policy=self.signature_policy,
|
|
366
|
+
)
|
|
367
|
+
report.cache = self.cache_report()
|
|
368
|
+
|
|
369
|
+
attempts: List[str] = []
|
|
370
|
+
for name, runner in self._strategy_order():
|
|
371
|
+
log.info("trying driver install strategy %s", name)
|
|
372
|
+
outcome = runner(bundle, timeout=timeout)
|
|
373
|
+
report.commands.extend(outcome.setdefault("commands", []))
|
|
374
|
+
if outcome.get("ok"):
|
|
375
|
+
report.ok = True
|
|
376
|
+
report.strategy_used = name
|
|
377
|
+
report.requires_reboot = bool(outcome.get("requires_reboot"))
|
|
378
|
+
report.message = outcome.get("message", f"installed via {name}")
|
|
379
|
+
break
|
|
380
|
+
attempts.append(f"{name}: {outcome.get('message', 'failed')}")
|
|
381
|
+
if outcome.get("fatal"):
|
|
382
|
+
break
|
|
383
|
+
else:
|
|
384
|
+
report.ok = False
|
|
385
|
+
|
|
386
|
+
if not report.ok:
|
|
387
|
+
raise DriverInstallError(
|
|
388
|
+
"all driver installation strategies failed",
|
|
389
|
+
attempts=attempts,
|
|
390
|
+
code="driver-install-failed",
|
|
391
|
+
hint=f"Inspect {self.log_dir} (nefcon output is captured there).",
|
|
392
|
+
details={"driver": self.spec.name, "attempts": attempts},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
report.device_present = wait_for_virtual_display(
|
|
396
|
+
timeout=20.0, extra_markers=self.markers
|
|
397
|
+
)
|
|
398
|
+
if not report.device_present:
|
|
399
|
+
report.message += " (device registered but its monitor has not appeared yet)"
|
|
400
|
+
log.warning("driver installed but no virtual monitor is visible yet")
|
|
401
|
+
if report.requires_reboot:
|
|
402
|
+
report.message += " - a reboot is required to finish the installation"
|
|
403
|
+
log.warning("driver install reported ERROR_SUCCESS_REBOOT_REQUIRED (3010)")
|
|
404
|
+
return report
|
|
405
|
+
|
|
406
|
+
def _progress_logger(self):
|
|
407
|
+
state = {"next": 0.0}
|
|
408
|
+
|
|
409
|
+
def _progress(done: int, total: Optional[int]) -> None:
|
|
410
|
+
now = time.monotonic()
|
|
411
|
+
if now < state["next"] and (total is None or done < total):
|
|
412
|
+
return
|
|
413
|
+
state["next"] = now + 2.0
|
|
414
|
+
if total:
|
|
415
|
+
log.info("downloading driver package: %.1f%% (%d/%d bytes)", 100.0 * done / total, done, total)
|
|
416
|
+
else:
|
|
417
|
+
log.info("downloading driver package: %d bytes", done)
|
|
418
|
+
|
|
419
|
+
return _progress
|
|
420
|
+
|
|
421
|
+
def _strategy_order(self):
|
|
422
|
+
"""Yield ``(name, callable)`` pairs in the order dictated by ``strategy``."""
|
|
423
|
+
available = {
|
|
424
|
+
"nefcon": self._strategy_nefcon,
|
|
425
|
+
"nefcon-manual": self._strategy_nefcon_manual,
|
|
426
|
+
"pnputil": self._strategy_pnputil,
|
|
427
|
+
"scoop": self._strategy_scoop,
|
|
428
|
+
}
|
|
429
|
+
if self.strategy == "auto":
|
|
430
|
+
order = ["nefcon", "nefcon-manual", "pnputil"]
|
|
431
|
+
else:
|
|
432
|
+
order = [self.strategy]
|
|
433
|
+
for name in order:
|
|
434
|
+
runner = available.get(name)
|
|
435
|
+
if runner is None:
|
|
436
|
+
continue
|
|
437
|
+
if name == "scoop" and not self.allow_scoop:
|
|
438
|
+
log.debug("skipping the scoop strategy (allow_scoop=False)")
|
|
439
|
+
continue
|
|
440
|
+
yield name, runner
|
|
441
|
+
|
|
442
|
+
# -- individual strategies -------------------------------------------------------
|
|
443
|
+
def _nefcon(self, *, console: bool = False) -> Path:
|
|
444
|
+
tool = "nefcon_console" if console else "nefcon"
|
|
445
|
+
return self.bundles.tool(self.manifest, tool, progress=self._progress_logger())
|
|
446
|
+
|
|
447
|
+
def _log_file(self, name: str) -> Path:
|
|
448
|
+
return self.log_dir / f"{name}.log"
|
|
449
|
+
|
|
450
|
+
def _strategy_nefcon(self, bundle, *, timeout: Optional[float] = None) -> dict:
|
|
451
|
+
"""``nefconw install <inf> <hwid> --no-duplicates``: device node + driver in one step."""
|
|
452
|
+
try:
|
|
453
|
+
exe = self._nefcon()
|
|
454
|
+
except OffscreenMountError as exc:
|
|
455
|
+
return {"ok": False, "message": f"nefcon unavailable: {exc}", "commands": []}
|
|
456
|
+
commands: List[dict] = []
|
|
457
|
+
last_message = ""
|
|
458
|
+
for hardware_id in self.spec.all_hardware_ids:
|
|
459
|
+
argv = [str(exe), "install", str(bundle.inf_path), hardware_id, "--no-duplicates"]
|
|
460
|
+
result = run_command(
|
|
461
|
+
argv,
|
|
462
|
+
timeout=timeout or self.install_timeout,
|
|
463
|
+
log_path=self._log_file("nefcon-install"),
|
|
464
|
+
)
|
|
465
|
+
commands.append(result.to_dict())
|
|
466
|
+
if result.ok or result.requires_reboot:
|
|
467
|
+
return {
|
|
468
|
+
"ok": True,
|
|
469
|
+
"requires_reboot": result.requires_reboot,
|
|
470
|
+
"message": f"nefcon install succeeded for {hardware_id}",
|
|
471
|
+
"commands": commands,
|
|
472
|
+
"result": result,
|
|
473
|
+
}
|
|
474
|
+
last_message = (result.stderr or result.stdout or "").strip()[-400:]
|
|
475
|
+
log.warning("nefcon install failed for %s (exit %s): %s", hardware_id, result.returncode, last_message)
|
|
476
|
+
return {
|
|
477
|
+
"ok": False,
|
|
478
|
+
"message": f"nefcon install failed: {last_message or 'unknown error'}",
|
|
479
|
+
"commands": commands,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
def _strategy_nefcon_manual(self, bundle, *, timeout: Optional[float] = None) -> dict:
|
|
483
|
+
"""Explicit ``--create-device-node`` + ``--install-driver`` (matches the docs)."""
|
|
484
|
+
try:
|
|
485
|
+
exe = self._nefcon()
|
|
486
|
+
except OffscreenMountError as exc:
|
|
487
|
+
return {"ok": False, "message": f"nefcon unavailable: {exc}", "commands": []}
|
|
488
|
+
commands: List[dict] = []
|
|
489
|
+
create = run_command(
|
|
490
|
+
[
|
|
491
|
+
str(exe),
|
|
492
|
+
"--create-device-node",
|
|
493
|
+
"--hardware-id",
|
|
494
|
+
self.spec.hardware_id,
|
|
495
|
+
"--class-name",
|
|
496
|
+
self.spec.class_name,
|
|
497
|
+
"--class-guid",
|
|
498
|
+
self.spec.class_guid,
|
|
499
|
+
"--no-duplicates",
|
|
500
|
+
],
|
|
501
|
+
timeout=timeout or self.install_timeout,
|
|
502
|
+
log_path=self._log_file("nefcon-create-node"),
|
|
503
|
+
)
|
|
504
|
+
commands.append(create.to_dict())
|
|
505
|
+
# Already-existing node is not an error for us; the install step is what matters.
|
|
506
|
+
if not create.ok and not _looks_like_already_exists(create):
|
|
507
|
+
return {
|
|
508
|
+
"ok": False,
|
|
509
|
+
"message": f"device node creation failed: {_tail(create)}",
|
|
510
|
+
"commands": commands,
|
|
511
|
+
}
|
|
512
|
+
install = run_command(
|
|
513
|
+
[str(exe), "--install-driver", "--inf-path", str(bundle.inf_path)],
|
|
514
|
+
timeout=timeout or self.install_timeout,
|
|
515
|
+
log_path=self._log_file("nefcon-install-driver"),
|
|
516
|
+
)
|
|
517
|
+
commands.append(install.to_dict())
|
|
518
|
+
if install.ok or install.requires_reboot:
|
|
519
|
+
return {
|
|
520
|
+
"ok": True,
|
|
521
|
+
"requires_reboot": install.requires_reboot,
|
|
522
|
+
"message": "device node created and driver installed",
|
|
523
|
+
"commands": commands,
|
|
524
|
+
"result": install,
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
"ok": False,
|
|
528
|
+
"message": f"--install-driver failed: {_tail(install)}",
|
|
529
|
+
"commands": commands,
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
def _strategy_pnputil(self, bundle, *, timeout: Optional[float] = None) -> dict:
|
|
533
|
+
"""Fallback: add the package to the driver store and let PnP bind the ROOT node."""
|
|
534
|
+
pnputil = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "pnputil.exe")
|
|
535
|
+
if not os.path.isfile(pnputil):
|
|
536
|
+
return {"ok": False, "message": "pnputil.exe not found", "commands": []}
|
|
537
|
+
commands: List[dict] = []
|
|
538
|
+
add = run_command(
|
|
539
|
+
[pnputil, "/add-driver", str(bundle.inf_path), "/install"],
|
|
540
|
+
timeout=timeout or self.install_timeout,
|
|
541
|
+
log_path=self._log_file("pnputil-add-driver"),
|
|
542
|
+
)
|
|
543
|
+
commands.append(add.to_dict())
|
|
544
|
+
if add.ok or _REBOOT_REQUIRED == add.returncode:
|
|
545
|
+
# pnputil cannot create a ROOT-enumerated node; nefcon (or the installer the
|
|
546
|
+
# user ran) must have created one already for the driver to bind.
|
|
547
|
+
if self.is_present():
|
|
548
|
+
return {
|
|
549
|
+
"ok": True,
|
|
550
|
+
"requires_reboot": add.returncode == _REBOOT_REQUIRED,
|
|
551
|
+
"message": "driver store updated and the device is present",
|
|
552
|
+
"commands": commands,
|
|
553
|
+
"result": add,
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
"ok": False,
|
|
557
|
+
"message": (
|
|
558
|
+
"pnputil added the package but no ROOT\\MttVDD device node exists, so the "
|
|
559
|
+
"driver cannot bind (this is the documented pnputil limitation)"
|
|
560
|
+
),
|
|
561
|
+
"commands": commands,
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
"ok": False,
|
|
565
|
+
"message": f"pnputil failed: {_tail(add)}",
|
|
566
|
+
"commands": commands,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
def _strategy_scoop(self, bundle, *, timeout: Optional[float] = None) -> dict:
|
|
570
|
+
"""Opt-in convenience path for machines that already use Scoop."""
|
|
571
|
+
import shutil
|
|
572
|
+
|
|
573
|
+
if not self.allow_scoop:
|
|
574
|
+
return {"ok": False, "message": "scoop strategy disabled (allow_scoop=False)", "commands": []}
|
|
575
|
+
scoop = shutil.which("scoop")
|
|
576
|
+
if not scoop:
|
|
577
|
+
return {"ok": False, "message": "scoop is not on PATH", "commands": []}
|
|
578
|
+
commands: List[dict] = []
|
|
579
|
+
for argv in (
|
|
580
|
+
[scoop, "bucket", "add", "extras"],
|
|
581
|
+
[scoop, "bucket", "add", "nonportable"],
|
|
582
|
+
[scoop, "install", "iddsampledriver-ge9-np", "-g"],
|
|
583
|
+
):
|
|
584
|
+
result = run_command(
|
|
585
|
+
argv, timeout=timeout or self.install_timeout, log_path=self._log_file("scoop")
|
|
586
|
+
)
|
|
587
|
+
commands.append(result.to_dict())
|
|
588
|
+
if not result.ok and "already" not in (result.stderr or "").lower():
|
|
589
|
+
return {
|
|
590
|
+
"ok": False,
|
|
591
|
+
"message": f"scoop step failed: {_tail(result)}",
|
|
592
|
+
"commands": commands,
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
"ok": self.is_present(),
|
|
596
|
+
"message": "scoop finished" if self.is_present() else "scoop ran but no device appeared",
|
|
597
|
+
"commands": commands,
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
# -- removal ---------------------------------------------------------------------
|
|
601
|
+
def uninstall(
|
|
602
|
+
self,
|
|
603
|
+
*,
|
|
604
|
+
purge_files: bool = False,
|
|
605
|
+
purge_driver_store: bool = False,
|
|
606
|
+
timeout: Optional[float] = None,
|
|
607
|
+
elevate: bool = True,
|
|
608
|
+
) -> InstallReport:
|
|
609
|
+
"""Remove the device node, optionally purging the store package and cached files.
|
|
610
|
+
|
|
611
|
+
Removal is a privileged operation (measured: nefcon refuses with "This command requires
|
|
612
|
+
elevated privileges"), so an unelevated caller is routed through the same UAC helper that
|
|
613
|
+
installation uses. ``elevate=False`` keeps the in-process behaviour for callers that are
|
|
614
|
+
already elevated (the helper passes it that way).
|
|
615
|
+
"""
|
|
616
|
+
require_windows("driver uninstall")
|
|
617
|
+
if elevate and not is_admin():
|
|
618
|
+
return self._uninstall_elevated(
|
|
619
|
+
purge_files=purge_files, purge_driver_store=purge_driver_store, timeout=timeout
|
|
620
|
+
)
|
|
621
|
+
return self._uninstall_in_process(
|
|
622
|
+
purge_files=purge_files, purge_driver_store=purge_driver_store, timeout=timeout
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
def _uninstall_in_process(
|
|
626
|
+
self,
|
|
627
|
+
*,
|
|
628
|
+
purge_files: bool = False,
|
|
629
|
+
purge_driver_store: bool = False,
|
|
630
|
+
timeout: Optional[float] = None,
|
|
631
|
+
) -> InstallReport:
|
|
632
|
+
report = self.detect()
|
|
633
|
+
report.action = "uninstall"
|
|
634
|
+
if not report.device_present:
|
|
635
|
+
report.ok = True
|
|
636
|
+
report.message = "no virtual display device was registered"
|
|
637
|
+
else:
|
|
638
|
+
try:
|
|
639
|
+
exe = self._nefcon()
|
|
640
|
+
except OffscreenMountError as exc:
|
|
641
|
+
raise DriverInstallError(
|
|
642
|
+
f"nefcon is required to remove the device node: {exc}",
|
|
643
|
+
hint="Run `VirtualDesktop fetch-driver` first (needs network access).",
|
|
644
|
+
) from exc
|
|
645
|
+
remove = run_command(
|
|
646
|
+
[
|
|
647
|
+
str(exe),
|
|
648
|
+
"--remove-device-node",
|
|
649
|
+
"--hardware-id",
|
|
650
|
+
self.spec.hardware_id,
|
|
651
|
+
"--class-guid",
|
|
652
|
+
self.spec.class_guid,
|
|
653
|
+
],
|
|
654
|
+
timeout=timeout or self.install_timeout,
|
|
655
|
+
log_path=self._log_file("nefcon-remove-device-node"),
|
|
656
|
+
)
|
|
657
|
+
report.commands.append(remove.to_dict())
|
|
658
|
+
report.ok = remove.ok or remove.requires_reboot
|
|
659
|
+
report.requires_reboot = remove.requires_reboot
|
|
660
|
+
report.message = "device node removed" if report.ok else f"removal failed: {_tail(remove)}"
|
|
661
|
+
if not report.ok:
|
|
662
|
+
raise DriverInstallError(
|
|
663
|
+
f"could not remove the virtual display device: {_tail(remove)}",
|
|
664
|
+
attempts=[remove.to_dict()],
|
|
665
|
+
hint=f"See {self._log_file('nefcon-remove-device-node')}",
|
|
666
|
+
)
|
|
667
|
+
if purge_driver_store:
|
|
668
|
+
purge = run_command(
|
|
669
|
+
[
|
|
670
|
+
str(exe),
|
|
671
|
+
"--remove-driver-store-package",
|
|
672
|
+
"--inf-name",
|
|
673
|
+
Path(self.spec.inf_member).name,
|
|
674
|
+
"--class-guid",
|
|
675
|
+
self.spec.class_guid,
|
|
676
|
+
],
|
|
677
|
+
timeout=timeout or self.install_timeout,
|
|
678
|
+
log_path=self._log_file("nefcon-purge-package"),
|
|
679
|
+
)
|
|
680
|
+
report.commands.append(purge.to_dict())
|
|
681
|
+
if not purge.ok:
|
|
682
|
+
log.warning("driver-store purge failed: %s", _tail(purge))
|
|
683
|
+
if purge_files:
|
|
684
|
+
report.skipped_reason = (
|
|
685
|
+
"cached driver tree removed"
|
|
686
|
+
if self.bundles.remove_driver_tree(self.spec)
|
|
687
|
+
else "no cached driver tree to remove"
|
|
688
|
+
)
|
|
689
|
+
return report
|
|
690
|
+
|
|
691
|
+
def _uninstall_elevated(
|
|
692
|
+
self,
|
|
693
|
+
*,
|
|
694
|
+
purge_files: bool,
|
|
695
|
+
purge_driver_store: bool,
|
|
696
|
+
timeout: Optional[float] = None,
|
|
697
|
+
) -> InstallReport:
|
|
698
|
+
"""Run the removal through UAC, exactly like installation does."""
|
|
699
|
+
log.info("removing the virtual display; requesting elevation")
|
|
700
|
+
self._announce("removing the virtual display driver")
|
|
701
|
+
log_dir = str(self.log_dir)
|
|
702
|
+
try:
|
|
703
|
+
result = elevate_and_run_helper(
|
|
704
|
+
[
|
|
705
|
+
"--uninstall",
|
|
706
|
+
"--cache-dir",
|
|
707
|
+
str(self.cache_dir),
|
|
708
|
+
"--driver",
|
|
709
|
+
self.spec.name,
|
|
710
|
+
"--install-timeout",
|
|
711
|
+
str(self.install_timeout),
|
|
712
|
+
"--log-dir",
|
|
713
|
+
log_dir,
|
|
714
|
+
*(["--purge-files"] if purge_files else []),
|
|
715
|
+
*(["--purge-driver-store"] if purge_driver_store else []),
|
|
716
|
+
],
|
|
717
|
+
timeout=timeout or (self.install_timeout + 300.0),
|
|
718
|
+
log_dir=log_dir,
|
|
719
|
+
extra_env=self._helper_env(),
|
|
720
|
+
)
|
|
721
|
+
except ElevationDenied:
|
|
722
|
+
raise
|
|
723
|
+
if result.timed_out:
|
|
724
|
+
raise DriverInstallError(
|
|
725
|
+
"the elevated uninstaller did not report a result before the timeout",
|
|
726
|
+
hint=f"Check {self.log_dir} for the elevated log.",
|
|
727
|
+
code="driver-uninstall-timeout",
|
|
728
|
+
)
|
|
729
|
+
response = result.response or {}
|
|
730
|
+
report = InstallReport(
|
|
731
|
+
action="uninstall",
|
|
732
|
+
ok=bool(response.get("ok")),
|
|
733
|
+
device_present=bool(response.get("device_present")),
|
|
734
|
+
driver=str(response.get("driver", self.spec.name)),
|
|
735
|
+
elevated=True,
|
|
736
|
+
requires_reboot=bool(response.get("requires_reboot")),
|
|
737
|
+
commands=list(response.get("commands") or []),
|
|
738
|
+
cache=self.cache_report(),
|
|
739
|
+
message=str(response.get("message", "")),
|
|
740
|
+
)
|
|
741
|
+
if not report.ok:
|
|
742
|
+
detail = response.get("error") or report.message or "unknown failure"
|
|
743
|
+
raise DriverInstallError(
|
|
744
|
+
f"the elevated removal failed: {detail}",
|
|
745
|
+
attempts=[c.get("argv", []) for c in report.commands],
|
|
746
|
+
hint=f"Full log: {self.log_dir}",
|
|
747
|
+
details={"response": response},
|
|
748
|
+
)
|
|
749
|
+
log.info("elevated removal succeeded")
|
|
750
|
+
return report
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _tail(result: CommandResult, limit: int = 400) -> str:
|
|
754
|
+
text = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
755
|
+
return text[-limit:] if text else f"exit code {result.returncode}"
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _looks_like_already_exists(result: CommandResult) -> bool:
|
|
759
|
+
haystack = f"{result.stdout} {result.stderr}".lower()
|
|
760
|
+
return "already exists" in haystack or "0x800700b7" in haystack or "183" in haystack
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def install_from_helper(
|
|
764
|
+
*,
|
|
765
|
+
cache_dir: Optional[str] = None,
|
|
766
|
+
strategy: str = "auto",
|
|
767
|
+
signature_policy: str = "warn",
|
|
768
|
+
driver: Optional[str] = None,
|
|
769
|
+
install_timeout: float = 600.0,
|
|
770
|
+
log_dir: Optional[str] = None,
|
|
771
|
+
response_pipe: Optional[str] = None,
|
|
772
|
+
uninstall: bool = False,
|
|
773
|
+
purge_files: bool = False,
|
|
774
|
+
purge_driver_store: bool = False,
|
|
775
|
+
elevate: bool = True,
|
|
776
|
+
) -> int:
|
|
777
|
+
"""Entry point for the elevated child process (``_elevated-install``).
|
|
778
|
+
|
|
779
|
+
Handles both directions: installing (default) and **removing** the device
|
|
780
|
+
(``uninstall=True``). Removal needs the same privilege as installation — measured: nefcon
|
|
781
|
+
answers ``This command requires elevated privileges`` otherwise, which is why
|
|
782
|
+
``VirtualDesktop uninstall-driver`` goes through this helper exactly like installation does.
|
|
783
|
+
|
|
784
|
+
``elevate`` must be ``False`` when *this* call already runs inside the elevated child. It is
|
|
785
|
+
the caller's decision, not a token probe: the token probe (``is_admin()``) reports "not
|
|
786
|
+
elevated" inside the elevated child on some configurations, and ``uninstall()`` would then
|
|
787
|
+
ask UAC for elevation a second time — spawning a nested helper that nobody is left to
|
|
788
|
+
approve, so the outer helper waited on its pipe until the parent gave up
|
|
789
|
+
(measured: ``driver-uninstall-timeout`` with no ``consent.exe`` and no helper process).
|
|
790
|
+
"""
|
|
791
|
+
payload: Dict[str, Any] = {"ok": False, "action": "uninstall" if uninstall else "install"}
|
|
792
|
+
exit_code = 1
|
|
793
|
+
installer: Optional[DriverInstaller] = None
|
|
794
|
+
try:
|
|
795
|
+
installer = DriverInstaller(
|
|
796
|
+
cache_dir=cache_dir,
|
|
797
|
+
driver=driver,
|
|
798
|
+
strategy=strategy,
|
|
799
|
+
signature_policy=signature_policy,
|
|
800
|
+
install_timeout=install_timeout,
|
|
801
|
+
)
|
|
802
|
+
if uninstall:
|
|
803
|
+
report = installer.uninstall(
|
|
804
|
+
purge_files=purge_files,
|
|
805
|
+
purge_driver_store=purge_driver_store,
|
|
806
|
+
timeout=install_timeout,
|
|
807
|
+
elevate=elevate,
|
|
808
|
+
)
|
|
809
|
+
payload = report.to_dict()
|
|
810
|
+
payload["ok"] = bool(report.ok) and not installer.is_present()
|
|
811
|
+
payload["device_present"] = installer.is_present()
|
|
812
|
+
exit_code = 0 if payload["ok"] else 1
|
|
813
|
+
else:
|
|
814
|
+
report = installer.install()
|
|
815
|
+
payload = report.to_dict()
|
|
816
|
+
payload["ok"] = True
|
|
817
|
+
payload["device_present"] = report.device_present
|
|
818
|
+
exit_code = 0
|
|
819
|
+
except Exception as exc: # noqa: BLE001 - the child must never die silently
|
|
820
|
+
log.exception("elevated driver operation failed")
|
|
821
|
+
payload["ok"] = False
|
|
822
|
+
payload["error"] = str(exc)
|
|
823
|
+
payload["error_code"] = getattr(exc, "code", type(exc).__name__)
|
|
824
|
+
payload["hint"] = getattr(exc, "hint", None)
|
|
825
|
+
if isinstance(exc, DriverInstallError):
|
|
826
|
+
payload["commands"] = list(exc.details.get("attempts") or [])
|
|
827
|
+
payload["cache"] = installer.cache_report() if installer else {}
|
|
828
|
+
finally:
|
|
829
|
+
if response_pipe:
|
|
830
|
+
write_response(response_pipe, payload)
|
|
831
|
+
return exit_code
|