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.
- {driverclient-0.2.12 → driverclient-0.2.17}/PKG-INFO +1 -1
- {driverclient-0.2.12 → driverclient-0.2.17}/pyproject.toml +1 -1
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/config.py +16 -1
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/proc.py +12 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/capture.py +227 -28
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/install.py +76 -3
- {driverclient-0.2.12 → driverclient-0.2.17}/.gitignore +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/README.md +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/config.json +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/core/hwid.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/events.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/main.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/automate.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/diagnose.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/resolve.py +0 -0
- {driverclient-0.2.12 → driverclient-0.2.17}/src/driverclient/ops/scan.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -54,8 +54,17 @@ DEFAULTS: dict = {
|
|
|
54
54
|
},
|
|
55
55
|
|
|
56
56
|
# ── Windows Update ────────────────────────────────────────────────────
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
-
|
|
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(
|
|
543
|
-
|
|
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
|
|
714
|
+
return None
|
|
550
715
|
|
|
551
716
|
|
|
552
|
-
def _poll_and_submit_incremental(wu_start: datetime, cfg: dict
|
|
717
|
+
def _poll_and_submit_incremental(wu_start: datetime, cfg: dict,
|
|
718
|
+
trigger: "_WuTrigger | None") -> CaptureResult:
|
|
553
719
|
"""
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
559
|
-
poll_secs
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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]
|
|
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
|
-
|
|
585
|
-
|
|
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]
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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(
|
|
108
|
-
total=len(
|
|
109
|
-
all_results = _stream_install(
|
|
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
|
|
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
|