driverclient 0.2.12__tar.gz → 0.2.17__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 (23) hide show
  1. {driverclient-0.2.12 → driverclient-0.2.17}/PKG-INFO +1 -1
  2. {driverclient-0.2.12 → driverclient-0.2.17}/pyproject.toml +1 -1
  3. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/config.py +16 -1
  5. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/proc.py +12 -0
  6. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/capture.py +227 -28
  7. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/install.py +76 -3
  8. {driverclient-0.2.12 → driverclient-0.2.17}/.gitignore +0 -0
  9. {driverclient-0.2.12 → driverclient-0.2.17}/README.md +0 -0
  10. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/__main__.py +0 -0
  11. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/config.json +0 -0
  12. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/__init__.py +0 -0
  13. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/hardware.py +0 -0
  14. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/http.py +0 -0
  15. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/hwid.py +0 -0
  16. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/events.py +0 -0
  17. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/main.py +0 -0
  18. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/__init__.py +0 -0
  19. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/automate.py +0 -0
  20. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/diagnose.py +0 -0
  21. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/resolve.py +0 -0
  22. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/scan.py +0 -0
  23. {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.12
3
+ Version: 0.2.17
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.12"
7
+ version = "0.2.17"
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.12"
28
+ __version__ = "0.2.17"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -54,8 +54,17 @@ DEFAULTS: dict = {
54
54
  },
55
55
 
56
56
  # ── Windows Update ────────────────────────────────────────────────────
57
- "wu_wait_minutes": 10,
57
+ # ABSOLUTE ceiling for the WU wait — a safety backstop, NOT the expected
58
+ # wait. The loop stops as soon as WU actually goes idle (see below); this
59
+ # only bounds a stuck/hanging WU so SA can never stall.
60
+ "wu_wait_minutes": 30,
58
61
  "wu_poll_interval_seconds": 30,
62
+ # Empty polls required AFTER Windows Update has gone idle before we stop.
63
+ # While WU is still working (install process alive), empty polls are NOT
64
+ # counted — so a scan/download that takes 2 min or 8 min both get exactly
65
+ # the time they need. This is what makes the wait adaptive instead of a
66
+ # fixed timer.
67
+ "wu_quiescent_polls": 2,
59
68
  # Classes to skip when deciding which not-found HWIDs trigger WU.
60
69
  # Empty list = no filtering (default — make no assumptions).
61
70
  # Use lowercase class names as reported by pnputil, e.g. "hidclass", "bluetooth".
@@ -70,6 +79,12 @@ DEFAULTS: dict = {
70
79
  # no new repo drivers or installs nothing.
71
80
  "automate_rescan_passes": 3,
72
81
  "force_resolve": False,
82
+ # The server now offers every driver matching each device (primary + extensions
83
+ # + companions). Before downloading each, skip it if its INF is already in the
84
+ # Windows driver store at the same-or-newer version — so a still-current big
85
+ # driver (e.g. graphics) is never re-downloaded, while missing/extension drivers
86
+ # still install. Set False to force-reinstall everything the server offers.
87
+ "skip_installed_drivers": True,
73
88
 
74
89
  # ── HTTP timeouts (seconds) ───────────────────────────────────────────
75
90
  "timeout_tiny": 10,
@@ -39,3 +39,15 @@ def run(cmd, **kwargs) -> subprocess.CompletedProcess:
39
39
  for key, value in _no_window_kwargs().items():
40
40
  kwargs.setdefault(key, value)
41
41
  return subprocess.run(cmd, **kwargs)
42
+
43
+
44
+ def popen(cmd, **kwargs) -> subprocess.Popen:
45
+ """subprocess.Popen() that never shows a console window on Windows.
46
+
47
+ Same no-window guard as run(), for long-running children the caller wants to
48
+ supervise (poll/kill) instead of blocking on — e.g. the background Windows
49
+ Update install. All other behavior is identical to subprocess.Popen.
50
+ """
51
+ for key, value in _no_window_kwargs().items():
52
+ kwargs.setdefault(key, value)
53
+ return subprocess.Popen(cmd, **kwargs)
@@ -297,11 +297,12 @@ def wu_update(force: bool = False) -> CaptureResult:
297
297
  f"triggering Windows Update… ({skip_detail})",
298
298
  total=len(wu_hwids), **skip_reasons)
299
299
 
300
- if not _trigger_wu():
300
+ trigger = _trigger_wu()
301
+ if trigger is None:
301
302
  return CaptureResult(still_missing=len(wu_hwids))
302
303
 
303
304
  wu_start = datetime.now(timezone.utc)
304
- total = _poll_and_submit_incremental(wu_start, cfg)
305
+ total = _poll_and_submit_incremental(wu_start, cfg, trigger)
305
306
  total.wu_triggered = True
306
307
  total.still_missing = max(0, len(wu_hwids) - total.submitted)
307
308
  total.reboot_required = _wu_reboot_pending()
@@ -326,11 +327,12 @@ def wu_full() -> CaptureResult:
326
327
  events.start("windows_update",
327
328
  "[wu-full] Triggering full Windows Update scan (no HWID filtering)…")
328
329
 
329
- if not _trigger_wu():
330
+ trigger = _trigger_wu()
331
+ if trigger is None:
330
332
  return CaptureResult()
331
333
 
332
334
  wu_start = datetime.now(timezone.utc)
333
- total = _poll_and_submit_incremental(wu_start, cfg)
335
+ total = _poll_and_submit_incremental(wu_start, cfg, trigger)
334
336
  total.wu_triggered = True
335
337
  _post_wu_session(cfg, wu_start, [], total.submitted)
336
338
  events.done("windows_update",
@@ -536,34 +538,211 @@ def _wu_reboot_pending() -> bool:
536
538
  return False
537
539
 
538
540
 
539
- def _trigger_wu() -> bool:
540
- for cmd in (["usoclient", "StartScan"], ["wuauclt", "/detectnow"]):
541
+ # PowerShell that drives a real Search → Download → Install through the Windows
542
+ # Update Agent COM API (Microsoft.Update.Session). Built into every Windows — no
543
+ # PSGallery module needed. It BLOCKS until download+install finishes, so the
544
+ # process staying alive IS the "WU is still working" signal the poll loop reads.
545
+ #
546
+ # It searches the MICROSOFT UPDATE service (service id 7971f918-…), exactly like
547
+ # the Settings ▸ "Check for updates" button with "receive updates for other
548
+ # Microsoft products" on — that superset is what surfaces driver updates + other
549
+ # Microsoft products, not the narrower default Windows Update service. The MU
550
+ # service is registered on the fly; if that is blocked (WSUS / policy-managed),
551
+ # it falls back to the default service so WU still runs.
552
+ _WU_COM_SCRIPT = r"""
553
+ $ErrorActionPreference = 'Stop'
554
+ $MU_SERVICE_ID = '7971f918-a847-4430-9279-4a52d1efe18d'
555
+ try {
556
+ $session = New-Object -ComObject Microsoft.Update.Session
557
+ $searcher = $session.CreateUpdateSearcher()
558
+
559
+ # Target Microsoft Update so this matches the Settings "Check for updates"
560
+ # behaviour (drivers + other Microsoft products). Register the service if
561
+ # needed, then point the searcher at it explicitly.
562
+ try {
563
+ $sm = New-Object -ComObject Microsoft.Update.ServiceManager
564
+ # AddService2 flags 7 = registerServiceWithAU(1)+allowPendingRegistration(2)+allowOnlineRegistration(4)
565
+ [void]$sm.AddService2($MU_SERVICE_ID, 7, '')
566
+ $searcher.ServerSelection = 3 # ssOthers
567
+ $searcher.ServiceID = $MU_SERVICE_ID
568
+ Write-Output "SERVICE microsoft_update"
569
+ } catch {
570
+ Write-Output ("SERVICE windows_update_fallback " + $_.Exception.Message)
571
+ }
572
+
573
+ Write-Output "SEARCHING"
574
+ $result = $searcher.Search("IsInstalled=0 and IsHidden=0")
575
+ if ($result.Updates.Count -eq 0) { Write-Output "NO_UPDATES"; exit 0 }
576
+
577
+ $toDownload = New-Object -ComObject Microsoft.Update.UpdateColl
578
+ foreach ($u in $result.Updates) {
579
+ if (-not $u.EulaAccepted) { try { $u.AcceptEula() } catch {} }
580
+ [void]$toDownload.Add($u)
581
+ }
582
+ Write-Output ("DOWNLOADING " + $toDownload.Count)
583
+ $downloader = $session.CreateUpdateDownloader()
584
+ $downloader.Updates = $toDownload
585
+ [void]$downloader.Download()
586
+
587
+ $toInstall = New-Object -ComObject Microsoft.Update.UpdateColl
588
+ foreach ($u in $result.Updates) { if ($u.IsDownloaded) { [void]$toInstall.Add($u) } }
589
+ if ($toInstall.Count -eq 0) { Write-Output "NONE_DOWNLOADED"; exit 0 }
590
+
591
+ Write-Output ("INSTALLING " + $toInstall.Count)
592
+ $installer = $session.CreateUpdateInstaller()
593
+ $installer.Updates = $toInstall
594
+ $r = $installer.Install()
595
+ Write-Output ("INSTALLED count=" + $toInstall.Count + " result=" + $r.ResultCode + " reboot=" + $r.RebootRequired)
596
+ exit 0
597
+ } catch {
598
+ Write-Output ("WU_ERROR " + $_.Exception.Message)
599
+ exit 1
600
+ }
601
+ """
602
+
603
+
604
+ @dataclass
605
+ class _WuTrigger:
606
+ """Handle for a launched Windows Update run.
607
+
608
+ proc is the live install process on the COM path (its liveness == WU is
609
+ still working); it is None for the fire-and-forget usoclient/wuauclt
610
+ fallbacks, which give no real progress signal.
611
+ """
612
+ method: str
613
+ proc: "subprocess.Popen | None" = None
614
+ workdir: "str | None" = None
615
+ log_fh: object = None
616
+ log_path: "str | None" = None
617
+
618
+ def is_running(self) -> bool:
619
+ return self.proc is not None and self.proc.poll() is None
620
+
621
+ def terminate(self) -> None:
622
+ """Kill a still-running install and clean up temp files. Idempotent and
623
+ never raises — the hard-cap backstop that guarantees no lingering WU
624
+ process can keep SA busy past the ceiling."""
625
+ try:
626
+ if self.proc is not None and self.proc.poll() is None:
627
+ self.proc.kill()
628
+ except Exception:
629
+ pass
630
+ try:
631
+ if self.log_fh is not None:
632
+ self.log_fh.close()
633
+ except Exception:
634
+ pass
635
+ try:
636
+ if self.workdir:
637
+ shutil.rmtree(self.workdir, ignore_errors=True)
638
+ except Exception:
639
+ pass
640
+
641
+
642
+ def _wu_install_com() -> "_WuTrigger | None":
643
+ """Preferred trigger: launch the COM-API Search→Download→Install script as a
644
+ background process. Returns a _WuTrigger with a live proc, or None if
645
+ PowerShell is unavailable / launch fails (caller falls back)."""
646
+ try:
647
+ workdir = tempfile.mkdtemp(prefix="dc_wu_")
648
+ script_path = Path(workdir) / "wu_install.ps1"
649
+ log_path = Path(workdir) / "wu_install.log"
650
+ script_path.write_text(_WU_COM_SCRIPT, encoding="utf-8")
651
+ log_fh = open(log_path, "w", encoding="utf-8", errors="replace")
652
+ p = proc.popen(
653
+ ["powershell", "-ExecutionPolicy", "Bypass", "-NoProfile",
654
+ "-NonInteractive", "-File", str(script_path)],
655
+ stdout=log_fh, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
656
+ )
657
+ events.ok("windows_update",
658
+ "[wu-update] Windows Update install started (COM API) — "
659
+ "search → download → install running in background")
660
+ return _WuTrigger(method="com", proc=p, workdir=workdir,
661
+ log_fh=log_fh, log_path=str(log_path))
662
+ except FileNotFoundError:
663
+ return None # powershell not on PATH
664
+ except Exception as e:
665
+ events.warn("windows_update",
666
+ f"[wu-update] COM-API WU launch failed ({e}) — trying usoclient",
667
+ error=str(e))
668
+ return None
669
+
670
+
671
+ def _wu_install_usoclient() -> "_WuTrigger | None":
672
+ """Fallback trigger: usoclient full sequence. These are asynchronous and
673
+ undocumented (no completion signal), so the poll loop treats WU as busy
674
+ until its fallback floor. Returns None if usoclient is unavailable."""
675
+ ran = False
676
+ for args in (["StartScan"], ["StartDownload"], ["StartInstall"]):
541
677
  try:
542
- proc.run(cmd, capture_output=True, timeout=30)
543
- events.ok("windows_update", "[wu-update] Windows Update scan triggered")
544
- return True
678
+ proc.run(["usoclient", *args], capture_output=True, timeout=30)
679
+ ran = True
545
680
  except Exception:
546
681
  continue
682
+ if ran:
683
+ events.ok("windows_update",
684
+ "[wu-update] Windows Update triggered via usoclient "
685
+ "(scan → download → install)")
686
+ return _WuTrigger(method="usoclient")
687
+ return None
688
+
689
+
690
+ def _trigger_wu() -> "_WuTrigger | None":
691
+ """Trigger Windows Update to actually install, best method first:
692
+ 1. COM API (real Search→Download→Install, with a liveness signal)
693
+ 2. usoclient full sequence (fire-and-forget fallback)
694
+ 3. wuauclt /detectnow (legacy last resort — a no-op on Win10/11)
695
+ Returns a _WuTrigger, or None if nothing could be triggered."""
696
+ trigger = _wu_install_com()
697
+ if trigger is not None:
698
+ return trigger
699
+
700
+ trigger = _wu_install_usoclient()
701
+ if trigger is not None:
702
+ return trigger
703
+
704
+ try:
705
+ proc.run(["wuauclt", "/detectnow"], capture_output=True, timeout=30)
706
+ events.ok("windows_update",
707
+ "[wu-update] Triggered wuauclt /detectnow (legacy fallback)")
708
+ return _WuTrigger(method="wuauclt")
709
+ except Exception:
710
+ pass
711
+
547
712
  events.error("windows_update", "[wu-update] Could not trigger Windows Update",
548
713
  error="trigger_failed")
549
- return False
714
+ return None
550
715
 
551
716
 
552
- def _poll_and_submit_incremental(wu_start: datetime, cfg: dict) -> CaptureResult:
717
+ def _poll_and_submit_incremental(wu_start: datetime, cfg: dict,
718
+ trigger: "_WuTrigger | None") -> CaptureResult:
553
719
  """
554
- Poll every wu_poll_interval_seconds for new DriverStore packages.
555
- Submit each new batch immediately. Stop after wu_wait_minutes total
556
- OR after 2 consecutive polls with no new packages.
720
+ Wait on Windows Update, submitting each new DriverStore package as it lands.
721
+
722
+ The stop decision is driven by WU's REAL activity, not a fixed timer:
723
+ • While WU is still working, empty polls are NOT counted — a scan/download
724
+ that takes 2 min or 8 min both get exactly the time they need.
725
+ (COM path: "working" = the install process is alive. Fallback path: no
726
+ liveness signal exists, so WU is assumed busy until a short floor.)
727
+ • Once WU goes idle, `wu_quiescent_polls` consecutive empty polls end the
728
+ wait (catches drivers that land right at the end).
729
+ • `wu_wait_minutes` is an ABSOLUTE hard cap — a backstop so a stuck WU can
730
+ never keep SA waiting. Any still-running install is killed at the cap.
557
731
  """
558
- wait_min = cfg.get("wu_wait_minutes", 10)
559
- poll_secs = cfg.get("wu_poll_interval_seconds", 30)
560
- deadline = wu_start + timedelta(minutes=wait_min)
561
- seen:set[str] = set()
562
- total = CaptureResult()
563
- empty_polls = 0
732
+ wait_min = cfg.get("wu_wait_minutes", 30)
733
+ poll_secs = cfg.get("wu_poll_interval_seconds", 30)
734
+ quiescent_needed = max(1, int(cfg.get("wu_quiescent_polls", 2)))
735
+ deadline = wu_start + timedelta(minutes=wait_min)
736
+ fallback_floor = wu_start + timedelta(minutes=min(5, wait_min))
737
+ has_liveness = trigger is not None and trigger.proc is not None
738
+
739
+ seen: set[str] = set()
740
+ total = CaptureResult()
741
+ empty_polls = 0
564
742
 
565
743
  events.progress("windows_update",
566
- f"[wu-update] Polling every {poll_secs}s, max {wait_min} min…")
744
+ f"[wu-update] Waiting on Windows Update — polling every {poll_secs}s, "
745
+ f"stops early once WU goes idle (hard cap {wait_min} min)")
567
746
 
568
747
  while datetime.now(timezone.utc) < deadline:
569
748
  all_new = _enumerate_packages_since(wu_start, cfg)
@@ -581,17 +760,37 @@ def _poll_and_submit_incremental(wu_start: datetime, cfg: dict) -> CaptureResult
581
760
  current=len(new_batch))
