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.
Files changed (25) hide show
  1. {driverclient-0.2.19 → driverclient-0.2.21}/PKG-INFO +1 -1
  2. {driverclient-0.2.19 → driverclient-0.2.21}/pyproject.toml +1 -1
  3. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/config.json +7 -0
  5. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/config.py +20 -0
  6. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/http.py +6 -1
  7. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/main.py +2 -0
  8. driverclient-0.2.21/src/driverclient/ops/download.py +63 -0
  9. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/install.py +128 -167
  10. driverclient-0.2.21/src/driverclient/ops/offline.py +101 -0
  11. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/resolve.py +15 -5
  12. {driverclient-0.2.19 → driverclient-0.2.21}/.gitignore +0 -0
  13. {driverclient-0.2.19 → driverclient-0.2.21}/README.md +0 -0
  14. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/__main__.py +0 -0
  15. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/__init__.py +0 -0
  16. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/hardware.py +0 -0
  17. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/hwid.py +0 -0
  18. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/core/proc.py +0 -0
  19. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/events.py +0 -0
  20. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/__init__.py +0 -0
  21. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/automate.py +0 -0
  22. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/capture.py +0 -0
  23. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/diagnose.py +0 -0
  24. {driverclient-0.2.19 → driverclient-0.2.21}/src/driverclient/ops/scan.py +0 -0
  25. {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.19
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.19"
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"
@@ -25,7 +25,7 @@ from driverclient.main import run as _run
25
25
 
26
26
  __all__ = ["DriverClient", "ClientEvent"]
27
27
 
28
- __version__ = "0.2.19"
28
+ __version__ = "0.2.21"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -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
- headers = {**_headers(), "Accept-Encoding": "zstd"}
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 Future, ThreadPoolExecutor, as_completed
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 http_download_retry, http_post_retry
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
- # Live driver-store index: INF name -> highest installed version. Built once,
159
- # then UPDATED as each driver installs this pass (see _record_installed) so a
160
- # later package of the same driver family (same INF) at an equal-or-older
161
- # version is skipped the driver store already has it. This is skip-if-present
162
- # (idempotency), NOT deduplication: no repo package is removed; a newer repo
163
- # version still installs over an older store version (store_ver >= repo_ver skips).
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, idx, skip_installed)
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
- Submit ALL downloads (all levels) simultaneously, then install level-by-level.
226
- Each driver installs the moment its own download Future completes.
227
-
228
- `idx` is the live driver-store index (INF -> installed version); it is read to
229
- skip a driver the store already has and updated after each successful install
230
- so same-family duplicates later in the pass are skipped too. Installs run one
231
- at a time here (main thread), so mutating `idx` needs no lock.
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 = _dedup_and_group(drivers)
237
- executor = ThreadPoolExecutor(max_workers=parallel)
238
- future_info, level_futs = _submit_all_downloads(by_level, base, repo_url, cfg, executor)
239
-
240
- all_results: list[DriverResult] = []
241
- for level in sorted(by_level):
242
- events.progress("install",
243
- f"[install] {_level_label(level)} ({len(level_futs[level])} driver(s))…",
244
- category=_level_label(level), count=len(level_futs[level]))
245
- for fut in as_completed(level_futs[level]):
246
- all_results.append(_process_completed(fut, future_info, cfg, idx, skip_installed))
247
-
248
- executor.shutdown(wait=False)
249
- return all_results
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 _submit_all_downloads(
266
- by_level: dict[int, list[dict]],
267
- base: Path,
268
- repo_url: str,
269
- cfg: dict,
270
- executor: ThreadPoolExecutor,
271
- ) -> tuple[dict[Future, tuple[int, dict]], dict[int, list[Future]]]:
272
- """Submit every driver download to the pool at once. Returns lookup maps."""
273
- future_info: dict[Future, tuple[int, dict]] = {}
274
- level_futs: dict[int, list[Future]] = defaultdict(list)
275
- for level in sorted(by_level):
276
- for driver in by_level[level]:
277
- fut = executor.submit(_download_driver, driver, base, repo_url, cfg)
278
- future_info[fut] = (level, driver)
279
- level_futs[level].append(fut)
280
- return future_info, level_futs
281
-
282
-
283
- def _process_completed(
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
- dest, dl_ok = fut.result()
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
- return DriverResult(binding_id=bid, success=False, error=str(e))
322
+ ok, err, reboot = False, str(e), False
323
+ dur = int((time.monotonic() - t0) * 1000)
308
324
 
309
- if not dl_ok:
310
- return DriverResult(binding_id=bid, success=False, error="download_failed")
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
- line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms){tag}"
319
- emit = events.ok if ok else events.error
320
- emit("install", line, binding_id=bid, category=cat, duration_ms=dur,
321
- error=err, reboot_required=reboot)
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
- def _record_installed(driver: dict, idx: dict) -> None:
327
- """Fold a just-installed driver's INF(s)+version into the live store index, so a
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) -> ResolveResult:
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
- "inbox_hwids": inbox_ids,
116
- "installed_versions": {**scan_result.installed_versions, **canon_versions},
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