driverclient 0.2.13__tar.gz → 0.2.18__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.13 → driverclient-0.2.18}/PKG-INFO +1 -1
  2. {driverclient-0.2.13 → driverclient-0.2.18}/pyproject.toml +1 -1
  3. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/config.json +2 -1
  5. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/config.py +10 -1
  6. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/core/proc.py +12 -0
  7. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/capture.py +287 -28
  8. {driverclient-0.2.13 → driverclient-0.2.18}/.gitignore +0 -0
  9. {driverclient-0.2.13 → driverclient-0.2.18}/README.md +0 -0
  10. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/__main__.py +0 -0
  11. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/core/__init__.py +0 -0
  12. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/core/hardware.py +0 -0
  13. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/core/http.py +0 -0
  14. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/core/hwid.py +0 -0
  15. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/events.py +0 -0
  16. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/main.py +0 -0
  17. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/__init__.py +0 -0
  18. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/automate.py +0 -0
  19. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/diagnose.py +0 -0
  20. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/install.py +0 -0
  21. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/resolve.py +0 -0
  22. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/ops/scan.py +0 -0
  23. {driverclient-0.2.13 → driverclient-0.2.18}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.13
3
+ Version: 0.2.18
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.13"
7
+ version = "0.2.18"
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.13"
28
+ __version__ = "0.2.18"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -19,8 +19,9 @@
19
19
  "usb": [".inf", ".sys", ".cat", ".dll"]
20
20
  },
21
21
 
22
- "wu_wait_minutes": 10,
22
+ "wu_wait_minutes": 30,
23
23
  "wu_poll_interval_seconds": 30,
24
+ "wu_quiescent_polls": 2,
24
25
  "wu_ignore_classes": ["hidclass", "bluetooth", "printer"],
25
26
 
26
27
  "automate_wu_fallback": true,