582
761
  total.merge(_export_and_submit(new_batch, cfg))
583
762
  else:
584
- empty_polls += 1
585
- if empty_polls >= 2:
763
+ # Is WU genuinely still working? Don't give up during its long
764
+ # initial scan/download latency.
765
+ if has_liveness:
766
+ busy = trigger.is_running()
767
+ else:
768
+ busy = datetime.now(timezone.utc) < fallback_floor
769
+
770
+ if busy:
771
+ empty_polls = 0
772
+ elapsed = int((datetime.now(timezone.utc) - wu_start).total_seconds())
586
773
  events.progress("windows_update",
587
- "[wu-update] 2 consecutive empty polls stopping")
588
- break
589
- remaining = int((deadline - datetime.now(timezone.utc)).total_seconds())
590
- events.progress("windows_update",
591
- f"[wu-update] no new drivers — {remaining}s remaining…")
774
+ f"[wu-update] WU still working {elapsed}s elapsed, "
775
+ f"no new drivers yet…")
776
+ else:
777
+ empty_polls += 1
778
+ if empty_polls >= quiescent_needed:
779
+ events.progress("windows_update",
780
+ f"[wu-update] WU idle + {empty_polls} quiescent poll(s) — stopping")
781
+ break
782
+ remaining = int((deadline - datetime.now(timezone.utc)).total_seconds())
783
+ events.progress("windows_update",
784
+ f"[wu-update] WU idle, no new drivers — "
785
+ f"{empty_polls}/{quiescent_needed} quiescent, {remaining}s cap left…")
592
786
 
