driverclient 0.2.19__tar.gz → 0.2.21__tar.gz
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.
- {driverclient-0.2.19 → driverclient-0.2.21}/PKG-INFO +1 -1
- {driverclient-0.2.19 → driverclient-0.2.21}/pyproject.toml +1 -1
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/config.json +7 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/config.py +20 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/http.py +6 -1
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/main.py +2 -0
- driverclient-0.2.21/src/driverclient/ops/download.py +63 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/install.py +128 -167
- driverclient-0.2.21/src/driverclient/ops/offline.py +101 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/resolve.py +15 -5
- {driverclient-0.2.19 → driverclient-0.2.21}/.gitignore +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/README.md +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/hwid.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/proc.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/events.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/automate.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/capture.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/diagnose.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/scan.py +0 -0
- {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: driverclient
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.21
|
|
4
4
|
Summary: Driver Server client with an embeddable connector for config injection at runtime.
|
|
5
5
|
Project-URL: Homepage, https://example.com/driverclient
|
|
6
6
|
Author-email: Raja Sanaullah <sanaullah@99technologies.com>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "driverclient"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.21"
|
|
8
8
|
description = "Driver Server client with an embeddable connector for config injection at runtime."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
|
+
"_comment_mode": "mode = 'live' (default): SA/first-boot install+reboot+WU+capture. mode = 'offline': WinPE download-only to offline_driver_dir, then win11.bat.j2 DISM-injects. See examples/config.json for the full prod-deploy note.",
|
|
3
|
+
"mode": "live",
|
|
2
4
|
"local_repo_url": "http://localhost:8000",
|
|
3
5
|
"node_key": "REPLACE_WITH_YOUR_NODE_KEY",
|
|
4
6
|
|
|
5
7
|
"default_command": "automate",
|
|
6
8
|
|
|
9
|
+
"_comment_compression": "zstd downloads decompressed on-the-fly; keep true, set false only if zstd fails in WinPE.",
|
|
10
|
+
"compression": true,
|
|
7
11
|
"parallel_downloads": 6,
|
|
8
12
|
"parallel_uploads": 8,
|
|
9
13
|
"parallel_exports": 4,
|
|
10
14
|
|
|
15
|
+
"_comment_offline_driver_dir": "offline mode writes files here (WinPE); DISM injects into the applied image W:\\. Ignored in live mode.",
|
|
16
|
+
"offline_driver_dir": "W:\\drivers",
|
|
17
|
+
|
|
11
18
|
"driver_root": "C:\\DriverServer",
|
|
12
19
|
"cache_dir": "C:\\DriverServer\\client",
|
|
13
20
|
"work_dir": "C:\\DriverServer\\client\\staging",
|
|
@@ -13,6 +13,26 @@ DEFAULTS: dict = {
|
|
|
13
13
|
"local_repo_url": "http://localhost:8000",
|
|
14
14
|
"node_key": "",
|
|
15
15
|
|
|
16
|
+
# ── Mode ──────────────────────────────────────────────────────────────
|
|
17
|
+
# "live" = SA / first-boot: batched pnputil install + reboot loop + WU +
|
|
18
|
+
# capture (today's behaviour; handles the post-boot device tail).
|
|
19
|
+
# "offline" = pre-boot / WinPE: scan → resolve → DOWNLOAD ONLY to
|
|
20
|
+
# offline_driver_dir. No install, no reboot, no WU, no capture —
|
|
21
|
+
# win11.bat.j2's DISM step injects the files into the image.
|
|
22
|
+
"mode": "live",
|
|
23
|
+
|
|
24
|
+
# ── Offline (pre-boot / WinPE) ────────────────────────────────────────
|
|
25
|
+
# Where offline mode writes the decompressed driver files. DISM
|
|
26
|
+
# (win11.bat.j2) injects them into the applied image at W:\ afterward — the
|
|
27
|
+
# client only downloads; it never runs DISM itself.
|
|
28
|
+
"offline_driver_dir": "W:\\drivers",
|
|
29
|
+
|
|
30
|
+
# Request zstd-compressed downloads (decompressed on-the-fly in core/http.py):
|
|
31
|
+
# GPU ≈ 450 MB instead of 1.3 GB. True everywhere by default; set False only
|
|
32
|
+
# as a WinPE fallback if zstd ever fails to load there. Gates the
|
|
33
|
+
# Accept-Encoding: zstd request header sent by http_download.
|
|
34
|
+
"compression": True,
|
|
35
|
+
|
|
16
36
|
# ── Command ───────────────────────────────────────────────────────────
|
|
17
37
|
"default_command": "automate",
|
|
18
38
|
|
|
@@ -194,7 +194,12 @@ def http_download(url: str, dest: Path, sha256_expected: str,
|
|
|
194
194
|
"""
|
|
195
195
|
cfg = get()
|
|
196
196
|
t = timeout or cfg["timeout_long"]
|
|
197
|
-
|
|
197
|
+
# Only advertise zstd when compression is enabled. Offline/WinPE keeps it on
|
|
198
|
+
# (GPU ≈ 450 MB vs 1.3 GB); set compression=false as a fallback if zstd ever
|
|
199
|
+
# fails to load in WinPE, and the server serves the file uncompressed.
|
|
200
|
+
headers = dict(_headers())
|
|
201
|
+
if cfg.get("compression", True):
|
|
202
|
+
headers["Accept-Encoding"] = "zstd"
|
|
198
203
|
|
|
199
204
|
req = urllib.request.Request(url, headers=headers)
|
|
200
205
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -28,6 +28,7 @@ from driverclient.ops.capture import (
|
|
|
28
28
|
)
|
|
29
29
|
from driverclient.ops.diagnose import run_diagnostics
|
|
30
30
|
from driverclient.ops.install import resolve_and_install
|
|
31
|
+
from driverclient.ops.offline import resolve_and_download
|
|
31
32
|
from driverclient.ops.resolve import resolve
|
|
32
33
|
from driverclient.ops.scan import scan
|
|
33
34
|
|
|
@@ -35,6 +36,7 @@ _OPERATIONS: dict[str, callable] = {
|
|
|
35
36
|
"scan": scan,
|
|
36
37
|
"resolve": resolve,
|
|
37
38
|
"resolve-and-install": resolve_and_install,
|
|
39
|
+
"offline": resolve_and_download,
|
|
38
40
|
"capture-all": capture_all,
|
|
39
41
|
"capture-missing": capture_missing,
|
|
40
42
|
"capture-delta": capture_delta,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/download.py — Shared driver-file download primitives.
|
|
3
|
+
|
|
4
|
+
Both the LIVE install path (ops/install.py) and the OFFLINE / pre-boot path
|
|
5
|
+
(ops/offline.py) fetch the same package files from the repo:
|
|
6
|
+
|
|
7
|
+
GET {repo_url}/files/{package_id}/{filename} (streamed, zstd on-the-fly,
|
|
8
|
+
sha256-verified — see core/http.py)
|
|
9
|
+
|
|
10
|
+
Factored out here so the two modes share one download implementation:
|
|
11
|
+
• download_driver() — fetch every file for ONE driver into base/<binding_id>/
|
|
12
|
+
• download_drivers() — fan the above out across a driver list in parallel
|
|
13
|
+
"""
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from driverclient.core.http import http_download_retry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def download_driver(driver: dict, base: Path, repo_url: str,
|
|
21
|
+
cfg: dict) -> tuple[Path, bool]:
|
|
22
|
+
"""Download all files for one driver into base/<binding_id>/.
|
|
23
|
+
Returns (dest_dir, success). success is False if ANY file failed."""
|
|
24
|
+
bid = driver.get("binding_id") or driver.get("package_id", "")
|
|
25
|
+
pkg_id = driver["package_id"]
|
|
26
|
+
dest = base / bid
|
|
27
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
ok = True
|
|
30
|
+
for f in driver.get("files", []):
|
|
31
|
+
url = f"{repo_url}/files/{pkg_id}/{f['filename']}"
|
|
32
|
+
path = dest / f["filename"]
|
|
33
|
+
if not http_download_retry(url, path, f["sha256"],
|
|
34
|
+
timeout=cfg["timeout_long"]):
|
|
35
|
+
ok = False
|
|
36
|
+
|
|
37
|
+
return dest, ok
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def download_drivers(drivers: list[dict], base: Path,
|
|
41
|
+
cfg: dict) -> list[tuple[dict, Path | None, bool]]:
|
|
42
|
+
"""Download a list of drivers into `base` in parallel.
|
|
43
|
+
|
|
44
|
+
Returns a list of (driver, dest_dir | None, success) in completion order.
|
|
45
|
+
A driver that raised during download yields (driver, None, False) rather
|
|
46
|
+
than propagating — the caller decides how to report failures.
|
|
47
|
+
"""
|
|
48
|
+
repo_url = cfg["local_repo_url"]
|
|
49
|
+
parallel = cfg.get("parallel_downloads", 6)
|
|
50
|
+
|
|
51
|
+
results: list[tuple[dict, Path | None, bool]] = []
|
|
52
|
+
with ThreadPoolExecutor(max_workers=parallel) as executor:
|
|
53
|
+
fut_map = {executor.submit(download_driver, d, base, repo_url, cfg): d
|
|
54
|
+
for d in drivers}
|
|
55
|
+
for fut in as_completed(fut_map):
|
|
56
|
+
driver = fut_map[fut]
|
|
57
|
+
try:
|
|
58
|
+
dest, ok = fut.result()
|
|
59
|
+
except Exception:
|
|
60
|
+
results.append((driver, None, False))
|
|
61
|
+
continue
|
|
62
|
+
results.append((driver, dest, ok))
|
|
63
|
+
return results
|
|
@@ -19,14 +19,15 @@ import subprocess
|
|
|
19
19
|
import time
|
|
20
20
|
import uuid
|
|
21
21
|
from collections import defaultdict
|
|
22
|
-
from concurrent.futures import
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
23
23
|
from dataclasses import dataclass, field
|
|
24
24
|
from pathlib import Path
|
|
25
25
|
|
|
26
26
|
from driverclient import events
|
|
27
27
|
from driverclient.config import get
|
|
28
28
|
from driverclient.core import proc
|
|
29
|
-
from driverclient.core.http import
|
|
29
|
+
from driverclient.core.http import http_post_retry
|
|
30
|
+
from driverclient.ops.download import download_driver
|
|
30
31
|
from driverclient.ops.resolve import ResolveResult, resolve
|
|
31
32
|
|
|
32
33
|
# Install order: lower number first (hardware dependency)
|
|
@@ -155,12 +156,13 @@ def resolve_and_install(force: bool = False) -> InstallResult:
|
|
|
155
156
|
# at the same-or-newer version — so a still-current big driver (graphics) is not
|
|
156
157
|
# re-downloaded, while missing/extension drivers still install. Toggle off with
|
|
157
158
|
# skip_installed_drivers=false to force-reinstall everything.
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
159
|
+
# Driver-store index (INF name -> highest installed version), built once to
|
|
160
|
+
# FILTER the download list: skip any package the store already has at the
|
|
161
|
+
# same-or-newer version. This is skip-if-present (idempotency), NOT dedup —
|
|
162
|
+
# no repo package is removed; a newer repo version still installs over an
|
|
163
|
+
# older store version (store_ver >= repo_ver skips). Duplicate versions that
|
|
164
|
+
# survive the filter are resolved at install time by a single batched pnputil
|
|
165
|
+
# pass, where Windows picks the best driver per device.
|
|
164
166
|
skip_installed = cfg.get("skip_installed_drivers", True)
|
|
165
167
|
idx: dict = _installed_inf_index() if skip_installed else {}
|
|
166
168
|
|
|
@@ -188,7 +190,7 @@ def resolve_and_install(force: bool = False) -> InstallResult:
|
|
|
188
190
|
events.start("install",
|
|
189
191
|
f"[install] {len(drivers)} driver(s) — starting parallel downloads…",
|
|
190
192
|
total=len(drivers))
|
|
191
|
-
all_results = _stream_install(drivers, work_dir, cfg
|
|
193
|
+
all_results = _stream_install(drivers, work_dir, cfg)
|
|
192
194
|
|
|
193
195
|
shutil.rmtree(work_dir, ignore_errors=True)
|
|
194
196
|
|
|
@@ -219,34 +221,59 @@ def resolve_and_install(force: bool = False) -> InstallResult:
|
|
|
219
221
|
|
|
220
222
|
# ── Streaming pipeline ──────────────────────────────────────────────────────────
|
|
221
223
|
|
|
222
|
-
def _stream_install(drivers: list[dict], base: Path, cfg: dict
|
|
223
|
-
idx: dict, skip_installed: bool) -> list[DriverResult]:
|
|
224
|
+
def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverResult]:
|
|
224
225
|
"""
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
Download every driver in parallel, then install in the fewest passes possible:
|
|
227
|
+
|
|
228
|
+
• all INF drivers -> ONE `pnputil /add-driver <base>\\*.inf /subdirs /install`
|
|
229
|
+
pass. Windows stages every package and installs the best/newest driver per
|
|
230
|
+
device in a single PnP reconciliation — far faster than one pnputil call
|
|
231
|
+
per driver, and it resolves duplicate versions itself (so "no dedup" is
|
|
232
|
+
handled at install time here, not by dropping packages).
|
|
233
|
+
• EXE-installer drivers -> run their silent installer individually (EXEs
|
|
234
|
+
cannot be batched).
|
|
235
|
+
|
|
236
|
+
Downloads stay fully parallel; only the install strategy changed from one
|
|
237
|
+
pnputil call per driver to a single batched call.
|
|
232
238
|
"""
|
|
233
239
|
repo_url = cfg["local_repo_url"]
|
|
234
240
|
parallel = cfg.get("parallel_downloads", 6)
|
|
235
241
|
|
|
236
|
-
by_level
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
242
|
+
by_level = _dedup_and_group(drivers)
|
|
243
|
+
ordered = [d for level in sorted(by_level) for d in by_level[level]]
|
|
244
|
+
|
|
245
|
+
events.progress("install",
|
|
246
|
+
f"[install] Downloading {len(ordered)} driver(s) in parallel…",
|
|
247
|
+
total=len(ordered))
|
|
248
|
+
|
|
249
|
+
results: list[DriverResult] = []
|
|
250
|
+
downloaded: list[tuple[dict, Path]] = []
|
|
251
|
+
with ThreadPoolExecutor(max_workers=parallel) as executor:
|
|
252
|
+
fut_map = {executor.submit(download_driver, d, base, repo_url, cfg): d
|
|
253
|
+
for d in ordered}
|
|
254
|
+
for fut in as_completed(fut_map):
|
|
255
|
+
driver = fut_map[fut]
|
|
256
|
+
bid = driver.get("binding_id") or driver.get("package_id", "")
|
|
257
|
+
try:
|
|
258
|
+
dest, dl_ok = fut.result()
|
|
259
|
+
except Exception as e:
|
|
260
|
+
results.append(DriverResult(binding_id=bid, success=False, error=str(e)))
|
|
261
|
+
continue
|
|
262
|
+
if dl_ok:
|
|
263
|
+
downloaded.append((driver, dest))
|
|
264
|
+
else:
|
|
265
|
+
results.append(DriverResult(binding_id=bid, success=False,
|
|
266
|
+
error="download_failed"))
|
|
267
|
+
|
|
268
|
+
inf_drivers = [(d, p) for d, p in downloaded
|
|
269
|
+
if d.get("install_type", "inf_silent") != "exe_silent"]
|
|
270
|
+
exe_drivers = [(d, p) for d, p in downloaded
|
|
271
|
+
if d.get("install_type", "inf_silent") == "exe_silent"]
|
|
272
|
+
|
|
273
|
+
results += _batch_install_infs(inf_drivers, base, cfg)
|
|
274
|
+
for driver, dest in exe_drivers:
|
|
275
|
+
results.append(_install_one_exe(driver, dest, cfg))
|
|
276
|
+
return results
|
|
250
277
|
|
|
251
278
|
|
|
252
279
|
def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
|
|
@@ -262,104 +289,84 @@ def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
|
|
|
262
289
|
return by_level
|
|
263
290
|
|
|
264
291
|
|
|
265
|
-
def
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
fut: Future,
|
|
285
|
-
future_info: dict[Future, tuple[int, dict]],
|
|
286
|
-
cfg: dict,
|
|
287
|
-
idx: dict,
|
|
288
|
-
skip_installed: bool,
|
|
289
|
-
) -> DriverResult:
|
|
290
|
-
"""Handle one completed download Future: install if ok, return DriverResult."""
|
|
291
|
-
_, driver = future_info[fut]
|
|
292
|
-
bid = driver.get("binding_id", "")
|
|
293
|
-
cat = driver.get("category", "other")
|
|
294
|
-
|
|
295
|
-
# A driver installed earlier THIS pass may have added the same INF (another
|
|
296
|
-
# package of the same driver family), so a package that was missing at
|
|
297
|
-
# pass-start can now already be in the store at an equal-or-newer version.
|
|
298
|
-
# Re-check against the live index and skip the redundant re-install.
|
|
299
|
-
if skip_installed and _already_in_store(driver, idx):
|
|
300
|
-
events.ok("install", f" ⭑ [{cat}] {bid} already in driver store — skipped",
|
|
301
|
-
binding_id=bid, category=cat, skipped=True)
|
|
302
|
-
return DriverResult(binding_id=bid, success=True, skipped=True)
|
|
303
|
-
|
|
292
|
+
def _batch_install_infs(inf_drivers: list[tuple[dict, Path]], base: Path,
|
|
293
|
+
cfg: dict) -> list[DriverResult]:
|
|
294
|
+
"""Install every downloaded INF driver in ONE pnputil pass.
|
|
295
|
+
|
|
296
|
+
`pnputil /add-driver <base>\\*.inf /subdirs /install` recurses the staging tree
|
|
297
|
+
(each driver lives in its own subdir), adds every package to the driver store
|
|
298
|
+
and installs the best match onto each device in a single PnP reconciliation —
|
|
299
|
+
the fast path the old driverserver used (launcher.bat). pnputil does not report
|
|
300
|
+
a clean per-package status, so the batch's outcome is attributed to each driver
|
|
301
|
+
and pnputil's own output lines are surfaced so nothing is hidden by batching."""
|
|
302
|
+
if not inf_drivers:
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
n = len(inf_drivers)
|
|
306
|
+
events.progress("install",
|
|
307
|
+
f"[install] Installing {n} INF driver(s) in one batched pnputil pass…",
|
|
308
|
+
total=n)
|
|
309
|
+
t0 = time.monotonic()
|
|
310
|
+
stdout = ""
|
|
304
311
|
try:
|
|
305
|
-
|
|
312
|
+
r = proc.run(
|
|
313
|
+
["pnputil", "/add-driver", str(base / "*.inf"), "/subdirs", "/install"],
|
|
314
|
+
capture_output=True, text=True,
|
|
315
|
+
timeout=cfg.get("timeout_install", 600),
|
|
316
|
+
)
|
|
317
|
+
stdout = r.stdout or ""
|
|
318
|
+
ok, err, reboot = _classify(r.returncode, r.stdout, r.stderr)
|
|
319
|
+
except subprocess.TimeoutExpired:
|
|
320
|
+
ok, err, reboot = False, "Batch install timed out", False
|
|
306
321
|
except Exception as e:
|
|
307
|
-
|
|
322
|
+
ok, err, reboot = False, str(e), False
|
|
323
|
+
dur = int((time.monotonic() - t0) * 1000)
|
|
308
324
|
|
|
309
|
-
|
|
310
|
-
|
|
325
|
+
for line in stdout.splitlines():
|
|
326
|
+
s = line.strip()
|
|
327
|
+
if s:
|
|
328
|
+
events.progress("install", f" {s}")
|
|
311
329
|
|
|
312
|
-
t0 = time.monotonic()
|
|
313
|
-
ok, err, reboot = _install_driver(driver, dest, cfg)
|
|
314
|
-
dur = int((time.monotonic() - t0) * 1000)
|
|
315
|
-
if ok:
|
|
316
|
-
_record_installed(driver, idx) # update live index so same-family dupes skip
|
|
317
330
|
tag = " (reboot required)" if reboot else ""
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
331
|
+
(events.ok if ok else events.error)(
|
|
332
|
+
"install",
|
|
333
|
+
f" {'✓' if ok else '✗'} batch-installed {n} INF driver(s) ({dur}ms){tag}",
|
|
334
|
+
count=n, duration_ms=dur, reboot_required=reboot, error=err)
|
|
335
|
+
|
|
336
|
+
return [
|
|
337
|
+
DriverResult(binding_id=(d.get("binding_id") or d.get("package_id", "")),
|
|
338
|
+
success=ok, error="" if ok else err,
|
|
339
|
+
duration_ms=dur, reboot_required=reboot)
|
|
340
|
+
for d, _ in inf_drivers
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _install_one_exe(driver: dict, driver_dir: Path, cfg: dict) -> DriverResult:
|
|
345
|
+
"""Run one EXE-installer driver's silent setup (EXEs cannot be batched)."""
|
|
346
|
+
bid = driver.get("binding_id") or driver.get("package_id", "")
|
|
347
|
+
t0 = time.monotonic()
|
|
348
|
+
try:
|
|
349
|
+
exe_files = list(driver_dir.glob("*.exe"))
|
|
350
|
+
if not exe_files:
|
|
351
|
+
return DriverResult(binding_id=bid, success=False, error="No EXE file found")
|
|
352
|
+
flags = driver.get("install_flags", "/S")
|
|
353
|
+
r = proc.run([str(exe_files[0])] + flags.split(),
|
|
354
|
+
capture_output=True, timeout=cfg["timeout_install"])
|
|
355
|
+
ok, err, reboot = _classify(r.returncode)
|
|
356
|
+
except subprocess.TimeoutExpired:
|
|
357
|
+
ok, err, reboot = False, "Install timed out", False
|
|
358
|
+
except Exception as e:
|
|
359
|
+
ok, err, reboot = False, str(e), False
|
|
360
|
+
dur = int((time.monotonic() - t0) * 1000)
|
|
361
|
+
(events.ok if ok else events.error)(
|
|
362
|
+
"install", f" {'✓' if ok else '✗'} [exe] {bid} ({dur}ms)",
|
|
363
|
+
binding_id=bid, duration_ms=dur, error=err, reboot_required=reboot)
|
|
322
364
|
return DriverResult(binding_id=bid, success=ok, error=err,
|
|
323
365
|
duration_ms=dur, reboot_required=reboot)
|
|
324
366
|
|
|
325
367
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
later package of the same driver family at an equal-or-older version is skipped
|
|
329
|
-
this pass — mirrors what `pnputil /enum-drivers` will report on the next scan."""
|
|
330
|
-
ver = _parse_ver(driver.get("version_label", ""))
|
|
331
|
-
if not ver:
|
|
332
|
-
return
|
|
333
|
-
for f in driver.get("files", []):
|
|
334
|
-
name = f.get("filename", "").lower()
|
|
335
|
-
if f.get("file_role") == "inf" or name.endswith(".inf"):
|
|
336
|
-
if idx.get(name) is None or ver > idx[name]:
|
|
337
|
-
idx[name] = ver
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
def _download_driver(driver: dict, base: Path, repo_url: str,
|
|
341
|
-
cfg: dict) -> tuple[Path, bool]:
|
|
342
|
-
"""Download all files for one driver. Returns (dest_dir, success)."""
|
|
343
|
-
bid = driver.get("binding_id") or driver.get("package_id", "")
|
|
344
|
-
pkg_id = driver["package_id"]
|
|
345
|
-
dest = base / bid
|
|
346
|
-
dest.mkdir(parents=True, exist_ok=True)
|
|
347
|
-
|
|
348
|
-
ok = True
|
|
349
|
-
for f in driver.get("files", []):
|
|
350
|
-
url = f"{repo_url}/files/{pkg_id}/{f['filename']}"
|
|
351
|
-
path = dest / f["filename"]
|
|
352
|
-
if not http_download_retry(url, path, f["sha256"],
|
|
353
|
-
timeout=cfg["timeout_long"]):
|
|
354
|
-
ok = False
|
|
355
|
-
|
|
356
|
-
return dest, ok
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def _level_label(level: int) -> str:
|
|
360
|
-
labels = {0: "chipset", 1: "firmware", 2: "storage",
|
|
361
|
-
3: "network", 5: "display", 6: "audio", 10: "other"}
|
|
362
|
-
return labels.get(level, f"level-{level}")
|
|
368
|
+
# Driver-file download lives in ops/download.py (download_driver), shared with
|
|
369
|
+
# the offline / pre-boot path.
|
|
363
370
|
|
|
364
371
|
|
|
365
372
|
# ── Driver installation ─────────────────────────────────────────────────────────
|
|
@@ -397,52 +404,6 @@ def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool
|
|
|
397
404
|
return False, (stderr.strip() or stdout.strip() or f"exit {returncode}")[:200], False
|
|
398
405
|
|
|
399
406
|
|
|
400
|
-
def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
|
|
401
|
-
install_type = driver.get("install_type", "inf_silent")
|
|
402
|
-
|
|
403
|
-
try:
|
|
404
|
-
if install_type == "inf_silent":
|
|
405
|
-
inf_files = list(driver_dir.glob("*.inf"))
|
|
406
|
-
if not inf_files:
|
|
407
|
-
return False, "No INF file found", False
|
|
408
|
-
r = proc.run(
|
|
409
|
-
["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
|
|
410
|
-
capture_output=True, text=True,
|
|
411
|
-
timeout=cfg["timeout_long"],
|
|
412
|
-
)
|
|
413
|
-
return _classify(r.returncode, r.stdout, r.stderr)
|
|
414
|
-
|
|
415
|
-
if install_type == "exe_silent":
|
|
416
|
-
exe_files = list(driver_dir.glob("*.exe"))
|
|
417
|
-
if not exe_files:
|
|
418
|
-
return False, "No EXE file found", False
|
|
419
|
-
flags = driver.get("install_flags", "/S")
|
|
420
|
-
r = proc.run(
|
|
421
|
-
[str(exe_files[0])] + flags.split(),
|
|
422
|
-
capture_output=True,
|
|
423
|
-
timeout=cfg["timeout_install"],
|
|
424
|
-
)
|
|
425
|
-
return _classify(r.returncode)
|
|
426
|
-
|
|
427
|
-
if install_type == "firmware_flash":
|
|
428
|
-
inf_files = list(driver_dir.glob("*.inf"))
|
|
429
|
-
if not inf_files:
|
|
430
|
-
return False, "No firmware INF", False
|
|
431
|
-
r = proc.run(
|
|
432
|
-
["pnputil", "/add-driver", str(inf_files[0]), "/install"],
|
|
433
|
-
capture_output=True, text=True,
|
|
434
|
-
timeout=cfg["timeout_long"],
|
|
435
|
-
)
|
|
436
|
-
return _classify(r.returncode, r.stdout, r.stderr)
|
|
437
|
-
|
|
438
|
-
except subprocess.TimeoutExpired:
|
|
439
|
-
return False, "Install timed out", False
|
|
440
|
-
except Exception as e:
|
|
441
|
-
return False, str(e), False
|
|
442
|
-
|
|
443
|
-
return False, f"Unknown install_type: {install_type}", False
|
|
444
|
-
|
|
445
|
-
|
|
446
407
|
# ── Confirm ─────────────────────────────────────────────────────────────────────
|
|
447
408
|
|
|
448
409
|
def _confirm(trace_id: str, ok: list[DriverResult], fail: list[DriverResult],
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/offline.py — Offline / pre-boot (WinPE) driver staging.
|
|
3
|
+
|
|
4
|
+
The FAST path, matching the old system: run inside WinPE against a freshly
|
|
5
|
+
applied Windows image, enumerate the machine's real HWIDs, resolve them against
|
|
6
|
+
the driverserver, and DOWNLOAD the matched driver files to offline_driver_dir
|
|
7
|
+
(default W:\\drivers). That's ALL this op does — it never installs.
|
|
8
|
+
|
|
9
|
+
win11.bat.j2 runs the injection afterward:
|
|
10
|
+
|
|
11
|
+
X:\\tools\\driverclient\\driverclient.exe (this op, mode=offline)
|
|
12
|
+
dism /image:W:\\ /add-driver /driver:W:\\drivers /recurse /forceunsigned
|
|
13
|
+
|
|
14
|
+
So there is NO live install, NO reboot loop, NO Windows Update, NO capture here —
|
|
15
|
+
everything is injected offline into the image and is present instantly at first
|
|
16
|
+
boot. The small post-boot device tail that WinPE can't see is handled later by
|
|
17
|
+
the LIVE mode (SA finalization).
|
|
18
|
+
|
|
19
|
+
Layout: each driver's files land in offline_driver_dir/<binding_id>/, so
|
|
20
|
+
duplicate INF filenames across packages never collide and DISM /recurse picks
|
|
21
|
+
them all up.
|
|
22
|
+
|
|
23
|
+
Public API:
|
|
24
|
+
resolve_and_download() -> OfflineResult
|
|
25
|
+
"""
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from driverclient import events
|
|
30
|
+
from driverclient.config import get
|
|
31
|
+
from driverclient.ops.download import download_drivers
|
|
32
|
+
from driverclient.ops.resolve import resolve
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class OfflineResult:
|
|
37
|
+
downloaded_ok: list[str] = field(default_factory=list)
|
|
38
|
+
download_failed: list[str] = field(default_factory=list)
|
|
39
|
+
not_found: list[str] = field(default_factory=list)
|
|
40
|
+
trace_id: str = ""
|
|
41
|
+
target_dir: str = ""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok_count(self) -> int: return len(self.downloaded_ok)
|
|
45
|
+
@property
|
|
46
|
+
def fail_count(self) -> int: return len(self.download_failed)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_and_download() -> OfflineResult:
|
|
50
|
+
"""
|
|
51
|
+
Scan → resolve (empty installed_versions → full matched set) → download every
|
|
52
|
+
matched driver's files to offline_driver_dir. No install. Returns OfflineResult.
|
|
53
|
+
"""
|
|
54
|
+
cfg = get()
|
|
55
|
+
target = Path(cfg.get("offline_driver_dir", "W:\\drivers"))
|
|
56
|
+
|
|
57
|
+
# force=True: fresh WinPE session, no meaningful cache to reuse — always scan
|
|
58
|
+
# the real hardware. offline=True: send empty installed_versions.
|
|
59
|
+
result = resolve(force=True, offline=True)
|
|
60
|
+
|
|
61
|
+
if not result.has_drivers:
|
|
62
|
+
events.done("offline",
|
|
63
|
+
"[offline] Nothing to download — no matched drivers",
|
|
64
|
+
total=0, not_found=len(result.not_found))
|
|
65
|
+
return OfflineResult(trace_id=result.trace_id,
|
|
66
|
+
not_found=result.not_found,
|
|
67
|
+
target_dir=str(target))
|
|
68
|
+
|
|
69
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
drivers = result.drivers
|
|
72
|
+
events.start("offline",
|
|
73
|
+
f"[offline] {len(drivers)} driver(s) → downloading to {target} …",
|
|
74
|
+
total=len(drivers))
|
|
75
|
+
|
|
76
|
+
downloaded = download_drivers(drivers, target, cfg)
|
|
77
|
+
|
|
78
|
+
ok: list[str] = []
|
|
79
|
+
failed: list[str] = []
|
|
80
|
+
for driver, _dest, success in downloaded:
|
|
81
|
+
bid = driver.get("binding_id") or driver.get("package_id", "")
|
|
82
|
+
(ok if success else failed).append(bid)
|
|
83
|
+
|
|
84
|
+
for bid in failed:
|
|
85
|
+
events.error("offline", f" ✗ {bid}: download_failed", binding_id=bid)
|
|
86
|
+
|
|
87
|
+
events.done(
|
|
88
|
+
"offline",
|
|
89
|
+
f"[offline] Done — {len(ok)} downloaded {len(failed)} failed "
|
|
90
|
+
f"→ {target} (DISM injects offline next)",
|
|
91
|
+
total=len(downloaded), ok=len(ok), failed=len(failed),
|
|
92
|
+
target_dir=str(target),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return OfflineResult(
|
|
96
|
+
downloaded_ok=ok,
|
|
97
|
+
download_failed=failed,
|
|
98
|
+
not_found=result.not_found,
|
|
99
|
+
trace_id=result.trace_id,
|
|
100
|
+
target_dir=str(target),
|
|
101
|
+
)
|
|
@@ -54,11 +54,17 @@ class ResolveResult:
|
|
|
54
54
|
return bool(self.not_found or self.stale)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def resolve(force: bool = False) -> ResolveResult:
|
|
57
|
+
def resolve(force: bool = False, offline: bool = False) -> ResolveResult:
|
|
58
58
|
"""
|
|
59
59
|
Load (or run) a hardware scan, then POST to /resolve.
|
|
60
60
|
Returns ResolveResult with drivers[] and not_found[].
|
|
61
61
|
Saves result to ds_resolve.json.
|
|
62
|
+
|
|
63
|
+
offline: pre-boot / WinPE mode. The scan runs against WinPE, not the applied
|
|
64
|
+
image, so any "installed" versions it sees are irrelevant to the target. Send
|
|
65
|
+
installed_versions empty (and no inbox hints) so the server offers the FULL
|
|
66
|
+
matched set — every physically-present device's driver, GPU included — to be
|
|
67
|
+
DISM-injected into the fresh image. See ops/offline.py.
|
|
62
68
|
"""
|
|
63
69
|
cfg = get()
|
|
64
70
|
|
|
@@ -69,10 +75,11 @@ def resolve(force: bool = False) -> ResolveResult:
|
|
|
69
75
|
return cached
|
|
70
76
|
|
|
71
77
|
scan_result: ScanResult = scan(force=force)
|
|
72
|
-
return _query_repo(scan_result, cfg, force)
|
|
78
|
+
return _query_repo(scan_result, cfg, force, offline)
|
|
73
79
|
|
|
74
80
|
|
|
75
|
-
def _query_repo(scan_result: ScanResult, cfg: dict, force: bool
|
|
81
|
+
def _query_repo(scan_result: ScanResult, cfg: dict, force: bool,
|
|
82
|
+
offline: bool = False) -> ResolveResult:
|
|
76
83
|
repo_url = cfg["local_repo_url"]
|
|
77
84
|
session_id = str(uuid.uuid4())
|
|
78
85
|
|
|
@@ -112,8 +119,11 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
|
|
|
112
119
|
"firmware_version": scan_result.firmware_version,
|
|
113
120
|
"hwids": scan_result.all_hwids,
|
|
114
121
|
"hwid_groups": hwid_groups,
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
# Offline: no inbox hints and empty installed_versions → server offers the
|
|
123
|
+
# full matched set for the fresh image (nothing counts as already-current).
|
|
124
|
+
"inbox_hwids": [] if offline else inbox_ids,
|
|
125
|
+
"installed_versions": {} if offline
|
|
126
|
+
else {**scan_result.installed_versions, **canon_versions},
|
|
117
127
|
"force": force or cfg.get("force_resolve", False),
|
|
118
128
|
}
|
|
119
129
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|