@@ -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".
@@ -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,36 +538,272 @@ 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."""
541
625
  try:
542
- proc.run(cmd, capture_output=True, timeout=30)
543
- events.ok("windows_update", "[wu-update] Windows Update scan triggered")
544
- return True
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"]):
677
+ try:
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
715
+
716
+
717
+ def _emit_wu_log_line(line: str) -> None:
718
+ """Turn one raw status line from the WU PowerShell into a friendly GUI event
719
+ so the operator sees WU's real phase (searching / downloading / installing)
720
+ live, instead of only a generic 'still working' tick."""
721
+ up = line.upper()
722
+ if up.startswith("SERVICE MICROSOFT_UPDATE"):
723
+ events.ok("windows_update",
724
+ "[wu-update] Using the Microsoft Update service (drivers + Microsoft products)")
725
+ elif up.startswith("SERVICE WINDOWS_UPDATE_FALLBACK"):
726
+ detail = line.split(None, 2)[2] if len(line.split(None, 2)) > 2 else ""
727
+ events.warn("windows_update",
728
+ f"[wu-update] Microsoft Update unavailable — falling back to default "
729
+ f"Windows Update service {detail}".rstrip())
730
+ elif up == "SEARCHING":
731
+ events.progress("windows_update",
732
+ "[wu-update] Searching Windows Update for applicable updates… "
733
+ "(the first scan can take several minutes)")
734
+ elif up == "NO_UPDATES":
735
+ events.progress("windows_update", "[wu-update] Search complete — no applicable updates found")
736
+ elif up.startswith("DOWNLOADING"):
737
+ n = line.split()[-1] if line.split() else "?"
738
+ events.progress("windows_update", f"[wu-update] Downloading {n} update(s)…")
739
+ elif up == "NONE_DOWNLOADED":
740
+ events.warn("windows_update", "[wu-update] Nothing downloaded")
741
+ elif up.startswith("INSTALLING"):
742
+ n = line.split()[-1] if line.split() else "?"
743
+ events.progress("windows_update", f"[wu-update] Installing {n} update(s)…")
744
+ elif up.startswith("INSTALLED"):
745
+ events.ok("windows_update", f"[wu-update] {line}")
746
+ elif up.startswith("WU_ERROR"):
747
+ detail = line[len("WU_ERROR"):].strip()
748
+ events.warn("windows_update", f"[wu-update] Windows Update error: {detail}", error=detail)
749
+ else:
750
+ events.progress("windows_update", f"[wu-update] {line}")
751
+
752
+
753
+ def _drain_wu_log(trigger: "_WuTrigger | None", pos: int) -> int:
754
+ """Emit any new lines the WU PowerShell wrote to its log since offset `pos`,
755
+ return the new offset. Best-effort — a read failure just leaves `pos`
756
+ unchanged and never raises into the poll loop. Only the COM path writes a
757
+ log (trigger.log_path); the usoclient/wuauclt fallbacks have none."""
758
+ if trigger is None or not trigger.log_path:
759
+ return pos
760
+ try:
761
+ with open(trigger.log_path, "r", encoding="utf-8", errors="replace") as fh:
762
+ fh.seek(pos)
763
+ chunk = fh.read()
764
+ new_pos = fh.tell()
765
+ except Exception:
766
+ return pos
767
+ for line in chunk.splitlines():
768
+ line = line.strip()
769
+ if line:
770
+ _emit_wu_log_line(line)
771
+ return new_pos
550
772
 
551
773
 
552
- def _poll_and_submit_incremental(wu_start: datetime, cfg: dict) -> CaptureResult:
774
+ def _poll_and_submit_incremental(wu_start: datetime, cfg: dict,
775
+ trigger: "_WuTrigger | None") -> CaptureResult:
553
776
  """
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.
777
+ Wait on Windows Update, submitting each new DriverStore package as it lands.
778
+
779
+ The stop decision is driven by WU's REAL activity, not a fixed timer:
780
+ • While WU is still working, empty polls are NOT counted — a scan/download
781
+ that takes 2 min or 8 min both get exactly the time they need.
782
+ (COM path: "working" = the install process is alive. Fallback path: no
783
+ liveness signal exists, so WU is assumed busy until a short floor.)
784
+ • Once WU goes idle, `wu_quiescent_polls` consecutive empty polls end the
785
+ wait (catches drivers that land right at the end).
786
+ • `wu_wait_minutes` is an ABSOLUTE hard cap — a backstop so a stuck WU can
787
+ never keep SA waiting. Any still-running install is killed at the cap.
557
788
  """
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
789
+ wait_min = cfg.get("wu_wait_minutes", 30)
790
+ poll_secs = cfg.get("wu_poll_interval_seconds", 30)
791
+ quiescent_needed = max(1, int(cfg.get("wu_quiescent_polls", 2)))
792
+ deadline = wu_start + timedelta(minutes=wait_min)
793
+ fallback_floor = wu_start + timedelta(minutes=min(5, wait_min))
794
+ has_liveness = trigger is not None and trigger.proc is not None
795
+
796
+ seen: set[str] = set()
797
+ total = CaptureResult()
798
+ empty_polls = 0
799
+ log_pos = 0
564
800
 
565
801
  events.progress("windows_update",
566
- f"[wu-update] Polling every {poll_secs}s, max {wait_min} min…")
802
+ f"[wu-update] Waiting on Windows Update — polling every {poll_secs}s, "
803
+ f"stops early once WU goes idle (hard cap {wait_min} min)")
567
804
 
568
805
  while datetime.now(timezone.utc) < deadline:
806
+ log_pos = _drain_wu_log(trigger, log_pos) # surface WU's live phase
569
807
  all_new = _enumerate_packages_since(wu_start, cfg)
570
808
  new_batch = [
571
809
  p for p in all_new
@@ -581,17 +819,38 @@ def _poll_and_submit_incremental(wu_start: datetime, cfg: dict) -> CaptureResult
581
819
  current=len(new_batch))
582
820
  total.merge(_export_and_submit(new_batch, cfg))
583
821
  else:
584
- empty_polls += 1
585
- if empty_polls >= 2:
822
+ # Is WU genuinely still working? Don't give up during its long
823
+ # initial scan/download latency.
824
+ if has_liveness:
825
+ busy = trigger.is_running()
826
+ else:
827
+ busy = datetime.now(timezone.utc) < fallback_floor
828
+
829
+ if busy:
830
+ empty_polls = 0
831
+ elapsed = int((datetime.now(timezone.utc) - wu_start).total_seconds())
586
832
  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…")
833
+ f"[wu-update] WU still working {elapsed}s elapsed, "
834
+ f"no new drivers yet…")
835
+ else:
836
+ empty_polls += 1
837
+ if empty_polls >= quiescent_needed:
838
+ events.progress("windows_update",
839
+ f"[wu-update] WU idle + {empty_polls} quiescent poll(s) — stopping")
840
+ break
841
+ remaining = int((deadline - datetime.now(timezone.utc)).total_seconds())
842
+ events.progress("windows_update",
843
+ f"[wu-update] WU idle, no new drivers — "
844
+ f"{empty_polls}/{quiescent_needed} quiescent, {remaining}s cap left…")
592
845
 
593
846
  time.sleep(poll_secs)
847
+ else:
848
+ events.warn("windows_update",
849
+ f"[wu-update] Reached {wait_min} min hard cap — stopping WU wait")
594
850
 
851
+ _drain_wu_log(trigger, log_pos) # flush any final lines before cleanup
852
+ if trigger is not None:
853
+ trigger.terminate()
595
854
  return total
596
855
 
597
856
 
File without changes
File without changes