593
787
  time.sleep(poll_secs)
788
+ else:
789
+ events.warn("windows_update",
790
+ f"[wu-update] Reached {wait_min} min hard cap — stopping WU wait")
594
791
 
792
+ if trigger is not None:
793
+ trigger.terminate()
595
794
  return total
596
795
 
597
796
 
@@ -13,6 +13,7 @@ Download pipeline:
13
13
  Public API:
14
14
  resolve_and_install(force=False) -> InstallResult
15
15
  """
16
+ import re
16
17
  import shutil
17
18
  import subprocess
18
19
  import time
@@ -86,6 +87,54 @@ class InstallResult:
86
87
  return any(r.reboot_required for r in self.installed_ok)
87
88
 
88
89
 
90
+ def _parse_ver(s: str) -> tuple | None:
91
+ """Extract an x.x.x.x version tuple from a string (e.g. a pnputil 'Driver
92
+ Version: 12/02/2021 1.0.3.0' line or a repo version_label). None if absent."""
93
+ m = re.search(r"(\d+)\.(\d+)\.(\d+)\.(\d+)", s or "")
94
+ return tuple(int(x) for x in m.groups()) if m else None
95
+
96
+
97
+ def _installed_inf_index() -> dict:
98
+ """Map original INF name (lowercase) → highest installed driver-version tuple,
99
+ parsed from `pnputil /enum-drivers`. Lets us skip re-downloading/installing a
100
+ driver the machine already has at the same-or-newer version. Best-effort: on any
101
+ error/parse miss it returns {} so nothing is ever wrongly skipped."""
102
+ idx: dict = {}
103
+ try:
104
+ r = proc.run(["pnputil", "/enum-drivers"], capture_output=True, text=True, timeout=120)
105
+ if r.returncode != 0:
106
+ return {}
107
+ cur = None
108
+ for line in r.stdout.splitlines():
109
+ low = line.lower()
110
+ if "original name" in low and ":" in line:
111
+ cur = line.split(":", 1)[1].strip().lower()
112
+ elif "driver version" in low and cur:
113
+ v = _parse_ver(line)
114
+ if v and (cur not in idx or v > idx[cur]):
115
+ idx[cur] = v
116
+ cur = None
117
+ except Exception:
118
+ return {}
119
+ return idx
120
+
121
+
122
+ def _already_in_store(driver: dict, idx: dict) -> bool:
123
+ """True when every INF this driver ships is already in the driver store at a
124
+ same-or-newer version — so it needn't be downloaded/installed again. Conservative:
125
+ returns False (→ install) whenever the version or an INF can't be confirmed."""
126
+ if not idx:
127
+ return False
128
+ ver = _parse_ver(driver.get("version_label", ""))
129
+ if not ver:
130
+ return False
131
+ infs = [f["filename"].lower() for f in driver.get("files", [])
132
+ if f.get("file_role") == "inf" or f.get("filename", "").lower().endswith(".inf")]
133
+ if not infs:
134
+ return False
135
+ return all(idx.get(name) is not None and idx[name] >= ver for name in infs)
136
+
137
+
89
138
  def resolve_and_install(force: bool = False) -> InstallResult:
90
139
  """
91
140
  Run resolve (or load cache) then download + install all available drivers.
@@ -99,14 +148,38 @@ def resolve_and_install(force: bool = False) -> InstallResult:
99
148
  total=0)
100
149
  return InstallResult(trace_id=result.trace_id)
101
150
 
151
+ # Server now offers EVERY driver matching each device (primary + extensions +
152
+ # companions), so nothing is dropped by an assumption. Freshness is decided HERE
153
+ # against the real driver store: skip any package whose INF(s) are already present
154
+ # at the same-or-newer version — so a still-current big driver (graphics) is not
155
+ # re-downloaded, while missing/extension drivers still install. Toggle off with
156
+ # skip_installed_drivers=false to force-reinstall everything.
157
+ drivers = result.drivers
158
+ if cfg.get("skip_installed_drivers", True):
159
+ idx = _installed_inf_index()
160
+ kept = [d for d in drivers if not _already_in_store(d, idx)]
161
+ skipped = len(drivers) - len(kept)
162
+ if skipped:
163
+ events.progress(
164
+ "install",
165
+ f"[install] {skipped} driver(s) already current in the driver store — skipping",
166
+ skipped=skipped,
167
+ )
168
+ drivers = kept
169
+ if not drivers:
170
+ events.done("install",
171
+ "[install] Nothing to install — all resolved drivers already current",
172
+ total=0)
173
+ return InstallResult(trace_id=result.trace_id)
174
+
102
175
  session_id = str(uuid.uuid4())
103
176
  work_dir = Path(cfg.get("work_dir", "C:\\DriverServer\\client\\staging")) / session_id
104
177
  work_dir.mkdir(parents=True, exist_ok=True)
105
178
 
106
179
  events.start("install",
107
- f"[install] {len(result.drivers)} driver(s) — starting parallel downloads…",
108
- total=len(result.drivers))
109
- all_results = _stream_install(result.drivers, work_dir, cfg)
180
+ f"[install] {len(drivers)} driver(s) — starting parallel downloads…",
181
+ total=len(drivers))
182
+ all_results = _stream_install(drivers, work_dir, cfg)
110
183
 
111
184
  shutil.rmtree(work_dir, ignore_errors=True)
112
185
 
File without changes
File without changes