From 642874591fe3815ae0008c6041ec37a8989faef0 Mon Sep 17 00:00:00 2001 From: Maurice Daly Date: Mon, 24 Aug 2026 12:07:28 +0100 Subject: [PATCH 1/4] Version 10.2.4 is live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What's New & Fixed - OEM Support — Dell and Lenovo now join HP with a Latest Drivers build type. Instead of the OEM's monthly SCCM driver pack, packages can be built from the newest individual drivers pulled straight from the Dell Command Update catalog (Dell) and the Lenovo per-model update catalog (Lenovo). Other manufacturers continue to use their SCCM driver pack. - Driver Packaging — New Update Cadence control (Off, Daily, Weekly, Monthly) throttles how often a Latest Drivers pack is re-evaluated on repeat or scheduled runs. Within the window the existing pack is retained, and a new pack is built only when the underlying driver set actually changes — tracked per model so unchanged sets are skipped rather than rebuilt every run. - Intune Deployment — Auto-deployed packages can now target one or more specific Entra security groups (for example a pilot ring plus a broad ring) instead of only All Devices, applied across each package's assignment. - User Interface — Added a live in-progress build tile so long-running builds show current activity instead of appearing stuck, plus new "New" feature indicators and icons that highlight the Latest Drivers build type and related options, and assorted layout/theme refinements. - Documentation — User guide and README updated for the Dell/Lenovo Latest Drivers build type and update cadence, with new design/feasibility documentation added for the Dell On-Demand (DCU) catalog and Lenovo Latest Driver sourcing. - Telemetry / Backend — Deterministic driver-package contents manifest with a cross-environment-stable content hash, ready to submit to the (pending) backend API for global de-duplication. - User Interface — New "What's New" modal shown once per version after an upgrade; the "New" feature pills now also clear when you interact with a section's controls, not just hover. - Reporting — Individual per-driver download failures are now captured and surfaced in the build "View Failures" modal (previously only logged and silently skipped). - Driver Packaging — Dell and Lenovo Latest Drivers now honour the Concurrent Driver Downloads setting (parallel downloads), which previously only applied to HP. --- Data/DriverAutomationToolNotes.txt | 12 + Data/DriverAutomationToolRev.txt | 2 +- .../DriverAutomationToolCore.psd1 | 5 +- .../DriverAutomationToolCore.psm1 | 5342 +++++++++++------ Driver Automation Tool/UI/MainApplication.ps1 | 552 +- Driver Automation Tool/UI/MainWindow.xaml | 303 +- 6 files changed, 4364 insertions(+), 1852 deletions(-) diff --git a/Data/DriverAutomationToolNotes.txt b/Data/DriverAutomationToolNotes.txt index f096730..dbe6fec 100644 --- a/Data/DriverAutomationToolNotes.txt +++ b/Data/DriverAutomationToolNotes.txt @@ -3,6 +3,18 @@ Release Notes IMPORTANT - DAT Downloads have been moved to GitHub - https://github.com/maurice-daly/DriverAutomationTool +Version 10.2.4 +What's New & Fixed +- OEM Support — Dell and Lenovo now join HP with a Latest Drivers build type. Instead of the OEM's monthly SCCM driver pack, packages can be built from the newest individual drivers pulled straight from the Dell Command Update catalog (Dell) and the Lenovo per-model update catalog (Lenovo). Other manufacturers continue to use their SCCM driver pack. +- Driver Packaging — New Update Cadence control (Off, Daily, Weekly, Monthly) throttles how often a Latest Drivers pack is re-evaluated on repeat or scheduled runs. Within the window the existing pack is retained, and a new pack is built only when the underlying driver set actually changes — tracked per model so unchanged sets are skipped rather than rebuilt every run. +- Intune Deployment — Auto-deployed packages can now target one or more specific Entra security groups (for example a pilot ring plus a broad ring) instead of only All Devices, applied across each package's assignment. +- User Interface — Added a live in-progress build tile so long-running builds show current activity instead of appearing stuck, plus new "New" feature indicators and icons that highlight the Latest Drivers build type and related options, and assorted layout/theme refinements. +- Documentation — User guide and README updated for the Dell/Lenovo Latest Drivers build type and update cadence, with new design/feasibility documentation added for the Dell On-Demand (DCU) catalog and Lenovo Latest Driver sourcing. +- Telemetry / Backend — Deterministic driver-package contents manifest with a cross-environment-stable content hash, ready to submit to the (pending) backend API for global de-duplication. +- User Interface — New "What's New" modal shown once per version after an upgrade; the "New" feature pills now also clear when you interact with a section's controls, not just hover. +- Reporting — Individual per-driver download failures are now captured and surfaced in the build "View Failures" modal (previously only logged and silently skipped). +- Driver Packaging — Dell and Lenovo Latest Drivers now honour the Concurrent Driver Downloads setting (parallel downloads), which previously only applied to HP. + Version 10.2.3 What's New & Fixed - Security / FIPS Compliance — Package integrity hashing now uses SHA256 instead of MD5, so the tool no longer errors out with "not part of the Windows Platform FIPS validated cryptographic algorithms" on FIPS-enforced hosts. Where an OEM catalog only publishes an MD5 hash, verification now degrades gracefully to Authenticode/HTTPS trust rather than failing the download. (#900) diff --git a/Data/DriverAutomationToolRev.txt b/Data/DriverAutomationToolRev.txt index 8d22a71..88c2cad 100644 --- a/Data/DriverAutomationToolRev.txt +++ b/Data/DriverAutomationToolRev.txt @@ -1 +1 @@ -10.2.3 \ No newline at end of file +10.2.4 \ No newline at end of file diff --git a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 index f9e23e1..b534408 100644 --- a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 +++ b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'DriverAutomationToolCore.psm1' - ModuleVersion = '10.2.3.0' + ModuleVersion = '10.2.4.0' GUID = 'a3e0e746-8e3a-4c5b-b8d0-3b2e4f6a9c1d' Author = 'Maurice Daly' CompanyName = 'MSEndpointMgr' @@ -110,6 +110,9 @@ 'Send-DATDriverReport', 'Send-DATBiosReport', 'Send-DATSummaryReport', + 'ConvertTo-DATCanonicalJson', + 'Get-DATDriverManifestContentHash', + 'Send-DATDriverManifest', 'Test-DATTelemetryConnection', 'Get-DATBiosCatalog', 'Get-DATDriverCatalog', diff --git a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 index 5aded85..e067fcd 100644 --- a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 +++ b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 @@ -4,7 +4,7 @@ Organization: MSEndpointMgr / Patch My PC Filename: DriverAutomationToolCore.psm1 Purpose: Core functions for Driver Automation Tool v2.0 - Version: 10.2.3.0 + Version: 10.2.4.0 =========================================================================== #> @@ -37,8 +37,8 @@ if ($PSVersionTable.PSVersion.Major -le 5) { #region Variables -[version]$global:ScriptRelease = "10.2.3.0" -$global:ScriptBuildDate = "21-08-2026" +[version]$global:ScriptRelease = "10.2.4.0" +$global:ScriptBuildDate = "23-08-2026" $global:ReleaseNotesURL = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/DriverAutomationToolNotes.txt" $global:DATConfigUrl = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/refs/heads/master/Data/DATAPIConfig.json" $OEMLinksURL = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/OEMLinks.xml" @@ -702,6 +702,9 @@ function Get-DATOEMModelInfo { $DellCabFile = [string]($DellXMLCabinetSource | Split-Path -Leaf) $DellXMLFile = $DellCabFile.TrimEnd(".cab") + ".xml" $DellWindowsVersion = $WindowsVersion.Replace(" ", "") + # Latest Drivers (DCU) builds carry a date-stamp version (fingerprint-based); + # SCCM pack mode shows the enterprise catalog dellVersion. + $DellDriverPackSource = (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource try { Write-DATLogEntry -Value "[Dell] Catalog cab path: $(Join-Path $global:TempDirectory $DellCabFile)" -Severity 1 Write-DATLogEntry -Value "[Dell] Catalog XML extract path: $(Join-Path $global:TempDirectory $DellXMLFile)" -Severity 1 @@ -742,7 +745,7 @@ function Get-DATOEMModelInfo { Baseboards = $(if ($sysIds) { $sysIds -join "," } else { "" }) OS = $WindowsVersion 'OS Build' = 'All' - Version = $Model.DellVersion + Version = $(if ($DellDriverPackSource -eq 'SoftPaqs') { (Get-Date -Format 'ddMMyyyy') } else { $Model.DellVersion }) } } } catch { @@ -752,6 +755,8 @@ function Get-DATOEMModelInfo { "Lenovo" { $LenovoXMLSource = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Lenovo" }).Link | Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 $LenovoXMLCabFile = $LenovoXMLSource | Split-Path -Leaf + # Latest Drivers (Model-XML) builds carry a date-stamp version; SCCM mode shows the pack date. + $LenovoDriverPackSource = (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource try { Write-DATLogEntry -Value "[Lenovo] Catalog download path: $(Join-Path $global:TempDirectory $LenovoXMLCabFile)" -Severity 1 if (-not (Test-Path "$global:TempDirectory\$LenovoXMLCabFile")) { @@ -783,7 +788,7 @@ function Get-DATOEMModelInfo { 'OS Build' = $WindowsBuild HasGFX = $hasGFX GFXBrand = $gfxBrand - Version = $lenovoDate + Version = $(if ($LenovoDriverPackSource -eq 'SoftPaqs') { (Get-Date -Format 'ddMMyyyy') } else { $lenovoDate }) } } } catch { @@ -1526,6 +1531,148 @@ function Test-DATLongPathsEnabled { } } +function Get-DATHPMetaValue { + <# + .SYNOPSIS + Defensively reads a value out of an HP SoftPaq metadata object (CVA sections), tolerating + the varied section/key layouts HPCMSL returns across versions. Returns $null if not found. + #> + param( + $Meta, + [string[]]$Keys + ) + if ($null -eq $Meta) { return $null } + try { + # Top-level keys first, then one level into each section (metadata is section-keyed). + foreach ($k in $Keys) { + if ($Meta.Keys -contains $k) { + $v = $Meta[$k] + if ($v -isnot [System.Collections.IDictionary] -and "$v".Trim()) { return "$v".Trim() } + } + } + foreach ($section in $Meta.Values) { + if ($section -is [System.Collections.IDictionary]) { + foreach ($k in $Keys) { + if ($section.Keys -contains $k) { + $vv = $section[$k] + if ("$vv".Trim()) { return "$vv".Trim() } + } + } + } + } + } catch { } + return $null +} + +function New-DATDriverManifest { + <# + .SYNOPSIS + Writes DATDriverManifest.json into the folder captured into the WIM, so the package + contents (component list + enumerated INF drivers) can be read back externally without + the tool -- by mounting/extracting the WIM or from the expanded package. + #> + param( + [Parameter(Mandatory = $true)][string]$TargetFolder, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$OEM, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Model, + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$OS, + [AllowEmptyString()][string]$PackageVersion = '', + [object[]]$Components = @(), + [AllowEmptyString()][string]$Source = '' + ) + try { + if (-not (Test-Path -Path $TargetFolder -PathType Container)) { return $null } + + # Canonicalise the root so relative paths are correct even if the caller passed an 8.3 + # short path (Get-ChildItem always returns long-form full paths). + $targetFull = (Get-Item -LiteralPath $TargetFolder).FullName.TrimEnd('\', '/') + + # Enumerate INF driver files; best-effort parse of Class / Provider / DriverVer (date,version). + $drivers = New-Object System.Collections.Generic.List[object] + $infFiles = @(Get-ChildItem -Path $TargetFolder -Filter '*.inf' -Recurse -File -ErrorAction SilentlyContinue) + foreach ($inf in $infFiles) { + $class = $null; $provider = $null; $ver = $null; $date = $null + try { + foreach ($line in (Get-Content -Path $inf.FullName -TotalCount 80 -ErrorAction SilentlyContinue)) { + if (-not $class -and $line -match '^\s*Class\s*=\s*(.+?)\s*$') { $class = $Matches[1].Trim() } + if (-not $provider -and $line -match '^\s*Provider\s*=\s*(.+?)\s*$') { $provider = ($Matches[1] -replace '%', '').Trim() } + if (-not $ver -and $line -match '^\s*DriverVer\s*=\s*(.+?)\s*$') { + $parts = $Matches[1] -split ',' + $date = $parts[0].Trim() + if ($parts.Count -gt 1) { $ver = $parts[1].Trim() } + } + } + } catch { } + $relPath = $inf.FullName + if ($relPath.StartsWith($targetFull, [System.StringComparison]::OrdinalIgnoreCase)) { + $relPath = $relPath.Substring($targetFull.Length).TrimStart('\', '/') + } else { + $relPath = $inf.Name + } + $drivers.Add([ordered]@{ + inf = $inf.Name + path = $relPath + class = $class + provider = $provider + version = $ver + date = $date + }) + } + + $manifest = [ordered]@{ + schema = 'DAT Driver Package manifest v1' + generated = (Get-Date).ToUniversalTime().ToString('o') + toolVersion = "$global:ScriptRelease" + oem = $OEM + model = $Model + os = $OS + packageVersion = $PackageVersion + source = $Source + componentCount = @($Components).Count + driverInfCount = $drivers.Count + components = @($Components) + drivers = $drivers + } + + $manifestPath = Join-Path $TargetFolder 'DATDriverManifest.json' + $manifest | ConvertTo-Json -Depth 6 | Set-Content -Path $manifestPath -Encoding UTF8 -Force + Write-DATLogEntry -Value "[$OEM] Driver manifest written: $manifestPath ($($drivers.Count) INF file(s), $(@($Components).Count) component(s))" -Severity 1 + + # Cache a deterministic, content-only copy of the manifest so Send-DATDriverManifest can + # submit it with a cross-environment-stable content hash. This deliberately omits the + # volatile fields above (generated timestamp, toolVersion, source) and sorts both arrays so + # identical package contents serialize identically on any machine. Covers both SCCM + # driver-pack builds and individual driver packs (all flow through this function). + try { + $canonicalDrivers = @(@($drivers) | Sort-Object -Property @{ Expression = { ConvertTo-DATCanonicalJson -InputObject $_ } } -Culture ([System.Globalization.CultureInfo]::InvariantCulture)) + $canonicalComponents = @(@($Components) | Sort-Object -Property @{ Expression = { ConvertTo-DATCanonicalJson -InputObject $_ } } -Culture ([System.Globalization.CultureInfo]::InvariantCulture)) + $canonicalContent = [ordered]@{ + schema = 'DAT Driver Package manifest v1' + oem = "$OEM".Trim() + model = "$Model".Trim() + os = "$OS".Trim() + packageVersion = "$PackageVersion".Trim() + componentCount = @($Components).Count + driverInfCount = $drivers.Count + components = $canonicalComponents + drivers = $canonicalDrivers + } + $script:DATLastDriverManifest = [pscustomobject]@{ + Key = ('{0}|{1}|{2}' -f "$OEM".Trim(), "$Model".Trim(), "$OS".Trim()) + Content = $canonicalContent + } + } catch { + $script:DATLastDriverManifest = $null + Write-DATLogEntry -Value "[$OEM] Failed to cache canonical driver manifest: $($_.Exception.Message)" -Severity 2 + } + + return $manifestPath + } catch { + Write-DATLogEntry -Value "[$OEM] Failed to write driver manifest: $($_.Exception.Message)" -Severity 2 + return $null + } +} + function Invoke-DATDriverFilePackaging { param ( [string]$FilePath, @@ -1537,7 +1684,9 @@ function Invoke-DATDriverFilePackaging { [string]$Platform, [string[]]$SupplementalFilePaths = @(), [string]$CustomDriverPath, - [string]$DownloadOnlyExtractDestination + [string]$DownloadOnlyExtractDestination, + [AllowEmptyString()][string]$PackageVersion = '', + [object[]]$Components = @() ) # Always use the temp directory for extraction and WIM creation, then copy the @@ -1898,6 +2047,20 @@ function Invoke-DATDriverFilePackaging { Write-DATLogEntry -Value "[$OEM] Custom drivers injected: $customFileCount files ($customInfCount .inf files)" -Severity 1 } + # Embed a machine-readable manifest of the package contents (components + INF drivers) so it is + # captured into the WIM / expanded package and can be read back externally without the tool. + $null = New-DATDriverManifest -TargetFolder $DriverFolder -OEM $OEM -Model $Model -OS $OS ` + -PackageVersion $PackageVersion -Components $Components + + # Submit the deterministic contents manifest to the telemetry API. This runs for every driver + # package regardless of source (SCCM driver pack or individual driver pack) and platform, and + # no-ops safely when telemetry is disabled or the backend endpoint is not yet published. + try { + $null = Send-DATDriverManifest -OEM $OEM -Model $Model -OS $OS -Platform $Platform + } catch { + Write-DATLogEntry -Value "[Telemetry] Driver manifest submission failed: $($_.Exception.Message)" -Severity 2 + } + # Download Only with extraction enabled -- stage the expanded content next to the # downloaded file so the admin can access the extracted drivers, then clean up temp. # No WIM is created in this mode. @@ -3959,6 +4122,41 @@ function Write-DATOfflineManifestAndScript { return $manifestPath } +function Add-DATDriverDownloadFailure { + <# + .SYNOPSIS + Records an individual driver/component download or extract failure so the post-build + "View Failures" report can list exactly which drivers failed -- even when the model package + still built from the components that succeeded. Registry-backed (cross-runspace safe), + capped, and never throws. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][AllowEmptyString()][string]$OEM, + [Parameter(Mandatory)][AllowEmptyString()][string]$Model, + [Parameter(Mandatory)][AllowEmptyString()][string]$Driver, + [Parameter(Mandatory)][AllowEmptyString()][string]$Reason + ) + try { + $list = New-Object System.Collections.Generic.List[object] + $raw = (Get-ItemProperty -Path $global:RegPath -Name 'DriverDownloadFailures' -ErrorAction SilentlyContinue).DriverDownloadFailures + if (-not [string]::IsNullOrWhiteSpace($raw)) { + # Assign before wrapping: on PS 5.1 @( | ConvertFrom-Json) inline collapses a + # multi-element array to a single object; a temp var then @() keeps the count correct. + $parsed = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue + foreach ($item in @($parsed)) { if ($null -ne $item) { [void]$list.Add($item) } } + } + # Cap the list so the serialized payload stays within registry string limits. + if ($list.Count -ge 200) { return } + $reasonText = "$Reason" + if ($reasonText.Length -gt 200) { $reasonText = $reasonText.Substring(0, 197) + '...' } + [void]$list.Add([pscustomobject]@{ OEM = "$OEM"; Model = "$Model"; Driver = "$Driver"; Reason = $reasonText }) + $json = ConvertTo-Json -InputObject @($list) -Depth 4 -Compress + Set-DATRegistryValue -Name 'DriverDownloadFailures' -Value $json -Type String + Set-DATRegistryValue -Name 'FailedDrivers' -Value "$($list.Count)" -Type String + } catch { } +} + function Start-DATModelProcessing { [CmdletBinding()] param ( @@ -4176,6 +4374,10 @@ function Start-DATModelProcessing { # Cleared at the start of every run so stale failures from a previous build are not shown. $buildFailures = [System.Collections.Generic.List[object]]::new() Remove-ItemProperty -Path $global:RegPath -Name 'BuildFailures' -ErrorAction SilentlyContinue + # Individual driver/component download failures (Latest Drivers builds) are recorded separately + # so they surface in the report even when the model package still builds. Cleared each run. + Remove-ItemProperty -Path $global:RegPath -Name 'DriverDownloadFailures' -ErrorAction SilentlyContinue + Set-DATRegistryValue -Name 'FailedDrivers' -Value '0' -Type String # Collect per-package-type skips (already at the current version) so the progress modal can render # them distinctly (grey "skipped") instead of as green builds, and so "Packages Created" reads @@ -4488,6 +4690,16 @@ function Start-DATModelProcessing { $spRefKey = Get-DATHPSoftPaqManifestKey -Model $modelName -OSVersion $windowsVersion -Build $windowsBuild -Architecture $arch [void](Update-DATHPSoftPaqManifestReference -Key $spRefKey -Field 'intuneAppId' -Value "$($intuneResult.AppId)") } + # Same for the Dell Latest Drivers manifest (no-op for SCCM-pack Dell builds). + if ($oem -eq 'Dell' -and $null -ne $intuneResult -and -not [string]::IsNullOrEmpty($intuneResult.AppId)) { + $dellRefKey = Get-DATDellLatestManifestKey -Model $modelName -OSVersion $windowsVersion -Build $windowsBuild -Architecture $arch + [void](Update-DATDellLatestManifestReference -Key $dellRefKey -Field 'intuneAppId' -Value "$($intuneResult.AppId)") + } + # Same for the Lenovo Latest Drivers manifest (no-op for SCCM-pack Lenovo builds). + if ($oem -eq 'Lenovo' -and $null -ne $intuneResult -and -not [string]::IsNullOrEmpty($intuneResult.AppId)) { + $lnvRefKey = Get-DATLenovoLatestManifestKey -Model $modelName -OSVersion $windowsVersion -Build $windowsBuild -Architecture $arch + [void](Update-DATLenovoLatestManifestReference -Key $lnvRefKey -Field 'intuneAppId' -Value "$($intuneResult.AppId)") + } # Auto-deploy and auto-assignment-filter for driver packages if ($null -ne $intuneResult -and -not [string]::IsNullOrEmpty($intuneResult.AppId)) { @@ -4500,8 +4712,8 @@ function Start-DATModelProcessing { if ($null -ne $deployReg.DeployAllDevices -and $deployReg.DeployAllDevices -eq 1 -and ($null -eq $deployReg.AutoAssignmentFilter -or $deployReg.AutoAssignmentFilter -ne 1)) { try { - $targetGroupId = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { $deployReg.DeployTargetGroupId } else { 'adadadad-808e-44e2-905a-0b7873a8a531' } - $targetGroupName = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupName)) { $deployReg.DeployTargetGroupName } else { 'All Devices' } + $targetGroupId = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { @($deployReg.DeployTargetGroupId -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } else { @('adadadad-808e-44e2-905a-0b7873a8a531') } + $targetGroupName = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupName)) { ($deployReg.DeployTargetGroupName -split ';;') -join ', ' } else { 'All Devices' } Set-DATRegistryValue -Name "RunningMode" -Value "Deploying" -Type String Set-DATRegistryValue -Name "RunningMessage" -Value "Deploying to ${targetGroupName}: $oem $modelName" -Type String Set-DATIntuneAppAssignment -AppId $intuneResult.AppId -GroupId $targetGroupId -Intent 'Required' -IMENotifications $imeNotifications @@ -4530,7 +4742,7 @@ function Start-DATModelProcessing { $filterParams['Model'] = $modelName if (-not [string]::IsNullOrEmpty($baseboards)) { $filterParams['Baseboards'] = $baseboards } } - if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { $filterParams['TargetGroupId'] = $deployReg.DeployTargetGroupId } + if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { $filterParams['TargetGroupId'] = @($deployReg.DeployTargetGroupId -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } Invoke-DATAutoAssignmentFilter @filterParams Write-DATLogEntry -Value "[Intune] Auto-assignment filter applied for driver package: $oem $modelName ($filterMode)" -Severity 1 } catch { @@ -4625,6 +4837,16 @@ function Start-DATModelProcessing { $spRefKey = Get-DATHPSoftPaqManifestKey -Model $modelName -OSVersion $windowsVersion -Build $windowsBuild -Architecture $arch [void](Update-DATHPSoftPaqManifestReference -Key $spRefKey -Field 'configMgrPackageId' -Value "$cmResult") } + # Same for the Dell Latest Drivers manifest (no-op for SCCM-pack Dell builds). + if ($oem -eq 'Dell') { + $dellRefKey = Get-DATDellLatestManifestKey -Model $modelName -OSVersion $windowsVersion -Build $windowsBuild -Architecture $arch + [void](Update-DATDellLatestManifestReference -Key $dellRefKey -Field 'configMgrPackageId' -Value "$cmResult") + } + # Same for the Lenovo Latest Drivers manifest (no-op for SCCM-pack Lenovo builds). + if ($oem -eq 'Lenovo') { + $lnvRefKey = Get-DATLenovoLatestManifestKey -Model $modelName -OSVersion $windowsVersion -Build $windowsBuild -Architecture $arch + [void](Update-DATLenovoLatestManifestReference -Key $lnvRefKey -Field 'configMgrPackageId' -Value "$cmResult") + } # Telemetry: driver report with WIM hash (before cleanup). The hash # runs on a timeout-guarded runspace so a stalled file read can never @@ -5026,8 +5248,8 @@ function Start-DATModelProcessing { if ($null -ne $deployReg.DeployAllDevices -and $deployReg.DeployAllDevices -eq 1 -and ($null -eq $deployReg.AutoAssignmentFilter -or $deployReg.AutoAssignmentFilter -ne 1)) { try { - $targetGroupId = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { $deployReg.DeployTargetGroupId } else { 'adadadad-808e-44e2-905a-0b7873a8a531' } - $targetGroupName = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupName)) { $deployReg.DeployTargetGroupName } else { 'All Devices' } + $targetGroupId = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { @($deployReg.DeployTargetGroupId -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } else { @('adadadad-808e-44e2-905a-0b7873a8a531') } + $targetGroupName = if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupName)) { ($deployReg.DeployTargetGroupName -split ';;') -join ', ' } else { 'All Devices' } Set-DATRegistryValue -Name "RunningMode" -Value "Deploying" -Type String Set-DATRegistryValue -Name "RunningMessage" -Value "Deploying BIOS to ${targetGroupName}: $oem $modelName" -Type String Set-DATIntuneAppAssignment -AppId $biosIntuneResult.AppId -GroupId $targetGroupId -Intent 'Required' -IMENotifications $imeNotifications @@ -5056,7 +5278,7 @@ function Start-DATModelProcessing { $filterParams['Model'] = $modelName if (-not [string]::IsNullOrEmpty($baseboards)) { $filterParams['Baseboards'] = $baseboards } } - if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { $filterParams['TargetGroupId'] = $deployReg.DeployTargetGroupId } + if (-not [string]::IsNullOrEmpty($deployReg.DeployTargetGroupId)) { $filterParams['TargetGroupId'] = @($deployReg.DeployTargetGroupId -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } Invoke-DATAutoAssignmentFilter @filterParams Write-DATLogEntry -Value "[Intune] Auto-assignment filter applied for BIOS package: $oem $modelName ($filterMode)" -Severity 1 } catch { @@ -6146,1066 +6368,2115 @@ function Test-DATHPCMSLReady { return $result } -function Invoke-DATOEMDownloadModule { +function Get-DATLatestDriverConcurrency { + <# + .SYNOPSIS + Returns the configured "Concurrent Driver Downloads" count (1-8, default 2) shared by the + Dell, HP and Lenovo "Latest Drivers" builds. Reads the 'HPConcurrentDownloads' registry value. + #> + [CmdletBinding()] + [OutputType([int])] + param() + $n = 2 + try { + $v = (Get-ItemProperty -Path $global:RegPath -Name 'HPConcurrentDownloads' -ErrorAction SilentlyContinue).HPConcurrentDownloads + if ($null -ne $v) { $p = 0; if ([int]::TryParse("$v", [ref]$p) -and $p -ge 1 -and $p -le 8) { $n = $p } } + } catch { } + return $n +} + +function Invoke-DATConcurrentDriverDownload { + <# + .SYNOPSIS + Downloads a set of individual driver files, in parallel when the user's "Concurrent Driver + Downloads" setting is above 1. Used by the Dell and Lenovo "Latest Drivers" builds (the twin + of the HP SoftPaq worker pool). Returns the file names that landed on disk. + .DESCRIPTION + Concurrency spawns up to MaxConcurrency system curl.exe processes (Microsoft-signed, present + on Windows 10 1803+), one per file, and computes progress from bytes on disk so the shared + progress registry keys have a single writer. Honours user abort (kills active curls), then + retries any first-pass failures sequentially through the fully validated + Invoke-DATContentDownload path (HTTPS enforcement, curl pinning, resume). Unrecoverable + failures are recorded via Add-DATDriverDownloadFailure. Falls back to a fully sequential + download when MaxConcurrency is 1 or system curl is unavailable, preserving existing behaviour. + .PARAMETER Items + Objects each exposing .Url (https), .FileName and .Name (display name for logs/failures). + #> [CmdletBinding()] + [OutputType([string[]])] param ( - [Parameter(Mandatory)][string]$OEM, - [string]$Model, - [string]$SystemSKU, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Items, + [Parameter(Mandatory)][string]$DestinationDirectory, + [Parameter(Mandatory)][AllowEmptyString()][string]$OEM, + [AllowEmptyString()][string]$Model = '', + [int]$MaxConcurrency = 1 + ) + + if (-not (Test-Path -LiteralPath $DestinationDirectory)) { + New-Item -Path $DestinationDirectory -ItemType Directory -Force | Out-Null + } + + $list = @($Items | Where-Object { $_ -and $_.Url -and $_.FileName }) + $total = $list.Count + if ($total -eq 0) { return @() } + + $systemCurl = Join-Path $env:SystemRoot 'System32\curl.exe' + $useConcurrent = ($MaxConcurrency -gt 1) -and (Test-Path -LiteralPath $systemCurl) + + if (-not $useConcurrent) { + # Sequential fallback -- full validation/resume via Invoke-DATContentDownload (concurrency 1 + # or no system curl). Preserves the original per-driver progress message and behaviour. + $i = 0 + foreach ($it in $list) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "$OEM download aborted by user" } + $i++ + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading $OEM driver $i of ${total}: $($it.Name)" -Type String + Set-DATRegistryValue -Name "BytesTransferred" -Value "$i" -Type String + try { + Invoke-DATContentDownload -DownloadURL $it.Url -DownloadDestination $DestinationDirectory + } catch { + Write-DATLogEntry -Value "[$OEM] Download failed for $($it.FileName): $($_.Exception.Message) -- skipping" -Severity 3 + Add-DATDriverDownloadFailure -OEM $OEM -Model $Model -Driver "$($it.Name)" -Reason "Download failed: $($_.Exception.Message)" + } + } + } else { + Write-DATLogEntry -Value "[$OEM] Downloading $total drivers ($MaxConcurrency concurrent)..." -Severity 1 + + # Resolve the proxy config (credentials via a temp config file, never on the command line) + # and the curl window mode ONCE, then share across all workers. + $proxyCfg = $null + try { $proxyCfg = New-DATCurlProxyConfigFile } catch { $proxyCfg = $null } + $proxyArgs = '' + try { $proxyArgs = Get-DATCurlProxyArgs } catch { $proxyArgs = '' } + $curlRunMode = (Get-ItemProperty -Path $global:RegPath -Name 'CurlRunMode' -ErrorAction SilentlyContinue).CurlRunMode + $winStyle = if ($curlRunMode -eq 'Show Window') { 'Normal' } else { 'Hidden' } + + $queue = [System.Collections.Generic.Queue[object]]::new() + foreach ($it in $list) { $queue.Enqueue($it) } + $activeProcs = @{} # FileName -> @{ Proc; Item } + $failedItems = New-Object System.Collections.Generic.List[object] + $startTime = Get-Date + + try { + while ($queue.Count -gt 0 -or $activeProcs.Count -gt 0) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "$OEM download aborted by user" } + + # Fill slots up to the concurrency limit. + while ($activeProcs.Count -lt $MaxConcurrency -and $queue.Count -gt 0) { + $it = $queue.Dequeue() + $u = $null + if (-not ([System.Uri]::TryCreate([string]$it.Url, [System.UriKind]::Absolute, [ref]$u)) -or $u.Scheme -ne 'https') { + Write-DATLogEntry -Value "[$OEM] Rejected non-HTTPS driver URL for $($it.FileName): $($it.Url)" -Severity 3 + Add-DATDriverDownloadFailure -OEM $OEM -Model $Model -Driver "$($it.Name)" -Reason 'Download URL is not HTTPS' + continue + } + $outFile = Join-Path $DestinationDirectory $it.FileName + if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue } + $argList = "--location --proto =https --max-redirs 5 --output `"$outFile`" --url `"$($it.Url)`" --connect-timeout 30 --retry 10 --retry-delay 60 --retry-max-time 600 --retry-connrefused $proxyArgs" + if ($proxyCfg) { $argList = "--config `"$proxyCfg`" $argList" } + $proc = Start-Process -FilePath $systemCurl -ArgumentList $argList -WindowStyle $winStyle -PassThru + $activeProcs[$it.FileName] = @{ Proc = $proc; Item = $it } + Write-DATLogEntry -Value "[$OEM] Started download: $($it.Name) (PID $($proc.Id))" -Severity 1 + } + + # Reap completed processes. + $finishedNames = @($activeProcs.Keys | Where-Object { $activeProcs[$_].Proc.HasExited }) + foreach ($fn in $finishedNames) { + $entry = $activeProcs[$fn]; $activeProcs.Remove($fn) + $outFile = Join-Path $DestinationDirectory $fn + $sizeOk = (Test-Path -LiteralPath $outFile) -and ((Get-Item -LiteralPath $outFile -ErrorAction SilentlyContinue).Length -gt 0) + if ($entry.Proc.ExitCode -eq 0 -and $sizeOk) { + Write-DATLogEntry -Value "[$OEM] Download completed: $($entry.Item.Name)" -Severity 1 + } else { + Write-DATLogEntry -Value "[$OEM] Download failed (curl exit $($entry.Proc.ExitCode)): $($entry.Item.Name) -- will retry sequentially" -Severity 2 + [void]$failedItems.Add($entry.Item) + if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue } + } + } + + # Progress from disk (single writer). + $doneCount = $total - $queue.Count - $activeProcs.Count + $bytes = (@(Get-ChildItem -Path $DestinationDirectory -File -ErrorAction SilentlyContinue) | Measure-Object -Property Length -Sum).Sum + if ($null -eq $bytes) { $bytes = [long]0 } + $mb = [math]::Round($bytes / 1MB, 2) + Set-DATRegistryValue -Name "BytesTransferred" -Value "$doneCount" -Type String + Set-DATRegistryValue -Name "DownloadSize" -Value "$mb MB" -Type String + $elapsed = ((Get-Date) - $startTime).TotalSeconds + if ($elapsed -gt 0 -and $mb -gt 0) { + Set-DATRegistryValue -Name "DownloadSpeed" -Value "$([math]::Round($mb / $elapsed, 2)) MB/s" -Type String + } + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading $OEM drivers: $doneCount of $total ($($activeProcs.Count) active) -- $mb MB" -Type String + + Start-Sleep -Seconds 2 + } + } finally { + foreach ($fn in @($activeProcs.Keys)) { + $p = $activeProcs[$fn].Proc + if (-not $p.HasExited) { + try { $p.Kill() } catch { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + Write-DATLogEntry -Value "[$OEM] Killed download process (PID $($p.Id)) on abort/error" -Severity 2 + } + } + if ($proxyCfg -and (Test-Path -LiteralPath $proxyCfg)) { Remove-Item -LiteralPath $proxyCfg -Force -ErrorAction SilentlyContinue } + } + + # Retry first-pass failures sequentially through the fully validated path (resume-capable). + if ($failedItems.Count -gt 0) { + Write-DATLogEntry -Value "[$OEM] Retrying $($failedItems.Count) failed download(s) sequentially..." -Severity 2 + foreach ($it in $failedItems) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "$OEM download aborted by user" } + Set-DATRegistryValue -Name "RunningMessage" -Value "Retrying $OEM driver: $($it.Name)" -Type String + try { + Invoke-DATContentDownload -DownloadURL $it.Url -DownloadDestination $DestinationDirectory + } catch { + Write-DATLogEntry -Value "[$OEM] Retry failed for $($it.FileName): $($_.Exception.Message)" -Severity 3 + } + if (-not (Test-Path -LiteralPath (Join-Path $DestinationDirectory $it.FileName))) { + Add-DATDriverDownloadFailure -OEM $OEM -Model $Model -Driver "$($it.Name)" -Reason 'Download failed after concurrent and sequential retry' + } + } + } + } + + # Return the file names that are present on disk. + $present = New-Object System.Collections.Generic.List[string] + foreach ($it in $list) { + if (Test-Path -LiteralPath (Join-Path $DestinationDirectory $it.FileName)) { [void]$present.Add([string]$it.FileName) } + } + return $present.ToArray() +} + +function Invoke-DATDellLatestDriverPackage { + <# + .SYNOPSIS + Builds a Dell "Latest Drivers" package from the Dell Command Update (DCU) System Update + catalog (CatalogIndexPC.cab -> per-model slice). Resolves the newest individual driver + DUPs for the model/OS/arch, downloads and verifies them, extracts each to raw drivers, + then packages via the common WIM pipeline. Mirrors the HP "Latest Drivers" (SoftPaqs) path. + .DESCRIPTION + Reuses Invoke-DATContentDownload (HTTPS, cached, proxy-aware), Get-DATVerificationHash, + New-DATDriverManifest (embedded WIM manifest) and Invoke-DATDriverFilePackaging. A DUP-set + fingerprint drives skip-if-unchanged via the Dell Latest Drivers manifest. + .OUTPUTS + The build version string. Sets $global:DATSoftPaqBuildSkipped = $true when the DUP set is + unchanged and the existing package is retained. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$Model, + [AllowEmptyString()][string]$SystemSKU, + [AllowEmptyString()][string]$WindowsVersion, [Parameter(Mandatory)][AllowEmptyString()][string]$WindowsBuild, - [string]$WindowsVersion, - [string]$Architecture = "x64", + [string]$Architecture = 'x64', [string]$DownloadDestination, [string]$PackageDestination, - [string]$RegPath, - [string]$LogDirectory, [string]$TempDirectory, - [string]$RunningMode = "Download Only", + [string]$RunningMode = 'Download Only', [string]$CustomDriverPath, - [string]$CatalogDownloadURL, - [string]$CatalogVersion, + [bool]$ExtractDownloadOnlyContent = $true, + [xml]$OEMLinks, [switch]$ForceRebuild, - [string[]]$ExistingPackageIds = @(), [switch]$VerifyRemoteExistence, - [bool]$ExtractDownloadOnlyContent = $true - ) - - [Net.ServicePointManager]::SecurityProtocol = ( - [Net.ServicePointManager]::SecurityProtocol -bor - [Net.SecurityProtocolType]::Tls12 + [string[]]$ExistingPackageIds = @() ) - try { - if ([Enum]::IsDefined([Net.SecurityProtocolType], 12288)) { - [Net.ServicePointManager]::SecurityProtocol = ( - [Net.ServicePointManager]::SecurityProtocol -bor - ([Net.SecurityProtocolType]12288) - ) - } - } catch { } - - if (-not (Test-Path $TempDirectory)) { New-Item -Path $TempDirectory -ItemType Directory -Force | Out-Null } - if (-not (Test-Path $DownloadDestination)) { New-Item -Path $DownloadDestination -ItemType Directory -Force | Out-Null } - - $OEMLinksURL = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/OEMLinks.xml" - Set-DATRegistryValue -Name "RunningMessage" -Value "Resolving download link for $OEM $Model..." -Type String - Write-DATLogEntry -Value "[$OEM] Resolving download link for $Model (SKU: $SystemSKU)" -Severity 1 + $OEM = 'Dell' + Set-DATRegistryValue -Name "RunningMessage" -Value "Resolving latest Dell drivers for $Model..." -Type String + Write-DATLogEntry -Value "[Dell] Latest Drivers mode (DCU catalog) for $Model (SKU: $SystemSKU, $WindowsVersion $WindowsBuild $Architecture)" -Severity 1 - # Retry helper for catalog downloads (transient network issues) - function Invoke-CatalogDownload { - param([string]$Uri, [string]$OutFile, [int]$MaxAttempts = 3, [int]$TimeoutSec = 60) - for ($i = 1; $i -le $MaxAttempts; $i++) { - try { - $proxyParams = Get-DATWebRequestProxy - Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop @proxyParams - return - } catch { - Write-DATLogEntry -Value "[Warning] - Catalog download attempt $i/$MaxAttempts failed: $($_.Exception.Message)" -Severity 2 - if ($i -lt $MaxAttempts) { Start-Sleep -Seconds 5 } else { throw } + # -- Nested helpers (version-tolerant DCU schema access) -- + function Get-DcuAttr { param($Node, [string[]]$Names) + foreach ($n in $Names) { $v = $Node.$n; if ($null -ne $v -and "$v".Trim() -ne '') { return "$v".Trim() } } + return $null + } + function Get-DcuDisplay { param($Node) + if ($null -eq $Node) { return $null } + $disp = $Node.Display + if ($disp) { + $first = @($disp) | Select-Object -First 1 + $text = if ($first -is [string]) { $first } else { $first.InnerText } + if ($text -and "$text".Trim() -ne '') { return "$text".Trim() } + } + if ($Node.InnerText -and "$($Node.InnerText)".Trim() -ne '') { return "$($Node.InnerText)".Trim() } + return $null + } + function Get-DcuHash { param($Component) + $hashes = @($Component.Cryptography.Hash) + foreach ($alg in 'SHA256', 'SHA1', 'MD5') { + $node = $hashes | Where-Object { (Get-DcuAttr -Node $_ -Names @('algorithm', 'Algorithm')) -ieq $alg } | Select-Object -First 1 + if ($node) { + $val = if ($node -is [string]) { $node } else { $node.InnerText } + if ($val -and "$val".Trim() -ne '') { return [PSCustomObject]@{ Algorithm = $alg; Value = "$val".Trim() } } } } + return $null + } + # Supersession-collapse key: sorted set of alphabetic-only words. Dell revises a driver's name + # by changing the chipset model list (which contains digits) while keeping the vendor + function + # words, so digit-bearing tokens are dropped and the remaining descriptor identifies the driver. + $sigStopWords = @('and', 'the', 'of', 'for', 'with', 'to', 'plus', 'uwd', 'dch', 'a', 'an') + function Get-DcuNameSignature { param([string]$Name) + if ([string]::IsNullOrWhiteSpace($Name)) { return '' } + $toks = $Name -split '[\s/,()]+' | + ForEach-Object { $_.Trim().ToLowerInvariant() } | + Where-Object { $_ -and ($_ -notmatch '\d') -and ($sigStopWords -notcontains $_) } + return (($toks | Sort-Object -Unique) -join ' ') + } + + # DCU osCode prefixes (Dell-specific; the arch comes from the separate osArch attribute). + switch -Wildcard ($WindowsVersion) { + '*Windows 11*' { $osPrefixes = @('W11', 'W21', 'WT') } + '*Windows 10*' { $osPrefixes = @('W10', 'WT') } + default { $osPrefixes = @('W11', 'W21', 'WT') } + } + + $DellBaseURL = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match 'Dell' }).Link | + Where-Object { $_.Type -eq 'DownloadBase' } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($DellBaseURL)) { $DellBaseURL = 'https://downloads.dell.com' } + $DellBaseURL = $DellBaseURL.TrimEnd('/') + + # Dell Latest temp dirs: Temp\DellLatest\Model\OS\Build\{Catalog,DUPs,Staging} + $osTag = ($WindowsVersion -replace '\s', '') + $dellRoot = Join-Path $TempDirectory "DellLatest\$Model\$osTag\$WindowsBuild" + $catalogDir = Join-Path $dellRoot 'Catalog' + $dupDir = Join-Path $dellRoot 'DUPs' + $stagingDir = Join-Path $dellRoot 'Staging' + foreach ($d in @($dellRoot, $catalogDir, $dupDir, $stagingDir)) { + if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null } + } + # Start staging empty so a rebuild never mixes drivers from a prior DUP set. + Get-ChildItem -Path $stagingDir -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + + $systemSKUs = @($SystemSKU -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + # -- Cadence gate: skip before any catalog download when within the update cadence window -- + $manifestKeyEarly = Get-DATDellLatestManifestKey -Model $Model -OSVersion $WindowsVersion -Build $WindowsBuild -Architecture $Architecture + $entryEarly = (Get-DATDellLatestManifest)[$manifestKeyEarly] + $cadence = Get-DATLatestDriverCadence + if (-not $ForceRebuild -and $cadence -ne 'Off' -and $null -ne $entryEarly -and + -not (Test-DATLatestCadenceElapsed -LastActivity "$($entryEarly.lastChecked)" -Cadence $cadence)) { + $present = Test-DATLatestPackagePresent -Entry $entryEarly -OEM $OEM -Model $Model -WindowsVersion $WindowsVersion ` + -WindowsBuild $WindowsBuild -RunningMode $RunningMode -PackageDestination $PackageDestination ` + -DownloadDestination $DownloadDestination -VerifyRemoteExistence:$VerifyRemoteExistence -ExistingPackageIds $ExistingPackageIds + if ($present) { + $nextDue = try { ([datetime]$entryEarly.lastChecked) } catch { Get-Date } + $nextDue = switch ($cadence) { 'Daily' { $nextDue.AddDays(1) } 'Weekly' { $nextDue.AddDays(7) } 'Monthly' { $nextDue.AddMonths(1) } default { $nextDue } } + Write-DATLogEntry -Value "[Dell] Within $cadence update cadence for $Model -- retaining existing package (next eligible $($nextDue.ToString('yyyy-MM-dd')))" -Severity 1 -UpdateUI + $global:DATSoftPaqBuildSkipped = $true + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return "$($entryEarly.version)" + } } - try { - $proxyParams = Get-DATWebRequestProxy - $webContent = $null - for ($i = 1; $i -le 3; $i++) { - try { - $webContent = (Invoke-WebRequest -Uri $OEMLinksURL -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop @proxyParams).Content + # -- 1. Download + expand the catalog index (fetched fresh each build for currency) -- + $proxyParams = Get-DATWebRequestProxy + $indexUrl = "$DellBaseURL/catalog/CatalogIndexPC.cab" + $indexCab = Join-Path $catalogDir 'CatalogIndexPC.cab' + $indexXml = Join-Path $catalogDir 'CatalogIndexPC.xml' + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Dell DCU catalog index..." -Type String + Invoke-WebRequest -Uri $indexUrl -OutFile $indexCab -UseBasicParsing -TimeoutSec 120 @proxyParams + & expand.exe "$indexCab" -F:* "$catalogDir" -R 2>&1 | Out-Null + if (-not (Test-Path $indexXml)) { throw "Dell DCU catalog index not found after extraction" } + + [xml]$indexDoc = Get-Content -Path $indexXml -Raw + $groupManifests = @($indexDoc.ManifestIndex.GroupManifest) + if ($groupManifests.Count -eq 0) { $groupManifests = @($indexDoc.SelectNodes('//*[local-name()="GroupManifest"]')) } + Write-DATLogEntry -Value "[Dell] DCU index contains $($groupManifests.Count) group manifests" -Severity 1 + + # -- 2. Match the model slice by SystemID (SystemSKU) or model name -- + $matchedGroup = $null + $matchedVia = $null + foreach ($group in $groupManifests) { + $models = @($group.SupportedSystems.Brand.Model) + if ($models.Count -eq 0) { $models = @($group.SelectNodes('.//*[local-name()="Model"]')) } + foreach ($m in $models) { + $mId = Get-DcuAttr -Node $m -Names @('systemID', 'SystemID', 'systemId') + $mName = Get-DcuDisplay -Node $m + $idMatch = $mId -and ($systemSKUs -contains $mId) + $nameMatch = $mName -and ($mName -ieq $Model -or $mName -like "*$Model*") + if ($idMatch -or ($nameMatch -and $systemSKUs.Count -eq 0)) { + $matchedGroup = $group + $matchedVia = if ($idMatch) { "SystemID $mId" } else { "name '$mName'" } break - } catch { - if ($i -lt 3) { - Write-DATLogEntry -Value "[Warning] - OEM links catalog attempt $i failed: $($_.Exception.Message). Retrying in 5s..." -Severity 2 - Start-Sleep -Seconds 5 - } else { throw } } } - [xml]$OEMLinks = $webContent - } catch { - Write-DATLogEntry -Value "[Error] - Failed to download OEM links catalog: $($_.Exception.Message)" -Severity 3 - throw "OEM links catalog unavailable: $($_.Exception.Message)" + if ($matchedGroup) { break } } + if (-not $matchedGroup) { + throw "No Dell DCU model manifest matched $Model (SKU: $SystemSKU). Verify the SystemSKU against live hardware." + } + Write-DATLogEntry -Value "[Dell] Matched DCU model slice via $matchedVia" -Severity 1 - $downloadURL = $null - $downloadFileName = $null - $gfxDownloadURL = $null - $gfxDownloadFileName = $null - $gfxBrand = $null - $callerCatalogVersion = $CatalogVersion - $catalogVersion = $null - $catalogFileHash = '' - $catalogHashMethod = '' - - # If a direct download URL was provided from the DAT API catalog, use it and skip OEM catalog lookup - # Only accept URLs that point to a downloadable file (not info/landing pages) - if (-not [string]::IsNullOrEmpty($CatalogDownloadURL) -and $CatalogDownloadURL -match '\.(msi|exe|cab|zip|wim)(\?|$)') { - # HP Individual SoftPaqs mode: ignore the pre-resolved driver pack URL so the HP SoftPaq - # discovery block runs instead of downloading the monolithic pack. - $HPDriverPackSource = if ($OEM -eq 'HP') { - (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource - } else { $null } - if ($OEM -eq 'HP' -and $HPDriverPackSource -eq 'SoftPaqs') { - Write-DATLogEntry -Value "[HP] Individual SoftPaqs mode -- ignoring pre-resolved driver pack URL: $CatalogDownloadURL" -Severity 1 - # Leave $downloadURL null so the HP SoftPaq switch block runs - } else { - $downloadURL = $CatalogDownloadURL - $downloadFileName = ($CatalogDownloadURL -split '\?')[0] | Split-Path -Leaf - if (-not [string]::IsNullOrEmpty($callerCatalogVersion)) { - $catalogVersion = $callerCatalogVersion - Write-DATLogEntry -Value "[$OEM] Using catalog version from DAT API: $catalogVersion" -Severity 1 - } - Write-DATLogEntry -Value "[$OEM] Using pre-resolved download URL from DAT API catalog: $downloadFileName" -Severity 1 - } - } elseif (-not [string]::IsNullOrEmpty($CatalogDownloadURL)) { - Write-DATLogEntry -Value "[$OEM] DAT API catalog URL is not a direct download link, falling back to OEM catalog: $CatalogDownloadURL" -Severity 2 + $slicePath = Get-DcuAttr -Node $matchedGroup.ManifestInformation -Names @('path', 'Path') + if (-not $slicePath) { throw "Dell DCU model manifest has no slice path" } + $slicePath = $slicePath -replace '\\', '/' + $sliceUrl = "$DellBaseURL/$($slicePath.TrimStart('/'))" + $sliceCabName = ($slicePath | Split-Path -Leaf) + $sliceCab = Join-Path $catalogDir $sliceCabName + $sliceXml = Join-Path $catalogDir ([IO.Path]::ChangeExtension($sliceCabName, 'xml')) + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Dell DCU model catalog..." -Type String + Invoke-WebRequest -Uri $sliceUrl -OutFile $sliceCab -UseBasicParsing -TimeoutSec 120 @proxyParams + & expand.exe "$sliceCab" -F:* "$catalogDir" -R 2>&1 | Out-Null + if (-not (Test-Path $sliceXml)) { + $stem = [IO.Path]::GetFileNameWithoutExtension($sliceCabName) + $sliceXml = Get-ChildItem -Path $catalogDir -Filter "$stem*.xml" | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName } + if (-not $sliceXml -or -not (Test-Path $sliceXml)) { throw "Dell DCU model slice XML not found after extraction" } - if ([string]::IsNullOrEmpty($downloadURL)) { - switch ($OEM) { - "Dell" { - $DellLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Dell" }).Link | - Where-Object { $_.Type -eq "XMLCabinetSource" } | Select-Object -ExpandProperty URL -First 1 - if ([string]::IsNullOrEmpty($DellLink)) { throw "Dell catalog URL not found in OEM links" } + [xml]$sliceDoc = Get-Content -Path $sliceXml -Raw + $components = @($sliceDoc.Manifest.SoftwareComponent) + if ($components.Count -eq 0) { $components = @($sliceDoc.SelectNodes('//*[local-name()="SoftwareComponent"]')) } + Write-DATLogEntry -Value "[Dell] DCU model catalog contains $($components.Count) software components" -Severity 1 - $DellCabFile = [string]($DellLink | Split-Path -Leaf) - $DellXMLFile = $DellCabFile.TrimEnd(".cab") + ".xml" - $DellCabPath = Join-Path $TempDirectory $DellCabFile - $DellXMLPath = Join-Path $TempDirectory $DellXMLFile + # -- 3. Filter to applicable driver DUPs (componentType DRVR, matching OS/arch) -- + $applicable = foreach ($c in $components) { + $ctype = Get-DcuAttr -Node $c.ComponentType -Names @('value', 'Value') + if (-not $ctype) { $ctype = Get-DcuAttr -Node $c -Names @('componentType', 'ComponentType') } + if (-not $ctype -or $ctype.ToUpperInvariant() -ne 'DRVR') { continue } - if (-not (Test-Path $DellXMLPath)) { - Write-DATLogEntry -Value "[$OEM] Downloading Dell catalog..." -Severity 1 - Write-DATLogEntry -Value "[$OEM] Catalog cab path: $DellCabPath" -Severity 1 - Write-DATLogEntry -Value "[$OEM] Catalog XML extract path: $DellXMLPath" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Dell driver catalog..." -Type String - if (-not (Test-Path $DellCabPath)) { - $proxyParams = Get-DATWebRequestProxy - Invoke-WebRequest -Uri $DellLink -OutFile $DellCabPath -UseBasicParsing -TimeoutSec 60 @proxyParams - } - & expand.exe "$DellCabPath" -F:* "$TempDirectory" -R 2>&1 | Out-Null - } else { - Write-DATLogEntry -Value "[$OEM] Using cached Dell catalog: $DellXMLPath" -Severity 1 + $osNodes = @($c.SupportedOperatingSystems.OperatingSystem) + if ($osNodes.Count -gt 0) { + $osOk = $false + foreach ($os in $osNodes) { + $osArchVal = Get-DcuAttr -Node $os -Names @('osArch', 'OsArch') + if ($osArchVal -and ($osArchVal -ine $Architecture)) { continue } + $osCodeVal = Get-DcuAttr -Node $os -Names @('osCode', 'OsCode') + if (-not $osCodeVal) { $osOk = $true; break } + foreach ($p in $osPrefixes) { if ($osCodeVal -like "$p*") { $osOk = $true; break } } + if ($osOk) { break } } + if (-not $osOk) { continue } + } - if (-not (Test-Path $DellXMLPath)) { throw "Dell catalog XML not found after extraction" } + if ($systemSKUs.Count -gt 0) { + $compSysIds = @($c.SupportedSystems.Brand.Model | ForEach-Object { Get-DcuAttr -Node $_ -Names @('systemID', 'SystemID', 'systemId') }) | Where-Object { $_ } + if ($compSysIds.Count -gt 0 -and -not ($compSysIds | Where-Object { $systemSKUs -contains $_ })) { continue } + } - [xml]$DellModelXML = Get-Content -Path $DellXMLPath -Raw - $DellWindowsVersion = $WindowsVersion.Replace(" ", "") + $path = Get-DcuAttr -Node $c -Names @('path', 'Path') + if (-not $path) { continue } - # Split comma-separated SystemSKU into individual IDs for -contains matching - $systemSKUs = @($SystemSKU -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) - # Extract core model identifier (last token) for fallback matching - $coreModelName = ($Model -split '\s+')[-1] + $nameText = Get-DcuDisplay -Node $c.Name + if (-not $nameText) { $nameText = Get-DcuAttr -Node $c -Names @('name', 'Name') } + $catText = Get-DcuDisplay -Node $c.Category + if (-not $catText) { $catText = Get-DcuAttr -Node $c.Category -Names @('value', 'Value') } - $matchingPkg = $DellModelXML.driverpackmanifest.driverpackage | Where-Object { - ($_.SupportedOperatingSystems.OperatingSystem.osCode -eq $DellWindowsVersion) -and - ($_.SupportedOperatingSystems.OperatingSystem.osArch -match $Architecture) -and - ($_.SupportedSystems.Brand.Model.name -contains $Model -or - @($_.SupportedSystems.Brand.Model.SystemID | Where-Object { $_ -in $systemSKUs }).Count -gt 0) - } | Select-Object -First 1 + $releaseDateRaw = Get-DcuAttr -Node $c -Names @('releaseDate', 'ReleaseDate', 'dateTime') + $releaseSort = [datetime]::MinValue + if ($releaseDateRaw) { [void][datetime]::TryParse($releaseDateRaw, [ref]$releaseSort) } + $critText = $null + if ($c.Criticality) { + $critText = Get-DcuDisplay -Node $c.Criticality + if (-not $critText) { + $critVal = Get-DcuAttr -Node $c.Criticality -Names @('value', 'Value') + $critText = switch ($critVal) { '1' { 'Recommended' } '2' { 'Urgent' } '3' { 'Optional' } default { $critVal } } + } + } + $catHash = Get-DcuHash -Component $c - # Fallback 1: try with core model identifier (handles "Pro Laptops PA14250" vs "PA14250") - if ($null -eq $matchingPkg -and $coreModelName -ne $Model) { - $matchingPkg = $DellModelXML.driverpackmanifest.driverpackage | Where-Object { - ($_.SupportedOperatingSystems.OperatingSystem.osCode -eq $DellWindowsVersion) -and - ($_.SupportedOperatingSystems.OperatingSystem.osArch -match $Architecture) -and - ($_.SupportedSystems.Brand.Model.name -contains $coreModelName) - } | Select-Object -First 1 - if ($matchingPkg) { - Write-DATLogEntry -Value "[$OEM] Matched via core model identifier: $coreModelName (full catalog model: $Model)" -Severity 1 + [PSCustomObject]@{ + Type = 'DRVR' + Category = $catText + Name = $nameText + Signature = Get-DcuNameSignature -Name $nameText + Version = Get-DcuAttr -Node $c -Names @('dellVersion', 'vendorVersion', 'version') + ReleaseDate = $releaseDateRaw + ReleaseSort = $releaseSort + Criticality = $critText + Identifier = Get-DcuAttr -Node $c -Names @('releaseID', 'packageID', 'identifier') + Hash = if ($catHash) { $catHash.Value } else { $null } + HashAlgorithm = if ($catHash) { $catHash.Algorithm } else { $null } + Path = ($path -replace '\\', '/') + FileName = (($path -replace '\\', '/') | Split-Path -Leaf) + } + } + $applicable = @($applicable) + if ($applicable.Count -eq 0) { + throw "No applicable Dell driver DUPs found for $Model ($WindowsVersion $Architecture) in the DCU catalog." + } + + # Collapse to the newest DUP per driver. Dell publishes many superseded revisions of the same + # driver under slightly different names (the chipset model list changes); grouping by the + # digit-free name signature within a category collapses them so only the newest version ships. + $selected = $applicable | + Group-Object -Property { "$($_.Category)|$($_.Signature)" } | + ForEach-Object { $_.Group | Sort-Object -Property ReleaseSort, Version -Descending | Select-Object -First 1 } | + Sort-Object Category, Name + $selected = @($selected) + Write-DATLogEntry -Value "[Dell] Selected $($selected.Count) driver component(s) (newest per driver, collapsed from $($applicable.Count) applicable DUPs)" -Severity 1 + + # -- 4. Fingerprint the DUP set; skip rebuild when unchanged -- + $manifestKey = Get-DATDellLatestManifestKey -Model $Model -OSVersion $WindowsVersion -Build $WindowsBuild -Architecture $Architecture + $identifiers = @($selected | ForEach-Object { if ($_.Identifier) { $_.Identifier } else { $_.FileName } }) + $fingerprint = Get-DATDellDUPFingerprint -Identifiers $identifiers + $manifest = Get-DATDellLatestManifest + $entry = $manifest[$manifestKey] + $listUnchanged = ($null -ne $entry) -and (-not [string]::IsNullOrEmpty($fingerprint)) -and ("$($entry.fingerprint)" -eq $fingerprint) + + if ($listUnchanged) { + $packageStillExists = $true + $missingReason = '' + switch ($RunningMode) { + 'Intune' { + if ($VerifyRemoteExistence) { + $storedRef = "$($entry.intuneAppId)" + if ([string]::IsNullOrEmpty($storedRef)) { $packageStillExists = $false; $missingReason = 'no Intune application id was recorded' } + elseif ($ExistingPackageIds -notcontains $storedRef) { $packageStillExists = $false; $missingReason = "Intune application $storedRef no longer exists" } } } - - # Fallback 2: wildcard match - if ($null -eq $matchingPkg) { - $matchingPkg = $DellModelXML.driverpackmanifest.driverpackage | Where-Object { - ($_.SupportedOperatingSystems.OperatingSystem.osCode -eq $DellWindowsVersion) -and - ($_.SupportedOperatingSystems.OperatingSystem.osArch -match $Architecture) -and - ($_.SupportedSystems.Brand.Model.name -like "*$coreModelName*") - } | Select-Object -First 1 + 'Configuration Manager' { + if ($VerifyRemoteExistence) { + $storedRef = "$($entry.configMgrPackageId)" + if ([string]::IsNullOrEmpty($storedRef)) { $packageStillExists = $false; $missingReason = 'no ConfigMgr package was recorded' } + elseif ($ExistingPackageIds -notcontains $storedRef) { $packageStillExists = $false; $missingReason = "ConfigMgr package $storedRef no longer exists" } + } } - - if ($null -ne $matchingPkg) { - $catalogVersion = $matchingPkg.dellVersion - # Fallback: if catalog entry has no dellVersion, use the version passed from the caller - if ([string]::IsNullOrEmpty($catalogVersion) -and -not [string]::IsNullOrEmpty($callerCatalogVersion)) { - $catalogVersion = $callerCatalogVersion - Write-DATLogEntry -Value "[$OEM] Catalog entry missing dellVersion -- using caller-provided version: $catalogVersion" -Severity 1 + 'WIM Package Only' { + $wimFinalPath = Join-Path $PackageDestination "$OEM\$Model\$WindowsVersion $WindowsBuild\DriverPackage.wim" + if (-not (Test-Path -LiteralPath $wimFinalPath)) { $packageStillExists = $false; $missingReason = 'the WIM package is missing' } + } + 'Download Only' { + if (-not ((Test-Path -LiteralPath $DownloadDestination) -and (@(Get-ChildItem -LiteralPath $DownloadDestination -File -ErrorAction SilentlyContinue).Count -gt 0))) { + $packageStillExists = $false; $missingReason = 'the downloaded files are missing' } - $DellBaseURL = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Dell" }).Link | - Where-Object { $_.Type -eq "DownloadBase" } | Select-Object -ExpandProperty URL -First 1 - if ([string]::IsNullOrEmpty($DellBaseURL)) { $DellBaseURL = "https://downloads.dell.com" } - $DellBaseURL = $DellBaseURL.TrimEnd('/') - $dellPath = $matchingPkg.path.TrimStart('/') - $downloadURL = "$DellBaseURL/$dellPath" - $downloadFileName = $matchingPkg.path | Split-Path -Leaf - Write-DATLogEntry -Value "[$OEM] Found driver pack: $downloadFileName (version: $catalogVersion)" -Severity 1 - } else { - throw "No matching Dell driver package found for $Model ($DellWindowsVersion $Architecture)" } } - "HP" { - # Check user preference for HP driver source: DriverPack (single SCCM pack) or SoftPaqs (individual drivers) - $HPDriverPackSource = (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource - if ([string]::IsNullOrEmpty($HPDriverPackSource)) { $HPDriverPackSource = 'DriverPack' } - Write-DATLogEntry -Value "[HP] Driver pack source mode: $HPDriverPackSource" -Severity 1 - if ($HPDriverPackSource -eq 'DriverPack') { - # ── SCCM Driver Pack mode: use HP catalog XML to find the monolithic driver pack ── - # This downloads a single .exe driver pack from ftp.hp.com (like Dell/Lenovo) - $HPXMLCabinetSource = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "HP" }).Link | - Where-Object { $_.Type -eq "XMLCabinetSource" } | Select-Object -ExpandProperty URL -First 1 - if ([string]::IsNullOrEmpty($HPXMLCabinetSource)) { throw "HP catalog URL not found in OEM links" } + if ($ForceRebuild) { + Write-DATLogEntry -Value "[Dell] Latest driver set unchanged for $Model but Force Update is set -- rebuilding" -Severity 1 + } elseif (-not $packageStillExists) { + Write-DATLogEntry -Value "[Dell] Latest driver set unchanged for $Model but $missingReason -- rebuilding" -Severity 1 + } else { + $stableVersion = "$($entry.version)" + $shortFp = if (-not [string]::IsNullOrEmpty($fingerprint)) { $fingerprint.Substring(0, [Math]::Min(8, $fingerprint.Length)) } else { 'n/a' } + Write-DATLogEntry -Value "[Dell] Latest driver set unchanged since last build for $Model ($($selected.Count) components, v$stableVersion, fingerprint $shortFp) -- skipping rebuild" -Severity 1 -UpdateUI + try { + $entry | Add-Member -NotePropertyName lastVerified -NotePropertyValue (Get-Date -Format 'o') -Force + $entry | Add-Member -NotePropertyName lastChecked -NotePropertyValue (Get-Date -Format 'o') -Force + $manifest[$manifestKey] = $entry + [void](Save-DATDellLatestManifest -Manifest $manifest) + } catch { + Write-DATLogEntry -Value "[Dell] Failed to update Latest Drivers manifest verification time: $($_.Exception.Message)" -Severity 2 + } + $global:DATSoftPaqBuildSkipped = $true + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return $stableVersion + } + } - $HPCabFile = [string]($HPXMLCabinetSource | Split-Path -Leaf) - $HPXMLFile = $HPCabFile.TrimEnd(".cab") + ".xml" - $HPCabPath = Join-Path $TempDirectory $HPCabFile - $HPXMLPath = Join-Path $TempDirectory $HPXMLFile + $buildVersion = if ($listUnchanged) { "$($entry.version)" } else { (Get-Date -Format 'ddMMyyyy') } - if (-not (Test-Path $HPXMLPath)) { - Write-DATLogEntry -Value "[HP] Downloading HP catalog..." -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading HP driver catalog..." -Type String - if (-not (Test-Path $HPCabPath)) { - Invoke-CatalogDownload -Uri $HPXMLCabinetSource -OutFile $HPCabPath - } - & expand.exe "$HPCabPath" -F:* "$TempDirectory" -R 2>&1 | Out-Null - } else { - Write-DATLogEntry -Value "[HP] Using cached HP catalog: $HPXMLPath" -Severity 1 - } + # -- 5. Download, verify and extract each selected DUP -- + $total = $selected.Count + # Report the extra individual driver downloads (this build fetches N DUPs, not one pack) so the + # "Downloads Required" tile counts them: base estimate already counts 1 driver, add the rest. + if ($total -gt 1) { + $prevExtra = 0 + try { $prevExtra = [int](Get-ItemProperty -Path $global:RegPath -Name 'LatestDownloadsExtra' -ErrorAction SilentlyContinue).LatestDownloadsExtra } catch { $prevExtra = 0 } + Set-DATRegistryValue -Name "LatestDownloadsExtra" -Value "$($prevExtra + ($total - 1))" -Type String + } + $dellComponents = New-Object System.Collections.Generic.List[object] + Set-DATRegistryValue -Name "DownloadBytes" -Value "$total" -Type String + Set-DATRegistryValue -Name "BytesTransferred" -Value "0" -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Download" -Type String + # Download all selected DUPs -- concurrently when the user's Concurrent Driver Downloads setting + # is above 1, otherwise sequentially. Returns the file names that landed on disk; per-driver + # download failures are recorded inside the helper. + $dellDownloadItems = @($selected | ForEach-Object { [pscustomobject]@{ Url = "$DellBaseURL/$($_.Path.TrimStart('/'))"; FileName = $_.FileName; Name = $_.Name } }) + $downloadedFileNames = Invoke-DATConcurrentDriverDownload -Items $dellDownloadItems -DestinationDirectory $dupDir ` + -OEM 'Dell' -Model $Model -MaxConcurrency (Get-DATLatestDriverConcurrency) + + foreach ($dup in $selected) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "Dell download aborted by user" } + + $dupFile = Join-Path $dupDir $dup.FileName + if ($downloadedFileNames -notcontains $dup.FileName -or -not (Test-Path $dupFile)) { + # Failed to download (already recorded by the concurrent downloader) -- skip component. + continue + } - if (-not (Test-Path $HPXMLPath)) { throw "HP catalog XML not found after extraction" } + # Integrity: verify the catalog hash when available; DUPs are Authenticode-signed as a fallback. + if ($dup.Hash -and $dup.HashAlgorithm) { + $actual = Get-DATVerificationHash -FilePath $dupFile -Algorithm $dup.HashAlgorithm + if ($actual -and ($actual -ieq $dup.Hash)) { + Write-DATLogEntry -Value "[Dell] Hash verified ($($dup.HashAlgorithm)): $($dup.FileName)" -Severity 1 + } elseif ($actual) { + Write-DATLogEntry -Value "[Dell] Hash mismatch ($($dup.HashAlgorithm)) for $($dup.FileName) -- expected $($dup.Hash), got $actual. Skipping component." -Severity 3 + Add-DATDriverDownloadFailure -OEM 'Dell' -Model $Model -Driver "$($dup.Name)" -Reason "Hash mismatch ($($dup.HashAlgorithm)) -- file integrity check failed" + Remove-Item $dupFile -Force -ErrorAction SilentlyContinue + continue + } + } - [xml]$HPModelXML = Get-Content -Path $HPXMLPath -Raw - $HPModelSoftPaqs = $HPModelXML.NewDataSet.HPClientDriverPackCatalog.ProductOSDriverPackList.ProductOSDriverPack + # Extract the driver DUP to raw INF payloads (Dell DUP switches: /s /e=). + $outDir = Join-Path $stagingDir ([IO.Path]::GetFileNameWithoutExtension($dup.FileName)) + if (-not (Test-Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } + try { + $exProc = Start-Process -FilePath $dupFile -ArgumentList "/s", "/e=`"$outDir`"" -Wait -PassThru -WindowStyle Hidden + $infCount = @(Get-ChildItem -Path $outDir -Recurse -Filter *.inf -ErrorAction SilentlyContinue).Count + if ($infCount -eq 0) { + Write-DATLogEntry -Value "[Dell] $($dup.FileName) extracted no INF files (exit $($exProc.ExitCode)) -- component may not be a driver DUP" -Severity 2 + } + } catch { + Write-DATLogEntry -Value "[Dell] Extraction failed for $($dup.FileName): $($_.Exception.Message)" -Severity 3 + Add-DATDriverDownloadFailure -OEM 'Dell' -Model $Model -Driver "$($dup.Name)" -Reason "Extraction failed: $($_.Exception.Message)" + continue + } - # Match by model name and OS - $matchingPack = $HPModelSoftPaqs | Where-Object { - ($_.SystemName -replace '^HP\s+', '').Trim() -eq $Model -and - $_.OSName -match $WindowsVersion -and $_.OSName -match $WindowsBuild - } | Select-Object -First 1 + $dellComponents.Add([ordered]@{ + id = $dup.Identifier + name = $dup.Name + version = $dup.Version + category = $dup.Category + releaseDate = $dup.ReleaseDate + criticality = $dup.Criticality + type = 'DRVR' + }) + } - # Fallback: match by baseboard/platform ID - if ($null -eq $matchingPack) { - $SKUList = $SystemSKU -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -match '^[a-f0-9]{4}$' } - $matchingPack = $HPModelSoftPaqs | Where-Object { - $packMatch = $_.OSName -match $WindowsVersion -and $_.OSName -match $WindowsBuild - if ($packMatch) { - $sysIds = @($_.SystemId | ForEach-Object { $_.ToLower() }) - $packMatch = @($SKUList | Where-Object { $_ -in $sysIds }).Count -gt 0 - } - $packMatch - } | Select-Object -First 1 - } + $stagedFiles = @(Get-ChildItem -Path $stagingDir -Recurse -File -ErrorAction SilentlyContinue).Count + Write-DATLogEntry -Value "[Dell] Extraction complete: $stagedFiles driver files staged from $($dellComponents.Count) component(s)" -Severity 1 + if ($stagedFiles -eq 0) { + throw "No Dell driver files were extracted from the DCU catalog for $Model" + } - if ($null -ne $matchingPack) { - $spId = $matchingPack.SoftPaqId - # Resolve the SoftPaq metadata (version + download URL) from the SoftPaqList - # section -- the ProductOSDriverPack node itself carries neither. - $hpSoftPaq = $HPModelXML.NewDataSet.HPClientDriverPackCatalog.SoftPaqList.SoftPaq | - Where-Object { $_.Id -eq $spId } | Select-Object -First 1 - $downloadURL = if ($hpSoftPaq -and -not [string]::IsNullOrEmpty($hpSoftPaq.Url)) { $hpSoftPaq.Url } else { '' } - # Normalise the SoftPaq number (catalog may or may not include the 'sp' prefix). - $spNumber = "$spId" -replace '^sp', '' - if ([string]::IsNullOrEmpty($downloadURL)) { - # Fall back to constructing the ftp.hp.com URL from the SoftPaq number. - $spRange = $spNumber.Substring(0, $spNumber.Length - 3) - $downloadURL = "https://ftp.hp.com/pub/softpaq/sp${spRange}001-${spRange}500/sp$spNumber.exe" - } - $downloadFileName = "sp$spNumber.exe" - $catalogVersion = if ($hpSoftPaq) { $hpSoftPaq.Version } else { '' } - # Fallback: if the catalog entry has no version, use the version passed from the - # caller (HP catalog version resolved during model enumeration), then a date stamp. - if ([string]::IsNullOrEmpty($catalogVersion) -and -not [string]::IsNullOrEmpty($callerCatalogVersion)) { - $catalogVersion = $callerCatalogVersion - Write-DATLogEntry -Value "[HP] Catalog entry missing version -- using caller-provided version: $catalogVersion" -Severity 1 - } - if ([string]::IsNullOrEmpty($catalogVersion)) { $catalogVersion = (Get-Date -Format 'ddMMyyyy') } - Write-DATLogEntry -Value "[HP] Found SCCM driver pack: SP$spId ($downloadFileName)" -Severity 1 - Write-DATLogEntry -Value "[HP] Download URL: $downloadURL" -Severity 1 - # Fall through to common download path below (same as Dell/Lenovo) - } else { - throw "No matching HP SCCM driver pack found for $Model ($WindowsVersion $WindowsBuild)" - } - } else { - # ── Individual SoftPaqs mode: use HPCMSL to discover and download each driver ── - # HP uses HPCMSL to discover required SoftPaqs, then downloads, extracts, and - # copies only the INF-targeted driver folders to a staging directory. + # -- 6. Package via the common WIM pipeline (embeds DATDriverManifest.json) -- + if ($RunningMode -ne "Download Only" -or $ExtractDownloadOnlyContent) { + $packageDest = if (-not [string]::IsNullOrEmpty($PackageDestination)) { $PackageDestination } else { $DownloadDestination } + $packagingPlatform = if ($RunningMode -eq 'WIM Package Only') { 'WIM Package Only' } + elseif ($RunningMode -eq 'Configuration Manager (Offline)') { 'Configuration Manager' } + else { $RunningMode } + Set-DATRegistryValue -Name "RunningMessage" -Value "Creating WIM package for Dell $Model..." -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Packaging" -Type String + Write-DATLogEntry -Value "[Dell] Creating WIM package from Latest Drivers staging directory..." -Severity 1 - # Validate HPCMSL - Write-DATLogEntry -Value "[HP] Validating HPCMSL module before starting build..." -Severity 1 - $hpCheck = Test-DATHPCMSLReady -AutoInstall - if (-not $hpCheck.Ready) { - throw "HPCMSL prerequisite check failed: $($hpCheck.Error)" - } - Write-DATLogEntry -Value "[HP] Starting driver package build for $Model (SKU: $SystemSKU)" -Severity 1 -UpdateUI - Write-DATLogEntry -Value "[HP] Parameters: SKU=$SystemSKU, Build=$WindowsBuild, Version=$WindowsVersion" -Severity 1 - Write-DATLogEntry -Value "[HP] Download Destination: $DownloadDestination" -Severity 1 - Write-DATLogEntry -Value "[HP] Temp Directory: $TempDirectory" -Severity 1 + $null = Invoke-DATDriverFilePackaging -FilePath $stagingDir -OEM $OEM -Model $Model ` + -OS $WindowsVersion -Destination $packageDest -Platform $packagingPlatform ` + -CustomDriverPath $CustomDriverPath -DownloadOnlyExtractDestination $DownloadDestination ` + -PackageVersion $buildVersion -Components $dellComponents.ToArray() + } - # Determine OS parameter for HPCMSL - switch -Wildcard ($WindowsVersion) { - "*Windows 11*" { $HPOS = "Win11" } - "*Windows 10*" { $HPOS = "Win10" } - default { $HPOS = "Win11" } - } + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + Write-DATLogEntry -Value "[Dell] Latest Drivers package process completed successfully" -Severity 1 -UpdateUI - # Build HP-specific temp path: Temp\HP\Model\OS\OSVer - $HPTempDirectory = Join-Path $TempDirectory "HP\$Model\$HPOS\$WindowsBuild" - if (-not (Test-Path $HPTempDirectory)) { New-Item -Path $HPTempDirectory -ItemType Directory -Force | Out-Null } - $HPExtractDir = Join-Path $HPTempDirectory "Extracted" - $HPStagingDir = Join-Path $HPTempDirectory "Staging" - foreach ($dir in @($HPTempDirectory, $HPExtractDir, $HPStagingDir)) { - if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } - } - Write-DATLogEntry -Value "[HP] Temp download: $HPTempDirectory" -Severity 1 - Write-DATLogEntry -Value "[HP] Extract: $HPExtractDir" -Severity 1 - Write-DATLogEntry -Value "[HP] Staging: $HPStagingDir" -Severity 1 + # Persist the manifest so an unchanged DUP set skips rebuild next time. + try { + $manifestSave = Get-DATDellLatestManifest + $existingRef = $manifestSave[$manifestKey] + $manifestSave[$manifestKey] = [PSCustomObject]@{ + systemSku = "$SystemSKU" + componentIds = @($identifiers | Sort-Object) + fingerprint = $fingerprint + version = $buildVersion + lastBuilt = (Get-Date -Format 'o') + lastChecked = (Get-Date -Format 'o') + lastVerified = (Get-Date -Format 'o') + intuneAppId = if ($existingRef) { "$($existingRef.intuneAppId)" } else { '' } + configMgrPackageId = if ($existingRef) { "$($existingRef.configMgrPackageId)" } else { '' } + } + [void](Save-DATDellLatestManifest -Manifest $manifestSave) + Write-DATLogEntry -Value "[Dell] Latest Drivers manifest updated for $Model (v$buildVersion, $($dellComponents.Count) components)" -Severity 1 + } catch { + Write-DATLogEntry -Value "[Dell] Failed to update Latest Drivers manifest: $($_.Exception.Message)" -Severity 2 + } - # Split comma-separated SKUs; try each in order until one returns SoftPaqs - $SKUList = $SystemSKU -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[a-fA-F0-9]{4}$' } - if ($SKUList.Count -eq 0) { - throw "No valid 4-character platform IDs found in SKU: $SystemSKU" - } - Write-DATLogEntry -Value "[HP] Platform IDs to try: $($SKUList -join ', ')" -Severity 1 + return $buildVersion +} - # Read concurrent download setting (1-4, default 2) - $HPConcurrentDownloads = 2 - $regConcurrency = (Get-ItemProperty -Path $global:RegPath -Name 'HPConcurrentDownloads' -ErrorAction SilentlyContinue).HPConcurrentDownloads - if (-not [string]::IsNullOrEmpty($regConcurrency)) { - $parsedConcurrency = 0 - if ([int]::TryParse($regConcurrency, [ref]$parsedConcurrency) -and $parsedConcurrency -ge 1 -and $parsedConcurrency -le 4) { - $HPConcurrentDownloads = $parsedConcurrency - } - } - Write-DATLogEntry -Value "[HP] Concurrent downloads: $HPConcurrentDownloads" -Severity 1 +function Invoke-DATLenovoLatestDriverPackage { + <# + .SYNOPSIS + Builds a Lenovo "Latest Drivers" package from Lenovo's public per-model Model-XML update + catalog (https://download.lenovo.com/catalog/{MachineType}_win{ver}.xml). Resolves the newest + individual driver packages for the model/OS/build, downloads and verifies them, extracts each + to raw drivers, then packages via the common WIM pipeline. Twin of the Dell/HP Latest paths. + .DESCRIPTION + Reuses Invoke-DATContentDownload, Get-DATVerificationHash, New-DATDriverManifest and + Invoke-DATDriverFilePackaging. A package-set fingerprint drives change tracking and an + update-cadence gate throttles rebuilds -- both via the Lenovo Latest Drivers manifest. + .OUTPUTS + The build version string. Sets $global:DATSoftPaqBuildSkipped = $true when retained. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$Model, + [AllowEmptyString()][string]$SystemSKU, + [AllowEmptyString()][string]$WindowsVersion, + [Parameter(Mandatory)][AllowEmptyString()][string]$WindowsBuild, + [string]$Architecture = 'x64', + [string]$DownloadDestination, + [string]$PackageDestination, + [string]$TempDirectory, + [string]$RunningMode = 'Download Only', + [string]$CustomDriverPath, + [bool]$ExtractDownloadOnlyContent = $true, + [xml]$OEMLinks, + [switch]$ForceRebuild, + [switch]$VerifyRemoteExistence, + [string[]]$ExistingPackageIds = @() + ) - # ── Step 1: Discover SoftPaqs using New-HPDriverPack -WhatIf ────────── - # Pre-flight: verify that HPCMSL supports the requested OS version - $hpDPCmd = Get-Command -Name New-HPDriverPack -ErrorAction SilentlyContinue - if ($hpDPCmd) { - $osVerAttr = $hpDPCmd.Parameters['OSVer'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } - if ($osVerAttr -and $WindowsBuild -notin $osVerAttr.ValidValues) { - $supportedValues = $osVerAttr.ValidValues -join ', ' - Write-DATLogEntry -Value "[HP] HPCMSL does not support OSVer '$WindowsBuild'. Supported values: $supportedValues. Update HPCMSL: Install-Module HPCMSL -Force -AllowClobber" -Severity 3 - throw "HPCMSL does not support OS version '$WindowsBuild'. Update HPCMSL to the latest version: Install-Module -Name HPCMSL -Force -AllowClobber" - } + $OEM = 'Lenovo' + $LenovoBase = 'https://download.lenovo.com' + Set-DATRegistryValue -Name "RunningMessage" -Value "Resolving latest Lenovo drivers for $Model..." -Type String + Write-DATLogEntry -Value "[Lenovo] Latest Drivers mode (Model-XML catalog) for $Model (MT: $SystemSKU, $WindowsVersion $WindowsBuild $Architecture)" -Severity 1 + + # -- Nested helpers -- + $sigStopWords = @('and', 'the', 'of', 'for', 'with', 'to', 'plus', 'uwd', 'dch', 'a', 'an', 'driver', 'gen') + function Get-LnvSignature { param([string]$Name) + if ([string]::IsNullOrWhiteSpace($Name)) { return '' } + $core = ($Name -split '\s-\s')[0] + $toks = $core -split '[\s/,()]+' | ForEach-Object { $_.Trim().ToLowerInvariant() } | + Where-Object { $_ -and ($_ -notmatch '\d') -and ($sigStopWords -notcontains $_) } + return (($toks | Sort-Object -Unique) -join ' ') + } + function ConvertTo-LnvVersion { param([string]$Version) + $v = $null + if ([version]::TryParse((($Version -replace '[^0-9.]', ' ').Trim() -split '\s+')[0], [ref]$v)) { return $v } + return ([version]'0.0') + } + # Package applies to a target build if it has no <_WindowsBuildVersion>, or any Version matches + # (trailing '^' = ">= build", 'v' = "<= build", bare = exact). + function Test-LnvBuildApplicable { param($DescRoot, [int]$TargetBuild) + if ($TargetBuild -le 0) { return $true } + $verNodes = $DescRoot.SelectNodes('.//*[local-name()="_WindowsBuildVersion"]/*[local-name()="Version"]') + if (-not $verNodes -or $verNodes.Count -eq 0) { return $true } + foreach ($vn in $verNodes) { + $raw = "$($vn.InnerText)".Trim(); if (-not $raw) { continue } + $op = if ($raw.EndsWith('^')) { '^' } elseif ($raw.EndsWith('v')) { 'v' } else { '' } + $baseNum = 0 + if (-not [int]::TryParse(($raw -replace '[^\d]', ''), [ref]$baseNum)) { continue } + switch ($op) { + '^' { if ($TargetBuild -ge $baseNum) { return $true } } + 'v' { if ($TargetBuild -le $baseNum) { return $true } } + default { if ($TargetBuild -eq $baseNum) { return $true } } } + } + return $false + } - $SoftPaqIDs = @() - $DiscoveryPlatformID = $null + # Windows feature-update -> build number, for evaluating package <_WindowsBuildVersion> conditions. + $winBuildMap = @{ + 'Win11' = @{ '21H2' = 22000; '22H2' = 22621; '23H2' = 22631; '24H2' = 26100; '25H2' = 26200 } + 'Win10' = @{ '20H2' = 19042; '21H1' = 19043; '21H2' = 19044; '22H2' = 19045 } + } + $osMajor = if ($WindowsVersion -match '10') { '10' } else { '11' } + $osKey = "Win$osMajor" + $targetBuildNum = 0 + if ($WindowsBuild -and $winBuildMap[$osKey].ContainsKey($WindowsBuild)) { $targetBuildNum = $winBuildMap[$osKey][$WindowsBuild] } + + # Categories that are NOT hardware drivers (excluded from a driver pack). + $nonDriverCategories = @('Software and Utilities', 'BIOS UEFI', 'Advanced Firmware') + + # -- Cadence gate: skip before any catalog download when within the cadence window -- + $manifestKey = Get-DATLenovoLatestManifestKey -Model $Model -OSVersion $WindowsVersion -Build $WindowsBuild -Architecture $Architecture + $manifest = Get-DATLenovoLatestManifest + $entry = $manifest[$manifestKey] + $cadence = Get-DATLatestDriverCadence + if (-not $ForceRebuild -and $cadence -ne 'Off' -and $null -ne $entry -and + -not (Test-DATLatestCadenceElapsed -LastActivity "$($entry.lastChecked)" -Cadence $cadence)) { + $present = Test-DATLatestPackagePresent -Entry $entry -OEM $OEM -Model $Model -WindowsVersion $WindowsVersion ` + -WindowsBuild $WindowsBuild -RunningMode $RunningMode -PackageDestination $PackageDestination ` + -DownloadDestination $DownloadDestination -VerifyRemoteExistence:$VerifyRemoteExistence -ExistingPackageIds $ExistingPackageIds + if ($present) { + $nextDue = try { ([datetime]$entry.lastChecked) } catch { Get-Date } + $nextDue = switch ($cadence) { 'Daily' { $nextDue.AddDays(1) } 'Weekly' { $nextDue.AddDays(7) } 'Monthly' { $nextDue.AddMonths(1) } default { $nextDue } } + Write-DATLogEntry -Value "[Lenovo] Within $cadence update cadence for $Model -- retaining existing package (next eligible $($nextDue.ToString('yyyy-MM-dd'))) " -Severity 1 -UpdateUI + $global:DATSoftPaqBuildSkipped = $true + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return "$($entry.version)" + } + } - # Resolve PowerShell executable for child processes - $discoveryPwshExe = if ($PSVersionTable.PSVersion.Major -ge 7) { - (Get-Process -Id $PID).Path - } else { - "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - } + # -- Lenovo Latest temp dirs -- + $osTag = ($WindowsVersion -replace '\s', '') + $lnvRoot = Join-Path $TempDirectory "LenovoLatest\$Model\$osTag\$WindowsBuild" + $catalogDir = Join-Path $lnvRoot 'Catalog' + $pkgDlDir = Join-Path $lnvRoot 'Packages' + $stagingDir = Join-Path $lnvRoot 'Staging' + foreach ($d in @($lnvRoot, $catalogDir, $pkgDlDir, $stagingDir)) { if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null } } + Get-ChildItem -Path $stagingDir -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue - foreach ($PlatformID in $SKUList) { - Write-DATLogEntry -Value "[HP] Querying required SoftPaqs for platform $PlatformID (WhatIf)..." -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Querying HP SoftPaqs for platform $PlatformID..." -Type String + $proxyParams = Get-DATWebRequestProxy - try { - # Run New-HPDriverPack -WhatIf in a child process to capture Write-Host output. - # HPCMSL writes the SoftPaq list via Write-Host which cannot be captured in-process - # on PS 5.1 (the WPF app host swallows it). A child process redirects all output to stdout. - $discoveryOutputFile = Join-Path $HPTempDirectory "discovery_${PlatformID}.txt" - $discoveryScript = Join-Path ([System.IO.Path]::GetTempPath()) "DAT_HPDiscovery_${PlatformID}_$([System.IO.Path]::GetRandomFileName()).ps1" - $discoveryScriptContent = @" -`$ErrorActionPreference = 'Stop' -Import-Module HPCMSL -Force -New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -Format wim -Path "$DownloadDestination" -TempDownloadPath "$HPTempDirectory" -WhatIf *>&1 -"@ - Set-Content -Path $discoveryScript -Value $discoveryScriptContent -Encoding UTF8 + # -- 1. Resolve the Machine Type + download the Model-XML catalog -- + $mtList = @($SystemSKU -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[A-Za-z0-9]{4}$' }) + if ($mtList.Count -eq 0) { throw "No valid Lenovo Machine Type found in '$SystemSKU' for $Model." } - $discoveryProc = Start-Process -FilePath $discoveryPwshExe ` - -ArgumentList '-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', $discoveryScript ` - -WindowStyle Hidden -PassThru -Wait ` - -RedirectStandardOutput $discoveryOutputFile -RedirectStandardError ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "DAT_HPDiscovery_err.txt")) + $modelXml = $null; $usedMt = $null + foreach ($mtCandidate in $mtList) { + $mtLower = $mtCandidate.ToLowerInvariant() + $modelXmlUrl = "$LenovoBase/catalog/${mtLower}_win${osMajor}.xml" + $candidatePath = Join-Path $catalogDir "${mtLower}_win${osMajor}.xml" + try { + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Lenovo catalog for MT $mtCandidate..." -Type String + Invoke-WebRequest -Uri $modelXmlUrl -OutFile $candidatePath -UseBasicParsing -TimeoutSec 120 @proxyParams + $modelXml = $candidatePath; $usedMt = $mtCandidate + Write-DATLogEntry -Value "[Lenovo] Model catalog: $modelXmlUrl" -Severity 1 + break + } catch { + Write-DATLogEntry -Value "[Lenovo] No Model-XML for MT $mtCandidate / Win$osMajor -- trying next" -Severity 2 + } + } + if (-not $modelXml) { throw "No Lenovo Model-XML catalog found for $Model (MTs: $($mtList -join ', ') / Win$osMajor)." } - Remove-Item -Path $discoveryScript -Force -ErrorAction SilentlyContinue + [xml]$mdoc = Get-Content -Path $modelXml -Raw + $pkgNodes = @($mdoc.packages.package) + if ($pkgNodes.Count -eq 0) { $pkgNodes = @($mdoc.SelectNodes('//*[local-name()="package"]')) } + Write-DATLogEntry -Value "[Lenovo] Model catalog lists $($pkgNodes.Count) package(s)" -Severity 1 - if (Test-Path $discoveryOutputFile) { - $allLines = @(Get-Content -Path $discoveryOutputFile -ErrorAction SilentlyContinue) - Remove-Item -Path $discoveryOutputFile -Force -ErrorAction SilentlyContinue - } else { - $allLines = @() - } + # -- 2. Fetch each descriptor, filter to applicable drivers -- + $applicable = New-Object System.Collections.Generic.List[object] + foreach ($p in $pkgNodes) { + $loc = "$($p.location)".Trim(); if (-not $loc) { continue } + $category = "$($p.category)".Trim() + if ($nonDriverCategories -contains $category) { continue } - Write-DATLogEntry -Value "[HP] Discovery output: $($allLines.Count) lines captured for platform $PlatformID" -Severity 1 + $descName = ($loc -split '\?')[0] | Split-Path -Leaf + $descPath = Join-Path $catalogDir $descName + try { + if (-not (Test-Path $descPath)) { Invoke-WebRequest -Uri $loc -OutFile $descPath -UseBasicParsing -TimeoutSec 60 @proxyParams } + $ddoc = New-Object System.Xml.XmlDocument + $ddoc.Load($descPath) # .Load handles the BOM that the [xml] cast rejects + } catch { + Write-DATLogEntry -Value "[Lenovo] Descriptor fetch/parse failed ($descName): $($_.Exception.Message)" -Severity 2 + continue + } + $root = $ddoc.DocumentElement - $SoftPaqIDs = @($allLines | Where-Object { $_ -match '^\s+(?:sp)?(\d{4,})' } | ForEach-Object { - if ($_ -match '^\s+(?:sp)?(\d{4,})') { $Matches[1] } - }) + # Only PackageType 2 (device driver) belongs in a pnputil-applied pack. Lenovo types: + # 1=Application, 2=Driver, 3=BIOS, 4=Firmware (SSD/ME/Thunderbolt-retimer/dock flashers -- + # self-contained utilities with no driver INF that pnputil cannot apply). Empty type is kept. + $pkgTypeCode = "$($root.PackageType.type)".Trim() + if ($pkgTypeCode -eq '1' -or $pkgTypeCode -eq '3' -or $pkgTypeCode -eq '4') { + $skipTitle = "$($root.Title.InnerText)".Trim(); if (-not $skipTitle) { $skipTitle = $descName } + Write-DATLogEntry -Value "[Lenovo] Skipping non-driver package (PackageType $pkgTypeCode): $skipTitle" -Severity 1 + continue + } - if ($SoftPaqIDs.Count -gt 0) { - $DiscoveryPlatformID = $PlatformID - Write-DATLogEntry -Value "[HP] Found $($SoftPaqIDs.Count) SoftPaqs for platform ${PlatformID}:" -Severity 1 - foreach ($line in ($allLines | Where-Object { $_ -match '^\s+(?:sp)?(\d{4,})' })) { - Write-DATLogEntry -Value "-- Download required - $($line.Trim())" -Severity 1 - } - break - } else { - # Log the output for debugging - foreach ($line in $allLines) { - Write-DATLogEntry -Value "[HP] Discovery output: $line" -Severity 1 - } - Write-DATLogEntry -Value "[HP] No SoftPaqs found for platform $PlatformID -- trying next" -Severity 2 - } - } catch { - if ($_.Exception.Message -match 'does not belong to the set') { - Write-DATLogEntry -Value "[HP] HPCMSL does not support OSVer '$WindowsBuild'. Update HPCMSL: Install-Module HPCMSL -Force -AllowClobber" -Severity 3 - throw "HPCMSL does not support OS version '$WindowsBuild'. Update HPCMSL to the latest version: Install-Module -Name HPCMSL -Force -AllowClobber" - } - Write-DATLogEntry -Value "[HP] WhatIf failed for ${PlatformID}: $($_.Exception.Message)" -Severity 2 - } - } + if ($targetBuildNum -gt 0 -and -not (Test-LnvBuildApplicable -DescRoot $root -TargetBuild $targetBuildNum)) { continue } - if ($SoftPaqIDs.Count -eq 0) { - throw "No HP SoftPaqs found for any platform ID: $($SKUList -join ', ')" - } + $title = "$($root.Title.InnerText)".Trim(); if (-not $title) { $title = "$($root.Title)".Trim() } + $version = "$($root.version)".Trim() + $sevType = "$($root.Severity.type)".Trim() + $severity = switch ($sevType) { '1' { 'Critical' } '2' { 'Recommended' } '3' { 'Optional' } default { $sevType } } + $releaseDate = "$($root.ReleaseDate.InnerText)".Trim(); if (-not $releaseDate) { $releaseDate = "$($root.ReleaseDate)".Trim() } + $extractCmd = "$($root.ExtractCommand)".Trim() - # Deduplicate the discovered SoftPaq list, preserving first-seen order. HPCMSL's - # WhatIf output can list the same SoftPaq more than once (e.g. HP Hotkey Support, - # Intel Video Driver), which previously caused the same package to be downloaded - # and extracted twice, inflated the fingerprint, and broke the progress counter - # (the per-id process map collides on duplicate concurrent downloads). (#810) - $seenSoftPaqIds = [System.Collections.Generic.HashSet[string]]::new() - $dedupedSoftPaqIDs = [System.Collections.Generic.List[string]]::new() - foreach ($spId in $SoftPaqIDs) { - if ($seenSoftPaqIds.Add($spId)) { $dedupedSoftPaqIDs.Add($spId) } - } - $duplicateSoftPaqCount = $SoftPaqIDs.Count - $dedupedSoftPaqIDs.Count - if ($duplicateSoftPaqCount -gt 0) { - $dupNoun = if ($duplicateSoftPaqCount -eq 1) { 'entry' } else { 'entries' } - Write-DATLogEntry -Value "[HP] Removed $duplicateSoftPaqCount duplicate SoftPaq $dupNoun from discovery list (SP$($dedupedSoftPaqIDs -join ', SP'))" -Severity 2 - } - $SoftPaqIDs = @($dedupedSoftPaqIDs) + $fileNode = $root.Files.Installer.File | Select-Object -First 1 + $fileName = "$($fileNode.Name)".Trim() + if (-not $fileName) { continue } + $descFolder = ($loc -replace '/[^/]+$', '/') - # ── SoftPaq fingerprint check: skip rebuild when the list is unchanged ── - # The discovered SoftPaq set is fingerprinted and compared against the stored - # manifest. If unchanged (and not forced), the existing package is retained and - # we short-circuit before any download/extract/packaging work. - $spManifestKey = Get-DATHPSoftPaqManifestKey -Model $Model -OSVersion $WindowsVersion -Build $WindowsBuild -Architecture $Architecture - $spFingerprint = Get-DATSoftPaqFingerprint -SoftPaqIds $SoftPaqIDs - $spManifest = Get-DATHPSoftPaqManifest - $spEntry = $spManifest[$spManifestKey] - $spListUnchanged = ($null -ne $spEntry) -and (-not [string]::IsNullOrEmpty($spFingerprint)) -and ("$($spEntry.fingerprint)" -eq $spFingerprint) + $applicable.Add([PSCustomObject]@{ + Category = $category + Title = $title + Signature = "$category|$(Get-LnvSignature -Name $title)" + Version = $version + VersionSort = (ConvertTo-LnvVersion -Version $version) + Severity = $severity + ReleaseDate = $releaseDate + Id = "$($root.id)" + Hash = "$($fileNode.CRC)".Trim() + FileName = $fileName + DownloadUrl = "$descFolder$fileName" + ExtractCmd = $extractCmd + }) + } + $applicable = $applicable.ToArray() + if ($applicable.Count -eq 0) { throw "No applicable Lenovo driver packages found for $Model (MT $usedMt / Win$osMajor $WindowsBuild)." } + + # Newest package per component (Lenovo curates, so this rarely collapses anything). + $selected = $applicable | + Group-Object -Property Signature | + ForEach-Object { $_.Group | Sort-Object -Property VersionSort, ReleaseDate -Descending | Select-Object -First 1 } | + Sort-Object Category, Title + $selected = @($selected) + Write-DATLogEntry -Value "[Lenovo] Selected $($selected.Count) driver package(s) (newest per component, from $($applicable.Count) applicable)" -Severity 1 + + # -- 3. Change tracking (fingerprint) skip -- + $identifiers = @($selected | ForEach-Object { "$($_.Id)|$($_.Version)" }) + $fingerprint = Get-DATLenovoPackageFingerprint -Identifiers $identifiers + $listUnchanged = ($null -ne $entry) -and (-not [string]::IsNullOrEmpty($fingerprint)) -and ("$($entry.fingerprint)" -eq $fingerprint) + + if ($listUnchanged -and -not $ForceRebuild) { + $present = Test-DATLatestPackagePresent -Entry $entry -OEM $OEM -Model $Model -WindowsVersion $WindowsVersion ` + -WindowsBuild $WindowsBuild -RunningMode $RunningMode -PackageDestination $PackageDestination ` + -DownloadDestination $DownloadDestination -VerifyRemoteExistence:$VerifyRemoteExistence -ExistingPackageIds $ExistingPackageIds + if ($present) { + $shortFp = if ($fingerprint) { $fingerprint.Substring(0, [Math]::Min(8, $fingerprint.Length)) } else { 'n/a' } + Write-DATLogEntry -Value "[Lenovo] Latest driver set unchanged for $Model ($($selected.Count) packages, v$($entry.version), fingerprint $shortFp) -- no new pack" -Severity 1 -UpdateUI + try { + $entry | Add-Member -NotePropertyName lastVerified -NotePropertyValue (Get-Date -Format 'o') -Force + $entry | Add-Member -NotePropertyName lastChecked -NotePropertyValue (Get-Date -Format 'o') -Force + $manifest[$manifestKey] = $entry + [void](Save-DATLenovoLatestManifest -Manifest $manifest) + } catch { } + $global:DATSoftPaqBuildSkipped = $true + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return "$($entry.version)" + } + } - if ($spListUnchanged) { - # Verify the previously built package still exists before skipping. For on-disk - # delivery modes this is a file check; for Intune/ConfigMgr we confirm the stored - # remote reference (app id / package name) is still present in the live environment. - $packageStillExists = $true - $missingReason = '' - switch ($RunningMode) { - 'Intune' { - if ($VerifyRemoteExistence) { - $storedRef = "$($spEntry.intuneAppId)" - if ([string]::IsNullOrEmpty($storedRef)) { - $packageStillExists = $false; $missingReason = 'no Intune application id was recorded' - } elseif ($ExistingPackageIds -notcontains $storedRef) { - $packageStillExists = $false; $missingReason = "Intune application $storedRef no longer exists" - } - } - } - 'Configuration Manager' { - if ($VerifyRemoteExistence) { - $storedRef = "$($spEntry.configMgrPackageId)" - if ([string]::IsNullOrEmpty($storedRef)) { - $packageStillExists = $false; $missingReason = 'no ConfigMgr package was recorded' - } elseif ($ExistingPackageIds -notcontains $storedRef) { - $packageStillExists = $false; $missingReason = "ConfigMgr package $storedRef no longer exists" - } - } - } - 'WIM Package Only' { - $wimFinalPath = Join-Path $PackageDestination "$OEM\$Model\$WindowsVersion $WindowsBuild\DriverPackage.wim" - if (-not (Test-Path -LiteralPath $wimFinalPath)) { - $packageStillExists = $false; $missingReason = 'the WIM package is missing' - } - } - 'Download Only' { - if (-not ((Test-Path -LiteralPath $DownloadDestination) -and (@(Get-ChildItem -LiteralPath $DownloadDestination -File -ErrorAction SilentlyContinue).Count -gt 0))) { - $packageStillExists = $false; $missingReason = 'the downloaded files are missing' - } - } - } + $buildVersion = if ($listUnchanged) { "$($entry.version)" } else { (Get-Date -Format 'ddMMyyyy') } - if ($ForceRebuild) { - Write-DATLogEntry -Value "[HP] SoftPaq list unchanged for $Model but Force Update is set -- rebuilding" -Severity 1 - } elseif (-not $packageStillExists) { - Write-DATLogEntry -Value "[HP] SoftPaq list unchanged for $Model but $missingReason -- rebuilding" -Severity 1 - } else { - $spStableVersion = "$($spEntry.version)" - Write-DATLogEntry -Value "[HP] SoftPaq list unchanged since last build for $Model ($($SoftPaqIDs.Count) SoftPaqs, v$spStableVersion) -- skipping rebuild" -Severity 1 -UpdateUI - # Surface the matched SoftPaqs, fingerprint and the verified package reference so - # the user can see exactly what was compared and which existing package was retained. - $spSortedIds = @($SoftPaqIDs | Sort-Object { [long]$_ }) - $spShortFingerprint = if (-not [string]::IsNullOrEmpty($spFingerprint)) { $spFingerprint.Substring(0, [Math]::Min(8, $spFingerprint.Length)) } else { 'n/a' } - Write-DATLogEntry -Value "[HP] Matched SoftPaqs (SP$($spSortedIds -join ', SP')) | fingerprint $spShortFingerprint" -Severity 1 - switch ($RunningMode) { - 'Intune' { - if ($VerifyRemoteExistence -and -not [string]::IsNullOrEmpty($spEntry.intuneAppId)) { - Write-DATLogEntry -Value "[HP] Verified existing Intune application $($spEntry.intuneAppId) still present -- retaining package" -Severity 1 - } - } - 'Configuration Manager' { - if ($VerifyRemoteExistence -and -not [string]::IsNullOrEmpty($spEntry.configMgrPackageId)) { - Write-DATLogEntry -Value "[HP] Verified existing ConfigMgr package $($spEntry.configMgrPackageId) still present -- retaining package" -Severity 1 - } - } - default { - Write-DATLogEntry -Value "[HP] Verified existing driver package on disk -- retaining package" -Severity 1 - } - } - try { - $spEntry | Add-Member -NotePropertyName lastVerified -NotePropertyValue (Get-Date -Format 'o') -Force - $spManifest[$spManifestKey] = $spEntry - [void](Save-DATHPSoftPaqManifest -Manifest $spManifest) - } catch { - Write-DATLogEntry -Value "[HP] Failed to update SoftPaq manifest verification time: $($_.Exception.Message)" -Severity 2 - } - $global:DATSoftPaqBuildSkipped = $true - Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String - return $spStableVersion - } - } + # -- 4. Download, verify and extract each selected package -- + $total = $selected.Count + if ($total -gt 1) { + $prevExtra = 0 + try { $prevExtra = [int](Get-ItemProperty -Path $global:RegPath -Name 'LatestDownloadsExtra' -ErrorAction SilentlyContinue).LatestDownloadsExtra } catch { $prevExtra = 0 } + Set-DATRegistryValue -Name "LatestDownloadsExtra" -Value "$($prevExtra + ($total - 1))" -Type String + } + $lnvComponents = New-Object System.Collections.Generic.List[object] + Set-DATRegistryValue -Name "DownloadBytes" -Value "$total" -Type String + Set-DATRegistryValue -Name "BytesTransferred" -Value "0" -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Download" -Type String + # Download all selected driver packages -- concurrently when the user's Concurrent Driver + # Downloads setting is above 1, otherwise sequentially. Returns the file names that landed on + # disk; per-driver download failures are recorded inside the helper. + $lnvDownloadItems = @($selected | ForEach-Object { [pscustomobject]@{ Url = $_.DownloadUrl; FileName = $_.FileName; Name = $_.Title } }) + $downloadedFileNames = Invoke-DATConcurrentDriverDownload -Items $lnvDownloadItems -DestinationDirectory $pkgDlDir ` + -OEM 'Lenovo' -Model $Model -MaxConcurrency (Get-DATLatestDriverConcurrency) + + foreach ($pkg in $selected) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "Lenovo download aborted by user" } + + $pkgFile = Join-Path $pkgDlDir $pkg.FileName + if ($downloadedFileNames -notcontains $pkg.FileName -or -not (Test-Path $pkgFile)) { + # Failed to download (already recorded by the concurrent downloader) -- skip. + continue + } - # Version stamp for this build: reuse the stored version when the SoftPaq set is - # unchanged (e.g. a forced rebuild of the same set), otherwise assign a fresh one. - $spBuildVersion = if ($spListUnchanged) { "$($spEntry.version)" } else { (Get-Date -Format 'ddMMyyyy') } + if ($pkg.Hash) { + $actual = Get-DATVerificationHash -FilePath $pkgFile -Algorithm SHA256 + if ($actual -and ($actual -ieq $pkg.Hash)) { + Write-DATLogEntry -Value "[Lenovo] SHA256 verified: $($pkg.FileName)" -Severity 1 + } elseif ($actual) { + Write-DATLogEntry -Value "[Lenovo] Hash mismatch for $($pkg.FileName) -- expected $($pkg.Hash), got $actual. Skipping." -Severity 3 + Add-DATDriverDownloadFailure -OEM 'Lenovo' -Model $Model -Driver "$($pkg.Title)" -Reason 'Hash mismatch (SHA256) -- file integrity check failed' + Remove-Item $pkgFile -Force -ErrorAction SilentlyContinue + continue + } + } - $totalSoftPaqs = $SoftPaqIDs.Count - Set-DATRegistryValue -Name "DownloadBytes" -Value "0" -Type String - Set-DATRegistryValue -Name "BytesTransferred" -Value "0" -Type String + # Extract via the package's own ExtractCommand (Inno Setup: /VERYSILENT /DIR=%PACKAGEPATH% /EXTRACT=YES). + $outDir = Join-Path $stagingDir ([IO.Path]::GetFileNameWithoutExtension($pkg.FileName)) + if (-not (Test-Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } + # Quote the substituted path -- Lenovo ExtractCommands use an unquoted /DIR=%PACKAGEPATH%, + # which Inno Setup truncates at the first space (temp paths like "C:\DAT Testing\..." extract nothing). + $extractArgs = ($pkg.ExtractCmd -replace '(?i)^\s*\S+\.exe\s*', '') -replace '"?%PACKAGEPATH%"?', "`"$outDir`"" + if ([string]::IsNullOrWhiteSpace($extractArgs)) { $extractArgs = "/VERYSILENT /DIR=`"$outDir`" /EXTRACT=`"YES`"" } + try { + $exProc = Start-Process -FilePath $pkgFile -ArgumentList $extractArgs -Wait -PassThru -WindowStyle Hidden + $infCount = @(Get-ChildItem -Path $outDir -Recurse -Filter *.inf -ErrorAction SilentlyContinue).Count + if ($infCount -eq 0) { Write-DATLogEntry -Value "[Lenovo] $($pkg.FileName) extracted no INF (exit $($exProc.ExitCode)) -- switches vary by package" -Severity 2 } + } catch { + Write-DATLogEntry -Value "[Lenovo] Extraction failed for $($pkg.FileName): $($_.Exception.Message)" -Severity 3 + Add-DATDriverDownloadFailure -OEM 'Lenovo' -Model $Model -Driver "$($pkg.Title)" -Reason "Extraction failed: $($_.Exception.Message)" + continue + } - # ── Step 2: Download SoftPaqs in parallel (separate processes) ──────── - # Check for abort before starting downloads - $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue - if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + $lnvComponents.Add([ordered]@{ + id = $pkg.Id + name = $pkg.Title + version = $pkg.Version + category = $pkg.Category + releaseDate = $pkg.ReleaseDate + criticality = $pkg.Severity + type = 'DRVR' + }) + } - Write-DATLogEntry -Value "[HP] Downloading $totalSoftPaqs SoftPaqs ($HPConcurrentDownloads concurrent processes)..." -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading $totalSoftPaqs HP SoftPaqs..." -Type String - Set-DATRegistryValue -Name "RunningMode" -Value "Download" -Type String + $stagedFiles = @(Get-ChildItem -Path $stagingDir -Recurse -File -ErrorAction SilentlyContinue).Count + Write-DATLogEntry -Value "[Lenovo] Extraction complete: $stagedFiles driver files staged from $($lnvComponents.Count) package(s)" -Severity 1 + if ($stagedFiles -eq 0) { throw "No Lenovo driver files were extracted from the Model-XML catalog for $Model" } - $DownloadStartTime = Get-Date + # -- 5. Package via the common WIM pipeline (embeds DATDriverManifest.json) -- + if ($RunningMode -ne "Download Only" -or $ExtractDownloadOnlyContent) { + $packageDest = if (-not [string]::IsNullOrEmpty($PackageDestination)) { $PackageDestination } else { $DownloadDestination } + $packagingPlatform = if ($RunningMode -eq 'WIM Package Only') { 'WIM Package Only' } + elseif ($RunningMode -eq 'Configuration Manager (Offline)') { 'Configuration Manager' } + else { $RunningMode } + Set-DATRegistryValue -Name "RunningMessage" -Value "Creating WIM package for Lenovo $Model..." -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Packaging" -Type String + Write-DATLogEntry -Value "[Lenovo] Creating WIM package from Latest Drivers staging directory..." -Severity 1 - # Build queue of SoftPaqs to download (skip cached) - $downloadQueue = [System.Collections.Generic.Queue[string]]::new() - $cachedCount = 0 - foreach ($spId in $SoftPaqIDs) { - $destFile = Join-Path $HPTempDirectory "SP$spId.exe" - if (Test-Path $destFile) { - Write-DATLogEntry -Value "[HP] SoftPaq SP$spId already cached -- skipping" -Severity 1 - $cachedCount++ - } else { - $downloadQueue.Enqueue($spId) - } - } - $completedDownloads = $cachedCount - $failedDownloads = @() - $activeProcs = @{} # spId -> Process object - $tempScripts = @{} # spId -> temp .ps1 path + $null = Invoke-DATDriverFilePackaging -FilePath $stagingDir -OEM $OEM -Model $Model ` + -OS "$WindowsVersion $WindowsBuild" -Destination $packageDest -Platform $packagingPlatform ` + -CustomDriverPath $CustomDriverPath -DownloadOnlyExtractDestination $DownloadDestination ` + -PackageVersion $buildVersion -Components $lnvComponents.ToArray() + } - # Resolve powershell executable path - $pwshExe = if ($PSVersionTable.PSVersion.Major -ge 7) { - (Get-Process -Id $PID).Path - } else { - "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - } + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + Write-DATLogEntry -Value "[Lenovo] Latest Drivers package process completed successfully" -Severity 1 -UpdateUI - try { - while ($downloadQueue.Count -gt 0 -or $activeProcs.Count -gt 0) { - # Check for abort - $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue - if ($abortReg.RunningState -eq 'Aborted') { - Write-DATLogEntry -Value "[HP] Abort detected -- killing download processes" -Severity 2 - throw "HP download aborted by user" - } + # Persist the tracking manifest (fingerprint + cadence timestamps). + try { + $manifestSave = Get-DATLenovoLatestManifest + $existingRef = $manifestSave[$manifestKey] + $nowIso = (Get-Date -Format 'o') + $manifestSave[$manifestKey] = [PSCustomObject]@{ + machineType = "$usedMt" + componentIds = @($identifiers | Sort-Object) + fingerprint = $fingerprint + version = $buildVersion + lastBuilt = $nowIso + lastChecked = $nowIso + lastVerified = $nowIso + intuneAppId = if ($existingRef) { "$($existingRef.intuneAppId)" } else { '' } + configMgrPackageId = if ($existingRef) { "$($existingRef.configMgrPackageId)" } else { '' } + } + [void](Save-DATLenovoLatestManifest -Manifest $manifestSave) + Write-DATLogEntry -Value "[Lenovo] Latest Drivers manifest updated for $Model (v$buildVersion, $($lnvComponents.Count) packages)" -Severity 1 + } catch { + Write-DATLogEntry -Value "[Lenovo] Failed to update Latest Drivers manifest: $($_.Exception.Message)" -Severity 2 + } - # Fill slots up to concurrency limit - while ($activeProcs.Count -lt $HPConcurrentDownloads -and $downloadQueue.Count -gt 0) { - $spId = $downloadQueue.Dequeue() - # Validate: SoftPaq IDs are always 4-8 digits; reject anything else to prevent command injection - if ($spId -notmatch '^\d{4,8}$') { - Write-DATLogEntry -Value "[HP][Warning] Skipping invalid SoftPaq ID: '$spId'" -Severity 2 - continue - } - $savePath = Join-Path $HPTempDirectory "SP$spId.exe" - $tmpScript = Join-Path ([System.IO.Path]::GetTempPath()) "DAT_SP_${spId}_$([System.IO.Path]::GetRandomFileName()).ps1" - $safeQuotedPath = $savePath -replace "'", "''" - Set-Content -Path $tmpScript -Value "Import-Module HPCMSL -Force`nGet-Softpaq -Number $spId -SaveAs '$safeQuotedPath' -MaxRetries 3 -Quiet" -Encoding UTF8 - $proc = Start-Process -FilePath $pwshExe -ArgumentList "-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", $tmpScript ` - -WindowStyle Hidden -PassThru - $activeProcs[$spId] = $proc - $tempScripts[$spId] = $tmpScript - Write-DATLogEntry -Value "[HP] Started SP$spId download (PID $($proc.Id))" -Severity 1 - } + return $buildVersion +} - # Check for completed processes - $finishedIds = @($activeProcs.Keys | Where-Object { $activeProcs[$_].HasExited }) - foreach ($spId in $finishedIds) { - $proc = $activeProcs[$spId] - $activeProcs.Remove($spId) - # Clean up temp script file - if ($tempScripts.ContainsKey($spId)) { - Remove-Item -Path $tempScripts[$spId] -ErrorAction SilentlyContinue - $tempScripts.Remove($spId) - } - $savePath = Join-Path $HPTempDirectory "SP$spId.exe" - if ($proc.ExitCode -eq 0 -and (Test-Path $savePath)) { - $completedDownloads++ - Write-DATLogEntry -Value "[HP] SP$spId download completed (PID $($proc.Id))" -Severity 1 - } else { - $failedDownloads += $spId - Write-DATLogEntry -Value "[HP] SP$spId download failed (exit code $($proc.ExitCode), PID $($proc.Id))" -Severity 3 - } - } +function Invoke-DATOEMDownloadModule { + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$OEM, + [string]$Model, + [string]$SystemSKU, + [Parameter(Mandatory)][AllowEmptyString()][string]$WindowsBuild, + [string]$WindowsVersion, + [string]$Architecture = "x64", + [string]$DownloadDestination, + [string]$PackageDestination, + [string]$RegPath, + [string]$LogDirectory, + [string]$TempDirectory, + [string]$RunningMode = "Download Only", + [string]$CustomDriverPath, + [string]$CatalogDownloadURL, + [string]$CatalogVersion, + [switch]$ForceRebuild, + [string[]]$ExistingPackageIds = @(), + [switch]$VerifyRemoteExistence, + [bool]$ExtractDownloadOnlyContent = $true + ) - # Update progress using actual bytes on disk - $spFiles = Get-ChildItem -Path $HPTempDirectory -Filter "SP*.exe" -ErrorAction SilentlyContinue - $downloadedBytes = ($spFiles | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum - if ($null -eq $downloadedBytes) { $downloadedBytes = [long]0 } - $downloadedMB = [math]::Round($downloadedBytes / 1MB, 2) + [Net.ServicePointManager]::SecurityProtocol = ( + [Net.ServicePointManager]::SecurityProtocol -bor + [Net.SecurityProtocolType]::Tls12 + ) + try { + if ([Enum]::IsDefined([Net.SecurityProtocolType], 12288)) { + [Net.ServicePointManager]::SecurityProtocol = ( + [Net.ServicePointManager]::SecurityProtocol -bor + ([Net.SecurityProtocolType]12288) + ) + } + } catch { } - # Estimate total size from average file size - $completedFiles = @($spFiles | Where-Object { $_.Length -gt 0 }) - if ($completedDownloads -gt 0 -and $completedFiles.Count -gt 0) { - $avgFileSize = $downloadedBytes / [math]::Max(1, $completedFiles.Count) - $estimatedTotal = [long]($avgFileSize * $totalSoftPaqs) - Set-DATRegistryValue -Name "DownloadBytes" -Value "$estimatedTotal" -Type String - } - Set-DATRegistryValue -Name "BytesTransferred" -Value "$downloadedBytes" -Type String - Set-DATRegistryValue -Name "DownloadSize" -Value "$downloadedMB MB" -Type String + if (-not (Test-Path $TempDirectory)) { New-Item -Path $TempDirectory -ItemType Directory -Force | Out-Null } + if (-not (Test-Path $DownloadDestination)) { New-Item -Path $DownloadDestination -ItemType Directory -Force | Out-Null } - $elapsed = ((Get-Date) - $DownloadStartTime).TotalSeconds - if ($elapsed -gt 0 -and $downloadedBytes -gt 0) { - $speed = [math]::Round(($downloadedMB / $elapsed), 2) - Set-DATRegistryValue -Name "DownloadSpeed" -Value "$speed MB/s" -Type String - } + $OEMLinksURL = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/OEMLinks.xml" - Set-DATRegistryValue -Name "RunningMessage" -Value "SoftPaq $completedDownloads of $totalSoftPaqs ($($activeProcs.Count) active) -- $downloadedMB MB" -Type String + Set-DATRegistryValue -Name "RunningMessage" -Value "Resolving download link for $OEM $Model..." -Type String + Write-DATLogEntry -Value "[$OEM] Resolving download link for $Model (SKU: $SystemSKU)" -Severity 1 - Start-Sleep -Seconds 2 - } - } finally { - # Kill all active download processes on abort or error - foreach ($spId in @($activeProcs.Keys)) { - $proc = $activeProcs[$spId] - if (-not $proc.HasExited) { - try { $proc.Kill() } catch { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } - Write-DATLogEntry -Value "[HP] Killed SP$spId download process (PID $($proc.Id))" -Severity 2 - } - if ($tempScripts.ContainsKey($spId)) { - Remove-Item -Path $tempScripts[$spId] -ErrorAction SilentlyContinue - } - } - # Kill any orphaned SoftPaq self-extracting processes - Get-Process -ErrorAction SilentlyContinue | Where-Object { - $_.ProcessName -match '^SP\d+$' - } | ForEach-Object { - try { $_.Kill() } catch { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } - } + # Retry helper for catalog downloads (transient network issues) + function Invoke-CatalogDownload { + param([string]$Uri, [string]$OutFile, [int]$MaxAttempts = 3, [int]$TimeoutSec = 60) + for ($i = 1; $i -le $MaxAttempts; $i++) { + try { + $proxyParams = Get-DATWebRequestProxy + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop @proxyParams + return + } catch { + Write-DATLogEntry -Value "[Warning] - Catalog download attempt $i/$MaxAttempts failed: $($_.Exception.Message)" -Severity 2 + if ($i -lt $MaxAttempts) { Start-Sleep -Seconds 5 } else { throw } } + } + } - # Final download count - $completedDownloads = $totalSoftPaqs - $failedDownloads.Count - Write-DATLogEntry -Value "[HP] Downloads complete: $completedDownloads of $totalSoftPaqs succeeded" -Severity 1 - - if ($failedDownloads.Count -gt 0) { - Write-DATLogEntry -Value "[HP] Failed SoftPaqs: $($failedDownloads -join ', ') -- retrying sequentially..." -Severity 2 - Set-DATRegistryValue -Name "RunningMessage" -Value "Retrying $($failedDownloads.Count) failed SoftPaqs..." -Type String + try { + $proxyParams = Get-DATWebRequestProxy + $webContent = $null + for ($i = 1; $i -le 3; $i++) { + try { + $webContent = (Invoke-WebRequest -Uri $OEMLinksURL -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop @proxyParams).Content + break + } catch { + if ($i -lt 3) { + Write-DATLogEntry -Value "[Warning] - OEM links catalog attempt $i failed: $($_.Exception.Message). Retrying in 5s..." -Severity 2 + Start-Sleep -Seconds 5 + } else { throw } + } + } + [xml]$OEMLinks = $webContent + } catch { + Write-DATLogEntry -Value "[Error] - Failed to download OEM links catalog: $($_.Exception.Message)" -Severity 3 + throw "OEM links catalog unavailable: $($_.Exception.Message)" + } - $retryFailed = @() - foreach ($spId in $failedDownloads) { - $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue - if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + $downloadURL = $null + $downloadFileName = $null + $gfxDownloadURL = $null + $gfxDownloadFileName = $null + $gfxBrand = $null + $callerCatalogVersion = $CatalogVersion + $catalogVersion = $null + $catalogFileHash = '' + $catalogHashMethod = '' - $savePath = Join-Path $HPTempDirectory "SP$spId.exe" - # Remove any partial file from the first attempt - if (Test-Path $savePath) { Remove-Item $savePath -Force -ErrorAction SilentlyContinue } + # If a direct download URL was provided from the DAT API catalog, use it and skip OEM catalog lookup + # Only accept URLs that point to a downloadable file (not info/landing pages) + if (-not [string]::IsNullOrEmpty($CatalogDownloadURL) -and $CatalogDownloadURL -match '\.(msi|exe|cab|zip|wim)(\?|$)') { + # HP/Dell/Lenovo Latest Drivers mode: ignore the pre-resolved SCCM driver pack URL so the + # OEM's Latest Drivers block runs instead of downloading the monolithic pack. + $buildTypeSource = if ($OEM -in @('HP', 'Dell', 'Lenovo')) { + (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource + } else { $null } + if (($OEM -in @('HP', 'Dell', 'Lenovo')) -and $buildTypeSource -eq 'SoftPaqs') { + Write-DATLogEntry -Value "[$OEM] Latest Drivers mode -- ignoring pre-resolved SCCM driver pack URL: $CatalogDownloadURL" -Severity 1 + # Leave $downloadURL null so the OEM Latest Drivers switch block runs + } else { + $downloadURL = $CatalogDownloadURL + $downloadFileName = ($CatalogDownloadURL -split '\?')[0] | Split-Path -Leaf + if (-not [string]::IsNullOrEmpty($callerCatalogVersion)) { + $catalogVersion = $callerCatalogVersion + Write-DATLogEntry -Value "[$OEM] Using catalog version from DAT API: $catalogVersion" -Severity 1 + } + Write-DATLogEntry -Value "[$OEM] Using pre-resolved download URL from DAT API catalog: $downloadFileName" -Severity 1 + } + } elseif (-not [string]::IsNullOrEmpty($CatalogDownloadURL)) { + Write-DATLogEntry -Value "[$OEM] DAT API catalog URL is not a direct download link, falling back to OEM catalog: $CatalogDownloadURL" -Severity 2 + } - Set-DATRegistryValue -Name "RunningMessage" -Value "Retrying SoftPaq SP$spId..." -Type String - Write-DATLogEntry -Value "[HP] Retrying SP$spId download..." -Severity 1 + if ([string]::IsNullOrEmpty($downloadURL)) { + switch ($OEM) { + "Dell" { + # Latest Drivers (DCU catalog) mode: resolve, download, extract and package the newest + # individual driver DUPs, then return -- bypassing the enterprise SCCM pack resolution. + $DellBuildType = (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource + if ($DellBuildType -eq 'SoftPaqs') { + return (Invoke-DATDellLatestDriverPackage -Model $Model -SystemSKU $SystemSKU ` + -WindowsVersion $WindowsVersion -WindowsBuild $WindowsBuild -Architecture $Architecture ` + -DownloadDestination $DownloadDestination -PackageDestination $PackageDestination ` + -TempDirectory $TempDirectory -RunningMode $RunningMode -CustomDriverPath $CustomDriverPath ` + -ExtractDownloadOnlyContent $ExtractDownloadOnlyContent -OEMLinks $OEMLinks ` + -ForceRebuild:$ForceRebuild -VerifyRemoteExistence:$VerifyRemoteExistence -ExistingPackageIds $ExistingPackageIds) + } + $DellLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Dell" }).Link | + Where-Object { $_.Type -eq "XMLCabinetSource" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($DellLink)) { throw "Dell catalog URL not found in OEM links" } - try { - $null = Get-Softpaq -Number $spId -SaveAs $savePath -MaxRetries 3 -ErrorAction Stop - $completedDownloads++ - Write-DATLogEntry -Value "[HP] SP$spId retry succeeded" -Severity 1 - } catch { - $retryFailed += $spId - Write-DATLogEntry -Value "[HP] SP$spId retry failed: $($_.Exception.Message)" -Severity 3 - } - } + $DellCabFile = [string]($DellLink | Split-Path -Leaf) + $DellXMLFile = $DellCabFile.TrimEnd(".cab") + ".xml" + $DellCabPath = Join-Path $TempDirectory $DellCabFile + $DellXMLPath = Join-Path $TempDirectory $DellXMLFile - $failedDownloads = @($retryFailed) - if ($failedDownloads.Count -gt 0) { - Write-DATLogEntry -Value "[HP] Permanently failed SoftPaqs after retry: $($failedDownloads -join ', ')" -Severity 3 - } else { - Write-DATLogEntry -Value "[HP] All SoftPaqs downloaded successfully after retry" -Severity 1 + if (-not (Test-Path $DellXMLPath)) { + Write-DATLogEntry -Value "[$OEM] Downloading Dell catalog..." -Severity 1 + Write-DATLogEntry -Value "[$OEM] Catalog cab path: $DellCabPath" -Severity 1 + Write-DATLogEntry -Value "[$OEM] Catalog XML extract path: $DellXMLPath" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Dell driver catalog..." -Type String + if (-not (Test-Path $DellCabPath)) { + $proxyParams = Get-DATWebRequestProxy + Invoke-WebRequest -Uri $DellLink -OutFile $DellCabPath -UseBasicParsing -TimeoutSec 60 @proxyParams } + & expand.exe "$DellCabPath" -F:* "$TempDirectory" -R 2>&1 | Out-Null + } else { + Write-DATLogEntry -Value "[$OEM] Using cached Dell catalog: $DellXMLPath" -Severity 1 } - # Remove failed IDs from the processing list - $successfulIDs = @($SoftPaqIDs | Where-Object { $_ -notin $failedDownloads }) - if ($successfulIDs.Count -eq 0) { - throw "All $totalSoftPaqs SoftPaq downloads failed for $Model" - } - - # ── Step 3: Extract and copy INF-targeted drivers ───────────────────── - $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue - if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + if (-not (Test-Path $DellXMLPath)) { throw "Dell catalog XML not found after extraction" } - Set-DATRegistryValue -Name "RunningMode" -Value "Extracting" -Type String - Write-DATLogEntry -Value "[HP] Extracting $($successfulIDs.Count) SoftPaqs and copying drivers..." -Severity 1 + [xml]$DellModelXML = Get-Content -Path $DellXMLPath -Raw + $DellWindowsVersion = $WindowsVersion.Replace(" ", "") - # OS identifier for INF path lookup - $OsId = if ($HPOS -eq 'Win11') { 'W11' } else { 'WT64' } - $fullInfPathName = "$($OsId)_$($WindowsBuild.ToUpper())_INFPath" - $fallbackInfPathName = "$($OsId)_INFPath" - Write-DATLogEntry -Value "[HP] INF path keys: primary=$fullInfPathName, fallback=$fallbackInfPathName" -Severity 1 + # Split comma-separated SystemSKU into individual IDs for -contains matching + $systemSKUs = @($SystemSKU -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + # Extract core model identifier (last token) for fallback matching + $coreModelName = ($Model -split '\s+')[-1] - $extractedCount = 0 - $skippedCount = 0 + $matchingPkg = $DellModelXML.driverpackmanifest.driverpackage | Where-Object { + ($_.SupportedOperatingSystems.OperatingSystem.osCode -eq $DellWindowsVersion) -and + ($_.SupportedOperatingSystems.OperatingSystem.osArch -match $Architecture) -and + ($_.SupportedSystems.Brand.Model.name -contains $Model -or + @($_.SupportedSystems.Brand.Model.SystemID | Where-Object { $_ -in $systemSKUs }).Count -gt 0) + } | Select-Object -First 1 - foreach ($spId in $successfulIDs) { - $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue - if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + # Fallback 1: try with core model identifier (handles "Pro Laptops PA14250" vs "PA14250") + if ($null -eq $matchingPkg -and $coreModelName -ne $Model) { + $matchingPkg = $DellModelXML.driverpackmanifest.driverpackage | Where-Object { + ($_.SupportedOperatingSystems.OperatingSystem.osCode -eq $DellWindowsVersion) -and + ($_.SupportedOperatingSystems.OperatingSystem.osArch -match $Architecture) -and + ($_.SupportedSystems.Brand.Model.name -contains $coreModelName) + } | Select-Object -First 1 + if ($matchingPkg) { + Write-DATLogEntry -Value "[$OEM] Matched via core model identifier: $coreModelName (full catalog model: $Model)" -Severity 1 + } + } - $spFile = Join-Path $HPTempDirectory "SP$spId.exe" - $spExtractDir = Join-Path $HPExtractDir "$spId" - $spStagingDir = Join-Path $HPStagingDir "$spId" + # Fallback 2: wildcard match + if ($null -eq $matchingPkg) { + $matchingPkg = $DellModelXML.driverpackmanifest.driverpackage | Where-Object { + ($_.SupportedOperatingSystems.OperatingSystem.osCode -eq $DellWindowsVersion) -and + ($_.SupportedOperatingSystems.OperatingSystem.osArch -match $Architecture) -and + ($_.SupportedSystems.Brand.Model.name -like "*$coreModelName*") + } | Select-Object -First 1 + } - $extractedCount++ - Set-DATRegistryValue -Name "RunningMessage" -Value "Extracting SoftPaq SP$spId ($extractedCount of $($successfulIDs.Count))..." -Type String - Set-DATRegistryValue -Name "BytesTransferred" -Value "$extractedCount" -Type String - Set-DATRegistryValue -Name "DownloadBytes" -Value "$($successfulIDs.Count)" -Type String - - if (-not (Test-Path $spFile)) { - Write-DATLogEntry -Value "[HP] SP$spId.exe not found -- skipping" -Severity 2 - $skippedCount++ - continue + if ($null -ne $matchingPkg) { + $catalogVersion = $matchingPkg.dellVersion + # Fallback: if catalog entry has no dellVersion, use the version passed from the caller + if ([string]::IsNullOrEmpty($catalogVersion) -and -not [string]::IsNullOrEmpty($callerCatalogVersion)) { + $catalogVersion = $callerCatalogVersion + Write-DATLogEntry -Value "[$OEM] Catalog entry missing dellVersion -- using caller-provided version: $catalogVersion" -Severity 1 } + $DellBaseURL = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Dell" }).Link | + Where-Object { $_.Type -eq "DownloadBase" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($DellBaseURL)) { $DellBaseURL = "https://downloads.dell.com" } + $DellBaseURL = $DellBaseURL.TrimEnd('/') + $dellPath = $matchingPkg.path.TrimStart('/') + $downloadURL = "$DellBaseURL/$dellPath" + $downloadFileName = $matchingPkg.path | Split-Path -Leaf + Write-DATLogEntry -Value "[$OEM] Found driver pack: $downloadFileName (version: $catalogVersion)" -Severity 1 + } else { + throw "No matching Dell driver package found for $Model ($DellWindowsVersion $Architecture)" + } + } + "HP" { + # Check user preference for HP driver source: DriverPack (single SCCM pack) or SoftPaqs (individual drivers) + $HPDriverPackSource = (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource + if ([string]::IsNullOrEmpty($HPDriverPackSource)) { $HPDriverPackSource = 'DriverPack' } + Write-DATLogEntry -Value "[HP] Driver pack source mode: $HPDriverPackSource" -Severity 1 - # Extract SoftPaq silently (timeout after 5 minutes to avoid hangs) - Write-DATLogEntry -Value "[HP] Extracting SP$spId..." -Severity 1 - if (-not (Test-Path $spExtractDir)) { New-Item -Path $spExtractDir -ItemType Directory -Force | Out-Null } - try { - $extractProc = Start-Process -FilePath $spFile -ArgumentList "-e", "-f `"$spExtractDir`"", "-s" ` - -WindowStyle Hidden -PassThru - if (-not $extractProc.WaitForExit(300000)) { - try { $extractProc.Kill() } catch {} - Write-DATLogEntry -Value "[HP] SP$spId extraction timed out after 5 minutes -- skipping" -Severity 3 - $skippedCount++ - continue - } - if ($extractProc.ExitCode -ne 0) { - Write-DATLogEntry -Value "[HP] SP$spId extraction exited with code $($extractProc.ExitCode)" -Severity 2 + if ($HPDriverPackSource -eq 'DriverPack') { + # ── SCCM Driver Pack mode: use HP catalog XML to find the monolithic driver pack ── + # This downloads a single .exe driver pack from ftp.hp.com (like Dell/Lenovo) + $HPXMLCabinetSource = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "HP" }).Link | + Where-Object { $_.Type -eq "XMLCabinetSource" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($HPXMLCabinetSource)) { throw "HP catalog URL not found in OEM links" } + + $HPCabFile = [string]($HPXMLCabinetSource | Split-Path -Leaf) + $HPXMLFile = $HPCabFile.TrimEnd(".cab") + ".xml" + $HPCabPath = Join-Path $TempDirectory $HPCabFile + $HPXMLPath = Join-Path $TempDirectory $HPXMLFile + + if (-not (Test-Path $HPXMLPath)) { + Write-DATLogEntry -Value "[HP] Downloading HP catalog..." -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading HP driver catalog..." -Type String + if (-not (Test-Path $HPCabPath)) { + Invoke-CatalogDownload -Uri $HPXMLCabinetSource -OutFile $HPCabPath } - } catch { - Write-DATLogEntry -Value "[HP] SP$spId extraction failed: $($_.Exception.Message)" -Severity 3 - $skippedCount++ - continue + & expand.exe "$HPCabPath" -F:* "$TempDirectory" -R 2>&1 | Out-Null + } else { + Write-DATLogEntry -Value "[HP] Using cached HP catalog: $HPXMLPath" -Severity 1 } - # Get metadata for INF path mapping - try { - $metadata = Get-HPSoftpaqMetadata -Number $spId -MaxRetries 3 - } catch { - Write-DATLogEntry -Value "[HP] SP$spId metadata lookup failed: $($_.Exception.Message) -- copying all extracted content" -Severity 2 - # Fallback: copy everything - if (-not (Test-Path $spStagingDir)) { New-Item -Path $spStagingDir -ItemType Directory -Force | Out-Null } - Copy-Item "$spExtractDir\*" $spStagingDir -Recurse -Force -ErrorAction SilentlyContinue - continue - } + if (-not (Test-Path $HPXMLPath)) { throw "HP catalog XML not found after extraction" } - if ($metadata.ContainsKey('Devices_INFPath')) { - # Determine which INF path key to use - $infPathName = if ($metadata.Devices_INFPath.ContainsKey($fullInfPathName)) { - $fullInfPathName - } elseif ($metadata.Devices_INFPath.ContainsKey($fallbackInfPathName)) { - $fallbackInfPathName - } else { $null } + [xml]$HPModelXML = Get-Content -Path $HPXMLPath -Raw + $HPModelSoftPaqs = $HPModelXML.NewDataSet.HPClientDriverPackCatalog.ProductOSDriverPackList.ProductOSDriverPack - if ($infPathName) { - $infPaths = @($metadata.Devices_INFPath[$infPathName]) - if (-not (Test-Path $spStagingDir)) { New-Item -Path $spStagingDir -ItemType Directory -Force | Out-Null } - foreach ($infPath in $infPaths) { - $infPath = $infPath.TrimStart('.\') - $absoluteInfPath = Join-Path $spExtractDir $infPath - if (Test-Path $absoluteInfPath) { - Write-DATLogEntry -Value "[HP] SP$spId copying INF path: $infPath" -Severity 1 - Copy-Item $absoluteInfPath $spStagingDir -Recurse -Force -ErrorAction SilentlyContinue - } else { - Write-DATLogEntry -Value "[HP] SP$spId INF path not found: $absoluteInfPath" -Severity 2 - } + # Match by model name and OS + $matchingPack = $HPModelSoftPaqs | Where-Object { + ($_.SystemName -replace '^HP\s+', '').Trim() -eq $Model -and + $_.OSName -match $WindowsVersion -and $_.OSName -match $WindowsBuild + } | Select-Object -First 1 + + # Fallback: match by baseboard/platform ID + if ($null -eq $matchingPack) { + $SKUList = $SystemSKU -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ -match '^[a-f0-9]{4}$' } + $matchingPack = $HPModelSoftPaqs | Where-Object { + $packMatch = $_.OSName -match $WindowsVersion -and $_.OSName -match $WindowsBuild + if ($packMatch) { + $sysIds = @($_.SystemId | ForEach-Object { $_.ToLower() }) + $packMatch = @($SKUList | Where-Object { $_ -in $sysIds }).Count -gt 0 } - } else { - Write-DATLogEntry -Value "[HP] SP$spId missing INF path key ($fullInfPathName / $fallbackInfPathName) -- skipping" -Severity 2 - $skippedCount++ + $packMatch + } | Select-Object -First 1 + } + + if ($null -ne $matchingPack) { + $spId = $matchingPack.SoftPaqId + # Resolve the SoftPaq metadata (version + download URL) from the SoftPaqList + # section -- the ProductOSDriverPack node itself carries neither. + $hpSoftPaq = $HPModelXML.NewDataSet.HPClientDriverPackCatalog.SoftPaqList.SoftPaq | + Where-Object { $_.Id -eq $spId } | Select-Object -First 1 + $downloadURL = if ($hpSoftPaq -and -not [string]::IsNullOrEmpty($hpSoftPaq.Url)) { $hpSoftPaq.Url } else { '' } + # Normalise the SoftPaq number (catalog may or may not include the 'sp' prefix). + $spNumber = "$spId" -replace '^sp', '' + if ([string]::IsNullOrEmpty($downloadURL)) { + # Fall back to constructing the ftp.hp.com URL from the SoftPaq number. + $spRange = $spNumber.Substring(0, $spNumber.Length - 3) + $downloadURL = "https://ftp.hp.com/pub/softpaq/sp${spRange}001-${spRange}500/sp$spNumber.exe" + } + $downloadFileName = "sp$spNumber.exe" + $catalogVersion = if ($hpSoftPaq) { $hpSoftPaq.Version } else { '' } + # Fallback: if the catalog entry has no version, use the version passed from the + # caller (HP catalog version resolved during model enumeration), then a date stamp. + if ([string]::IsNullOrEmpty($catalogVersion) -and -not [string]::IsNullOrEmpty($callerCatalogVersion)) { + $catalogVersion = $callerCatalogVersion + Write-DATLogEntry -Value "[HP] Catalog entry missing version -- using caller-provided version: $catalogVersion" -Severity 1 } + if ([string]::IsNullOrEmpty($catalogVersion)) { $catalogVersion = (Get-Date -Format 'ddMMyyyy') } + Write-DATLogEntry -Value "[HP] Found SCCM driver pack: SP$spId ($downloadFileName)" -Severity 1 + Write-DATLogEntry -Value "[HP] Download URL: $downloadURL" -Severity 1 + # Fall through to common download path below (same as Dell/Lenovo) } else { - Write-DATLogEntry -Value "[HP] SP$spId is not DPB compliant (no Devices_INFPath) -- skipping" -Severity 2 - $skippedCount++ + throw "No matching HP SCCM driver pack found for $Model ($WindowsVersion $WindowsBuild)" } - } - - # Clean up extracted temp files (keep staging) - Remove-Item -Path "$HPExtractDir\*" -Recurse -Force -ErrorAction SilentlyContinue + } else { + # ── Individual SoftPaqs mode: use HPCMSL to discover and download each driver ── + # HP uses HPCMSL to discover required SoftPaqs, then downloads, extracts, and + # copies only the INF-targeted driver folders to a staging directory. - $stagedFiles = (Get-ChildItem -Path $HPStagingDir -Recurse -File -ErrorAction SilentlyContinue).Count - Write-DATLogEntry -Value "[HP] Extraction complete: $stagedFiles driver files staged, $skippedCount SoftPaqs skipped" -Severity 1 + # Validate HPCMSL + Write-DATLogEntry -Value "[HP] Validating HPCMSL module before starting build..." -Severity 1 + $hpCheck = Test-DATHPCMSLReady -AutoInstall + if (-not $hpCheck.Ready) { + throw "HPCMSL prerequisite check failed: $($hpCheck.Error)" + } + Write-DATLogEntry -Value "[HP] Starting driver package build for $Model (SKU: $SystemSKU)" -Severity 1 -UpdateUI + Write-DATLogEntry -Value "[HP] Parameters: SKU=$SystemSKU, Build=$WindowsBuild, Version=$WindowsVersion" -Severity 1 + Write-DATLogEntry -Value "[HP] Download Destination: $DownloadDestination" -Severity 1 + Write-DATLogEntry -Value "[HP] Temp Directory: $TempDirectory" -Severity 1 - if ($stagedFiles -eq 0) { - throw "No driver files were extracted from HP SoftPaqs for $Model" + # Determine OS parameter for HPCMSL + switch -Wildcard ($WindowsVersion) { + "*Windows 11*" { $HPOS = "Win11" } + "*Windows 10*" { $HPOS = "Win10" } + default { $HPOS = "Win11" } } - # ── Step 4: Package (WIM creation via common path) ──────────────────── - # HP now flows into common packaging like other OEMs. - # Create a sentinel file so Invoke-DATDriverFilePackaging can find the staging dir. - # We bypass the common download+extract and call packaging directly. - if ($RunningMode -ne "Download Only" -or $ExtractDownloadOnlyContent) { - $packageDest = if (-not [string]::IsNullOrEmpty($PackageDestination)) { $PackageDestination } else { $DownloadDestination } - $packagingPlatform = if ($RunningMode -eq 'WIM Package Only') { 'WIM Package Only' } - elseif ($RunningMode -eq 'Configuration Manager (Offline)') { 'Configuration Manager' } - else { $RunningMode } + # Build HP-specific temp path: Temp\HP\Model\OS\OSVer + $HPTempDirectory = Join-Path $TempDirectory "HP\$Model\$HPOS\$WindowsBuild" + if (-not (Test-Path $HPTempDirectory)) { New-Item -Path $HPTempDirectory -ItemType Directory -Force | Out-Null } + $HPExtractDir = Join-Path $HPTempDirectory "Extracted" + $HPStagingDir = Join-Path $HPTempDirectory "Staging" + foreach ($dir in @($HPTempDirectory, $HPExtractDir, $HPStagingDir)) { + if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } + } + Write-DATLogEntry -Value "[HP] Temp download: $HPTempDirectory" -Severity 1 + Write-DATLogEntry -Value "[HP] Extract: $HPExtractDir" -Severity 1 + Write-DATLogEntry -Value "[HP] Staging: $HPStagingDir" -Severity 1 - # Invoke-DATDriverFilePackaging expects a single file to extract. - # For HP, the drivers are already extracted to $HPStagingDir. - # Call the packaging function with a virtual ".dir" path -- the function - # will detect the HP staging directory and skip extraction. - Set-DATRegistryValue -Name "RunningMessage" -Value "Creating WIM package for HP $Model..." -Type String - Set-DATRegistryValue -Name "RunningMode" -Value "Packaging" -Type String - Write-DATLogEntry -Value "[HP] Creating WIM package from staging directory..." -Severity 1 + # Split comma-separated SKUs; try each in order until one returns SoftPaqs + $SKUList = $SystemSKU -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^[a-fA-F0-9]{4}$' } + if ($SKUList.Count -eq 0) { + throw "No valid 4-character platform IDs found in SKU: $SystemSKU" + } + Write-DATLogEntry -Value "[HP] Platform IDs to try: $($SKUList -join ', ')" -Severity 1 - $null = Invoke-DATDriverFilePackaging -FilePath $HPStagingDir -OEM $OEM -Model $Model ` - -OS "$WindowsVersion $WindowsBuild" -Destination $packageDest -Platform $packagingPlatform ` - -CustomDriverPath $CustomDriverPath -DownloadOnlyExtractDestination $DownloadDestination + # Read concurrent download setting (1-4, default 2) + $HPConcurrentDownloads = 2 + $regConcurrency = (Get-ItemProperty -Path $global:RegPath -Name 'HPConcurrentDownloads' -ErrorAction SilentlyContinue).HPConcurrentDownloads + if (-not [string]::IsNullOrEmpty($regConcurrency)) { + $parsedConcurrency = 0 + if ([int]::TryParse($regConcurrency, [ref]$parsedConcurrency) -and $parsedConcurrency -ge 1 -and $parsedConcurrency -le 4) { + $HPConcurrentDownloads = $parsedConcurrency + } } + Write-DATLogEntry -Value "[HP] Concurrent downloads: $HPConcurrentDownloads" -Severity 1 - Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String - Write-DATLogEntry -Value "[HP] Driver package process completed successfully" -Severity 1 -UpdateUI + # ── Cadence gate: skip before SoftPaq discovery when within the update cadence window ── + $spCadenceKey = Get-DATHPSoftPaqManifestKey -Model $Model -OSVersion $WindowsVersion -Build $WindowsBuild -Architecture $Architecture + $spCadenceEntry = (Get-DATHPSoftPaqManifest)[$spCadenceKey] + $hpCadence = Get-DATLatestDriverCadence + if (-not $ForceRebuild -and $hpCadence -ne 'Off' -and $null -ne $spCadenceEntry -and + -not (Test-DATLatestCadenceElapsed -LastActivity "$($spCadenceEntry.lastChecked)" -Cadence $hpCadence)) { + $present = Test-DATLatestPackagePresent -Entry $spCadenceEntry -OEM 'HP' -Model $Model -WindowsVersion $WindowsVersion ` + -WindowsBuild $WindowsBuild -RunningMode $RunningMode -PackageDestination $PackageDestination ` + -DownloadDestination $DownloadDestination -VerifyRemoteExistence:$VerifyRemoteExistence -ExistingPackageIds $ExistingPackageIds + if ($present) { + $nextDue = try { ([datetime]$spCadenceEntry.lastChecked) } catch { Get-Date } + $nextDue = switch ($hpCadence) { 'Daily' { $nextDue.AddDays(1) } 'Weekly' { $nextDue.AddDays(7) } 'Monthly' { $nextDue.AddMonths(1) } default { $nextDue } } + Write-DATLogEntry -Value "[HP] Within $hpCadence update cadence for $Model -- retaining existing package (next eligible $($nextDue.ToString('yyyy-MM-dd')))" -Severity 1 -UpdateUI + $global:DATSoftPaqBuildSkipped = $true + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return "$($spCadenceEntry.version)" + } + } - # Persist the SoftPaq manifest so an unchanged list skips rebuild next time. - try { - $spManifestSave = Get-DATHPSoftPaqManifest - $existingRef = $spManifestSave[$spManifestKey] - $spManifestSave[$spManifestKey] = [PSCustomObject]@{ - platformId = "$DiscoveryPlatformID" - softPaqIds = @($SoftPaqIDs | Sort-Object { [long]$_ }) - fingerprint = $spFingerprint - version = $spBuildVersion - lastBuilt = (Get-Date -Format 'o') - lastVerified = (Get-Date -Format 'o') - intuneAppId = if ($existingRef) { "$($existingRef.intuneAppId)" } else { '' } - configMgrPackageId = if ($existingRef) { "$($existingRef.configMgrPackageId)" } else { '' } + # ── Step 1: Discover SoftPaqs using New-HPDriverPack -WhatIf ────────── + # Pre-flight: verify that HPCMSL supports the requested OS version + $hpDPCmd = Get-Command -Name New-HPDriverPack -ErrorAction SilentlyContinue + if ($hpDPCmd) { + $osVerAttr = $hpDPCmd.Parameters['OSVer'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } + if ($osVerAttr -and $WindowsBuild -notin $osVerAttr.ValidValues) { + $supportedValues = $osVerAttr.ValidValues -join ', ' + Write-DATLogEntry -Value "[HP] HPCMSL does not support OSVer '$WindowsBuild'. Supported values: $supportedValues. Update HPCMSL: Install-Module HPCMSL -Force -AllowClobber" -Severity 3 + throw "HPCMSL does not support OS version '$WindowsBuild'. Update HPCMSL to the latest version: Install-Module -Name HPCMSL -Force -AllowClobber" } - [void](Save-DATHPSoftPaqManifest -Manifest $spManifestSave) - Write-DATLogEntry -Value "[HP] SoftPaq manifest updated for $Model (v$spBuildVersion, $($SoftPaqIDs.Count) SoftPaqs)" -Severity 1 - } catch { - Write-DATLogEntry -Value "[HP] Failed to update SoftPaq manifest: $($_.Exception.Message)" -Severity 2 } - # HP SoftPaqs mode handles its own multi-file download -- skip common single-file download path - return $spBuildVersion - } # end else (Individual SoftPaqs mode) - } - "Lenovo" { - $LenovoLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Lenovo" }).Link | - Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 - if ([string]::IsNullOrEmpty($LenovoLink)) { throw "Lenovo catalog URL not found in OEM links" } - - $LenovoFile = [string]($LenovoLink | Split-Path -Leaf) - $LenovoFilePath = Join-Path $TempDirectory $LenovoFile + $SoftPaqIDs = @() + $DiscoveryPlatformID = $null + # Maps SoftPaq id -> descriptive name scraped from the WhatIf output (for the WIM manifest). + $spNameMap = @{} - if (-not (Test-Path $LenovoFilePath)) { - Write-DATLogEntry -Value "[$OEM] Downloading Lenovo catalog..." -Severity 1 - Write-DATLogEntry -Value "[$OEM] Catalog download path: $LenovoFilePath" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Lenovo driver catalog..." -Type String - Invoke-CatalogDownload -Uri $LenovoLink -OutFile $LenovoFilePath + # Resolve PowerShell executable for child processes + $discoveryPwshExe = if ($PSVersionTable.PSVersion.Major -ge 7) { + (Get-Process -Id $PID).Path } else { - Write-DATLogEntry -Value "[$OEM] Using cached Lenovo catalog: $LenovoFilePath" -Severity 1 + "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" } - [xml]$LenovoModelXML = Get-Content -Path $LenovoFilePath - $LenovoDrivers = $LenovoModelXML.ModelList.Model - $WinVer = "Win" + "$($WindowsVersion.Split(' ')[1])" - - $matchingModel = $LenovoDrivers | Where-Object { - $_.Name -eq $Model -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer - } | Select-Object -First 1 + foreach ($PlatformID in $SKUList) { + Write-DATLogEntry -Value "[HP] Querying required SoftPaqs for platform $PlatformID (WhatIf)..." -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Querying HP SoftPaqs for platform $PlatformID..." -Type String - if ($null -ne $matchingModel -and $null -ne $matchingModel.SCCM) { - $sccmNode = $matchingModel.SCCM | Where-Object { $_.Version -eq $WindowsBuild -and $_.OS -eq $WinVer } | Select-Object -First 1 - if ($null -eq $sccmNode) { $sccmNode = $matchingModel.SCCM } - $catalogVersion = if ($sccmNode.date) { $sccmNode.date } else { '' } - $downloadURL = $sccmNode.'#text' - if ($downloadURL -is [array]) { $downloadURL = $downloadURL[0] } - if ([string]::IsNullOrEmpty($downloadURL)) { $downloadURL = [string]$sccmNode } - $downloadFileName = $downloadURL | Split-Path -Leaf - Write-DATLogEntry -Value "[$OEM] Found SCCM pack: $downloadFileName" -Severity 1 - Write-DATLogEntry -Value "[$OEM] Resolved download URL: $downloadURL" -Severity 1 + try { + # Run New-HPDriverPack -WhatIf in a child process to capture Write-Host output. + # HPCMSL writes the SoftPaq list via Write-Host which cannot be captured in-process + # on PS 5.1 (the WPF app host swallows it). A child process redirects all output to stdout. + $discoveryOutputFile = Join-Path $HPTempDirectory "discovery_${PlatformID}.txt" + $discoveryScript = Join-Path ([System.IO.Path]::GetTempPath()) "DAT_HPDiscovery_${PlatformID}_$([System.IO.Path]::GetRandomFileName()).ps1" + $discoveryScriptContent = @" +`$ErrorActionPreference = 'Stop' +Import-Module HPCMSL -Force +New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -Format wim -Path "$DownloadDestination" -TempDownloadPath "$HPTempDirectory" -WhatIf *>&1 +"@ + Set-Content -Path $discoveryScript -Value $discoveryScriptContent -Encoding UTF8 - # Check for supplemental NVIDIA GFX driver package - $gfxNode = $matchingModel.GFX | Where-Object { - $_.os -eq $WinVer -and $_.version -eq $WindowsBuild - } | Select-Object -First 1 + $discoveryProc = Start-Process -FilePath $discoveryPwshExe ` + -ArgumentList '-NoProfile', '-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', $discoveryScript ` + -WindowStyle Hidden -PassThru -Wait ` + -RedirectStandardOutput $discoveryOutputFile -RedirectStandardError ([System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "DAT_HPDiscovery_err.txt")) - $gfxDownloadURL = $null - $gfxDownloadFileName = $null - if ($null -ne $gfxNode) { - $gfxDownloadURL = $gfxNode.'#text' - if ([string]::IsNullOrEmpty($gfxDownloadURL)) { $gfxDownloadURL = [string]$gfxNode } - $gfxDownloadFileName = $gfxDownloadURL | Split-Path -Leaf - $gfxBrand = $gfxNode.brand - Write-DATLogEntry -Value "[$OEM] Supplemental $gfxBrand GFX package found: $gfxDownloadFileName" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "$Model has supplemental $gfxBrand GFX drivers - both packages will be downloaded" -Type String - } - } else { - throw "No matching Lenovo driver package found for $Model ($WinVer $WindowsBuild)" - } - } - "Microsoft" { - # Try DAT API catalog first (has the most current URLs) - $datApiResolved = $false - try { - $driverCatalog = Get-DATDriverCatalog - if ($driverCatalog) { - $normalizedArch = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } - $matchingEntry = $driverCatalog | Where-Object { - $_.Manufacturer -eq 'Microsoft' -and - $_.DisplayName -eq $Model -and - $_.SupportedOS -match $WindowsVersion -and - $_.SupportedArchitecture -eq $normalizedArch -and - -not [string]::IsNullOrEmpty($_.DownloadURL) -and - $_.DownloadURL -match '\.(msi|exe|cab|zip|wim)(\?|$)' - } | Sort-Object { try { [datetime]$_.ReleaseDate } catch { [datetime]::MinValue } } -Descending | Select-Object -First 1 + Remove-Item -Path $discoveryScript -Force -ErrorAction SilentlyContinue - if ($matchingEntry) { - $downloadURL = $matchingEntry.DownloadURL - $downloadFileName = ($downloadURL -split '\?')[0] | Split-Path -Leaf - $catalogVersion = if ($matchingEntry.Version) { $matchingEntry.Version } elseif ($matchingEntry.ReleaseDate) { $matchingEntry.ReleaseDate } else { '' } - if (-not [string]::IsNullOrEmpty($matchingEntry.FileHash)) { - $catalogFileHash = $matchingEntry.FileHash - $catalogHashMethod = if (-not [string]::IsNullOrEmpty($matchingEntry.HashMethod)) { $matchingEntry.HashMethod } else { 'SHA256' } - } - Write-DATLogEntry -Value "[$OEM] Resolved from DAT API catalog: $downloadFileName (Version: $catalogVersion)" -Severity 1 - $datApiResolved = $true + if (Test-Path $discoveryOutputFile) { + $allLines = @(Get-Content -Path $discoveryOutputFile -ErrorAction SilentlyContinue) + Remove-Item -Path $discoveryOutputFile -Force -ErrorAction SilentlyContinue } else { - Write-DATLogEntry -Value "[$OEM] Model '$Model' not found in DAT API catalog for $WindowsVersion $normalizedArch -- trying OEM catalog" -Severity 2 + $allLines = @() } - } - } catch { - Write-DATLogEntry -Value "[$OEM] DAT API catalog lookup failed, falling back to OEM catalog: $($_.Exception.Message)" -Severity 2 - } - # Fall back to GitHub-hosted OEM catalog if DAT API didn't resolve - if (-not $datApiResolved) { - $MicrosoftCatalogPath = Join-Path $TempDirectory "OSDCatalogMicrosoftDriverPack.json" - if (-not (Test-Path $MicrosoftCatalogPath)) { - $MicrosoftCatalogSource = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/OSDCatalogMicrosoftDriverPack.json" - Write-DATLogEntry -Value "[$OEM] Downloading Microsoft OEM catalog..." -Severity 1 - Write-DATLogEntry -Value "[$OEM] Catalog download path: $MicrosoftCatalogPath" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Microsoft driver catalog..." -Type String - $proxyParams = Get-DATWebRequestProxy - Invoke-WebRequest -Uri $MicrosoftCatalogSource -OutFile $MicrosoftCatalogPath -UseBasicParsing -TimeoutSec 30 @proxyParams - } else { - Write-DATLogEntry -Value "[$OEM] Using cached Microsoft OEM catalog: $MicrosoftCatalogPath" -Severity 1 - } + Write-DATLogEntry -Value "[HP] Discovery output: $($allLines.Count) lines captured for platform $PlatformID" -Severity 1 - $MSModelList = Get-Content -Path $MicrosoftCatalogPath -Raw | ConvertFrom-Json - $MSArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'amd64' } - $matchingModel = $MSModelList | Where-Object { - $_.Model -eq $Model -and $_.OperatingSystem -match $WindowsVersion -and $_.OSArchitecture -eq $MSArchFilter - } | Select-Object -First 1 + $SoftPaqIDs = @($allLines | Where-Object { $_ -match '^\s+(?:sp)?(\d{4,})' } | ForEach-Object { + if ($_ -match '^\s+(?:sp)?(\d{4,})') { $Matches[1] } + }) - if ($null -ne $matchingModel -and -not [string]::IsNullOrEmpty($matchingModel.Url)) { - $downloadURL = $matchingModel.Url - $downloadFileName = $downloadURL | Split-Path -Leaf - $catalogVersion = if ($matchingModel.ReleaseDate) { $matchingModel.ReleaseDate } else { '' } - Write-DATLogEntry -Value "[$OEM] Found Surface driver (OEM catalog): $downloadFileName (ReleaseDate: $catalogVersion)" -Severity 1 - } else { - throw "No matching Microsoft driver package found for $Model ($WindowsVersion)" + if ($SoftPaqIDs.Count -gt 0) { + $DiscoveryPlatformID = $PlatformID + Write-DATLogEntry -Value "[HP] Found $($SoftPaqIDs.Count) SoftPaqs for platform ${PlatformID}:" -Severity 1 + foreach ($line in ($allLines | Where-Object { $_ -match '^\s+(?:sp)?(\d{4,})' })) { + if ($line -match '^\s+(?:sp)?(\d{4,})') { + $spName = ($line -replace '^\s*(?:sp)?\d{4,}\s*[-:]*\s*', '').Trim() + if ($spName) { $spNameMap[$Matches[1]] = $spName } + } + Write-DATLogEntry -Value "-- Download required - $($line.Trim())" -Severity 1 + } + break + } else { + # Log the output for debugging + foreach ($line in $allLines) { + Write-DATLogEntry -Value "[HP] Discovery output: $line" -Severity 1 + } + Write-DATLogEntry -Value "[HP] No SoftPaqs found for platform $PlatformID -- trying next" -Severity 2 + } + } catch { + if ($_.Exception.Message -match 'does not belong to the set') { + Write-DATLogEntry -Value "[HP] HPCMSL does not support OSVer '$WindowsBuild'. Update HPCMSL: Install-Module HPCMSL -Force -AllowClobber" -Severity 3 + throw "HPCMSL does not support OS version '$WindowsBuild'. Update HPCMSL to the latest version: Install-Module -Name HPCMSL -Force -AllowClobber" + } + Write-DATLogEntry -Value "[HP] WhatIf failed for ${PlatformID}: $($_.Exception.Message)" -Severity 2 } } - } - "Acer" { - $AcerLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Acer" }).Link | - Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 - if ([string]::IsNullOrEmpty($AcerLink)) { throw "Acer catalog URL not found in OEM links" } - - $AcerFile = [string]($AcerLink | Split-Path -Leaf) - $AcerFilePath = Join-Path $TempDirectory $AcerFile - if (-not (Test-Path $AcerFilePath)) { - Write-DATLogEntry -Value "[$OEM] Downloading Acer catalog..." -Severity 1 - Write-DATLogEntry -Value "[$OEM] Catalog download path: $AcerFilePath" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Acer driver catalog..." -Type String - Invoke-CatalogDownload -Uri $AcerLink -OutFile $AcerFilePath - } else { - Write-DATLogEntry -Value "[$OEM] Using cached Acer catalog: $AcerFilePath" -Severity 1 + if ($SoftPaqIDs.Count -eq 0) { + throw "No HP SoftPaqs found for any platform ID: $($SKUList -join ', ')" } - [xml]$AcerModelXML = Get-Content -Path $AcerFilePath - $AcerDrivers = $AcerModelXML.ModelList.Model - $WinVer = "Win" + "$($WindowsVersion.Split(' ')[1])" - - Write-DATLogEntry -Value "[$OEM] Searching catalog for: Name='$Model' OS='$WinVer' Build='$WindowsBuild'" -Severity 1 - - $matchingModel = $AcerDrivers | Where-Object { - $_.Name -eq $Model -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer - } | Select-Object -First 1 - - if ($null -eq $matchingModel) { - # Fuzzy fallback -- partial name match - Write-DATLogEntry -Value "[$OEM] Exact match not found, attempting partial name match for '$Model'" -Severity 2 - $matchingModel = $AcerDrivers | Where-Object { - $_.Name -like "*$Model*" -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer - } | Select-Object -First 1 + # Deduplicate the discovered SoftPaq list, preserving first-seen order. HPCMSL's + # WhatIf output can list the same SoftPaq more than once (e.g. HP Hotkey Support, + # Intel Video Driver), which previously caused the same package to be downloaded + # and extracted twice, inflated the fingerprint, and broke the progress counter + # (the per-id process map collides on duplicate concurrent downloads). (#810) + $seenSoftPaqIds = [System.Collections.Generic.HashSet[string]]::new() + $dedupedSoftPaqIDs = [System.Collections.Generic.List[string]]::new() + foreach ($spId in $SoftPaqIDs) { + if ($seenSoftPaqIds.Add($spId)) { $dedupedSoftPaqIDs.Add($spId) } } - - if ($null -ne $matchingModel -and $null -ne $matchingModel.SCCM) { - $downloadURL = $matchingModel.SCCM.'#text' - if ($downloadURL -is [array]) { $downloadURL = $downloadURL[0] } - if ([string]::IsNullOrEmpty($downloadURL)) { $downloadURL = [string]$matchingModel.SCCM } - $downloadFileName = $downloadURL | Split-Path -Leaf - Write-DATLogEntry -Value "[$OEM] Found driver pack: $downloadFileName" -Severity 1 - Write-DATLogEntry -Value "[$OEM] Resolved download URL: $downloadURL" -Severity 1 - } else { - # Log all available models/builds to aid diagnostics - $available = $AcerDrivers | Where-Object { $_.SCCM.OS -eq $WinVer } | Select-Object -ExpandProperty Name -Unique - Write-DATLogEntry -Value "[$OEM] Available models for ${WinVer}: $($available -join ', ')" -Severity 2 - throw "No matching Acer driver package found for $Model ($WinVer $WindowsBuild)" + $duplicateSoftPaqCount = $SoftPaqIDs.Count - $dedupedSoftPaqIDs.Count + if ($duplicateSoftPaqCount -gt 0) { + $dupNoun = if ($duplicateSoftPaqCount -eq 1) { 'entry' } else { 'entries' } + Write-DATLogEntry -Value "[HP] Removed $duplicateSoftPaqCount duplicate SoftPaq $dupNoun from discovery list (SP$($dedupedSoftPaqIDs -join ', SP'))" -Severity 2 } - } - default { - throw "Unsupported OEM: $OEM" - } - } - } # end if ([string]::IsNullOrEmpty($downloadURL)) -- skip OEM lookup when CatalogDownloadURL provided + $SoftPaqIDs = @($dedupedSoftPaqIDs) - if ([string]::IsNullOrEmpty($downloadURL)) { - throw "Failed to resolve download URL for $OEM $Model" - } + # ── SoftPaq fingerprint check: skip rebuild when the list is unchanged ── + # The discovered SoftPaq set is fingerprinted and compared against the stored + # manifest. If unchanged (and not forced), the existing package is retained and + # we short-circuit before any download/extract/packaging work. + $spManifestKey = Get-DATHPSoftPaqManifestKey -Model $Model -OSVersion $WindowsVersion -Build $WindowsBuild -Architecture $Architecture + $spFingerprint = Get-DATSoftPaqFingerprint -SoftPaqIds $SoftPaqIDs + $spManifest = Get-DATHPSoftPaqManifest + $spEntry = $spManifest[$spManifestKey] + $spListUnchanged = ($null -ne $spEntry) -and (-not [string]::IsNullOrEmpty($spFingerprint)) -and ("$($spEntry.fingerprint)" -eq $spFingerprint) - # If no hash was captured from the OEM-specific catalog, try the DAT API catalog - if ([string]::IsNullOrEmpty($catalogFileHash)) { - try { - $datCatalog = Get-DATDriverCatalog - if ($datCatalog) { - $hashMatch = $datCatalog | Where-Object { - $_.DownloadURL -eq $downloadURL -and - -not [string]::IsNullOrEmpty($_.FileHash) - } | Select-Object -First 1 - if ($hashMatch) { - $catalogFileHash = $hashMatch.FileHash - $catalogHashMethod = if (-not [string]::IsNullOrEmpty($hashMatch.HashMethod)) { $hashMatch.HashMethod } else { 'SHA256' } - Write-DATLogEntry -Value "[$OEM] File hash retrieved from DAT API catalog ($catalogHashMethod): $catalogFileHash" -Severity 1 - } - } - } catch { - Write-DATLogEntry -Value "[$OEM] DAT API catalog hash lookup skipped: $($_.Exception.Message)" -Severity 2 - } + if ($spListUnchanged) { + # Verify the previously built package still exists before skipping. For on-disk + # delivery modes this is a file check; for Intune/ConfigMgr we confirm the stored + # remote reference (app id / package name) is still present in the live environment. + $packageStillExists = $true + $missingReason = '' + switch ($RunningMode) { + 'Intune' { + if ($VerifyRemoteExistence) { + $storedRef = "$($spEntry.intuneAppId)" + if ([string]::IsNullOrEmpty($storedRef)) { + $packageStillExists = $false; $missingReason = 'no Intune application id was recorded' + } elseif ($ExistingPackageIds -notcontains $storedRef) { + $packageStillExists = $false; $missingReason = "Intune application $storedRef no longer exists" + } + } + } + 'Configuration Manager' { + if ($VerifyRemoteExistence) { + $storedRef = "$($spEntry.configMgrPackageId)" + if ([string]::IsNullOrEmpty($storedRef)) { + $packageStillExists = $false; $missingReason = 'no ConfigMgr package was recorded' + } elseif ($ExistingPackageIds -notcontains $storedRef) { + $packageStillExists = $false; $missingReason = "ConfigMgr package $storedRef no longer exists" + } + } + } + 'WIM Package Only' { + $wimFinalPath = Join-Path $PackageDestination "$OEM\$Model\$WindowsVersion $WindowsBuild\DriverPackage.wim" + if (-not (Test-Path -LiteralPath $wimFinalPath)) { + $packageStillExists = $false; $missingReason = 'the WIM package is missing' + } + } + 'Download Only' { + if (-not ((Test-Path -LiteralPath $DownloadDestination) -and (@(Get-ChildItem -LiteralPath $DownloadDestination -File -ErrorAction SilentlyContinue).Count -gt 0))) { + $packageStillExists = $false; $missingReason = 'the downloaded files are missing' + } + } + } + + if ($ForceRebuild) { + Write-DATLogEntry -Value "[HP] SoftPaq list unchanged for $Model but Force Update is set -- rebuilding" -Severity 1 + } elseif (-not $packageStillExists) { + Write-DATLogEntry -Value "[HP] SoftPaq list unchanged for $Model but $missingReason -- rebuilding" -Severity 1 + } else { + $spStableVersion = "$($spEntry.version)" + Write-DATLogEntry -Value "[HP] SoftPaq list unchanged since last build for $Model ($($SoftPaqIDs.Count) SoftPaqs, v$spStableVersion) -- skipping rebuild" -Severity 1 -UpdateUI + # Surface the matched SoftPaqs, fingerprint and the verified package reference so + # the user can see exactly what was compared and which existing package was retained. + $spSortedIds = @($SoftPaqIDs | Sort-Object { [long]$_ }) + $spShortFingerprint = if (-not [string]::IsNullOrEmpty($spFingerprint)) { $spFingerprint.Substring(0, [Math]::Min(8, $spFingerprint.Length)) } else { 'n/a' } + Write-DATLogEntry -Value "[HP] Matched SoftPaqs (SP$($spSortedIds -join ', SP')) | fingerprint $spShortFingerprint" -Severity 1 + switch ($RunningMode) { + 'Intune' { + if ($VerifyRemoteExistence -and -not [string]::IsNullOrEmpty($spEntry.intuneAppId)) { + Write-DATLogEntry -Value "[HP] Verified existing Intune application $($spEntry.intuneAppId) still present -- retaining package" -Severity 1 + } + } + 'Configuration Manager' { + if ($VerifyRemoteExistence -and -not [string]::IsNullOrEmpty($spEntry.configMgrPackageId)) { + Write-DATLogEntry -Value "[HP] Verified existing ConfigMgr package $($spEntry.configMgrPackageId) still present -- retaining package" -Severity 1 + } + } + default { + Write-DATLogEntry -Value "[HP] Verified existing driver package on disk -- retaining package" -Severity 1 + } + } + try { + $spEntry | Add-Member -NotePropertyName lastVerified -NotePropertyValue (Get-Date -Format 'o') -Force + $spEntry | Add-Member -NotePropertyName lastChecked -NotePropertyValue (Get-Date -Format 'o') -Force + $spManifest[$spManifestKey] = $spEntry + [void](Save-DATHPSoftPaqManifest -Manifest $spManifest) + } catch { + Write-DATLogEntry -Value "[HP] Failed to update SoftPaq manifest verification time: $($_.Exception.Message)" -Severity 2 + } + $global:DATSoftPaqBuildSkipped = $true + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return $spStableVersion + } + } + + # Version stamp for this build: reuse the stored version when the SoftPaq set is + # unchanged (e.g. a forced rebuild of the same set), otherwise assign a fresh one. + $spBuildVersion = if ($spListUnchanged) { "$($spEntry.version)" } else { (Get-Date -Format 'ddMMyyyy') } + + $totalSoftPaqs = $SoftPaqIDs.Count + # Report the extra individual driver downloads (N SoftPaqs, not one pack) so the + # "Downloads Required" tile counts them: base estimate already counts 1 driver. + if ($totalSoftPaqs -gt 1) { + $prevExtra = 0 + try { $prevExtra = [int](Get-ItemProperty -Path $global:RegPath -Name 'LatestDownloadsExtra' -ErrorAction SilentlyContinue).LatestDownloadsExtra } catch { $prevExtra = 0 } + Set-DATRegistryValue -Name "LatestDownloadsExtra" -Value "$($prevExtra + ($totalSoftPaqs - 1))" -Type String + } + Set-DATRegistryValue -Name "DownloadBytes" -Value "0" -Type String + Set-DATRegistryValue -Name "BytesTransferred" -Value "0" -Type String + + # ── Step 2: Download SoftPaqs in parallel (separate processes) ──────── + # Check for abort before starting downloads + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + + Write-DATLogEntry -Value "[HP] Downloading $totalSoftPaqs SoftPaqs ($HPConcurrentDownloads concurrent processes)..." -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading $totalSoftPaqs HP SoftPaqs..." -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Download" -Type String + + $DownloadStartTime = Get-Date + + # Build queue of SoftPaqs to download (skip cached) + $downloadQueue = [System.Collections.Generic.Queue[string]]::new() + $cachedCount = 0 + foreach ($spId in $SoftPaqIDs) { + $destFile = Join-Path $HPTempDirectory "SP$spId.exe" + if (Test-Path $destFile) { + Write-DATLogEntry -Value "[HP] SoftPaq SP$spId already cached -- skipping" -Severity 1 + $cachedCount++ + } else { + $downloadQueue.Enqueue($spId) + } + } + $completedDownloads = $cachedCount + $failedDownloads = @() + $activeProcs = @{} # spId -> Process object + $tempScripts = @{} # spId -> temp .ps1 path + + # Resolve powershell executable path + $pwshExe = if ($PSVersionTable.PSVersion.Major -ge 7) { + (Get-Process -Id $PID).Path + } else { + "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + } + + try { + while ($downloadQueue.Count -gt 0 -or $activeProcs.Count -gt 0) { + # Check for abort + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { + Write-DATLogEntry -Value "[HP] Abort detected -- killing download processes" -Severity 2 + throw "HP download aborted by user" + } + + # Fill slots up to concurrency limit + while ($activeProcs.Count -lt $HPConcurrentDownloads -and $downloadQueue.Count -gt 0) { + $spId = $downloadQueue.Dequeue() + # Validate: SoftPaq IDs are always 4-8 digits; reject anything else to prevent command injection + if ($spId -notmatch '^\d{4,8}$') { + Write-DATLogEntry -Value "[HP][Warning] Skipping invalid SoftPaq ID: '$spId'" -Severity 2 + continue + } + $savePath = Join-Path $HPTempDirectory "SP$spId.exe" + $tmpScript = Join-Path ([System.IO.Path]::GetTempPath()) "DAT_SP_${spId}_$([System.IO.Path]::GetRandomFileName()).ps1" + $safeQuotedPath = $savePath -replace "'", "''" + Set-Content -Path $tmpScript -Value "Import-Module HPCMSL -Force`nGet-Softpaq -Number $spId -SaveAs '$safeQuotedPath' -MaxRetries 3 -Quiet" -Encoding UTF8 + $proc = Start-Process -FilePath $pwshExe -ArgumentList "-NoProfile", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", $tmpScript ` + -WindowStyle Hidden -PassThru + $activeProcs[$spId] = $proc + $tempScripts[$spId] = $tmpScript + Write-DATLogEntry -Value "[HP] Started SP$spId download (PID $($proc.Id))" -Severity 1 + } + + # Check for completed processes + $finishedIds = @($activeProcs.Keys | Where-Object { $activeProcs[$_].HasExited }) + foreach ($spId in $finishedIds) { + $proc = $activeProcs[$spId] + $activeProcs.Remove($spId) + # Clean up temp script file + if ($tempScripts.ContainsKey($spId)) { + Remove-Item -Path $tempScripts[$spId] -ErrorAction SilentlyContinue + $tempScripts.Remove($spId) + } + $savePath = Join-Path $HPTempDirectory "SP$spId.exe" + if ($proc.ExitCode -eq 0 -and (Test-Path $savePath)) { + $completedDownloads++ + Write-DATLogEntry -Value "[HP] SP$spId download completed (PID $($proc.Id))" -Severity 1 + } else { + $failedDownloads += $spId + Write-DATLogEntry -Value "[HP] SP$spId download failed (exit code $($proc.ExitCode), PID $($proc.Id))" -Severity 3 + } + } + + # Update progress using actual bytes on disk + $spFiles = Get-ChildItem -Path $HPTempDirectory -Filter "SP*.exe" -ErrorAction SilentlyContinue + $downloadedBytes = ($spFiles | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum + if ($null -eq $downloadedBytes) { $downloadedBytes = [long]0 } + $downloadedMB = [math]::Round($downloadedBytes / 1MB, 2) + + # Estimate total size from average file size + $completedFiles = @($spFiles | Where-Object { $_.Length -gt 0 }) + if ($completedDownloads -gt 0 -and $completedFiles.Count -gt 0) { + $avgFileSize = $downloadedBytes / [math]::Max(1, $completedFiles.Count) + $estimatedTotal = [long]($avgFileSize * $totalSoftPaqs) + Set-DATRegistryValue -Name "DownloadBytes" -Value "$estimatedTotal" -Type String + } + Set-DATRegistryValue -Name "BytesTransferred" -Value "$downloadedBytes" -Type String + Set-DATRegistryValue -Name "DownloadSize" -Value "$downloadedMB MB" -Type String + + $elapsed = ((Get-Date) - $DownloadStartTime).TotalSeconds + if ($elapsed -gt 0 -and $downloadedBytes -gt 0) { + $speed = [math]::Round(($downloadedMB / $elapsed), 2) + Set-DATRegistryValue -Name "DownloadSpeed" -Value "$speed MB/s" -Type String + } + + Set-DATRegistryValue -Name "RunningMessage" -Value "SoftPaq $completedDownloads of $totalSoftPaqs ($($activeProcs.Count) active) -- $downloadedMB MB" -Type String + + Start-Sleep -Seconds 2 + } + } finally { + # Kill all active download processes on abort or error + foreach ($spId in @($activeProcs.Keys)) { + $proc = $activeProcs[$spId] + if (-not $proc.HasExited) { + try { $proc.Kill() } catch { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + Write-DATLogEntry -Value "[HP] Killed SP$spId download process (PID $($proc.Id))" -Severity 2 + } + if ($tempScripts.ContainsKey($spId)) { + Remove-Item -Path $tempScripts[$spId] -ErrorAction SilentlyContinue + } + } + # Kill any orphaned SoftPaq self-extracting processes + Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.ProcessName -match '^SP\d+$' + } | ForEach-Object { + try { $_.Kill() } catch { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } + } + } + + # Final download count + $completedDownloads = $totalSoftPaqs - $failedDownloads.Count + Write-DATLogEntry -Value "[HP] Downloads complete: $completedDownloads of $totalSoftPaqs succeeded" -Severity 1 + + if ($failedDownloads.Count -gt 0) { + Write-DATLogEntry -Value "[HP] Failed SoftPaqs: $($failedDownloads -join ', ') -- retrying sequentially..." -Severity 2 + Set-DATRegistryValue -Name "RunningMessage" -Value "Retrying $($failedDownloads.Count) failed SoftPaqs..." -Type String + + $retryFailed = @() + foreach ($spId in $failedDownloads) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + + $savePath = Join-Path $HPTempDirectory "SP$spId.exe" + # Remove any partial file from the first attempt + if (Test-Path $savePath) { Remove-Item $savePath -Force -ErrorAction SilentlyContinue } + + Set-DATRegistryValue -Name "RunningMessage" -Value "Retrying SoftPaq SP$spId..." -Type String + Write-DATLogEntry -Value "[HP] Retrying SP$spId download..." -Severity 1 + + try { + $null = Get-Softpaq -Number $spId -SaveAs $savePath -MaxRetries 3 -ErrorAction Stop + $completedDownloads++ + Write-DATLogEntry -Value "[HP] SP$spId retry succeeded" -Severity 1 + } catch { + $retryFailed += $spId + Write-DATLogEntry -Value "[HP] SP$spId retry failed: $($_.Exception.Message)" -Severity 3 + } + } + + $failedDownloads = @($retryFailed) + if ($failedDownloads.Count -gt 0) { + Write-DATLogEntry -Value "[HP] Permanently failed SoftPaqs after retry: $($failedDownloads -join ', ')" -Severity 3 + } else { + Write-DATLogEntry -Value "[HP] All SoftPaqs downloaded successfully after retry" -Severity 1 + } + } + + # Remove failed IDs from the processing list + $successfulIDs = @($SoftPaqIDs | Where-Object { $_ -notin $failedDownloads }) + if ($successfulIDs.Count -eq 0) { + throw "All $totalSoftPaqs SoftPaq downloads failed for $Model" + } + + # ── Step 3: Extract and copy INF-targeted drivers ───────────────────── + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + + Set-DATRegistryValue -Name "RunningMode" -Value "Extracting" -Type String + Write-DATLogEntry -Value "[HP] Extracting $($successfulIDs.Count) SoftPaqs and copying drivers..." -Severity 1 + + # OS identifier for INF path lookup + $OsId = if ($HPOS -eq 'Win11') { 'W11' } else { 'WT64' } + $fullInfPathName = "$($OsId)_$($WindowsBuild.ToUpper())_INFPath" + $fallbackInfPathName = "$($OsId)_INFPath" + Write-DATLogEntry -Value "[HP] INF path keys: primary=$fullInfPathName, fallback=$fallbackInfPathName" -Severity 1 + + $extractedCount = 0 + $skippedCount = 0 + # Rich per-SoftPaq detail for the WIM manifest (name/version/category/date). + $hpComponents = New-Object System.Collections.Generic.List[object] + + foreach ($spId in $successfulIDs) { + $abortReg = Get-ItemProperty -Path $global:RegPath -ErrorAction SilentlyContinue + if ($abortReg.RunningState -eq 'Aborted') { throw "HP download aborted by user" } + + $spFile = Join-Path $HPTempDirectory "SP$spId.exe" + $spExtractDir = Join-Path $HPExtractDir "$spId" + $spStagingDir = Join-Path $HPStagingDir "$spId" + + $extractedCount++ + Set-DATRegistryValue -Name "RunningMessage" -Value "Extracting SoftPaq SP$spId ($extractedCount of $($successfulIDs.Count))..." -Type String + Set-DATRegistryValue -Name "BytesTransferred" -Value "$extractedCount" -Type String + Set-DATRegistryValue -Name "DownloadBytes" -Value "$($successfulIDs.Count)" -Type String + + if (-not (Test-Path $spFile)) { + Write-DATLogEntry -Value "[HP] SP$spId.exe not found -- skipping" -Severity 2 + $skippedCount++ + continue + } + + # Extract SoftPaq silently (timeout after 5 minutes to avoid hangs) + Write-DATLogEntry -Value "[HP] Extracting SP$spId..." -Severity 1 + if (-not (Test-Path $spExtractDir)) { New-Item -Path $spExtractDir -ItemType Directory -Force | Out-Null } + try { + $extractProc = Start-Process -FilePath $spFile -ArgumentList "-e", "-f `"$spExtractDir`"", "-s" ` + -WindowStyle Hidden -PassThru + if (-not $extractProc.WaitForExit(300000)) { + try { $extractProc.Kill() } catch {} + Write-DATLogEntry -Value "[HP] SP$spId extraction timed out after 5 minutes -- skipping" -Severity 3 + $skippedCount++ + continue + } + if ($extractProc.ExitCode -ne 0) { + Write-DATLogEntry -Value "[HP] SP$spId extraction exited with code $($extractProc.ExitCode)" -Severity 2 + } + } catch { + Write-DATLogEntry -Value "[HP] SP$spId extraction failed: $($_.Exception.Message)" -Severity 3 + $skippedCount++ + continue + } + + # Get metadata for INF path mapping + try { + $metadata = Get-HPSoftpaqMetadata -Number $spId -MaxRetries 3 + } catch { + Write-DATLogEntry -Value "[HP] SP$spId metadata lookup failed: $($_.Exception.Message) -- copying all extracted content" -Severity 2 + $hpComponents.Add([ordered]@{ + id = "SP$spId" + name = if ($spNameMap.ContainsKey($spId)) { $spNameMap[$spId] } else { "SoftPaq $spId" } + version = $null + category = $null + releaseDate = $null + type = 'SoftPaq' + }) + # Fallback: copy everything + if (-not (Test-Path $spStagingDir)) { New-Item -Path $spStagingDir -ItemType Directory -Force | Out-Null } + Copy-Item "$spExtractDir\*" $spStagingDir -Recurse -Force -ErrorAction SilentlyContinue + continue + } + + $hpComponents.Add([ordered]@{ + id = "SP$spId" + name = if ($spNameMap.ContainsKey($spId)) { $spNameMap[$spId] } else { Get-DATHPMetaValue -Meta $metadata -Keys @('US', 'Title') } + version = Get-DATHPMetaValue -Meta $metadata -Keys @('Version') + category = Get-DATHPMetaValue -Meta $metadata -Keys @('Category') + releaseDate = Get-DATHPMetaValue -Meta $metadata -Keys @('DateReleased', 'ReleaseDate', 'Date') + type = 'SoftPaq' + }) + + if ($metadata.ContainsKey('Devices_INFPath')) { + # Determine which INF path key to use + $infPathName = if ($metadata.Devices_INFPath.ContainsKey($fullInfPathName)) { + $fullInfPathName + } elseif ($metadata.Devices_INFPath.ContainsKey($fallbackInfPathName)) { + $fallbackInfPathName + } else { $null } + + if ($infPathName) { + $infPaths = @($metadata.Devices_INFPath[$infPathName]) + if (-not (Test-Path $spStagingDir)) { New-Item -Path $spStagingDir -ItemType Directory -Force | Out-Null } + foreach ($infPath in $infPaths) { + $infPath = $infPath.TrimStart('.\') + $absoluteInfPath = Join-Path $spExtractDir $infPath + if (Test-Path $absoluteInfPath) { + Write-DATLogEntry -Value "[HP] SP$spId copying INF path: $infPath" -Severity 1 + Copy-Item $absoluteInfPath $spStagingDir -Recurse -Force -ErrorAction SilentlyContinue + } else { + Write-DATLogEntry -Value "[HP] SP$spId INF path not found: $absoluteInfPath" -Severity 2 + } + } + } else { + Write-DATLogEntry -Value "[HP] SP$spId missing INF path key ($fullInfPathName / $fallbackInfPathName) -- skipping" -Severity 2 + $skippedCount++ + } + } else { + Write-DATLogEntry -Value "[HP] SP$spId is not DPB compliant (no Devices_INFPath) -- skipping" -Severity 2 + $skippedCount++ + } + } + + # Clean up extracted temp files (keep staging) + Remove-Item -Path "$HPExtractDir\*" -Recurse -Force -ErrorAction SilentlyContinue + + $stagedFiles = (Get-ChildItem -Path $HPStagingDir -Recurse -File -ErrorAction SilentlyContinue).Count + Write-DATLogEntry -Value "[HP] Extraction complete: $stagedFiles driver files staged, $skippedCount SoftPaqs skipped" -Severity 1 + + if ($stagedFiles -eq 0) { + throw "No driver files were extracted from HP SoftPaqs for $Model" + } + + # ── Step 4: Package (WIM creation via common path) ──────────────────── + # HP now flows into common packaging like other OEMs. + # Create a sentinel file so Invoke-DATDriverFilePackaging can find the staging dir. + # We bypass the common download+extract and call packaging directly. + if ($RunningMode -ne "Download Only" -or $ExtractDownloadOnlyContent) { + $packageDest = if (-not [string]::IsNullOrEmpty($PackageDestination)) { $PackageDestination } else { $DownloadDestination } + $packagingPlatform = if ($RunningMode -eq 'WIM Package Only') { 'WIM Package Only' } + elseif ($RunningMode -eq 'Configuration Manager (Offline)') { 'Configuration Manager' } + else { $RunningMode } + + # Invoke-DATDriverFilePackaging expects a single file to extract. + # For HP, the drivers are already extracted to $HPStagingDir. + # Call the packaging function with a virtual ".dir" path -- the function + # will detect the HP staging directory and skip extraction. + Set-DATRegistryValue -Name "RunningMessage" -Value "Creating WIM package for HP $Model..." -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Packaging" -Type String + Write-DATLogEntry -Value "[HP] Creating WIM package from staging directory..." -Severity 1 + + $null = Invoke-DATDriverFilePackaging -FilePath $HPStagingDir -OEM $OEM -Model $Model ` + -OS "$WindowsVersion $WindowsBuild" -Destination $packageDest -Platform $packagingPlatform ` + -CustomDriverPath $CustomDriverPath -DownloadOnlyExtractDestination $DownloadDestination ` + -PackageVersion $spBuildVersion -Components $hpComponents.ToArray() + } + + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + Write-DATLogEntry -Value "[HP] Driver package process completed successfully" -Severity 1 -UpdateUI + + # Persist the SoftPaq manifest so an unchanged list skips rebuild next time. + try { + $spManifestSave = Get-DATHPSoftPaqManifest + $existingRef = $spManifestSave[$spManifestKey] + $spManifestSave[$spManifestKey] = [PSCustomObject]@{ + platformId = "$DiscoveryPlatformID" + softPaqIds = @($SoftPaqIDs | Sort-Object { [long]$_ }) + fingerprint = $spFingerprint + version = $spBuildVersion + lastBuilt = (Get-Date -Format 'o') + lastChecked = (Get-Date -Format 'o') + lastVerified = (Get-Date -Format 'o') + intuneAppId = if ($existingRef) { "$($existingRef.intuneAppId)" } else { '' } + configMgrPackageId = if ($existingRef) { "$($existingRef.configMgrPackageId)" } else { '' } + } + [void](Save-DATHPSoftPaqManifest -Manifest $spManifestSave) + Write-DATLogEntry -Value "[HP] SoftPaq manifest updated for $Model (v$spBuildVersion, $($SoftPaqIDs.Count) SoftPaqs)" -Severity 1 + } catch { + Write-DATLogEntry -Value "[HP] Failed to update SoftPaq manifest: $($_.Exception.Message)" -Severity 2 + } + + # HP SoftPaqs mode handles its own multi-file download -- skip common single-file download path + return $spBuildVersion + } # end else (Individual SoftPaqs mode) + } + "Lenovo" { + # Latest Drivers (Model-XML catalog) mode: resolve, download, extract and package the + # newest individual driver packages, then return -- bypassing the SCCM pack resolution. + $LenovoBuildType = (Get-ItemProperty -Path $global:RegPath -Name 'HPDriverPackSource' -ErrorAction SilentlyContinue).HPDriverPackSource + if ($LenovoBuildType -eq 'SoftPaqs') { + return (Invoke-DATLenovoLatestDriverPackage -Model $Model -SystemSKU $SystemSKU ` + -WindowsVersion $WindowsVersion -WindowsBuild $WindowsBuild -Architecture $Architecture ` + -DownloadDestination $DownloadDestination -PackageDestination $PackageDestination ` + -TempDirectory $TempDirectory -RunningMode $RunningMode -CustomDriverPath $CustomDriverPath ` + -ExtractDownloadOnlyContent $ExtractDownloadOnlyContent -OEMLinks $OEMLinks ` + -ForceRebuild:$ForceRebuild -VerifyRemoteExistence:$VerifyRemoteExistence -ExistingPackageIds $ExistingPackageIds) + } + $LenovoLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Lenovo" }).Link | + Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($LenovoLink)) { throw "Lenovo catalog URL not found in OEM links" } + + $LenovoFile = [string]($LenovoLink | Split-Path -Leaf) + $LenovoFilePath = Join-Path $TempDirectory $LenovoFile + + if (-not (Test-Path $LenovoFilePath)) { + Write-DATLogEntry -Value "[$OEM] Downloading Lenovo catalog..." -Severity 1 + Write-DATLogEntry -Value "[$OEM] Catalog download path: $LenovoFilePath" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Lenovo driver catalog..." -Type String + Invoke-CatalogDownload -Uri $LenovoLink -OutFile $LenovoFilePath + } else { + Write-DATLogEntry -Value "[$OEM] Using cached Lenovo catalog: $LenovoFilePath" -Severity 1 + } + + [xml]$LenovoModelXML = Get-Content -Path $LenovoFilePath + $LenovoDrivers = $LenovoModelXML.ModelList.Model + $WinVer = "Win" + "$($WindowsVersion.Split(' ')[1])" + + $matchingModel = $LenovoDrivers | Where-Object { + $_.Name -eq $Model -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer + } | Select-Object -First 1 + + if ($null -ne $matchingModel -and $null -ne $matchingModel.SCCM) { + $sccmNode = $matchingModel.SCCM | Where-Object { $_.Version -eq $WindowsBuild -and $_.OS -eq $WinVer } | Select-Object -First 1 + if ($null -eq $sccmNode) { $sccmNode = $matchingModel.SCCM } + $catalogVersion = if ($sccmNode.date) { $sccmNode.date } else { '' } + $downloadURL = $sccmNode.'#text' + if ($downloadURL -is [array]) { $downloadURL = $downloadURL[0] } + if ([string]::IsNullOrEmpty($downloadURL)) { $downloadURL = [string]$sccmNode } + $downloadFileName = $downloadURL | Split-Path -Leaf + Write-DATLogEntry -Value "[$OEM] Found SCCM pack: $downloadFileName" -Severity 1 + Write-DATLogEntry -Value "[$OEM] Resolved download URL: $downloadURL" -Severity 1 + + # Check for supplemental NVIDIA GFX driver package + $gfxNode = $matchingModel.GFX | Where-Object { + $_.os -eq $WinVer -and $_.version -eq $WindowsBuild + } | Select-Object -First 1 + + $gfxDownloadURL = $null + $gfxDownloadFileName = $null + if ($null -ne $gfxNode) { + $gfxDownloadURL = $gfxNode.'#text' + if ([string]::IsNullOrEmpty($gfxDownloadURL)) { $gfxDownloadURL = [string]$gfxNode } + $gfxDownloadFileName = $gfxDownloadURL | Split-Path -Leaf + $gfxBrand = $gfxNode.brand + Write-DATLogEntry -Value "[$OEM] Supplemental $gfxBrand GFX package found: $gfxDownloadFileName" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "$Model has supplemental $gfxBrand GFX drivers - both packages will be downloaded" -Type String + } + } else { + throw "No matching Lenovo driver package found for $Model ($WinVer $WindowsBuild)" + } + } + "Microsoft" { + # Try DAT API catalog first (has the most current URLs) + $datApiResolved = $false + try { + $driverCatalog = Get-DATDriverCatalog + if ($driverCatalog) { + $normalizedArch = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $matchingEntry = $driverCatalog | Where-Object { + $_.Manufacturer -eq 'Microsoft' -and + $_.DisplayName -eq $Model -and + $_.SupportedOS -match $WindowsVersion -and + $_.SupportedArchitecture -eq $normalizedArch -and + -not [string]::IsNullOrEmpty($_.DownloadURL) -and + $_.DownloadURL -match '\.(msi|exe|cab|zip|wim)(\?|$)' + } | Sort-Object { try { [datetime]$_.ReleaseDate } catch { [datetime]::MinValue } } -Descending | Select-Object -First 1 + + if ($matchingEntry) { + $downloadURL = $matchingEntry.DownloadURL + $downloadFileName = ($downloadURL -split '\?')[0] | Split-Path -Leaf + $catalogVersion = if ($matchingEntry.Version) { $matchingEntry.Version } elseif ($matchingEntry.ReleaseDate) { $matchingEntry.ReleaseDate } else { '' } + if (-not [string]::IsNullOrEmpty($matchingEntry.FileHash)) { + $catalogFileHash = $matchingEntry.FileHash + $catalogHashMethod = if (-not [string]::IsNullOrEmpty($matchingEntry.HashMethod)) { $matchingEntry.HashMethod } else { 'SHA256' } + } + Write-DATLogEntry -Value "[$OEM] Resolved from DAT API catalog: $downloadFileName (Version: $catalogVersion)" -Severity 1 + $datApiResolved = $true + } else { + Write-DATLogEntry -Value "[$OEM] Model '$Model' not found in DAT API catalog for $WindowsVersion $normalizedArch -- trying OEM catalog" -Severity 2 + } + } + } catch { + Write-DATLogEntry -Value "[$OEM] DAT API catalog lookup failed, falling back to OEM catalog: $($_.Exception.Message)" -Severity 2 + } + + # Fall back to GitHub-hosted OEM catalog if DAT API didn't resolve + if (-not $datApiResolved) { + $MicrosoftCatalogPath = Join-Path $TempDirectory "OSDCatalogMicrosoftDriverPack.json" + if (-not (Test-Path $MicrosoftCatalogPath)) { + $MicrosoftCatalogSource = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/OSDCatalogMicrosoftDriverPack.json" + Write-DATLogEntry -Value "[$OEM] Downloading Microsoft OEM catalog..." -Severity 1 + Write-DATLogEntry -Value "[$OEM] Catalog download path: $MicrosoftCatalogPath" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Microsoft driver catalog..." -Type String + $proxyParams = Get-DATWebRequestProxy + Invoke-WebRequest -Uri $MicrosoftCatalogSource -OutFile $MicrosoftCatalogPath -UseBasicParsing -TimeoutSec 30 @proxyParams + } else { + Write-DATLogEntry -Value "[$OEM] Using cached Microsoft OEM catalog: $MicrosoftCatalogPath" -Severity 1 + } + + $MSModelList = Get-Content -Path $MicrosoftCatalogPath -Raw | ConvertFrom-Json + $MSArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'amd64' } + $matchingModel = $MSModelList | Where-Object { + $_.Model -eq $Model -and $_.OperatingSystem -match $WindowsVersion -and $_.OSArchitecture -eq $MSArchFilter + } | Select-Object -First 1 + + if ($null -ne $matchingModel -and -not [string]::IsNullOrEmpty($matchingModel.Url)) { + $downloadURL = $matchingModel.Url + $downloadFileName = $downloadURL | Split-Path -Leaf + $catalogVersion = if ($matchingModel.ReleaseDate) { $matchingModel.ReleaseDate } else { '' } + Write-DATLogEntry -Value "[$OEM] Found Surface driver (OEM catalog): $downloadFileName (ReleaseDate: $catalogVersion)" -Severity 1 + } else { + throw "No matching Microsoft driver package found for $Model ($WindowsVersion)" + } + } + } + "Acer" { + $AcerLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Acer" }).Link | + Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($AcerLink)) { throw "Acer catalog URL not found in OEM links" } + + $AcerFile = [string]($AcerLink | Split-Path -Leaf) + $AcerFilePath = Join-Path $TempDirectory $AcerFile + + if (-not (Test-Path $AcerFilePath)) { + Write-DATLogEntry -Value "[$OEM] Downloading Acer catalog..." -Severity 1 + Write-DATLogEntry -Value "[$OEM] Catalog download path: $AcerFilePath" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Acer driver catalog..." -Type String + Invoke-CatalogDownload -Uri $AcerLink -OutFile $AcerFilePath + } else { + Write-DATLogEntry -Value "[$OEM] Using cached Acer catalog: $AcerFilePath" -Severity 1 + } + + [xml]$AcerModelXML = Get-Content -Path $AcerFilePath + $AcerDrivers = $AcerModelXML.ModelList.Model + $WinVer = "Win" + "$($WindowsVersion.Split(' ')[1])" + + Write-DATLogEntry -Value "[$OEM] Searching catalog for: Name='$Model' OS='$WinVer' Build='$WindowsBuild'" -Severity 1 + + $matchingModel = $AcerDrivers | Where-Object { + $_.Name -eq $Model -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer + } | Select-Object -First 1 + + if ($null -eq $matchingModel) { + # Fuzzy fallback -- partial name match + Write-DATLogEntry -Value "[$OEM] Exact match not found, attempting partial name match for '$Model'" -Severity 2 + $matchingModel = $AcerDrivers | Where-Object { + $_.Name -like "*$Model*" -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer + } | Select-Object -First 1 + } + + if ($null -ne $matchingModel -and $null -ne $matchingModel.SCCM) { + $downloadURL = $matchingModel.SCCM.'#text' + if ($downloadURL -is [array]) { $downloadURL = $downloadURL[0] } + if ([string]::IsNullOrEmpty($downloadURL)) { $downloadURL = [string]$matchingModel.SCCM } + $downloadFileName = $downloadURL | Split-Path -Leaf + Write-DATLogEntry -Value "[$OEM] Found driver pack: $downloadFileName" -Severity 1 + Write-DATLogEntry -Value "[$OEM] Resolved download URL: $downloadURL" -Severity 1 + } else { + # Log all available models/builds to aid diagnostics + $available = $AcerDrivers | Where-Object { $_.SCCM.OS -eq $WinVer } | Select-Object -ExpandProperty Name -Unique + Write-DATLogEntry -Value "[$OEM] Available models for ${WinVer}: $($available -join ', ')" -Severity 2 + throw "No matching Acer driver package found for $Model ($WinVer $WindowsBuild)" + } + } + default { + throw "Unsupported OEM: $OEM" + } + } + } # end if ([string]::IsNullOrEmpty($downloadURL)) -- skip OEM lookup when CatalogDownloadURL provided + + if ([string]::IsNullOrEmpty($downloadURL)) { + throw "Failed to resolve download URL for $OEM $Model" + } + + # If no hash was captured from the OEM-specific catalog, try the DAT API catalog + if ([string]::IsNullOrEmpty($catalogFileHash)) { + try { + $datCatalog = Get-DATDriverCatalog + if ($datCatalog) { + $hashMatch = $datCatalog | Where-Object { + $_.DownloadURL -eq $downloadURL -and + -not [string]::IsNullOrEmpty($_.FileHash) + } | Select-Object -First 1 + if ($hashMatch) { + $catalogFileHash = $hashMatch.FileHash + $catalogHashMethod = if (-not [string]::IsNullOrEmpty($hashMatch.HashMethod)) { $hashMatch.HashMethod } else { 'SHA256' } + Write-DATLogEntry -Value "[$OEM] File hash retrieved from DAT API catalog ($catalogHashMethod): $catalogFileHash" -Severity 1 + } + } + } catch { + Write-DATLogEntry -Value "[$OEM] DAT API catalog hash lookup skipped: $($_.Exception.Message)" -Severity 2 + } } # Pre-download URL reachability check @@ -7370,6 +8641,9 @@ New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -For Destination = $packageDest Platform = $packagingPlatform } + if (-not [string]::IsNullOrEmpty($catalogVersion)) { + $packagingParams['PackageVersion'] = "$catalogVersion" + } if ($RunningMode -eq 'Download Only') { $packagingParams['DownloadOnlyExtractDestination'] = $DownloadDestination } @@ -9558,13 +10832,15 @@ function Get-DATDeployedPackageVersions { function Set-DATIntuneAppAssignment { <# .SYNOPSIS - Creates a group assignment for an Intune Win32 app (Available or Required). - Supports regular groups, All Users, and All Devices. + Creates one or more group assignments for an Intune Win32 app (Available or Required). + Supports regular groups, All Users, and All Devices. Multiple group IDs are emitted as + separate assignment entries in a single /assign call (the action replaces the whole set, + so all targets must be sent together). #> [CmdletBinding()] param ( [Parameter(Mandatory)][string]$AppId, - [Parameter(Mandatory)][string]$GroupId, + [Parameter(Mandatory)][string[]]$GroupId, [Parameter(Mandatory)][ValidateSet('Available', 'Required')][string]$Intent, [ValidateSet('showAll', 'showReboot', 'hideAll')][string]$IMENotifications = 'showAll' ) @@ -9578,39 +10854,41 @@ function Set-DATIntuneAppAssignment { $allUsersId = 'acacacac-9df4-4c7d-9d50-4ef0226f57a9' $allDevicesId = 'adadadad-808e-44e2-905a-0b7873a8a531' - $target = switch ($GroupId) { - $allUsersId { - @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } - } - $allDevicesId { - @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } + $groupIds = @($GroupId | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() } | Select-Object -Unique) + if ($groupIds.Count -eq 0) { throw "Set-DATIntuneAppAssignment: no group ID supplied." } + + $assignments = foreach ($gid in $groupIds) { + $target = switch ($gid) { + $allUsersId { + @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget" } + } + $allDevicesId { + @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget" } + } + default { + @{ + "@odata.type" = "#microsoft.graph.groupAssignmentTarget" + groupId = $gid + } + } } - default { - @{ - "@odata.type" = "#microsoft.graph.groupAssignmentTarget" - groupId = $GroupId + @{ + "@odata.type" = "#microsoft.graph.mobileAppAssignment" + intent = $intentMap[$Intent] + target = $target + settings = @{ + "@odata.type" = "#microsoft.graph.win32LobAppAssignmentSettings" + notifications = $IMENotifications + installTimeSettings = $null + restartSettings = $null + deliveryOptimizationPriority = "notConfigured" } } } - $body = @{ - mobileAppAssignments = @( - @{ - "@odata.type" = "#microsoft.graph.mobileAppAssignment" - intent = $intentMap[$Intent] - target = $target - settings = @{ - "@odata.type" = "#microsoft.graph.win32LobAppAssignmentSettings" - notifications = $IMENotifications - installTimeSettings = $null - restartSettings = $null - deliveryOptimizationPriority = "notConfigured" - } - } - ) - } + $body = @{ mobileAppAssignments = @($assignments) } - Write-DATLogEntry -Value "[Intune] Assigning app $AppId to group $GroupId as $Intent" -Severity 1 + Write-DATLogEntry -Value "[Intune] Assigning app $AppId to group(s) $($groupIds -join ', ') as $Intent" -Severity 1 return Invoke-DATGraphRequest -Uri "/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body } @@ -9889,13 +11167,14 @@ function Find-DATIntuneAssignmentFilter { function Set-DATIntuneAppAssignmentWithFilter { <# .SYNOPSIS - Creates a group assignment for an Intune Win32 app with an assignment filter. - The filter is applied in "include" mode so only matching devices receive the app. + Creates one or more group assignments for an Intune Win32 app with an assignment filter. + The filter is applied in "include" mode so only matching devices receive the app. Multiple + group IDs are emitted as separate assignment entries in a single /assign call. #> [CmdletBinding()] param ( [Parameter(Mandatory)][string]$AppId, - [Parameter(Mandatory)][string]$GroupId, + [Parameter(Mandatory)][string[]]$GroupId, [Parameter(Mandatory)][ValidateSet('Available', 'Required')][string]$Intent, [Parameter(Mandatory)][string]$FilterId, [ValidateSet('include', 'exclude')][string]$FilterType = 'include', @@ -9907,37 +11186,39 @@ function Set-DATIntuneAppAssignmentWithFilter { $allUsersId = 'acacacac-9df4-4c7d-9d50-4ef0226f57a9' $allDevicesId = 'adadadad-808e-44e2-905a-0b7873a8a531' - $target = switch ($GroupId) { - $allUsersId { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget"; "deviceAndAppManagementAssignmentFilterId" = $FilterId; "deviceAndAppManagementAssignmentFilterType" = $FilterType } } - $allDevicesId { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget"; "deviceAndAppManagementAssignmentFilterId" = $FilterId; "deviceAndAppManagementAssignmentFilterType" = $FilterType } } - default { - @{ - "@odata.type" = "#microsoft.graph.groupAssignmentTarget" - groupId = $GroupId - "deviceAndAppManagementAssignmentFilterId" = $FilterId - "deviceAndAppManagementAssignmentFilterType" = $FilterType + $groupIds = @($GroupId | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() } | Select-Object -Unique) + if ($groupIds.Count -eq 0) { throw "Set-DATIntuneAppAssignmentWithFilter: no group ID supplied." } + + $assignments = foreach ($gid in $groupIds) { + $target = switch ($gid) { + $allUsersId { @{ "@odata.type" = "#microsoft.graph.allLicensedUsersAssignmentTarget"; "deviceAndAppManagementAssignmentFilterId" = $FilterId; "deviceAndAppManagementAssignmentFilterType" = $FilterType } } + $allDevicesId { @{ "@odata.type" = "#microsoft.graph.allDevicesAssignmentTarget"; "deviceAndAppManagementAssignmentFilterId" = $FilterId; "deviceAndAppManagementAssignmentFilterType" = $FilterType } } + default { + @{ + "@odata.type" = "#microsoft.graph.groupAssignmentTarget" + groupId = $gid + "deviceAndAppManagementAssignmentFilterId" = $FilterId + "deviceAndAppManagementAssignmentFilterType" = $FilterType + } + } + } + @{ + "@odata.type" = "#microsoft.graph.mobileAppAssignment" + intent = $intentMap[$Intent] + target = $target + settings = @{ + "@odata.type" = "#microsoft.graph.win32LobAppAssignmentSettings" + notifications = $IMENotifications + installTimeSettings = $null + restartSettings = $null + deliveryOptimizationPriority = "notConfigured" } } } - $body = @{ - mobileAppAssignments = @( - @{ - "@odata.type" = "#microsoft.graph.mobileAppAssignment" - intent = $intentMap[$Intent] - target = $target - settings = @{ - "@odata.type" = "#microsoft.graph.win32LobAppAssignmentSettings" - notifications = $IMENotifications - installTimeSettings = $null - restartSettings = $null - deliveryOptimizationPriority = "notConfigured" - } - } - ) - } + $body = @{ mobileAppAssignments = @($assignments) } - Write-DATLogEntry -Value "[Intune] Assigning app $AppId to group $GroupId as $Intent with filter $FilterId ($FilterType)" -Severity 1 + Write-DATLogEntry -Value "[Intune] Assigning app $AppId to group(s) $($groupIds -join ', ') as $Intent with filter $FilterId ($FilterType)" -Severity 1 return Invoke-DATGraphRequest -Uri "/deviceAppManagement/mobileApps/$AppId/assign" -Method POST -Body $body } @@ -9955,8 +11236,8 @@ function Invoke-DATAutoAssignmentFilter { .PARAMETER FilterMode 'Make' = one filter per manufacturer. 'Model' = one filter per make+model. .PARAMETER TargetGroupId - Optional. The Entra group Object ID to assign the app to. Defaults to the built-in - All Devices group. Supply a custom security group Object ID to scope the deployment. + Optional. One or more Entra group Object IDs to assign the app to. Defaults to the built-in + All Devices group. Supply custom security group Object ID(s) to scope the deployment. #> [CmdletBinding()] param ( @@ -9964,7 +11245,7 @@ function Invoke-DATAutoAssignmentFilter { [Parameter(Mandatory)][string]$Manufacturer, [string]$Model, [Parameter(Mandatory)][ValidateSet('Make', 'Model')][string]$FilterMode, - [string]$TargetGroupId = 'adadadad-808e-44e2-905a-0b7873a8a531', + [string[]]$TargetGroupId = @('adadadad-808e-44e2-905a-0b7873a8a531'), [string]$Baseboards, [ValidateSet('showAll', 'showReboot', 'hideAll')][string]$IMENotifications = 'showAll' ) @@ -10026,9 +11307,9 @@ function Invoke-DATAutoAssignmentFilter { Write-DATLogEntry -Value "[Intune] Created assignment filter: $filterName ($filterId)" -Severity 1 } - # Assign to the target group (All Devices by default, or a custom Entra group) with the filter in include mode + # Assign to the target group(s) (All Devices by default, or custom Entra group(s)) with the filter in include mode Set-DATIntuneAppAssignmentWithFilter -AppId $AppId -GroupId $TargetGroupId -Intent 'Required' -FilterId $filterId -FilterType 'include' -IMENotifications $IMENotifications - Write-DATLogEntry -Value "[Intune] App $AppId assigned to group $TargetGroupId with filter $filterName" -Severity 1 + Write-DATLogEntry -Value "[Intune] App $AppId assigned to group(s) $($TargetGroupId -join ', ') with filter $filterName" -Severity 1 } #endregion Assignment Filter Functions @@ -14455,899 +15736,1257 @@ function Get-DATFlash64W { '--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '--output', $zipFile, '--url', $flash64Url) - # Apply configured proxy (server only; credentials come from the --config file) - try { - $proxyCfg = Get-DATProxySettings - if ($proxyCfg.Mode -eq 'None') { - $curlArgs += @('--noproxy', '*') - } elseif ($proxyCfg.Mode -eq 'Manual' -and -not [string]::IsNullOrWhiteSpace($proxyCfg.Server)) { - $curlArgs += @('--proxy', $proxyCfg.Server) + # Apply configured proxy (server only; credentials come from the --config file) + try { + $proxyCfg = Get-DATProxySettings + if ($proxyCfg.Mode -eq 'None') { + $curlArgs += @('--noproxy', '*') + } elseif ($proxyCfg.Mode -eq 'Manual' -and -not [string]::IsNullOrWhiteSpace($proxyCfg.Server)) { + $curlArgs += @('--proxy', $proxyCfg.Server) + } + } catch { } + if ($curlProxyCfgFile) { $curlArgs = @('--config', $curlProxyCfgFile) + $curlArgs } + + $curlOutput = & "$curlExe" @curlArgs 2>&1 + if ($LASTEXITCODE -eq 0 -and (Test-Path -Path $zipFile) -and (Get-Item $zipFile).Length -gt 0) { + $downloaded = $true + Write-DATLogEntry -Value "[Flash64W] Download complete (curl): $([math]::Round((Get-Item $zipFile).Length / 1KB, 1)) KB" -Severity 1 + } else { + Write-DATLogEntry -Value "[Flash64W] curl download failed (exit $LASTEXITCODE): $($curlOutput -join ' ')" -Severity 2 + } + } catch { + Write-DATLogEntry -Value "[Flash64W] curl download error: $($_.Exception.Message)" -Severity 2 + } finally { + if ($curlProxyCfgFile) { Remove-Item -Path $curlProxyCfgFile -Force -ErrorAction SilentlyContinue } + } + } + + # Fallback: Invoke-WebRequest with browser-like headers + if (-not $downloaded) { + try { + Write-DATLogEntry -Value "[Flash64W] Falling back to Invoke-WebRequest" -Severity 1 + $proxyParams = Get-DATWebRequestProxy + if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } + $webHeaders = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } + Invoke-WebRequest -Uri $flash64Url -OutFile $zipFile -UseBasicParsing -TimeoutSec 120 -Headers $webHeaders @proxyParams -ErrorAction Stop + $downloaded = $true + Write-DATLogEntry -Value "[Flash64W] Download complete: $([math]::Round((Get-Item $zipFile).Length / 1KB, 1)) KB" -Severity 1 + } catch { + Write-DATLogEntry -Value "[Flash64W] Download failed: $($_.Exception.Message)" -Severity 3 + } + } + + if (-not $downloaded) { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + return $false + } + + # Extract ZIP and locate Flash64W.exe (may be in a subfolder) + $extracted = $false + try { + Write-DATLogEntry -Value "[Flash64W] Extracting ZIP archive" -Severity 1 + Expand-Archive -Path $zipFile -DestinationPath $zipExtract -Force -ErrorAction Stop + $flash64File = Get-ChildItem -Path $zipExtract -Filter 'Flash64W.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($flash64File) { $extracted = $true } + } catch { + Write-DATLogEntry -Value "[Flash64W] ZIP extraction failed: $($_.Exception.Message)" -Severity 3 + } + + if ($extracted -and $flash64File) { + Copy-Item -Path $flash64File.FullName -Destination $DestinationDir -Force + Write-DATLogEntry -Value "[Flash64W] Flash64W.exe v3.3.28 copied to $DestinationDir" -Severity 1 + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + return $true + } + + # Clean up temp download + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + Write-DATLogEntry -Value "[Flash64W] Extraction failed -- Flash64W.exe could not be obtained" -Severity 3 + return $false +} + +function Invoke-DATBiosPackaging { + <# + .SYNOPSIS + Packages a downloaded BIOS executable for deployment. + For Dell, the exe is self-contained and placed directly. + For HP and Lenovo, the exe is extracted first to expose internal flash utilities. + When SkipWim is set (ConfigMgr), extracted files are staged directly. + When SkipWim is not set (Intune), content is captured into a WIM. + .PARAMETER BiosFilePath + Path to the downloaded BIOS .exe file. + .PARAMETER OEM + Manufacturer name (Dell, HP, Lenovo). + .PARAMETER Model + Model name for folder structure. + .PARAMETER Version + BIOS version string. + .PARAMETER PackageDestination + Root package destination path. + .PARAMETER SkipWim + When set, skips WIM compression and returns the staging directory path. + Used for ConfigMgr deployments where raw files are preferred. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$BiosFilePath, + [Parameter(Mandatory)][string]$OEM, + [Parameter(Mandatory)][string]$Model, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$PackageDestination, + [switch]$SkipWim, + [switch]$IncludeFlash64W + ) + + # BIOS packages are OS-agnostic -- use "BIOS" as the subfolder instead of OS name. + # Build the WIM in the Temporary Storage Path, then copy to the Package Store -- + # same pattern as Invoke-DATDriverFilePackaging (avoids writing temp data into the + # package store and handles UNC destinations where DISM cannot create WIMs directly). + $localWorkDir = Join-Path $global:TempDirectory "BIOSBuild\$OEM\$Model" + $biosStaging = Join-Path $localWorkDir "Packaged\$OEM\$Model\BIOS" + $extractDir = Join-Path $global:TempDirectory "BIOSExtract\$OEM\$Model" + $wimFile = Join-Path $biosStaging "DriverPackage.wim" + $destBiosFolder = Join-Path $PackageDestination "$OEM\$Model\BIOS" + + # Clean previous + foreach ($dir in @($localWorkDir, $biosStaging, $extractDir)) { + if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -Path $dir -ItemType Directory -Force | Out-Null + } + + Set-DATRegistryValue -Name "RunningMode" -Value "Packaging" -Type String + Set-DATRegistryValue -Name "RunningMessage" -Value "Packaging BIOS update for $OEM $Model..." -Type String + + switch ($OEM) { + 'Acer' { + # Acer BIOS packages are ZIP archives -- extract to expose BIOS files + Write-DATLogEntry -Value "[BIOS] Acer: Extracting BIOS ZIP archive" -Severity 1 + try { + Expand-Archive -Path $BiosFilePath -DestinationPath $extractDir -Force -ErrorAction Stop + } catch { + throw "Acer BIOS extraction failed: $($_.Exception.Message)" + } + } + 'Dell' { + # Dell BIOS exe is self-contained -- copy directly for all deployment modes. + # The exe handles its own flash process; no extraction is needed. + Write-DATLogEntry -Value "[BIOS] Dell: Staging self-contained BIOS updater" -Severity 1 + Copy-Item -Path $BiosFilePath -Destination $extractDir -Force + } + 'HP' { + # HP BIOS SoftPaq is a self-extracting archive -- use the same expansion flags + # as the HP driver SoftPaq flow to expose HPFirmwareUpdRec64.exe / HPBIOSUPDREC64.exe etc. + Write-DATLogEntry -Value "[BIOS] HP: Extracting SoftPaq to expose flash utilities" -Severity 1 + try { + $extractProc = Start-Process -FilePath $BiosFilePath -ArgumentList "-e", "-f `"$extractDir`"", "-s" ` + -WindowStyle Hidden -PassThru + if (-not $extractProc.WaitForExit(300000)) { + try { $extractProc.Kill() } catch {} + throw "HP BIOS extraction timed out after 5 minutes" + } + if ($extractProc.ExitCode -ne 0) { + Write-DATLogEntry -Value "[BIOS] HP extraction exited with code $($extractProc.ExitCode)" -Severity 2 + } + } catch { + throw "HP BIOS extraction failed: $($_.Exception.Message)" + } + } + 'Lenovo' { + # Lenovo BIOS packages are Inno Setup self-extracting installers. + # Run the installer silently to extract files to the target directory, + # poll for extracted files, then kill the process tree before the [Run] + # section can flash the BIOS. + Write-DATLogEntry -Value "[BIOS] Lenovo: Extracting Inno Setup BIOS package to expose flash utilities" -Severity 1 + + # Known Lenovo flash utility process names -- kill immediately if spawned + $flashProcessNames = @('WinUPTP64', 'WinUPTP', 'wFlashGUIX64', 'wFlashGUI', + 'AFUWINx64', 'AFUWIN', 'Flash64', 'InsydeFlash') + + # Lenovo Inno Setup BIOS installers extract their flash payload to hardcoded + # system-drive locations (C:\DRIVERS\FLASH, C:\SWTOOLS\FLASH) regardless of the + # /DIR argument we pass. Snapshot those roots first so we can remove only the + # folders the installer creates, without touching anything already present (#863). + $lenovoResidualRoots = @( + (Join-Path $env:SystemDrive 'DRIVERS\FLASH'), + (Join-Path $env:SystemDrive 'SWTOOLS\FLASH') + ) + $lenovoResidualSnapshot = @{} + foreach ($residualRoot in $lenovoResidualRoots) { + if (Test-Path $residualRoot) { + $lenovoResidualSnapshot[$residualRoot] = @( + Get-ChildItem -Path $residualRoot -Force -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName) + } else { + # Root did not exist before extraction -- flag for full removal afterwards. + $lenovoResidualSnapshot[$residualRoot] = $null + } + } + + try { + Unblock-File -Path $BiosFilePath -ErrorAction SilentlyContinue + + # Clear any previous flash-killed flag + Remove-ItemProperty -Path $global:RegPath -Name 'LenovoFlashKilled' -ErrorAction SilentlyContinue + + $extractProc = Start-Process -FilePath $BiosFilePath ` + -ArgumentList "/VERYSILENT /DIR=`"$extractDir`" /EXTRACT=`"YES`" /SP- /SUPPRESSMSGBOXES /NORESTART" ` + -WindowStyle Hidden -PassThru + + # Poll until flash-related files appear AND the file count stabilises, + # confirming Inno Setup has finished writing all files before we kill it. + # Simultaneously monitor for flash utility processes and kill them immediately. + $maxWaitSec = 120 + $elapsed = 0 + $extractionDone = $false + $lastFileCount = 0 + $stableChecks = 0 + $requiredStableChecks = 4 # 4 x 500ms = 2 seconds of stable file count + while ($elapsed -lt $maxWaitSec -and -not $extractProc.HasExited) { + # TEMPORARILY DISABLED: kill flash utilities during extraction + <# foreach ($flashName in $flashProcessNames) { + $flashProcs = Get-Process -Name $flashName -ErrorAction SilentlyContinue + foreach ($fp in $flashProcs) { + try { + $fp.Kill() + Write-DATLogEntry -Value "[BIOS] Lenovo: Auto-killed flash utility $($fp.ProcessName) (PID $($fp.Id)) before it could run" -Severity 2 + Set-DATRegistryValue -Name 'LenovoFlashKilled' -Value $fp.ProcessName -Type String + } catch {} + } + } #> + + $extractedFiles = @(Get-ChildItem -Path $extractDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '\.(cmd|cap|rom|bin|exe)$' -and $_.Name -ne (Split-Path $BiosFilePath -Leaf) }) + if ($extractedFiles.Count -ge 2) { + if ($extractedFiles.Count -eq $lastFileCount) { + $stableChecks++ + } else { + $stableChecks = 0 + $lastFileCount = $extractedFiles.Count + } + if ($stableChecks -ge $requiredStableChecks) { + $extractionDone = $true + Write-DATLogEntry -Value "[BIOS] Lenovo: Extraction complete -- $($extractedFiles.Count) files detected and stable for $($requiredStableChecks * 0.5)s, terminating installer" -Severity 1 + break + } + } + Start-Sleep -Milliseconds 500 + $elapsed += 0.5 + } + + # TEMPORARILY DISABLED: Kill the Inno Setup process tree + <# if (-not $extractProc.HasExited) { + try { + # Kill child processes first (wFlashGUIX64.exe, AFUWINx64.EXE, etc.) + $children = Get-CimInstance -ClassName Win32_Process -Filter "ParentProcessId = $($extractProc.Id)" -ErrorAction SilentlyContinue + foreach ($child in $children) { + try { Stop-Process -Id $child.ProcessId -Force -ErrorAction SilentlyContinue } catch {} + Write-DATLogEntry -Value "[BIOS] Lenovo: Killed child process $($child.Name) (PID $($child.ProcessId))" -Severity 1 + } + $extractProc.Kill() + Write-DATLogEntry -Value "[BIOS] Lenovo: Killed Inno Setup installer process" -Severity 1 + } catch {} + } #> + + # TEMPORARILY DISABLED: Final sweep kill flash utilities + <# foreach ($flashName in $flashProcessNames) { + $flashProcs = Get-Process -Name $flashName -ErrorAction SilentlyContinue + foreach ($fp in $flashProcs) { + try { + $fp.Kill() + Write-DATLogEntry -Value "[BIOS] Lenovo: Post-extract killed flash utility $($fp.ProcessName) (PID $($fp.Id))" -Severity 2 + Set-DATRegistryValue -Name 'LenovoFlashKilled' -Value $fp.ProcessName -Type String + } catch {} + } + } #> + + if (-not $extractionDone) { + # Process exited on its own -- check if files were extracted + $extractedFiles = @(Get-ChildItem -Path $extractDir -File -ErrorAction SilentlyContinue) + if ($extractedFiles.Count -lt 2) { + throw "Lenovo BIOS extraction produced insufficient files (found: $($extractedFiles.Count))" + } } - } catch { } - if ($curlProxyCfgFile) { $curlArgs = @('--config', $curlProxyCfgFile) + $curlArgs } - $curlOutput = & "$curlExe" @curlArgs 2>&1 - if ($LASTEXITCODE -eq 0 -and (Test-Path -Path $zipFile) -and (Get-Item $zipFile).Length -gt 0) { - $downloaded = $true - Write-DATLogEntry -Value "[Flash64W] Download complete (curl): $([math]::Round((Get-Item $zipFile).Length / 1KB, 1)) KB" -Severity 1 - } else { - Write-DATLogEntry -Value "[Flash64W] curl download failed (exit $LASTEXITCODE): $($curlOutput -join ' ')" -Severity 2 + # Clean up: remove uninstall artifacts left by Inno Setup + Get-ChildItem -Path $extractDir -Filter 'unins*' -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + } catch { + throw "Lenovo BIOS extraction failed: $($_.Exception.Message)" + } finally { + # Remove residual folders the Lenovo installer created on the system drive. + # These hardcoded C:\DRIVERS\FLASH / C:\SWTOOLS\FLASH payload folders are + # created regardless of the /DIR argument and are otherwise never cleaned + # up by the build, tool close, or Purge button (#863). Runs on both success + # and failure so nothing is left behind. + foreach ($residualRoot in $lenovoResidualRoots) { + try { + if (-not (Test-Path $residualRoot)) { continue } + $preExisting = $lenovoResidualSnapshot[$residualRoot] + if ($null -eq $preExisting) { + # Root was created by this extraction -- remove it entirely. + Remove-Item -Path $residualRoot -Recurse -Force -ErrorAction SilentlyContinue + Write-DATLogEntry -Value "[BIOS] Lenovo: Removed residual extraction folder $residualRoot" -Severity 1 + # Remove the parent (DRIVERS / SWTOOLS) too if we left it empty. + $residualParent = Split-Path $residualRoot -Parent + if ((Test-Path $residualParent) -and + -not (Get-ChildItem -Path $residualParent -Force -ErrorAction SilentlyContinue)) { + Remove-Item -Path $residualParent -Recurse -Force -ErrorAction SilentlyContinue + Write-DATLogEntry -Value "[BIOS] Lenovo: Removed empty residual folder $residualParent" -Severity 1 + } + } else { + # Root pre-existed -- only remove entries the installer newly added. + $newEntries = Get-ChildItem -Path $residualRoot -Force -ErrorAction SilentlyContinue | + Where-Object { $preExisting -notcontains $_.FullName } + foreach ($entry in $newEntries) { + Remove-Item -Path $entry.FullName -Recurse -Force -ErrorAction SilentlyContinue + Write-DATLogEntry -Value "[BIOS] Lenovo: Removed residual extraction item $($entry.FullName)" -Severity 1 + } + } + } catch { + Write-DATLogEntry -Value "[BIOS] Lenovo: Could not clean residual folder $residualRoot -- $($_.Exception.Message)" -Severity 2 + } + } } - } catch { - Write-DATLogEntry -Value "[Flash64W] curl download error: $($_.Exception.Message)" -Severity 2 - } finally { - if ($curlProxyCfgFile) { Remove-Item -Path $curlProxyCfgFile -Force -ErrorAction SilentlyContinue } + } + default { + # Unknown OEM -- place exe directly + Write-DATLogEntry -Value "[BIOS] $OEM : Staging BIOS file directly (unknown extraction method)" -Severity 2 + Copy-Item -Path $BiosFilePath -Destination $extractDir -Force } } - # Fallback: Invoke-WebRequest with browser-like headers - if (-not $downloaded) { - try { - Write-DATLogEntry -Value "[Flash64W] Falling back to Invoke-WebRequest" -Severity 1 - $proxyParams = Get-DATWebRequestProxy - if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } - $webHeaders = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } - Invoke-WebRequest -Uri $flash64Url -OutFile $zipFile -UseBasicParsing -TimeoutSec 120 -Headers $webHeaders @proxyParams -ErrorAction Stop - $downloaded = $true - Write-DATLogEntry -Value "[Flash64W] Download complete: $([math]::Round((Get-Item $zipFile).Length / 1KB, 1)) KB" -Severity 1 - } catch { - Write-DATLogEntry -Value "[Flash64W] Download failed: $($_.Exception.Message)" -Severity 3 + # Verify extraction produced files + $extractedFiles = @(Get-ChildItem -Path $extractDir -Recurse -File -ErrorAction SilentlyContinue) + if ($extractedFiles.Count -eq 0) { + throw "BIOS extraction produced no files for $OEM $Model" + } + Write-DATLogEntry -Value "[BIOS] Extracted $($extractedFiles.Count) files" -Severity 1 + + # Stage Flash64W.exe for Dell when requested (ConfigMgr and WIM Package Only modes) + if ($IncludeFlash64W -and $OEM -eq 'Dell') { + Write-DATLogEntry -Value "[BIOS] Dell: Ensuring Flash64W.exe is staged in extraction directory" -Severity 1 + $flash64Result = Get-DATFlash64W -DestinationDir $extractDir + if (-not $flash64Result) { + Write-DATLogEntry -Value "[Warning] Flash64W.exe could not be obtained -- Dell BIOS package may not work in WinPE" -Severity 2 } } - if (-not $downloaded) { - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - return $false + if ($SkipWim) { + # ConfigMgr: return the temp extraction directory so New-DATConfigMgrPkg can + # copy its contents into the final versioned path ($PackagePath\$OEM\$Model\BIOS\$Version). + # Do NOT copy to the package store here -- that would place files at the unversioned + # $OEM\$Model\BIOS level, and ConfigMgr would then try to copy that into a child + # directory of itself ($OEM\$Model\BIOS\$Version), causing a self-overwrite error. + Write-DATLogEntry -Value "[BIOS] Staging $($extractedFiles.Count) files in temp: $extractDir (no WIM)" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Staging BIOS files for $OEM $Model..." -Type String + return [string]$extractDir + } + + # Intune: Capture extracted content into WIM using the preferred engine + Write-DATLogEntry -Value "[BIOS] Creating WIM: $wimFile" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Creating BIOS WIM for $OEM $Model..." -Type String + + $wimEngine = (Get-ItemProperty -Path $global:RegPath -Name 'WimEngine' -ErrorAction SilentlyContinue).WimEngine + if ([string]::IsNullOrEmpty($wimEngine) -or $wimEngine -notin @('dism','wimlib','7zip')) { + # No explicit choice: prefer bundled wimlib (self-contained, no dismhost/DISM providers -- + # avoids DISM-hangs-at-init). Fall back to DISM when wimlib is not bundled. + $bundledWimlib = Join-Path $global:ToolsDirectory 'Wimlib\wimlib-imagex.exe' + $wimEngine = if (Test-Path $bundledWimlib) { 'wimlib' } else { 'dism' } } - # Extract ZIP and locate Flash64W.exe (may be in a subfolder) - $extracted = $false try { - Write-DATLogEntry -Value "[Flash64W] Extracting ZIP archive" -Severity 1 - Expand-Archive -Path $zipFile -DestinationPath $zipExtract -Force -ErrorAction Stop - $flash64File = Get-ChildItem -Path $zipExtract -Filter 'Flash64W.exe' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($flash64File) { $extracted = $true } + if ($wimEngine -eq 'wimlib') { + $wimlibExe = Join-Path (Join-Path $global:ToolsDirectory 'Wimlib') 'wimlib-imagex.exe' + if (Test-Path $wimlibExe) { + Write-DATLogEntry -Value "[BIOS] Using wimlib-imagex for WIM creation" -Severity 1 + $proc = Start-Process -FilePath $wimlibExe ` + -ArgumentList "capture `"$extractDir`" `"$wimFile`" `"BIOS - $OEM $Model`" --compress=XPRESS --threads=0 --no-acls" ` + -WindowStyle Hidden -Wait -PassThru + if ($proc.ExitCode -ne 0) { throw "wimlib-imagex exited with code $($proc.ExitCode)" } + } else { + Write-DATLogEntry -Value "[BIOS] wimlib not found -- falling back to DISM" -Severity 2 + New-WindowsImage -ImagePath $wimFile -CapturePath $extractDir -Name "BIOS - $OEM $Model" -Description "BIOS $Version" -ErrorAction Stop | Out-Null + } + } elseif ($wimEngine -eq '7zip') { + $7zipExe = $null + foreach ($c in @((Join-Path $env:ProgramFiles '7-Zip\7z.exe'), (Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe'))) { + if (Test-Path $c) { $7zipExe = $c; break } + } + if (-not $7zipExe) { try { $7zipExe = (Get-Command '7z.exe' -ErrorAction Stop).Source } catch { } } + if (-not [string]::IsNullOrEmpty($7zipExe) -and (Test-Path $7zipExe)) { + Write-DATLogEntry -Value "[BIOS] Using 7-Zip for WIM creation" -Severity 1 + $proc = Start-Process -FilePath $7zipExe -ArgumentList "a -twim `"$wimFile`" `"$extractDir\*`" -mx=1" ` + -WindowStyle Hidden -Wait -PassThru + if ($proc.ExitCode -ne 0) { throw "7-Zip exited with code $($proc.ExitCode)" } + } else { + Write-DATLogEntry -Value "[BIOS] 7-Zip not found -- falling back to DISM" -Severity 2 + New-WindowsImage -ImagePath $wimFile -CapturePath $extractDir -Name "BIOS - $OEM $Model" -Description "BIOS $Version" -ErrorAction Stop | Out-Null + } + } else { + New-WindowsImage -ImagePath $wimFile -CapturePath $extractDir -Name "BIOS - $OEM $Model" -Description "BIOS $Version" -ErrorAction Stop | Out-Null + } + $wimSizeMB = [math]::Round((Get-Item $wimFile).Length / 1MB, 2) + Write-DATLogEntry -Value "[BIOS] WIM created: $wimSizeMB MB" -Severity 1 } catch { - Write-DATLogEntry -Value "[Flash64W] ZIP extraction failed: $($_.Exception.Message)" -Severity 3 + throw "BIOS WIM creation failed: $($_.Exception.Message)" } - if ($extracted -and $flash64File) { - Copy-Item -Path $flash64File.FullName -Destination $DestinationDir -Force - Write-DATLogEntry -Value "[Flash64W] Flash64W.exe v3.3.28 copied to $DestinationDir" -Severity 1 - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - return $true - } + # Copy WIM from temp to the Package Store destination + if (Test-Path $destBiosFolder) { Remove-Item $destBiosFolder -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -Path $destBiosFolder -ItemType Directory -Force | Out-Null + $destWimFile = Join-Path $destBiosFolder "DriverPackage.wim" + Write-DATLogEntry -Value "[BIOS] Copying WIM to package destination: $destWimFile" -Severity 1 + Copy-Item -Path $wimFile -Destination $destWimFile -Force + Write-DATLogEntry -Value "[BIOS] WIM copied to package destination successfully" -Severity 1 - # Clean up temp download - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - Write-DATLogEntry -Value "[Flash64W] Extraction failed -- Flash64W.exe could not be obtained" -Severity 3 - return $false + # Write a version marker so the pre-flight check can skip re-downloads when the version matches + $versionMarker = Join-Path $destBiosFolder ".biosversion" + Set-Content -Path $versionMarker -Value $Version -Encoding UTF8 -Force + + # Clean up temp directories + Remove-Item -Path $extractDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -Path $localWorkDir -Recurse -Force -ErrorAction SilentlyContinue + + return [string]$destWimFile } -function Invoke-DATBiosPackaging { +#endregion BIOS Catalog & Download + +#region Telemetry + +# Session-scoped cache for the remote config (fetched once per module load / tool session) +$script:DATTelemetryConfig = $null + +# Holds the canonical (content-only) driver manifest built by New-DATDriverManifest for the most +# recently packaged model, so Send-DATDriverManifest can submit it without rescanning the package. +$script:DATLastDriverManifest = $null + +function Get-DATTelemetryConfig { <# .SYNOPSIS - Packages a downloaded BIOS executable for deployment. - For Dell, the exe is self-contained and placed directly. - For HP and Lenovo, the exe is extracted first to expose internal flash utilities. - When SkipWim is set (ConfigMgr), extracted files are staged directly. - When SkipWim is not set (Intune), content is captured into a WIM. - .PARAMETER BiosFilePath - Path to the downloaded BIOS .exe file. - .PARAMETER OEM - Manufacturer name (Dell, HP, Lenovo). - .PARAMETER Model - Model name for folder structure. - .PARAMETER Version - BIOS version string. - .PARAMETER PackageDestination - Root package destination path. - .PARAMETER SkipWim - When set, skips WIM compression and returns the staging directory path. - Used for ConfigMgr deployments where raw files are preferred. + Returns the DAT API config (apiBaseUrl, endpoints, HMAC secret, kill switch, version gate). + Prefers the remote copy on GitHub -- the freshest source, which carries the server-controlled + kill switch, minimum-version gate and any rotated secret -- and caches it locally on success. + If the remote fetch fails (offline, GitHub outage, HTTP 429) it falls back to the last-known-good + local copy so the tool keeps working. Returns $null only when neither remote nor local is available. #> [CmdletBinding()] + [OutputType([PSCustomObject])] param ( - [Parameter(Mandatory)][string]$BiosFilePath, - [Parameter(Mandatory)][string]$OEM, - [Parameter(Mandatory)][string]$Model, - [Parameter(Mandatory)][string]$Version, - [Parameter(Mandatory)][string]$PackageDestination, - [switch]$SkipWim, - [switch]$IncludeFlash64W + [switch]$Force ) - # BIOS packages are OS-agnostic -- use "BIOS" as the subfolder instead of OS name. - # Build the WIM in the Temporary Storage Path, then copy to the Package Store -- - # same pattern as Invoke-DATDriverFilePackaging (avoids writing temp data into the - # package store and handles UNC destinations where DISM cannot create WIMs directly). - $localWorkDir = Join-Path $global:TempDirectory "BIOSBuild\$OEM\$Model" - $biosStaging = Join-Path $localWorkDir "Packaged\$OEM\$Model\BIOS" - $extractDir = Join-Path $global:TempDirectory "BIOSExtract\$OEM\$Model" - $wimFile = Join-Path $biosStaging "DriverPackage.wim" - $destBiosFolder = Join-Path $PackageDestination "$OEM\$Model\BIOS" - - # Clean previous - foreach ($dir in @($localWorkDir, $biosStaging, $extractDir)) { - if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -Path $dir -ItemType Directory -Force | Out-Null + if ($script:DATTelemetryConfig -and -not $Force) { + return $script:DATTelemetryConfig } - Set-DATRegistryValue -Name "RunningMode" -Value "Packaging" -Type String - Set-DATRegistryValue -Name "RunningMessage" -Value "Packaging BIOS update for $OEM $Model..." -Type String + $configUrl = $global:DATConfigUrl + $localPath = Join-Path $global:ScriptDirectory 'Data\DATAPIConfig.json' - switch ($OEM) { - 'Acer' { - # Acer BIOS packages are ZIP archives -- extract to expose BIOS files - Write-DATLogEntry -Value "[BIOS] Acer: Extracting BIOS ZIP archive" -Severity 1 + # 1. Prefer the remote copy. On success, cache it locally as the last-known-good fallback. + try { + $proxyParams = Get-DATWebRequestProxy + if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } + $response = Invoke-RestMethod -Uri $configUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop @proxyParams + if ($null -ne $response -and -not [string]::IsNullOrEmpty($response.apiBaseUrl)) { + $script:DATTelemetryConfig = $response try { - Expand-Archive -Path $BiosFilePath -DestinationPath $extractDir -Force -ErrorAction Stop + $dataDir = Split-Path $localPath -Parent + if (-not (Test-Path -LiteralPath $dataDir)) { New-Item -Path $dataDir -ItemType Directory -Force | Out-Null } + $response | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $localPath -Encoding UTF8 -ErrorAction Stop } catch { - throw "Acer BIOS extraction failed: $($_.Exception.Message)" + Write-DATLogEntry -Value "[Telemetry] Could not cache API config locally ($localPath): $($_.Exception.Message)" -Severity 2 } + Write-DATLogEntry -Value "[Telemetry] Remote config loaded from $configUrl (apiBaseUrl: $($response.apiBaseUrl))" -Severity 1 + return $script:DATTelemetryConfig } - 'Dell' { - # Dell BIOS exe is self-contained -- copy directly for all deployment modes. - # The exe handles its own flash process; no extraction is needed. - Write-DATLogEntry -Value "[BIOS] Dell: Staging self-contained BIOS updater" -Severity 1 - Copy-Item -Path $BiosFilePath -Destination $extractDir -Force - } - 'HP' { - # HP BIOS SoftPaq is a self-extracting archive -- use the same expansion flags - # as the HP driver SoftPaq flow to expose HPFirmwareUpdRec64.exe / HPBIOSUPDREC64.exe etc. - Write-DATLogEntry -Value "[BIOS] HP: Extracting SoftPaq to expose flash utilities" -Severity 1 - try { - $extractProc = Start-Process -FilePath $BiosFilePath -ArgumentList "-e", "-f `"$extractDir`"", "-s" ` - -WindowStyle Hidden -PassThru - if (-not $extractProc.WaitForExit(300000)) { - try { $extractProc.Kill() } catch {} - throw "HP BIOS extraction timed out after 5 minutes" - } - if ($extractProc.ExitCode -ne 0) { - Write-DATLogEntry -Value "[BIOS] HP extraction exited with code $($extractProc.ExitCode)" -Severity 2 - } - } catch { - throw "HP BIOS extraction failed: $($_.Exception.Message)" + Write-DATLogEntry -Value "[Telemetry] Remote config from $configUrl was empty/invalid -- falling back to local copy" -Severity 2 + } catch { + Write-DATLogEntry -Value "[Telemetry] Failed to fetch remote config from $configUrl : $($_.Exception.Message) -- falling back to local copy" -Severity 2 + } + + # 2. Fall back to the cached/bundled local copy so a GitHub outage cannot break the tool. + if (Test-Path -LiteralPath $localPath) { + try { + $localRaw = Get-Content -LiteralPath $localPath -Raw -ErrorAction Stop + $local = $localRaw | ConvertFrom-Json -ErrorAction Stop + if ($null -ne $local -and -not [string]::IsNullOrEmpty($local.apiBaseUrl)) { + $script:DATTelemetryConfig = $local + Write-DATLogEntry -Value "[Telemetry] Using local fallback config: $localPath (apiBaseUrl: $($local.apiBaseUrl))" -Severity 2 + return $script:DATTelemetryConfig } + } catch { + Write-DATLogEntry -Value "[Telemetry] Local fallback config unreadable ($localPath): $($_.Exception.Message)" -Severity 2 } - 'Lenovo' { - # Lenovo BIOS packages are Inno Setup self-extracting installers. - # Run the installer silently to extract files to the target directory, - # poll for extracted files, then kill the process tree before the [Run] - # section can flash the BIOS. - Write-DATLogEntry -Value "[BIOS] Lenovo: Extracting Inno Setup BIOS package to expose flash utilities" -Severity 1 + } - # Known Lenovo flash utility process names -- kill immediately if spawned - $flashProcessNames = @('WinUPTP64', 'WinUPTP', 'wFlashGUIX64', 'wFlashGUI', - 'AFUWINx64', 'AFUWIN', 'Flash64', 'InsydeFlash') + Write-DATLogEntry -Value "[Telemetry] No remote or local API config available -- API-dependent features stay disabled until connectivity returns" -Severity 3 + return $null +} - # Lenovo Inno Setup BIOS installers extract their flash payload to hardcoded - # system-drive locations (C:\DRIVERS\FLASH, C:\SWTOOLS\FLASH) regardless of the - # /DIR argument we pass. Snapshot those roots first so we can remove only the - # folders the installer creates, without touching anything already present (#863). - $lenovoResidualRoots = @( - (Join-Path $env:SystemDrive 'DRIVERS\FLASH'), - (Join-Path $env:SystemDrive 'SWTOOLS\FLASH') - ) - $lenovoResidualSnapshot = @{} - foreach ($residualRoot in $lenovoResidualRoots) { - if (Test-Path $residualRoot) { - $lenovoResidualSnapshot[$residualRoot] = @( - Get-ChildItem -Path $residualRoot -Force -ErrorAction SilentlyContinue | - Select-Object -ExpandProperty FullName) - } else { - # Root did not exist before extraction -- flag for full removal afterwards. - $lenovoResidualSnapshot[$residualRoot] = $null - } - } +function Set-DATUpgradeRequiredState { + <# + .SYNOPSIS + Records an API "upgrade required" (HTTP 426) result to the registry so the UI or the headless + runner can surface it. Internal helper used by Resolve-DATApiUpgradeRequired. + #> + [CmdletBinding()] + param ( + [AllowEmptyString()][string]$Message = '', + [AllowEmptyString()][string]$DownloadUrl = '', + [AllowEmptyString()][string]$MinimumVersion = '' + ) + try { + Set-DATRegistryValue -Name 'UpgradeRequired' -Value 1 -Type DWord + Set-DATRegistryValue -Name 'UpgradeMessage' -Value $Message -Type String + Set-DATRegistryValue -Name 'UpgradeDownloadUrl' -Value $DownloadUrl -Type String + Set-DATRegistryValue -Name 'UpgradeMinimumVersion' -Value $MinimumVersion -Type String + } catch { } +} - try { - Unblock-File -Path $BiosFilePath -ErrorAction SilentlyContinue +function Get-DATUpgradeRequiredState { + <# + .SYNOPSIS + Returns the pending API "upgrade required" (HTTP 426) state as a hashtable, or $null when no + upgrade signal is set. Read by the UI watcher and the headless runner. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param () + $flag = (Get-ItemProperty -Path $global:RegPath -Name 'UpgradeRequired' -ErrorAction SilentlyContinue).UpgradeRequired + if ($flag -ne 1) { return $null } + return @{ + Message = [string](Get-ItemProperty -Path $global:RegPath -Name 'UpgradeMessage' -ErrorAction SilentlyContinue).UpgradeMessage + DownloadUrl = [string](Get-ItemProperty -Path $global:RegPath -Name 'UpgradeDownloadUrl' -ErrorAction SilentlyContinue).UpgradeDownloadUrl + MinimumVersion = [string](Get-ItemProperty -Path $global:RegPath -Name 'UpgradeMinimumVersion' -ErrorAction SilentlyContinue).UpgradeMinimumVersion + } +} - # Clear any previous flash-killed flag - Remove-ItemProperty -Path $global:RegPath -Name 'LenovoFlashKilled' -ErrorAction SilentlyContinue +function Clear-DATUpgradeRequiredState { + <# + .SYNOPSIS + Clears the pending API "upgrade required" state after it has been shown to the user. + #> + [CmdletBinding()] + param () + foreach ($valueName in @('UpgradeRequired', 'UpgradeMessage', 'UpgradeDownloadUrl', 'UpgradeMinimumVersion')) { + try { Remove-ItemProperty -Path $global:RegPath -Name $valueName -ErrorAction SilentlyContinue } catch { } + } +} - $extractProc = Start-Process -FilePath $BiosFilePath ` - -ArgumentList "/VERYSILENT /DIR=`"$extractDir`" /EXTRACT=`"YES`" /SP- /SUPPRESSMSGBOXES /NORESTART" ` - -WindowStyle Hidden -PassThru +function Resolve-DATApiUpgradeRequired { + <# + .SYNOPSIS + Inspects a caught API error for HTTP 426 (Upgrade Required). On a match it records the upgrade + state (message / downloadUrl) and returns $true; otherwise returns $false. Engine-safe across + Windows PowerShell 5.1 (HttpWebResponse) and PowerShell 7 (HttpResponseMessage). + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)]$ErrorRecord, + [string]$Source = '[API]' + ) - # Poll until flash-related files appear AND the file count stabilises, - # confirming Inno Setup has finished writing all files before we kill it. - # Simultaneously monitor for flash utility processes and kill them immediately. - $maxWaitSec = 120 - $elapsed = 0 - $extractionDone = $false - $lastFileCount = 0 - $stableChecks = 0 - $requiredStableChecks = 4 # 4 x 500ms = 2 seconds of stable file count - while ($elapsed -lt $maxWaitSec -and -not $extractProc.HasExited) { - # TEMPORARILY DISABLED: kill flash utilities during extraction - <# foreach ($flashName in $flashProcessNames) { - $flashProcs = Get-Process -Name $flashName -ErrorAction SilentlyContinue - foreach ($fp in $flashProcs) { - try { - $fp.Kill() - Write-DATLogEntry -Value "[BIOS] Lenovo: Auto-killed flash utility $($fp.ProcessName) (PID $($fp.Id)) before it could run" -Severity 2 - Set-DATRegistryValue -Name 'LenovoFlashKilled' -Value $fp.ProcessName -Type String - } catch {} - } - } #> + # Status code: [int] of the StatusCode enum works on both HttpWebResponse (5.1) and + # HttpResponseMessage (7); .value__ is 5.1-only and must not be used. + $status = 0 + try { + $resp = $ErrorRecord.Exception.Response + if ($null -ne $resp) { $status = [int]$resp.StatusCode } + } catch { $status = 0 } + if ($status -ne 426) { return $false } - $extractedFiles = @(Get-ChildItem -Path $extractDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '\.(cmd|cap|rom|bin|exe)$' -and $_.Name -ne (Split-Path $BiosFilePath -Leaf) }) - if ($extractedFiles.Count -ge 2) { - if ($extractedFiles.Count -eq $lastFileCount) { - $stableChecks++ - } else { - $stableChecks = 0 - $lastFileCount = $extractedFiles.Count - } - if ($stableChecks -ge $requiredStableChecks) { - $extractionDone = $true - Write-DATLogEntry -Value "[BIOS] Lenovo: Extraction complete -- $($extractedFiles.Count) files detected and stable for $($requiredStableChecks * 0.5)s, terminating installer" -Severity 1 - break - } - } - Start-Sleep -Milliseconds 500 - $elapsed += 0.5 - } + # Body: PowerShell 7 exposes it via ErrorDetails.Message; fall back to the response stream for + # Windows PowerShell 5.1 where ErrorDetails may be empty. + $raw = $null + if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { + $raw = $ErrorRecord.ErrorDetails.Message + } else { + try { + $stream = $ErrorRecord.Exception.Response.GetResponseStream() + if ($stream) { + $reader = New-Object System.IO.StreamReader($stream) + $raw = $reader.ReadToEnd() + $reader.Dispose() + } + } catch { } + } - # TEMPORARILY DISABLED: Kill the Inno Setup process tree - <# if (-not $extractProc.HasExited) { - try { - # Kill child processes first (wFlashGUIX64.exe, AFUWINx64.EXE, etc.) - $children = Get-CimInstance -ClassName Win32_Process -Filter "ParentProcessId = $($extractProc.Id)" -ErrorAction SilentlyContinue - foreach ($child in $children) { - try { Stop-Process -Id $child.ProcessId -Force -ErrorAction SilentlyContinue } catch {} - Write-DATLogEntry -Value "[BIOS] Lenovo: Killed child process $($child.Name) (PID $($child.ProcessId))" -Severity 1 - } - $extractProc.Kill() - Write-DATLogEntry -Value "[BIOS] Lenovo: Killed Inno Setup installer process" -Severity 1 - } catch {} - } #> + $info = $null + if (-not [string]::IsNullOrWhiteSpace($raw)) { try { $info = $raw | ConvertFrom-Json } catch { } } - # TEMPORARILY DISABLED: Final sweep kill flash utilities - <# foreach ($flashName in $flashProcessNames) { - $flashProcs = Get-Process -Name $flashName -ErrorAction SilentlyContinue - foreach ($fp in $flashProcs) { - try { - $fp.Kill() - Write-DATLogEntry -Value "[BIOS] Lenovo: Post-extract killed flash utility $($fp.ProcessName) (PID $($fp.Id))" -Severity 2 - Set-DATRegistryValue -Name 'LenovoFlashKilled' -Value $fp.ProcessName -Type String - } catch {} - } - } #> + $message = if ($info -and $info.message) { [string]$info.message } else { + 'This version of the Driver Automation Tool is no longer supported. Please upgrade to continue.' + } + $downloadUrl = if ($info -and $info.downloadUrl) { [string]$info.downloadUrl } else { + 'https://github.com/maurice-daly/DriverAutomationTool/releases' + } + $minVersion = if ($info -and $info.minimumVersion) { [string]$info.minimumVersion } else { '' } + + Write-DATLogEntry -Value "$Source API rejected the client (HTTP 426 Upgrade Required): $message" -Severity 3 + Set-DATUpgradeRequiredState -Message $message -DownloadUrl $downloadUrl -MinimumVersion $minVersion + return $true +} - if (-not $extractionDone) { - # Process exited on its own -- check if files were extracted - $extractedFiles = @(Get-ChildItem -Path $extractDir -File -ErrorAction SilentlyContinue) - if ($extractedFiles.Count -lt 2) { - throw "Lenovo BIOS extraction produced insufficient files (found: $($extractedFiles.Count))" - } - } +function Get-DATHttpThrottleInfo { + <# + .SYNOPSIS + Classifies a caught HTTP error as Azure Storage throttling. Returns a hashtable with the HTTP + status, the Azure error Code (ServerBusy / OperationTimedOut / ...), a Retry-After delay in + seconds, and IsThrottle ($true for 429/503 or a throttle code). Engine-safe (PS 5.1 / 7). + #> + [CmdletBinding()] + [OutputType([hashtable])] + param ([Parameter(Mandatory)]$ErrorRecord) - # Clean up: remove uninstall artifacts left by Inno Setup - Get-ChildItem -Path $extractDir -Filter 'unins*' -File -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } catch { - throw "Lenovo BIOS extraction failed: $($_.Exception.Message)" - } finally { - # Remove residual folders the Lenovo installer created on the system drive. - # These hardcoded C:\DRIVERS\FLASH / C:\SWTOOLS\FLASH payload folders are - # created regardless of the /DIR argument and are otherwise never cleaned - # up by the build, tool close, or Purge button (#863). Runs on both success - # and failure so nothing is left behind. - foreach ($residualRoot in $lenovoResidualRoots) { - try { - if (-not (Test-Path $residualRoot)) { continue } - $preExisting = $lenovoResidualSnapshot[$residualRoot] - if ($null -eq $preExisting) { - # Root was created by this extraction -- remove it entirely. - Remove-Item -Path $residualRoot -Recurse -Force -ErrorAction SilentlyContinue - Write-DATLogEntry -Value "[BIOS] Lenovo: Removed residual extraction folder $residualRoot" -Severity 1 - # Remove the parent (DRIVERS / SWTOOLS) too if we left it empty. - $residualParent = Split-Path $residualRoot -Parent - if ((Test-Path $residualParent) -and - -not (Get-ChildItem -Path $residualParent -Force -ErrorAction SilentlyContinue)) { - Remove-Item -Path $residualParent -Recurse -Force -ErrorAction SilentlyContinue - Write-DATLogEntry -Value "[BIOS] Lenovo: Removed empty residual folder $residualParent" -Severity 1 - } - } else { - # Root pre-existed -- only remove entries the installer newly added. - $newEntries = Get-ChildItem -Path $residualRoot -Force -ErrorAction SilentlyContinue | - Where-Object { $preExisting -notcontains $_.FullName } - foreach ($entry in $newEntries) { - Remove-Item -Path $entry.FullName -Recurse -Force -ErrorAction SilentlyContinue - Write-DATLogEntry -Value "[BIOS] Lenovo: Removed residual extraction item $($entry.FullName)" -Severity 1 - } - } - } catch { - Write-DATLogEntry -Value "[BIOS] Lenovo: Could not clean residual folder $residualRoot -- $($_.Exception.Message)" -Severity 2 + $status = 0; $retryAfter = 0; $code = '' + try { + $resp = $ErrorRecord.Exception.Response + if ($null -ne $resp) { + $status = [int]$resp.StatusCode + try { + $h = $resp.Headers + if ($null -ne $h) { + # PS 5.1 HttpWebResponse exposes a string header; PS 7 HttpResponseMessage uses RetryAfter.Delta. + $raStr = $null + try { $raStr = $h['Retry-After'] } catch { $raStr = $null } + if ([string]::IsNullOrEmpty($raStr)) { + try { if ($h.RetryAfter -and $h.RetryAfter.Delta) { $retryAfter = [int]$h.RetryAfter.Delta.TotalSeconds } } catch { } + } else { + $tmp = 0; if ([int]::TryParse("$raStr", [ref]$tmp)) { $retryAfter = $tmp } } } - } - } - default { - # Unknown OEM -- place exe directly - Write-DATLogEntry -Value "[BIOS] $OEM : Staging BIOS file directly (unknown extraction method)" -Severity 2 - Copy-Item -Path $BiosFilePath -Destination $extractDir -Force + } catch { } } - } - - # Verify extraction produced files - $extractedFiles = @(Get-ChildItem -Path $extractDir -Recurse -File -ErrorAction SilentlyContinue) - if ($extractedFiles.Count -eq 0) { - throw "BIOS extraction produced no files for $OEM $Model" - } - Write-DATLogEntry -Value "[BIOS] Extracted $($extractedFiles.Count) files" -Severity 1 + } catch { $status = 0 } - # Stage Flash64W.exe for Dell when requested (ConfigMgr and WIM Package Only modes) - if ($IncludeFlash64W -and $OEM -eq 'Dell') { - Write-DATLogEntry -Value "[BIOS] Dell: Ensuring Flash64W.exe is staged in extraction directory" -Severity 1 - $flash64Result = Get-DATFlash64W -DestinationDir $extractDir - if (-not $flash64Result) { - Write-DATLogEntry -Value "[Warning] Flash64W.exe could not be obtained -- Dell BIOS package may not work in WinPE" -Severity 2 + try { + $body = $null + if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { + $body = $ErrorRecord.ErrorDetails.Message } - } + if (-not [string]::IsNullOrWhiteSpace($body) -and $body -match '([^<]+)') { $code = $Matches[1] } + } catch { } - if ($SkipWim) { - # ConfigMgr: return the temp extraction directory so New-DATConfigMgrPkg can - # copy its contents into the final versioned path ($PackagePath\$OEM\$Model\BIOS\$Version). - # Do NOT copy to the package store here -- that would place files at the unversioned - # $OEM\$Model\BIOS level, and ConfigMgr would then try to copy that into a child - # directory of itself ($OEM\$Model\BIOS\$Version), causing a self-overwrite error. - Write-DATLogEntry -Value "[BIOS] Staging $($extractedFiles.Count) files in temp: $extractDir (no WIM)" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Staging BIOS files for $OEM $Model..." -Type String - return [string]$extractDir - } + $isThrottle = ($status -eq 429 -or $status -eq 503 -or $code -in @('ServerBusy', 'TooManyRequests', 'OperationTimedOut')) + return @{ Status = $status; Code = $code; RetryAfter = $retryAfter; IsThrottle = [bool]$isThrottle } +} - # Intune: Capture extracted content into WIM using the preferred engine - Write-DATLogEntry -Value "[BIOS] Creating WIM: $wimFile" -Severity 1 - Set-DATRegistryValue -Name "RunningMessage" -Value "Creating BIOS WIM for $OEM $Model..." -Type String +function Test-DATTelemetryEnabled { + <# + .SYNOPSIS + Returns $true when telemetry is permitted (local opt-in, remote enabled, version check). + #> + [CmdletBinding()] + [OutputType([bool])] + param () - $wimEngine = (Get-ItemProperty -Path $global:RegPath -Name 'WimEngine' -ErrorAction SilentlyContinue).WimEngine - if ([string]::IsNullOrEmpty($wimEngine) -or $wimEngine -notin @('dism','wimlib','7zip')) { - # No explicit choice: prefer bundled wimlib (self-contained, no dismhost/DISM providers -- - # avoids DISM-hangs-at-init). Fall back to DISM when wimlib is not bundled. - $bundledWimlib = Join-Path $global:ToolsDirectory 'Wimlib\wimlib-imagex.exe' - $wimEngine = if (Test-Path $bundledWimlib) { 'wimlib' } else { 'dism' } + # 1. Local opt-in (TelemetryOptOut DWord = 1 means opted IN per existing UI convention) + $regOptOut = (Get-ItemProperty -Path $global:RegPath -Name 'TelemetryOptOut' -ErrorAction SilentlyContinue).TelemetryOptOut + if ($regOptOut -ne 1) { + Write-DATLogEntry -Value "[Telemetry] Disabled -- TelemetryOptOut registry value is '$regOptOut' (expected 1) at $($global:RegPath)" -Severity 2 + return $false } - try { - if ($wimEngine -eq 'wimlib') { - $wimlibExe = Join-Path (Join-Path $global:ToolsDirectory 'Wimlib') 'wimlib-imagex.exe' - if (Test-Path $wimlibExe) { - Write-DATLogEntry -Value "[BIOS] Using wimlib-imagex for WIM creation" -Severity 1 - $proc = Start-Process -FilePath $wimlibExe ` - -ArgumentList "capture `"$extractDir`" `"$wimFile`" `"BIOS - $OEM $Model`" --compress=XPRESS --threads=0 --no-acls" ` - -WindowStyle Hidden -Wait -PassThru - if ($proc.ExitCode -ne 0) { throw "wimlib-imagex exited with code $($proc.ExitCode)" } - } else { - Write-DATLogEntry -Value "[BIOS] wimlib not found -- falling back to DISM" -Severity 2 - New-WindowsImage -ImagePath $wimFile -CapturePath $extractDir -Name "BIOS - $OEM $Model" -Description "BIOS $Version" -ErrorAction Stop | Out-Null - } - } elseif ($wimEngine -eq '7zip') { - $7zipExe = $null - foreach ($c in @((Join-Path $env:ProgramFiles '7-Zip\7z.exe'), (Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe'))) { - if (Test-Path $c) { $7zipExe = $c; break } - } - if (-not $7zipExe) { try { $7zipExe = (Get-Command '7z.exe' -ErrorAction Stop).Source } catch { } } - if (-not [string]::IsNullOrEmpty($7zipExe) -and (Test-Path $7zipExe)) { - Write-DATLogEntry -Value "[BIOS] Using 7-Zip for WIM creation" -Severity 1 - $proc = Start-Process -FilePath $7zipExe -ArgumentList "a -twim `"$wimFile`" `"$extractDir\*`" -mx=1" ` - -WindowStyle Hidden -Wait -PassThru - if ($proc.ExitCode -ne 0) { throw "7-Zip exited with code $($proc.ExitCode)" } - } else { - Write-DATLogEntry -Value "[BIOS] 7-Zip not found -- falling back to DISM" -Severity 2 - New-WindowsImage -ImagePath $wimFile -CapturePath $extractDir -Name "BIOS - $OEM $Model" -Description "BIOS $Version" -ErrorAction Stop | Out-Null + # 2. Remote kill switch + $config = Get-DATTelemetryConfig + if ($null -eq $config) { return $false } + if (-not $config.telemetryEnabled) { return $false } + + # 3. Minimum version check + if (-not [string]::IsNullOrEmpty($config.minimumDatVersion)) { + try { + if ([version]$global:ScriptRelease -lt [version]$config.minimumDatVersion) { + Write-DATLogEntry -Value "[Telemetry] DAT version $($global:ScriptRelease) is below minimum $($config.minimumDatVersion) -- skipping" -Severity 2 + return $false } - } else { - New-WindowsImage -ImagePath $wimFile -CapturePath $extractDir -Name "BIOS - $OEM $Model" -Description "BIOS $Version" -ErrorAction Stop | Out-Null - } - $wimSizeMB = [math]::Round((Get-Item $wimFile).Length / 1MB, 2) - Write-DATLogEntry -Value "[BIOS] WIM created: $wimSizeMB MB" -Severity 1 - } catch { - throw "BIOS WIM creation failed: $($_.Exception.Message)" + } catch { } } - # Copy WIM from temp to the Package Store destination - if (Test-Path $destBiosFolder) { Remove-Item $destBiosFolder -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -Path $destBiosFolder -ItemType Directory -Force | Out-Null - $destWimFile = Join-Path $destBiosFolder "DriverPackage.wim" - Write-DATLogEntry -Value "[BIOS] Copying WIM to package destination: $destWimFile" -Severity 1 - Copy-Item -Path $wimFile -Destination $destWimFile -Force - Write-DATLogEntry -Value "[BIOS] WIM copied to package destination successfully" -Severity 1 + return $true +} - # Write a version marker so the pre-flight check can skip re-downloads when the version matches - $versionMarker = Join-Path $destBiosFolder ".biosversion" - Set-Content -Path $versionMarker -Value $Version -Encoding UTF8 -Force +function Get-DATTelemetryId { + <# + .SYNOPSIS + Returns the persistent telemetry GUID from the registry, or $null if not set. + #> + [CmdletBinding()] + [OutputType([string])] + param () + return (Get-ItemProperty -Path $global:RegPath -Name 'TelemetryGuid' -ErrorAction SilentlyContinue).TelemetryGuid +} - # Clean up temp directories - Remove-Item -Path $extractDir -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path $localWorkDir -Recurse -Force -ErrorAction SilentlyContinue +function Get-DATEnvironmentProfile { + <# + .SYNOPSIS + Returns the optional estate profile (device-count bucket and management + platform) from the registry. Values may be empty strings when unset. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param () - return [string]$destWimFile -} + $range = (Get-ItemProperty -Path $global:RegPath -Name 'DeviceCountRange' -ErrorAction SilentlyContinue).DeviceCountRange + $platform = (Get-ItemProperty -Path $global:RegPath -Name 'ManagementPlatform' -ErrorAction SilentlyContinue).ManagementPlatform -#endregion BIOS Catalog & Download + return @{ + DeviceCountRange = if ([string]::IsNullOrWhiteSpace($range)) { '' } else { [string]$range } + ManagementPlatform = if ([string]::IsNullOrWhiteSpace($platform)) { '' } else { [string]$platform } + } +} -#region Telemetry +function Set-DATEnvironmentProfile { + <# + .SYNOPSIS + Persists the optional estate profile to the registry. Empty values clear + the corresponding setting. + #> + [CmdletBinding()] + param ( + [AllowEmptyString()][string]$DeviceCountRange = '', + [AllowEmptyString()][string]$ManagementPlatform = '' + ) -# Session-scoped cache for the remote config (fetched once per module load / tool session) -$script:DATTelemetryConfig = $null + Set-DATRegistryValue -Name 'DeviceCountRange' -Value $DeviceCountRange -Type String + Set-DATRegistryValue -Name 'ManagementPlatform' -Value $ManagementPlatform -Type String +} -function Get-DATTelemetryConfig { +function Send-DATTelemetry { <# .SYNOPSIS - Returns the DAT API config (apiBaseUrl, endpoints, HMAC secret, kill switch, version gate). - Prefers the remote copy on GitHub -- the freshest source, which carries the server-controlled - kill switch, minimum-version gate and any rotated secret -- and caches it locally on success. - If the remote fetch fails (offline, GitHub outage, HTTP 429) it falls back to the last-known-good - local copy so the tool keeps working. Returns $null only when neither remote nor local is available. + Posts a telemetry payload to the specified API endpoint. + .PARAMETER Endpoint + Relative endpoint path from the config (e.g. 'telemetry/driver-report'). + .PARAMETER Body + Hashtable to serialize as JSON and POST. #> [CmdletBinding()] - [OutputType([PSCustomObject])] param ( - [switch]$Force + [Parameter(Mandatory)] + [string]$Endpoint, + + [Parameter(Mandatory)] + [hashtable]$Body ) - if ($script:DATTelemetryConfig -and -not $Force) { - return $script:DATTelemetryConfig + if (-not (Test-DATTelemetryEnabled)) { + Write-DATLogEntry -Value "[Telemetry] Skipped POST $Endpoint -- telemetry not enabled" -Severity 2 + return } - $configUrl = $global:DATConfigUrl - $localPath = Join-Path $global:ScriptDirectory 'Data\DATAPIConfig.json' + $config = Get-DATTelemetryConfig + if ($null -eq $config -or [string]::IsNullOrEmpty($config.apiBaseUrl)) { return } - # 1. Prefer the remote copy. On success, cache it locally as the last-known-good fallback. + $url = "$($config.apiBaseUrl)/$Endpoint" + $json = $Body | ConvertTo-Json -Depth 5 -Compress + # Encode the body to UTF-8 bytes ONCE so the signed content and the bytes + # sent on the wire are byte-identical. Windows PowerShell 5.1 otherwise + # transmits a string body as Latin1, corrupting non-ASCII characters and + # breaking server-side HMAC verification. + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($json) + + # HMAC-SHA256 request signing (softfail-safe -- skipped if secret is absent or computation fails). + # x-dat-version is set unconditionally so the API version gate always sees it, even when HMAC is skipped. + $headers = @{ 'x-dat-version' = [string]$global:ScriptRelease } try { - $proxyParams = Get-DATWebRequestProxy - if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } - $response = Invoke-RestMethod -Uri $configUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop @proxyParams - if ($null -ne $response -and -not [string]::IsNullOrEmpty($response.apiBaseUrl)) { - $script:DATTelemetryConfig = $response - try { - $dataDir = Split-Path $localPath -Parent - if (-not (Test-Path -LiteralPath $dataDir)) { New-Item -Path $dataDir -ItemType Directory -Force | Out-Null } - $response | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $localPath -Encoding UTF8 -ErrorAction Stop - } catch { - Write-DATLogEntry -Value "[Telemetry] Could not cache API config locally ($localPath): $($_.Exception.Message)" -Severity 2 - } - Write-DATLogEntry -Value "[Telemetry] Remote config loaded from $configUrl (apiBaseUrl: $($response.apiBaseUrl))" -Severity 1 - return $script:DATTelemetryConfig + $hmacSecret = $null + if ($config -and $config.PSObject.Properties['hmacSecret']) { + $hmacSecret = $config.hmacSecret + } + if (-not [string]::IsNullOrEmpty($hmacSecret)) { + $timestamp = (Get-Date).ToUniversalTime().ToString('o') + $keyBytes = [System.Text.Encoding]::UTF8.GetBytes($hmacSecret) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) + $sigBytes = $hmac.ComputeHash($bodyBytes) + $signature = -join ($sigBytes | ForEach-Object { $_.ToString('x2') }) + $hmac.Dispose() + $headers['x-dat-signature'] = $signature + $headers['x-dat-timestamp'] = $timestamp } - Write-DATLogEntry -Value "[Telemetry] Remote config from $configUrl was empty/invalid -- falling back to local copy" -Severity 2 } catch { - Write-DATLogEntry -Value "[Telemetry] Failed to fetch remote config from $configUrl : $($_.Exception.Message) -- falling back to local copy" -Severity 2 + Write-DATLogEntry -Value "[Telemetry] HMAC signing skipped: $($_.Exception.Message)" -Severity 2 } - # 2. Fall back to the cached/bundled local copy so a GitHub outage cannot break the tool. - if (Test-Path -LiteralPath $localPath) { - try { - $localRaw = Get-Content -LiteralPath $localPath -Raw -ErrorAction Stop - $local = $localRaw | ConvertFrom-Json -ErrorAction Stop - if ($null -ne $local -and -not [string]::IsNullOrEmpty($local.apiBaseUrl)) { - $script:DATTelemetryConfig = $local - Write-DATLogEntry -Value "[Telemetry] Using local fallback config: $localPath (apiBaseUrl: $($local.apiBaseUrl))" -Severity 2 - return $script:DATTelemetryConfig - } - } catch { - Write-DATLogEntry -Value "[Telemetry] Local fallback config unreadable ($localPath): $($_.Exception.Message)" -Severity 2 + try { + $proxyParams = Get-DATWebRequestProxy + if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } + $null = Invoke-RestMethod -Uri $url -Method POST -Body $bodyBytes -ContentType 'application/json; charset=utf-8' ` + -Headers $headers -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop @proxyParams + Write-DATLogEntry -Value "[Telemetry] POST $Endpoint -- success" -Severity 1 + Write-DATLogEntry -Value "[Telemetry] Payload: $json" -Severity 1 + if ($global:ExecutionMode -eq 'Scheduled Task') { + Write-Host "[Telemetry] POST $Endpoint -- success" + Write-Host "[Telemetry] Payload: $json" + } + } catch { + # HTTP 426 -- client version below the API minimum. Record the upgrade prompt (telemetry is + # fire-and-forget, so a failure must never surface as an error), then stop. + if (Resolve-DATApiUpgradeRequired -ErrorRecord $_ -Source '[Telemetry]') { return } + Write-DATLogEntry -Value "[Telemetry] POST $Endpoint -- failed: $($_.Exception.Message)" -Severity 2 + if ($global:ExecutionMode -eq 'Scheduled Task') { + Write-Host "[Telemetry] POST $Endpoint -- failed: $($_.Exception.Message)" } } - - Write-DATLogEntry -Value "[Telemetry] No remote or local API config available -- API-dependent features stay disabled until connectivity returns" -Severity 3 - return $null } -function Set-DATUpgradeRequiredState { +function Send-DATFeedback { <# .SYNOPSIS - Records an API "upgrade required" (HTTP 426) result to the registry so the UI or the headless - runner can surface it. Internal helper used by Resolve-DATApiUpgradeRequired. + Submits user feedback (thumbs up/down) to the DAT API. + .PARAMETER Rating + 'Positive' or 'Negative'. + .PARAMETER Comment + Optional comment text (used with negative feedback). #> [CmdletBinding()] param ( - [AllowEmptyString()][string]$Message = '', - [AllowEmptyString()][string]$DownloadUrl = '', - [AllowEmptyString()][string]$MinimumVersion = '' + [Parameter(Mandatory)] + [ValidateSet('Positive', 'Negative')] + [string]$Rating, + + [AllowEmptyString()] + [string]$Comment = '', + + [AllowEmptyString()] + [string]$Email = '', + + [bool]$FollowUp = $false ) + + $telemetryId = Get-DATTelemetryId + if ([string]::IsNullOrEmpty($telemetryId)) { + # Generate a one-time GUID if telemetry is not configured + $telemetryId = [guid]::NewGuid().ToString() + } + + $body = @{ + installId = $telemetryId + rating = $Rating + comment = $Comment + email = $Email + followUp = [bool]$FollowUp + submittedAt = (Get-Date).ToUniversalTime().ToString('o') + appVersion = $global:ScriptRelease + } + + $config = Get-DATTelemetryConfig + if ($null -eq $config -or [string]::IsNullOrEmpty($config.apiBaseUrl)) { + Write-DATLogEntry -Value "[Feedback] Cannot submit -- API config unavailable" -Severity 2 + return + } + + $url = "$($config.apiBaseUrl)/feedback" + $json = $body | ConvertTo-Json -Depth 5 -Compress + # Encode the body to UTF-8 bytes ONCE so the signed content and the bytes + # sent on the wire are byte-identical. Windows PowerShell 5.1 otherwise + # transmits a string body as Latin1, corrupting non-ASCII characters and + # breaking server-side HMAC verification. + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($json) + + # HMAC-SHA256 request signing (softfail-safe -- skipped if secret is absent or computation fails). + # x-dat-version is set unconditionally so the API version gate always sees it, even when HMAC is skipped. + $headers = @{ 'x-dat-version' = [string]$global:ScriptRelease } try { - Set-DATRegistryValue -Name 'UpgradeRequired' -Value 1 -Type DWord - Set-DATRegistryValue -Name 'UpgradeMessage' -Value $Message -Type String - Set-DATRegistryValue -Name 'UpgradeDownloadUrl' -Value $DownloadUrl -Type String - Set-DATRegistryValue -Name 'UpgradeMinimumVersion' -Value $MinimumVersion -Type String - } catch { } -} + $hmacSecret = $null + if ($config -and $config.PSObject.Properties['hmacSecret']) { + $hmacSecret = $config.hmacSecret + } + if (-not [string]::IsNullOrEmpty($hmacSecret)) { + $timestamp = (Get-Date).ToUniversalTime().ToString('o') + $keyBytes = [System.Text.Encoding]::UTF8.GetBytes($hmacSecret) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) + $sigBytes = $hmac.ComputeHash($bodyBytes) + $signature = -join ($sigBytes | ForEach-Object { $_.ToString('x2') }) + $hmac.Dispose() + $headers['x-dat-signature'] = $signature + $headers['x-dat-timestamp'] = $timestamp + } + } catch { + Write-DATLogEntry -Value "[Feedback] HMAC signing skipped: $($_.Exception.Message)" -Severity 2 + } -function Get-DATUpgradeRequiredState { - <# - .SYNOPSIS - Returns the pending API "upgrade required" (HTTP 426) state as a hashtable, or $null when no - upgrade signal is set. Read by the UI watcher and the headless runner. - #> - [CmdletBinding()] - [OutputType([hashtable])] - param () - $flag = (Get-ItemProperty -Path $global:RegPath -Name 'UpgradeRequired' -ErrorAction SilentlyContinue).UpgradeRequired - if ($flag -ne 1) { return $null } - return @{ - Message = [string](Get-ItemProperty -Path $global:RegPath -Name 'UpgradeMessage' -ErrorAction SilentlyContinue).UpgradeMessage - DownloadUrl = [string](Get-ItemProperty -Path $global:RegPath -Name 'UpgradeDownloadUrl' -ErrorAction SilentlyContinue).UpgradeDownloadUrl - MinimumVersion = [string](Get-ItemProperty -Path $global:RegPath -Name 'UpgradeMinimumVersion' -ErrorAction SilentlyContinue).UpgradeMinimumVersion + try { + $proxyParams = Get-DATWebRequestProxy + if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } + $null = Invoke-RestMethod -Uri $url -Method POST -Body $bodyBytes -ContentType 'application/json; charset=utf-8' ` + -Headers $headers -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop @proxyParams + Write-DATLogEntry -Value "[Feedback] Submitted $Rating feedback successfully" -Severity 1 + } catch { + [void](Resolve-DATApiUpgradeRequired -ErrorRecord $_ -Source '[Feedback]') + Write-DATLogEntry -Value "[Feedback] Submit failed: $($_.Exception.Message)" -Severity 2 + throw } } -function Clear-DATUpgradeRequiredState { +function Get-DATHPSoftPaqManifestPath { <# .SYNOPSIS - Clears the pending API "upgrade required" state after it has been shown to the user. + Returns the full path to the HP SoftPaq manifest file under Settings. #> [CmdletBinding()] - param () - foreach ($valueName in @('UpgradeRequired', 'UpgradeMessage', 'UpgradeDownloadUrl', 'UpgradeMinimumVersion')) { - try { Remove-ItemProperty -Path $global:RegPath -Name $valueName -ErrorAction SilentlyContinue } catch { } + [OutputType([string])] + param() + $settingsDir = Join-Path $global:ScriptDirectory 'Settings' + if (-not (Test-Path -LiteralPath $settingsDir)) { + try { New-Item -Path $settingsDir -ItemType Directory -Force | Out-Null } catch {} } + return (Join-Path $settingsDir 'HPSoftPaqManifest.json') } -function Resolve-DATApiUpgradeRequired { +function Get-DATDellLatestManifestPath { <# .SYNOPSIS - Inspects a caught API error for HTTP 426 (Upgrade Required). On a match it records the upgrade - state (message / downloadUrl) and returns $true; otherwise returns $false. Engine-safe across - Windows PowerShell 5.1 (HttpWebResponse) and PowerShell 7 (HttpResponseMessage). + Returns the full path to the Dell "Latest Drivers" (DCU) manifest file under Settings. #> [CmdletBinding()] - [OutputType([bool])] - param ( - [Parameter(Mandatory)]$ErrorRecord, - [string]$Source = '[API]' - ) - - # Status code: [int] of the StatusCode enum works on both HttpWebResponse (5.1) and - # HttpResponseMessage (7); .value__ is 5.1-only and must not be used. - $status = 0 - try { - $resp = $ErrorRecord.Exception.Response - if ($null -ne $resp) { $status = [int]$resp.StatusCode } - } catch { $status = 0 } - if ($status -ne 426) { return $false } - - # Body: PowerShell 7 exposes it via ErrorDetails.Message; fall back to the response stream for - # Windows PowerShell 5.1 where ErrorDetails may be empty. - $raw = $null - if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { - $raw = $ErrorRecord.ErrorDetails.Message - } else { - try { - $stream = $ErrorRecord.Exception.Response.GetResponseStream() - if ($stream) { - $reader = New-Object System.IO.StreamReader($stream) - $raw = $reader.ReadToEnd() - $reader.Dispose() - } - } catch { } - } - - $info = $null - if (-not [string]::IsNullOrWhiteSpace($raw)) { try { $info = $raw | ConvertFrom-Json } catch { } } - - $message = if ($info -and $info.message) { [string]$info.message } else { - 'This version of the Driver Automation Tool is no longer supported. Please upgrade to continue.' - } - $downloadUrl = if ($info -and $info.downloadUrl) { [string]$info.downloadUrl } else { - 'https://github.com/maurice-daly/DriverAutomationTool/releases' + [OutputType([string])] + param() + $settingsDir = Join-Path $global:ScriptDirectory 'Settings' + if (-not (Test-Path -LiteralPath $settingsDir)) { + try { New-Item -Path $settingsDir -ItemType Directory -Force | Out-Null } catch {} } - $minVersion = if ($info -and $info.minimumVersion) { [string]$info.minimumVersion } else { '' } - - Write-DATLogEntry -Value "$Source API rejected the client (HTTP 426 Upgrade Required): $message" -Severity 3 - Set-DATUpgradeRequiredState -Message $message -DownloadUrl $downloadUrl -MinimumVersion $minVersion - return $true + return (Join-Path $settingsDir 'DellLatestManifest.json') } -function Get-DATHttpThrottleInfo { +function Get-DATDellLatestManifestKey { <# .SYNOPSIS - Classifies a caught HTTP error as Azure Storage throttling. Returns a hashtable with the HTTP - status, the Azure error Code (ServerBusy / OperationTimedOut / ...), a Retry-After delay in - seconds, and IsThrottle ($true for 429/503 or a throttle code). Engine-safe (PS 5.1 / 7). + Builds a stable manifest key for a Dell model/OS/build/architecture combination. #> [CmdletBinding()] - [OutputType([hashtable])] - param ([Parameter(Mandatory)]$ErrorRecord) - - $status = 0; $retryAfter = 0; $code = '' - try { - $resp = $ErrorRecord.Exception.Response - if ($null -ne $resp) { - $status = [int]$resp.StatusCode - try { - $h = $resp.Headers - if ($null -ne $h) { - # PS 5.1 HttpWebResponse exposes a string header; PS 7 HttpResponseMessage uses RetryAfter.Delta. - $raStr = $null - try { $raStr = $h['Retry-After'] } catch { $raStr = $null } - if ([string]::IsNullOrEmpty($raStr)) { - try { if ($h.RetryAfter -and $h.RetryAfter.Delta) { $retryAfter = [int]$h.RetryAfter.Delta.TotalSeconds } } catch { } - } else { - $tmp = 0; if ([int]::TryParse("$raStr", [ref]$tmp)) { $retryAfter = $tmp } - } - } - } catch { } - } - } catch { $status = 0 } - - try { - $body = $null - if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { - $body = $ErrorRecord.ErrorDetails.Message - } - if (-not [string]::IsNullOrWhiteSpace($body) -and $body -match '([^<]+)') { $code = $Matches[1] } - } catch { } - - $isThrottle = ($status -eq 429 -or $status -eq 503 -or $code -in @('ServerBusy', 'TooManyRequests', 'OperationTimedOut')) - return @{ Status = $status; Code = $code; RetryAfter = $retryAfter; IsThrottle = [bool]$isThrottle } + [OutputType([string])] + param ( + [Parameter(Mandatory)][AllowEmptyString()][string]$Model, + [Parameter(Mandatory)][AllowEmptyString()][string]$OSVersion, + [Parameter(Mandatory)][AllowEmptyString()][string]$Build, + [Parameter(Mandatory)][AllowEmptyString()][string]$Architecture + ) + return ("Dell|{0}|{1}|{2}|{3}" -f $Model.Trim(), $OSVersion.Trim(), $Build.Trim(), $Architecture.Trim()) } -function Test-DATTelemetryEnabled { +function Get-DATDellDUPFingerprint { <# .SYNOPSIS - Returns $true when telemetry is permitted (local opt-in, remote enabled, version check). + Computes an order-independent SHA256 fingerprint of a Dell DUP identifier list (release + IDs are alphanumeric, unlike HP's numeric SoftPaq ids). Returns lowercase hex or $null. #> [CmdletBinding()] - [OutputType([bool])] - param () - - # 1. Local opt-in (TelemetryOptOut DWord = 1 means opted IN per existing UI convention) - $regOptOut = (Get-ItemProperty -Path $global:RegPath -Name 'TelemetryOptOut' -ErrorAction SilentlyContinue).TelemetryOptOut - if ($regOptOut -ne 1) { - Write-DATLogEntry -Value "[Telemetry] Disabled -- TelemetryOptOut registry value is '$regOptOut' (expected 1) at $($global:RegPath)" -Severity 2 - return $false + [OutputType([string])] + param ( + [Parameter(Mandatory)][AllowEmptyCollection()][AllowNull()][AllowEmptyString()][string[]]$Identifiers + ) + if ($null -eq $Identifiers) { return $null } + $valid = @($Identifiers | + ForEach-Object { if ($null -ne $_) { $_.Trim() } } | + Where-Object { $_ } | + Select-Object -Unique | + Sort-Object) + if ($valid.Count -eq 0) { return $null } + $joined = ($valid -join ',') + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined) + return [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() } +} - # 2. Remote kill switch - $config = Get-DATTelemetryConfig - if ($null -eq $config) { return $false } - if (-not $config.telemetryEnabled) { return $false } - - # 3. Minimum version check - if (-not [string]::IsNullOrEmpty($config.minimumDatVersion)) { +function Get-DATDellLatestManifest { + <# + .SYNOPSIS + Loads the Dell Latest Drivers manifest as a hashtable. Missing/corrupt files yield empty. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + $path = Get-DATDellLatestManifestPath + $manifest = @{} + if (Test-Path -LiteralPath $path) { try { - if ([version]$global:ScriptRelease -lt [version]$config.minimumDatVersion) { - Write-DATLogEntry -Value "[Telemetry] DAT version $($global:ScriptRelease) is below minimum $($config.minimumDatVersion) -- skipping" -Severity 2 - return $false + $raw = Get-Content -LiteralPath $path -Raw -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $obj = $raw | ConvertFrom-Json -ErrorAction Stop + foreach ($prop in $obj.PSObject.Properties) { + $manifest[$prop.Name] = $prop.Value + } } - } catch { } + } catch { + Write-DATLogEntry -Value "[Dell] Latest Drivers manifest unreadable, treating as empty: $($_.Exception.Message)" -Severity 2 + $manifest = @{} + } } - - return $true + return $manifest } -function Get-DATTelemetryId { +function Save-DATDellLatestManifest { <# .SYNOPSIS - Returns the persistent telemetry GUID from the registry, or $null if not set. + Atomically persists the Dell Latest Drivers manifest hashtable to disk. Never throws. #> [CmdletBinding()] - [OutputType([string])] - param () - return (Get-ItemProperty -Path $global:RegPath -Name 'TelemetryGuid' -ErrorAction SilentlyContinue).TelemetryGuid + [OutputType([bool])] + param ( + [Parameter(Mandatory)][hashtable]$Manifest + ) + $path = Get-DATDellLatestManifestPath + try { + $json = $Manifest | ConvertTo-Json -Depth 6 + $tmp = "$path.tmp" + Set-Content -LiteralPath $tmp -Value $json -Encoding UTF8 -ErrorAction Stop + Move-Item -LiteralPath $tmp -Destination $path -Force -ErrorAction Stop + return $true + } catch { + Write-DATLogEntry -Value "[Dell] Failed to save Latest Drivers manifest: $($_.Exception.Message)" -Severity 2 + return $false + } } -function Get-DATEnvironmentProfile { +function Update-DATDellLatestManifestReference { <# .SYNOPSIS - Returns the optional estate profile (device-count bucket and management - platform) from the registry. Values may be empty strings when unset. + Records the remote package reference (Intune app id / ConfigMgr package name) on an + existing Dell Latest Drivers manifest entry. No-ops when the entry is absent. #> [CmdletBinding()] - [OutputType([hashtable])] - param () + [OutputType([bool])] + param ( + [Parameter(Mandatory)][string]$Key, + [Parameter(Mandatory)][ValidateSet('intuneAppId', 'configMgrPackageId')][string]$Field, + [Parameter(Mandatory)][AllowEmptyString()][string]$Value + ) + $manifest = Get-DATDellLatestManifest + if (-not $manifest.ContainsKey($Key)) { return $false } + try { + $entry = $manifest[$Key] + $entry | Add-Member -NotePropertyName $Field -NotePropertyValue $Value -Force + $manifest[$Key] = $entry + return (Save-DATDellLatestManifest -Manifest $manifest) + } catch { + Write-DATLogEntry -Value "[Dell] Failed to record Latest Drivers manifest reference ($Field): $($_.Exception.Message)" -Severity 2 + return $false + } +} - $range = (Get-ItemProperty -Path $global:RegPath -Name 'DeviceCountRange' -ErrorAction SilentlyContinue).DeviceCountRange - $platform = (Get-ItemProperty -Path $global:RegPath -Name 'ManagementPlatform' -ErrorAction SilentlyContinue).ManagementPlatform +# ---- Shared "Latest Drivers" cadence + existence helpers (Dell / HP / Lenovo) -------------- - return @{ - DeviceCountRange = if ([string]::IsNullOrWhiteSpace($range)) { '' } else { [string]$range } - ManagementPlatform = if ([string]::IsNullOrWhiteSpace($platform)) { '' } else { [string]$platform } - } +function Get-DATLatestDriverCadence { + <# + .SYNOPSIS + Returns the Latest Drivers rebuild cadence: 'Off' (default), 'Daily', 'Weekly' or 'Monthly'. + This throttles how often an on-demand ("Latest Drivers") pack is re-evaluated/rebuilt. + #> + [CmdletBinding()] + [OutputType([string])] + param() + $v = (Get-ItemProperty -Path $global:RegPath -Name 'LatestDriverCadence' -ErrorAction SilentlyContinue).LatestDriverCadence + if ([string]::IsNullOrWhiteSpace($v) -or $v -notin @('Daily', 'Weekly', 'Monthly')) { return 'Off' } + return $v } -function Set-DATEnvironmentProfile { +function Test-DATLatestCadenceElapsed { <# .SYNOPSIS - Persists the optional estate profile to the registry. Empty values clear - the corresponding setting. + Returns $true when the cadence window has elapsed since $LastActivity, i.e. a rebuild + evaluation is due. Returns $true for cadence 'Off' or a missing/unparseable timestamp. #> [CmdletBinding()] + [OutputType([bool])] param ( - [AllowEmptyString()][string]$DeviceCountRange = '', - [AllowEmptyString()][string]$ManagementPlatform = '' + [AllowEmptyString()][AllowNull()][string]$LastActivity, + [AllowEmptyString()][AllowNull()][string]$Cadence ) - - Set-DATRegistryValue -Name 'DeviceCountRange' -Value $DeviceCountRange -Type String - Set-DATRegistryValue -Name 'ManagementPlatform' -Value $ManagementPlatform -Type String + if ([string]::IsNullOrWhiteSpace($Cadence) -or $Cadence -eq 'Off') { return $true } + if ([string]::IsNullOrWhiteSpace($LastActivity)) { return $true } + $last = [datetime]::MinValue + if (-not [datetime]::TryParse($LastActivity, [ref]$last)) { return $true } + $now = Get-Date + switch ($Cadence) { + 'Daily' { return ($now -ge $last.AddDays(1)) } + 'Weekly' { return ($now -ge $last.AddDays(7)) } + 'Monthly' { return ($now -ge $last.AddMonths(1)) } + default { return $true } + } } -function Send-DATTelemetry { +function Test-DATLatestPackagePresent { <# .SYNOPSIS - Posts a telemetry payload to the specified API endpoint. - .PARAMETER Endpoint - Relative endpoint path from the config (e.g. 'telemetry/driver-report'). - .PARAMETER Body - Hashtable to serialize as JSON and POST. + Returns $true when the previously built Latest Drivers package for a model still exists for + the given running mode: an on-disk WIM / downloaded files, or a recorded Intune/ConfigMgr + reference that is still present in $ExistingPackageIds (when -VerifyRemoteExistence). #> [CmdletBinding()] + [OutputType([bool])] param ( - [Parameter(Mandatory)] - [string]$Endpoint, - - [Parameter(Mandatory)] - [hashtable]$Body + [Parameter(Mandatory)]$Entry, + [Parameter(Mandatory)][string]$OEM, + [Parameter(Mandatory)][AllowEmptyString()][string]$Model, + [AllowEmptyString()][string]$WindowsVersion, + [AllowEmptyString()][string]$WindowsBuild, + [string]$RunningMode, + [AllowEmptyString()][string]$PackageDestination, + [AllowEmptyString()][string]$DownloadDestination, + [switch]$VerifyRemoteExistence, + [string[]]$ExistingPackageIds = @() ) - - if (-not (Test-DATTelemetryEnabled)) { - Write-DATLogEntry -Value "[Telemetry] Skipped POST $Endpoint -- telemetry not enabled" -Severity 2 - return - } - - $config = Get-DATTelemetryConfig - if ($null -eq $config -or [string]::IsNullOrEmpty($config.apiBaseUrl)) { return } - - $url = "$($config.apiBaseUrl)/$Endpoint" - $json = $Body | ConvertTo-Json -Depth 5 -Compress - # Encode the body to UTF-8 bytes ONCE so the signed content and the bytes - # sent on the wire are byte-identical. Windows PowerShell 5.1 otherwise - # transmits a string body as Latin1, corrupting non-ASCII characters and - # breaking server-side HMAC verification. - $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($json) - - # HMAC-SHA256 request signing (softfail-safe -- skipped if secret is absent or computation fails). - # x-dat-version is set unconditionally so the API version gate always sees it, even when HMAC is skipped. - $headers = @{ 'x-dat-version' = [string]$global:ScriptRelease } - try { - $hmacSecret = $null - if ($config -and $config.PSObject.Properties['hmacSecret']) { - $hmacSecret = $config.hmacSecret + switch ($RunningMode) { + 'Intune' { + if (-not $VerifyRemoteExistence) { return $true } + $ref = "$($Entry.intuneAppId)" + return ((-not [string]::IsNullOrEmpty($ref)) -and ($ExistingPackageIds -contains $ref)) } - if (-not [string]::IsNullOrEmpty($hmacSecret)) { - $timestamp = (Get-Date).ToUniversalTime().ToString('o') - $keyBytes = [System.Text.Encoding]::UTF8.GetBytes($hmacSecret) - $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) - $sigBytes = $hmac.ComputeHash($bodyBytes) - $signature = -join ($sigBytes | ForEach-Object { $_.ToString('x2') }) - $hmac.Dispose() - $headers['x-dat-signature'] = $signature - $headers['x-dat-timestamp'] = $timestamp + 'Configuration Manager' { + if (-not $VerifyRemoteExistence) { return $true } + $ref = "$($Entry.configMgrPackageId)" + return ((-not [string]::IsNullOrEmpty($ref)) -and ($ExistingPackageIds -contains $ref)) } - } catch { - Write-DATLogEntry -Value "[Telemetry] HMAC signing skipped: $($_.Exception.Message)" -Severity 2 - } - - try { - $proxyParams = Get-DATWebRequestProxy - if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } - $null = Invoke-RestMethod -Uri $url -Method POST -Body $bodyBytes -ContentType 'application/json; charset=utf-8' ` - -Headers $headers -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop @proxyParams - Write-DATLogEntry -Value "[Telemetry] POST $Endpoint -- success" -Severity 1 - Write-DATLogEntry -Value "[Telemetry] Payload: $json" -Severity 1 - if ($global:ExecutionMode -eq 'Scheduled Task') { - Write-Host "[Telemetry] POST $Endpoint -- success" - Write-Host "[Telemetry] Payload: $json" + 'WIM Package Only' { + $wim = Join-Path $PackageDestination "$OEM\$Model\$WindowsVersion $WindowsBuild\DriverPackage.wim" + return (Test-Path -LiteralPath $wim) } - } catch { - # HTTP 426 -- client version below the API minimum. Record the upgrade prompt (telemetry is - # fire-and-forget, so a failure must never surface as an error), then stop. - if (Resolve-DATApiUpgradeRequired -ErrorRecord $_ -Source '[Telemetry]') { return } - Write-DATLogEntry -Value "[Telemetry] POST $Endpoint -- failed: $($_.Exception.Message)" -Severity 2 - if ($global:ExecutionMode -eq 'Scheduled Task') { - Write-Host "[Telemetry] POST $Endpoint -- failed: $($_.Exception.Message)" + 'Download Only' { + return ((Test-Path -LiteralPath $DownloadDestination) -and (@(Get-ChildItem -LiteralPath $DownloadDestination -File -ErrorAction SilentlyContinue).Count -gt 0)) } + default { return $true } + } +} + +# ---- Lenovo "Latest Drivers" manifest helpers (twins of the Dell helpers) ------------------ + +function Get-DATLenovoLatestManifestPath { + <# + .SYNOPSIS + Returns the full path to the Lenovo "Latest Drivers" tracking manifest under Settings. + #> + [CmdletBinding()] + [OutputType([string])] + param() + $settingsDir = Join-Path $global:ScriptDirectory 'Settings' + if (-not (Test-Path -LiteralPath $settingsDir)) { + try { New-Item -Path $settingsDir -ItemType Directory -Force | Out-Null } catch {} } + return (Join-Path $settingsDir 'LenovoLatestManifest.json') +} + +function Get-DATLenovoLatestManifestKey { + <# + .SYNOPSIS + Builds a stable manifest key for a Lenovo model/OS/build/architecture combination. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)][AllowEmptyString()][string]$Model, + [Parameter(Mandatory)][AllowEmptyString()][string]$OSVersion, + [Parameter(Mandatory)][AllowEmptyString()][string]$Build, + [Parameter(Mandatory)][AllowEmptyString()][string]$Architecture + ) + return ("Lenovo|{0}|{1}|{2}|{3}" -f $Model.Trim(), $OSVersion.Trim(), $Build.Trim(), $Architecture.Trim()) } -function Send-DATFeedback { +function Get-DATLenovoPackageFingerprint { <# .SYNOPSIS - Submits user feedback (thumbs up/down) to the DAT API. - .PARAMETER Rating - 'Positive' or 'Negative'. - .PARAMETER Comment - Optional comment text (used with negative feedback). + Order-independent SHA256 fingerprint of a Lenovo package identifier list (alphanumeric ids, + each ideally suffixed with its version). Returns lowercase hex, or $null when empty. #> [CmdletBinding()] + [OutputType([string])] param ( - [Parameter(Mandatory)] - [ValidateSet('Positive', 'Negative')] - [string]$Rating, - - [AllowEmptyString()] - [string]$Comment = '', - - [AllowEmptyString()] - [string]$Email = '', - - [bool]$FollowUp = $false + [Parameter(Mandatory)][AllowEmptyCollection()][AllowNull()][AllowEmptyString()][string[]]$Identifiers ) - - $telemetryId = Get-DATTelemetryId - if ([string]::IsNullOrEmpty($telemetryId)) { - # Generate a one-time GUID if telemetry is not configured - $telemetryId = [guid]::NewGuid().ToString() - } - - $body = @{ - installId = $telemetryId - rating = $Rating - comment = $Comment - email = $Email - followUp = [bool]$FollowUp - submittedAt = (Get-Date).ToUniversalTime().ToString('o') - appVersion = $global:ScriptRelease - } - - $config = Get-DATTelemetryConfig - if ($null -eq $config -or [string]::IsNullOrEmpty($config.apiBaseUrl)) { - Write-DATLogEntry -Value "[Feedback] Cannot submit -- API config unavailable" -Severity 2 - return + if ($null -eq $Identifiers) { return $null } + $valid = @($Identifiers | + ForEach-Object { if ($null -ne $_) { $_.Trim() } } | + Where-Object { $_ } | + Select-Object -Unique | + Sort-Object) + if ($valid.Count -eq 0) { return $null } + $joined = ($valid -join ',') + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined) + return [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() } +} - $url = "$($config.apiBaseUrl)/feedback" - $json = $body | ConvertTo-Json -Depth 5 -Compress - # Encode the body to UTF-8 bytes ONCE so the signed content and the bytes - # sent on the wire are byte-identical. Windows PowerShell 5.1 otherwise - # transmits a string body as Latin1, corrupting non-ASCII characters and - # breaking server-side HMAC verification. - $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($json) - - # HMAC-SHA256 request signing (softfail-safe -- skipped if secret is absent or computation fails). - # x-dat-version is set unconditionally so the API version gate always sees it, even when HMAC is skipped. - $headers = @{ 'x-dat-version' = [string]$global:ScriptRelease } - try { - $hmacSecret = $null - if ($config -and $config.PSObject.Properties['hmacSecret']) { - $hmacSecret = $config.hmacSecret - } - if (-not [string]::IsNullOrEmpty($hmacSecret)) { - $timestamp = (Get-Date).ToUniversalTime().ToString('o') - $keyBytes = [System.Text.Encoding]::UTF8.GetBytes($hmacSecret) - $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) - $sigBytes = $hmac.ComputeHash($bodyBytes) - $signature = -join ($sigBytes | ForEach-Object { $_.ToString('x2') }) - $hmac.Dispose() - $headers['x-dat-signature'] = $signature - $headers['x-dat-timestamp'] = $timestamp +function Get-DATLenovoLatestManifest { + <# + .SYNOPSIS + Loads the Lenovo Latest Drivers manifest as a hashtable. Missing/corrupt files yield empty. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param() + $path = Get-DATLenovoLatestManifestPath + $manifest = @{} + if (Test-Path -LiteralPath $path) { + try { + $raw = Get-Content -LiteralPath $path -Raw -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $obj = $raw | ConvertFrom-Json -ErrorAction Stop + foreach ($prop in $obj.PSObject.Properties) { $manifest[$prop.Name] = $prop.Value } + } + } catch { + Write-DATLogEntry -Value "[Lenovo] Latest Drivers manifest unreadable, treating as empty: $($_.Exception.Message)" -Severity 2 + $manifest = @{} } - } catch { - Write-DATLogEntry -Value "[Feedback] HMAC signing skipped: $($_.Exception.Message)" -Severity 2 } + return $manifest +} +function Save-DATLenovoLatestManifest { + <# + .SYNOPSIS + Atomically persists the Lenovo Latest Drivers manifest hashtable to disk. Never throws. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)][hashtable]$Manifest + ) + $path = Get-DATLenovoLatestManifestPath try { - $proxyParams = Get-DATWebRequestProxy - if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } - $null = Invoke-RestMethod -Uri $url -Method POST -Body $bodyBytes -ContentType 'application/json; charset=utf-8' ` - -Headers $headers -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop @proxyParams - Write-DATLogEntry -Value "[Feedback] Submitted $Rating feedback successfully" -Severity 1 + $json = $Manifest | ConvertTo-Json -Depth 6 + $tmp = "$path.tmp" + Set-Content -LiteralPath $tmp -Value $json -Encoding UTF8 -ErrorAction Stop + Move-Item -LiteralPath $tmp -Destination $path -Force -ErrorAction Stop + return $true } catch { - [void](Resolve-DATApiUpgradeRequired -ErrorRecord $_ -Source '[Feedback]') - Write-DATLogEntry -Value "[Feedback] Submit failed: $($_.Exception.Message)" -Severity 2 - throw + Write-DATLogEntry -Value "[Lenovo] Failed to save Latest Drivers manifest: $($_.Exception.Message)" -Severity 2 + return $false } } -function Get-DATHPSoftPaqManifestPath { +function Update-DATLenovoLatestManifestReference { <# .SYNOPSIS - Returns the full path to the HP SoftPaq manifest file under Settings. + Records the remote package reference (Intune app id / ConfigMgr package name) on an existing + Lenovo Latest Drivers manifest entry. No-ops when the entry is absent. #> [CmdletBinding()] - [OutputType([string])] - param() - $settingsDir = Join-Path $global:ScriptDirectory 'Settings' - if (-not (Test-Path -LiteralPath $settingsDir)) { - try { New-Item -Path $settingsDir -ItemType Directory -Force | Out-Null } catch {} + [OutputType([bool])] + param ( + [Parameter(Mandatory)][string]$Key, + [Parameter(Mandatory)][ValidateSet('intuneAppId', 'configMgrPackageId')][string]$Field, + [Parameter(Mandatory)][AllowEmptyString()][string]$Value + ) + $manifest = Get-DATLenovoLatestManifest + if (-not $manifest.ContainsKey($Key)) { return $false } + try { + $entry = $manifest[$Key] + $entry | Add-Member -NotePropertyName $Field -NotePropertyValue $Value -Force + $manifest[$Key] = $entry + return (Save-DATLenovoLatestManifest -Manifest $manifest) + } catch { + Write-DATLogEntry -Value "[Lenovo] Failed to record Latest Drivers manifest reference ($Field): $($_.Exception.Message)" -Severity 2 + return $false } - return (Join-Path $settingsDir 'HPSoftPaqManifest.json') } function Get-DATHPSoftPaqManifestKey { @@ -15586,6 +17225,301 @@ function Get-DATPackageHash { } } +function ConvertTo-DATCanonicalJson { + <# + .SYNOPSIS + Serializes an object to deterministic ("canonical") JSON so that identical content yields a + byte-identical string -- and therefore an identical SHA256 hash -- on any machine, culture, + or PowerShell edition. + .DESCRIPTION + PowerShell's built-in ConvertTo-Json does NOT guarantee a stable property order (hashtable + enumeration order is not fixed) and it formats numbers using the current culture. Both make + it unusable when a hash of the output must match across environments. This writer instead: + - sorts every object/dictionary key with the ordinal comparer (recursively), + - preserves array order (callers are responsible for pre-sorting arrays whose order is not + semantically meaningful), + - formats numbers and booleans with the invariant culture, + - emits compact JSON (no insignificant whitespace) using UTF-8-safe escaping. + The result is intended for hashing, not for human display. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)][AllowNull()]$InputObject + ) + + $sb = [System.Text.StringBuilder]::new() + + $escape = { + param([string]$s) + $out = [System.Text.StringBuilder]::new() + [void]$out.Append('"') + foreach ($ch in $s.ToCharArray()) { + switch ($ch) { + '"' { [void]$out.Append('\"'); continue } + '\' { [void]$out.Append('\\'); continue } + "`b" { [void]$out.Append('\b'); continue } + "`f" { [void]$out.Append('\f'); continue } + "`n" { [void]$out.Append('\n'); continue } + "`r" { [void]$out.Append('\r'); continue } + "`t" { [void]$out.Append('\t'); continue } + default { + if ([int]$ch -lt 0x20) { + [void]$out.Append('\u') + [void]$out.Append(([int]$ch).ToString('x4', [System.Globalization.CultureInfo]::InvariantCulture)) + } else { + [void]$out.Append($ch) + } + } + } + } + [void]$out.Append('"') + return $out.ToString() + } + + $write = $null + $write = { + param($node) + + if ($null -eq $node) { [void]$sb.Append('null'); return } + + # Handle scalars FIRST. The -is checks below transparently see through any PSObject/ETS + # wrapper added by the pipeline (e.g. Sort-Object), so scalars must be tested before the + # structured-type unwrap -- otherwise unwrapping a wrapped scalar via BaseObject yields the + # wrong value ($null) and the scalar renders as an empty string. + if ($node -is [string]) { [void]$sb.Append((& $escape $node)); return } + if ($node -is [bool]) { [void]$sb.Append($(if ($node) { 'true' } else { 'false' })); return } + if ($node -is [System.Enum]) { [void]$sb.Append((& $escape ([string]$node))); return } + + if ($node -is [int] -or $node -is [long] -or $node -is [int16] -or $node -is [byte] -or ` + $node -is [double] -or $node -is [single] -or $node -is [decimal] -or $node -is [uint32] -or $node -is [uint64]) { + [void]$sb.Append([System.Convert]::ToString($node, [System.Globalization.CultureInfo]::InvariantCulture)) + return + } + + # Structured types: unwrap any PSObject wrapper to the underlying .NET object so the + # IDictionary / IEnumerable checks are reliable after pipeline wrapping. Use .psobject.BaseObject + # (a plain .BaseObject member does not exist on wrapped scalars/collections) and null-guard it. + if ($node -is [System.Management.Automation.PSObject] -and $null -ne $node.psobject.BaseObject) { + $node = $node.psobject.BaseObject + } + + if ($node -is [System.Collections.IDictionary]) { + [void]$sb.Append('{') + $first = $true + foreach ($key in ($node.Keys | Sort-Object -Property { [string]$_ } -Culture ([System.Globalization.CultureInfo]::InvariantCulture))) { + if (-not $first) { [void]$sb.Append(',') } + $first = $false + [void]$sb.Append((& $escape ([string]$key))) + [void]$sb.Append(':') + & $write $node[$key] + } + [void]$sb.Append('}') + return + } + + if ($node -is [System.Management.Automation.PSCustomObject]) { + [void]$sb.Append('{') + $first = $true + foreach ($prop in ($node.PSObject.Properties | Sort-Object -Property Name -Culture ([System.Globalization.CultureInfo]::InvariantCulture))) { + if (-not $first) { [void]$sb.Append(',') } + $first = $false + [void]$sb.Append((& $escape $prop.Name)) + [void]$sb.Append(':') + & $write $prop.Value + } + [void]$sb.Append('}') + return + } + + if ($node -is [System.Collections.IEnumerable]) { + [void]$sb.Append('[') + $first = $true + foreach ($item in $node) { + if (-not $first) { [void]$sb.Append(',') } + $first = $false + & $write $item + } + [void]$sb.Append(']') + return + } + + # Fallback: treat anything else as its string form. + [void]$sb.Append((& $escape ([string]$node))) + } + + & $write $InputObject + return $sb.ToString() +} + +function Get-DATDriverManifestContentHash { + <# + .SYNOPSIS + Computes the deterministic SHA256 content hash of a driver package manifest object. + .DESCRIPTION + Serializes the object with ConvertTo-DATCanonicalJson and hashes the UTF-8 bytes, so the + same package contents produce the same hash on every environment. The backend can therefore + deduplicate manifests globally by this hash instead of storing one copy per device. + Returns a lowercase hex string, or $null on failure. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)][AllowNull()]$Manifest + ) + if ($null -eq $Manifest) { return $null } + try { + $canonical = ConvertTo-DATCanonicalJson -InputObject $Manifest + $bytes = [System.Text.Encoding]::UTF8.GetBytes($canonical) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + } + } catch { + Write-DATLogEntry -Value "[Telemetry] Failed to compute driver manifest content hash: $($_.Exception.Message)" -Severity 2 + return $null + } +} + +function Get-DATDriverManifestSentPath { + <# + .SYNOPSIS + Returns the path to the per-install cache of already-submitted driver manifest content hashes. + #> + [CmdletBinding()] + [OutputType([string])] + param() + $settingsDir = Join-Path $global:ScriptDirectory 'Settings' + if (-not (Test-Path -LiteralPath $settingsDir)) { + try { New-Item -Path $settingsDir -ItemType Directory -Force | Out-Null } catch {} + } + return (Join-Path $settingsDir 'DriverManifestSent.json') +} + +function Send-DATDriverManifest { + <# + .SYNOPSIS + Submits the deterministic contents manifest of a driver package to the telemetry API. + .DESCRIPTION + Builds a canonical, content-only manifest (no timestamps, tool version, machine name or + absolute paths) so a SHA256 hash of the payload is identical across every environment for + the same package contents. This lets the backend deduplicate on the content hash rather than + storing a copy for each of potentially 1M+ reporting devices. + + The manifest content is produced by New-DATDriverManifest (cached in $script:DATLastDriverManifest + immediately before this call, covering both SCCM driver-pack builds and individual driver + packs). To further limit traffic, each install records the content hashes it has already + submitted and skips resending an unchanged manifest on subsequent runs. + + This function no-ops safely when the 'driverManifest' endpoint is not present in the API + config -- the backend for this feature is not yet deployed, so this keeps the client ready + without emitting failures until the endpoint exists. + .PARAMETER OEM + Manufacturer, used to validate the cached manifest matches this package. + .PARAMETER Model + Model name, used to validate the cached manifest matches this package. + .PARAMETER OS + OS label, used to validate the cached manifest matches this package. + .PARAMETER Platform + The build platform (Intune / Configuration Manager / WIM Package Only / Download Only). + .PARAMETER Architecture + OS architecture (x64 / ARM64), routing metadata only -- not part of the content hash. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)][AllowEmptyString()][string]$OEM, + [Parameter(Mandatory)][AllowEmptyString()][string]$Model, + [Parameter(Mandatory)][AllowEmptyString()][string]$OS, + [AllowEmptyString()][string]$Platform = '', + [AllowEmptyString()][string]$Architecture = '' + ) + + if (-not (Test-DATTelemetryEnabled)) { return $false } + + $config = Get-DATTelemetryConfig + if ($null -eq $config) { return $false } + + # Backend not yet deployed -- silently stay ready until the endpoint is published in the config. + $endpoint = $null + if ($config.PSObject.Properties['endpoints'] -and $config.endpoints.PSObject.Properties['driverManifest']) { + $endpoint = $config.endpoints.driverManifest + } + if ([string]::IsNullOrEmpty($endpoint)) { + Write-DATLogEntry -Value "[Telemetry] Driver manifest endpoint not configured -- skipping (feature pending backend)" -Severity 1 + return $false + } + + $cached = $script:DATLastDriverManifest + if ($null -eq $cached -or $null -eq $cached.Content) { + Write-DATLogEntry -Value "[Telemetry] No cached driver manifest content available to submit for $OEM $Model" -Severity 2 + return $false + } + + # Guard against a stale cache from an earlier package by confirming the identity matches. + $expectedKey = ('{0}|{1}|{2}' -f $OEM.Trim(), $Model.Trim(), $OS.Trim()) + if ("$($cached.Key)" -ne $expectedKey) { + Write-DATLogEntry -Value "[Telemetry] Cached driver manifest key '$($cached.Key)' does not match '$expectedKey' -- skipping manifest submission" -Severity 2 + return $false + } + + # Defensive: strip any volatile fields so the hashed content can never carry a creation date, + # tool version or other environment-specific value that would break cross-environment hashing. + $content = [ordered]@{} + foreach ($prop in $cached.Content.GetEnumerator()) { + if ($prop.Key -in @('generated', 'createdAt', 'created', 'timestamp', 'toolVersion', 'datVersion', 'machineName', 'source')) { continue } + $content[$prop.Key] = $prop.Value + } + + $contentHash = Get-DATDriverManifestContentHash -Manifest $content + if ([string]::IsNullOrEmpty($contentHash)) { return $false } + + # Per-install dedupe: never resend a manifest whose content is unchanged from a previous run. + $sentPath = Get-DATDriverManifestSentPath + $sentHashes = New-Object System.Collections.Generic.List[string] + if (Test-Path -LiteralPath $sentPath) { + try { + $raw = Get-Content -LiteralPath $sentPath -Raw -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $parsed = $raw | ConvertFrom-Json -ErrorAction Stop + foreach ($h in @($parsed)) { if (-not [string]::IsNullOrWhiteSpace($h)) { $sentHashes.Add([string]$h) } } + } + } catch { } + } + if ($sentHashes.Contains($contentHash)) { + Write-DATLogEntry -Value "[Telemetry] Driver manifest $contentHash already submitted -- skipping resend" -Severity 1 + return $false + } + + $body = @{ + telemetryId = Get-DATTelemetryId + manufacturer = $OEM + model = $Model + osVersion = $OS + osArchitecture = $Architecture + platform = $Platform + datVersion = [string]$global:ScriptRelease + executionMode = $global:ExecutionMode + contentHash = $contentHash + hashMethod = 'SHA256' + content = $content + } + + Send-DATTelemetry -Endpoint $endpoint -Body $body + + # Record the submitted hash (bounded) so repeat runs on this install do not resend it. + try { + $sentHashes.Add($contentHash) + $trimmed = @($sentHashes | Select-Object -Last 5000) + $trimmed | ConvertTo-Json -Compress | Set-Content -LiteralPath $sentPath -Encoding UTF8 -ErrorAction Stop + } catch { + Write-DATLogEntry -Value "[Telemetry] Could not update driver manifest sent-cache ($sentPath): $($_.Exception.Message)" -Severity 2 + } + return $true +} + function Send-DATDriverReport { <# .SYNOPSIS diff --git a/Driver Automation Tool/UI/MainApplication.ps1 b/Driver Automation Tool/UI/MainApplication.ps1 index 6f6cd5e..13ff1f4 100644 --- a/Driver Automation Tool/UI/MainApplication.ps1 +++ b/Driver Automation Tool/UI/MainApplication.ps1 @@ -2550,11 +2550,23 @@ function Show-DATBuildFailuresDialog { ) $failureList = @($Failures) - if ($failureList.Count -eq 0) { return } + + # Individual driver/component download failures (Latest Drivers builds) are recorded separately + # from model-level package failures so they can be shown even when the model itself succeeded. + $driverFailures = @() + try { + $ddfJson = (Get-ItemProperty -Path $global:RegPath -Name 'DriverDownloadFailures' -ErrorAction SilentlyContinue).DriverDownloadFailures + if (-not [string]::IsNullOrWhiteSpace($ddfJson)) { + $ddfParsed = $ddfJson | ConvertFrom-Json -ErrorAction Stop + $driverFailures = @($ddfParsed) + } + } catch { $driverFailures = @() } + + if ($failureList.Count -eq 0 -and $driverFailures.Count -eq 0) { return } # Normalise into one clean entry per failed model (de-duplicates repeated text). $groups = @(ConvertTo-DATFailureGroups -Failures $failureList) - if ($groups.Count -eq 0) { return } + if ($groups.Count -eq 0 -and $driverFailures.Count -eq 0) { return } $theme = Get-DATTheme -ThemeName $script:CurrentTheme $bgColor = [System.Windows.Media.ColorConverter]::ConvertFromString($theme['CardBackground']) @@ -2620,7 +2632,11 @@ function Show-DATBuildFailuresDialog { # Subtitle / count $subText = [System.Windows.Controls.TextBlock]::new() - $subText.Text = "$($groups.Count) model$(if ($groups.Count -ne 1) { 's' }) failed to build -- expand a row for details" + $subText.Text = if ($groups.Count -gt 0) { + "$($groups.Count) model$(if ($groups.Count -ne 1) { 's' }) failed to build -- expand a row for details" + } else { + "$($driverFailures.Count) individual driver download$(if ($driverFailures.Count -ne 1) { 's' }) failed -- see below" + } $subText.FontSize = 12 $subText.Foreground = $dimBrush $subText.HorizontalAlignment = 'Center' @@ -2752,6 +2768,55 @@ function Show-DATBuildFailuresDialog { $listPanel.Children.Add($card) | Out-Null } + # Individual driver download failures -- listed per model (these can occur even when the model's + # package still built from the components that succeeded, so they are shown as their own section). + if ($driverFailures.Count -gt 0) { + $ddHeader = [System.Windows.Controls.TextBlock]::new() + $ddHeader.Text = "Individual driver download failures ($($driverFailures.Count))" + $ddHeader.FontSize = 12 + $ddHeader.FontWeight = [System.Windows.FontWeights]::SemiBold + $ddHeader.Foreground = $dimBrush + $ddHeader.Margin = [System.Windows.Thickness]::new(2, 6, 0, 8) + $listPanel.Children.Add($ddHeader) | Out-Null + + $ddByModel = $driverFailures | Group-Object { "$($_.OEM) $($_.Model)".Trim() } + foreach ($mg in $ddByModel) { + $mcard = [System.Windows.Controls.Border]::new() + $mcard.Background = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Color]::FromArgb(60, $rowBgColor.R, $rowBgColor.G, $rowBgColor.B)) + $mcard.CornerRadius = [System.Windows.CornerRadius]::new(8) + $mcard.Padding = [System.Windows.Thickness]::new(12, 8, 12, 8) + $mcard.Margin = [System.Windows.Thickness]::new(0, 0, 0, 8) + $mcard.BorderBrush = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Color]::FromArgb(90, $errorBrush.Color.R, $errorBrush.Color.G, $errorBrush.Color.B)) + $mcard.BorderThickness = [System.Windows.Thickness]::new(1) + $mpanel = [System.Windows.Controls.StackPanel]::new() + + $mtitle = [System.Windows.Controls.TextBlock]::new() + $mtitle.Text = "$($mg.Name) -- $($mg.Count) driver$(if ($mg.Count -ne 1) { 's' })" + $mtitle.FontSize = 13 + $mtitle.FontWeight = [System.Windows.FontWeights]::SemiBold + $mtitle.Foreground = $fgBrush + $mtitle.TextTrimming = 'CharacterEllipsis' + $mtitle.Margin = [System.Windows.Thickness]::new(0, 0, 0, 4) + $mpanel.Children.Add($mtitle) | Out-Null + + foreach ($df in $mg.Group) { + $dname = "$($df.Driver)"; if ([string]::IsNullOrWhiteSpace($dname)) { $dname = 'Unknown driver' } + $drsn = "$($df.Reason)"; if ([string]::IsNullOrWhiteSpace($drsn)) { $drsn = 'see log for details' } + $dline = [System.Windows.Controls.TextBlock]::new() + $dline.Text = "- $dname : $drsn" + $dline.FontSize = 12 + $dline.Foreground = $dimBrush + $dline.TextWrapping = 'Wrap' + $dline.Margin = [System.Windows.Thickness]::new(0, 2, 0, 0) + $mpanel.Children.Add($dline) | Out-Null + } + $mcard.Child = $mpanel + $listPanel.Children.Add($mcard) | Out-Null + } + } + $scroll.Content = $listPanel $panel.Children.Add($scroll) | Out-Null @@ -3054,7 +3119,14 @@ function Show-DATBuildSummaryDialog { if (-not [string]::IsNullOrEmpty($failuresJson)) { try { $buildFailures = @($failuresJson | ConvertFrom-Json) } catch { $buildFailures = @() } } - if ($buildFailures.Count -gt 0) { + # Individual driver download failures are recorded separately and shown even when no model failed. + $driverFailuresJson = $null + try { $driverFailuresJson = (Get-ItemProperty -Path $global:RegPath -Name 'DriverDownloadFailures' -ErrorAction SilentlyContinue).DriverDownloadFailures } catch { $driverFailuresJson = $null } + $driverFailures = @() + if (-not [string]::IsNullOrEmpty($driverFailuresJson)) { + try { $ddfTmp = $driverFailuresJson | ConvertFrom-Json; $driverFailures = @($ddfTmp) } catch { $driverFailures = @() } + } + if ($buildFailures.Count -gt 0 -or $driverFailures.Count -gt 0) { $btnFailures = [System.Windows.Controls.Button]::new() $btnFailures.Height = 36 $btnFailures.HorizontalAlignment = 'Stretch' @@ -3076,7 +3148,7 @@ function Show-DATBuildSummaryDialog { $btnFailures.Foreground = $errorBrush $btnFailures.FontSize = 13 $btnFailures.FontWeight = [System.Windows.FontWeights]::SemiBold - $btnFailures.Content = "View Failures ($($buildFailures.Count))" + $btnFailures.Content = "View Failures ($($buildFailures.Count + $driverFailures.Count))" # Stash the captured data in script scope and use a plain (non-closure) handler. # A GetNewClosure() scriptblock is rebound to a new dynamic module session state, # which cannot see script-level functions like Show-DATBuildFailuresDialog (the @@ -5844,7 +5916,10 @@ function Update-DATBuildModalStats { # pre-build estimate undercounts whenever a model's grid status and the build's own # skip-if-current decision diverge, so grow the figure to match reality and stay truthful. if ($null -ne $script:BuildModalDownloadsValue) { - $required = [Math]::Max([int]$script:BuildInitialDownloads, $created) + # Include extra individual-file downloads reported by the build (Latest Drivers DUPs). + $extra = 0 + try { $extra = [int]$rv.LatestDownloadsExtra } catch { $extra = 0 } + $required = [Math]::Max([int]$script:BuildInitialDownloads + $extra, $created) $script:BuildModalDownloadsValue.Text = "$required" } @@ -7224,7 +7299,26 @@ function Set-DATActiveView { # Show target view $targetView = $Window.FindName($ViewName) - if ($null -ne $targetView) { $targetView.Visibility = 'Visible' } + if ($null -ne $targetView) { + $targetView.Visibility = 'Visible' + # Each view carries its own ScrollViewer, so a revisit would otherwise keep its old + # offset. Reset the view's OUTERMOST ScrollViewer to the top (deferred until after layout). + # Breadth-first + stop-at-first so we never recurse into nested/virtualized list scrollers. + $resetScroll = { + try { + $queue = New-Object System.Collections.Queue + $queue.Enqueue($targetView) + while ($queue.Count -gt 0) { + $node = $queue.Dequeue() + if ($null -eq $node) { continue } + if ($node -is [System.Windows.Controls.ScrollViewer]) { $node.ScrollToTop(); break } + $childCount = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($node) + for ($i = 0; $i -lt $childCount; $i++) { $queue.Enqueue([System.Windows.Media.VisualTreeHelper]::GetChild($node, $i)) } + } + } catch { } + }.GetNewClosure() + $Window.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [System.Action]$resetScroll) | Out-Null + } # Auto-load packages when navigating to Package Management if ($ViewName -eq 'view_Packages') { @@ -10419,6 +10513,10 @@ $btn_Build.Add_Click({ $global:SelectedModels = [System.Collections.ArrayList]::new() $downloadsRequired = 0 # count of new/updated packages that will actually download + # The deployed-version "current" suppression only applies to deployment platforms. WIM Package + # Only / Download Only have nothing deployed to compare against and always build every applicable + # package, so their tile must count them all. + $suppressCurrent = ($selectedPlatform -like '*Configuration Manager*') -or ($selectedPlatform -eq 'Intune') foreach ($model in $selectedModels) { # Determine the OS label for package naming $osForPackage = if ($model.Build -eq 'All') { @@ -10443,13 +10541,16 @@ $btn_Build.Add_Click({ # only, and vice versa. Leaves 'All' when both need work, when either is unknown, or when # the scan hasn't run -- the build's skip-if-current logic remains the safety net. $mBiosOnly = $(try { [bool]$model.BIOSOnly } catch { $false }) - $dvApplicable = (-not $mBiosOnly) -and -not [string]::IsNullOrEmpty($model.Version) - $biosApplicable = (-not [string]::IsNullOrEmpty($model.BIOSVersion)) -and ($model.OEM -ne 'Microsoft') + # Applicability mirrors the build-progress modal's row logic (driver row unless BIOS-only, + # BIOS row unless Microsoft) rather than requiring a catalog Version string, so the count is + # correct for new models (e.g. Latest Drivers on a model with no enterprise pack version yet). + $dvApplicable = (-not $mBiosOnly) + $biosApplicable = ($model.OEM -ne 'Microsoft') $driverCurrent = ($model.DriverStatus -eq 'Current') $biosCurrent = ($model.BIOSStatus -eq 'Current') $perModelPkgType = $buildPackageType - if ($buildPackageType -eq 'All' -and $script:DeployedVersionsFetched) { + if ($buildPackageType -eq 'All' -and $suppressCurrent -and $script:DeployedVersionsFetched) { if ($dvApplicable -and $driverCurrent -and $biosApplicable -and (-not $biosCurrent)) { $perModelPkgType = 'BIOS' } elseif ($biosApplicable -and $biosCurrent -and $dvApplicable -and (-not $driverCurrent)) { @@ -10459,9 +10560,9 @@ $btn_Build.Add_Click({ # Count downloads required -- packages that are new or updated (skip known-current ones) if (($perModelPkgType -in @('Drivers', 'All')) -and $dvApplicable -and - (-not ($script:DeployedVersionsFetched -and $driverCurrent))) { $downloadsRequired++ } + (-not ($suppressCurrent -and $script:DeployedVersionsFetched -and $driverCurrent))) { $downloadsRequired++ } if (($perModelPkgType -in @('BIOS', 'All')) -and $biosApplicable -and - (-not ($script:DeployedVersionsFetched -and $biosCurrent))) { $downloadsRequired++ } + (-not ($suppressCurrent -and $script:DeployedVersionsFetched -and $biosCurrent))) { $downloadsRequired++ } $modelObj = [PSCustomObject]@{ OEM = $model.OEM @@ -10493,6 +10594,9 @@ $btn_Build.Add_Click({ Set-DATRegistryValue -Name "FailedPackages" -Value "0" -Type String Set-DATRegistryValue -Name "PackagesCreated" -Value "0" -Type String Set-DATRegistryValue -Name "SkippedPackages" -Value "0" -Type String + # Extra individual-file downloads beyond the per-model package estimate (Latest Drivers: N DUPs + # instead of one pack). The build adds (count - 1) per Latest model so the tile reflects reality. + Set-DATRegistryValue -Name "LatestDownloadsExtra" -Value "0" -Type String # Clear the structured failure/skip lists so the progress modal cannot mark rows from a prior build. Remove-ItemProperty -Path $global:RegPath -Name 'BuildFailures' -ErrorAction SilentlyContinue Remove-ItemProperty -Path $global:RegPath -Name 'BuildSkippedCurrent' -ErrorAction SilentlyContinue @@ -16161,6 +16265,15 @@ $link_CurlDownload.Add_RequestNavigate({ $e.Handled = $true }) +# Show a warning when Latest Drivers is selected but HPCMSL (the HP pre-req) is not installed. +function Update-DATBuildTypeWarning { + $warn = $Window.FindName('txt_HPLatestDriversWarning') + if ($null -eq $warn) { return } + $combo = $Window.FindName('cmb_HPDriverPackSource') + $isLatest = ($null -ne $combo -and $null -ne $combo.SelectedItem -and [string]$combo.SelectedItem.Tag -eq 'SoftPaqs') + $warn.Visibility = if ($isLatest -and -not $script:HPCMSLAvailable) { 'Visible' } else { 'Collapsed' } +} + # HP CMSL status check function Update-DATHpcmslStatus { $hpcmslModule = Get-Module -ListAvailable -Name HPCMSL -ErrorAction SilentlyContinue | Select-Object -First 1 @@ -16186,6 +16299,7 @@ function Update-DATHpcmslStatus { $txt_HpcmslStatus.Foreground = $Window.FindResource('StatusWarning') $btn_InstallHpcmsl.Visibility = 'Visible' } + Update-DATBuildTypeWarning } Update-DATHpcmslStatus @@ -18104,32 +18218,121 @@ $btn_SearchDeployGroup = $Window.FindName('btn_SearchDeployGroup') $txt_DeployGroupStatus = $Window.FindName('txt_DeployGroupStatus') $cmb_DeployGroupResults = $Window.FindName('cmb_DeployGroupResults') $panel_DeployGroupSelected = $Window.FindName('panel_DeployGroupSelected') -$txt_DeployGroupSelectedName = $Window.FindName('txt_DeployGroupSelectedName') -$txt_DeployGroupSelectedId = $Window.FindName('txt_DeployGroupSelectedId') +$list_DeployGroups = $Window.FindName('list_DeployGroups') $btn_ClearDeployGroup = $Window.FindName('btn_ClearDeployGroup') # Flag used to suppress persistence while the results list is populated programmatically $script:DeployGroupSuppressSelection = $false -# Helper to persist and display the selected target group -function Set-DATDeployTargetGroup { - param ( - [Parameter(Mandatory)][AllowEmptyString()][string]$GroupId, - [Parameter(Mandatory)][AllowEmptyString()][string]$GroupName - ) - if ([string]::IsNullOrWhiteSpace($GroupId)) { +# Ordered collection of selected target groups; each entry is [pscustomobject]@{ Id; Name } +$script:DeployTargetGroups = [System.Collections.Generic.List[object]]::new() + +# Persist the current target-group list to the registry as ;;-delimited id/name lists, +# or clear the values when the list is empty (reverting to All Devices). +function Save-DATDeployTargetGroups { + if ($script:DeployTargetGroups.Count -eq 0) { Remove-ItemProperty -Path $global:RegPath -Name "DeployTargetGroupId" -Force -ErrorAction SilentlyContinue Remove-ItemProperty -Path $global:RegPath -Name "DeployTargetGroupName" -Force -ErrorAction SilentlyContinue + return + } + $ids = ($script:DeployTargetGroups | ForEach-Object { $_.Id }) -join ';;' + $names = ($script:DeployTargetGroups | ForEach-Object { $_.Name }) -join ';;' + Set-DATRegistryValue -Name "DeployTargetGroupId" -Value $ids -Type String + Set-DATRegistryValue -Name "DeployTargetGroupName" -Value $names -Type String +} + +# Rebuild the selected-group chip rows from $script:DeployTargetGroups +function Update-DATDeployGroupList { + if ($null -eq $list_DeployGroups -or $null -eq $panel_DeployGroupSelected) { return } + $list_DeployGroups.Children.Clear() + if ($script:DeployTargetGroups.Count -eq 0) { $panel_DeployGroupSelected.Visibility = 'Collapsed' - Write-DATActivityLog "Package Deployment: custom target group cleared (reverting to All Devices)" -Level Info return } - Set-DATRegistryValue -Name "DeployTargetGroupId" -Value $GroupId -Type String - Set-DATRegistryValue -Name "DeployTargetGroupName" -Value $GroupName -Type String - $txt_DeployGroupSelectedName.Text = $GroupName - $txt_DeployGroupSelectedId.Text = $GroupId $panel_DeployGroupSelected.Visibility = 'Visible' - Write-DATActivityLog "Package Deployment: custom target group set to '$GroupName' ($GroupId)" -Level Info + $theme = Get-DATTheme -ThemeName $script:CurrentTheme + $bgBrush = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($theme['InputBackground'])) + $fgBrush = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($theme['WindowForeground'])) + $subBrush = [System.Windows.Media.SolidColorBrush]::new([System.Windows.Media.ColorConverter]::ConvertFromString($theme['InputPlaceholder'])) + foreach ($g in $script:DeployTargetGroups) { + $row = [System.Windows.Controls.Border]::new() + $row.Background = $bgBrush + $row.CornerRadius = [System.Windows.CornerRadius]::new(6) + $row.Padding = [System.Windows.Thickness]::new(12, 8, 12, 8) + $row.Margin = [System.Windows.Thickness]::new(0, 0, 0, 6) + + $grid = [System.Windows.Controls.Grid]::new() + $c0 = [System.Windows.Controls.ColumnDefinition]::new(); $c0.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) + $c1 = [System.Windows.Controls.ColumnDefinition]::new(); $c1.Width = [System.Windows.GridLength]::Auto + $grid.ColumnDefinitions.Add($c0); $grid.ColumnDefinitions.Add($c1) + + $info = [System.Windows.Controls.StackPanel]::new() + $info.VerticalAlignment = 'Center' + $nameTb = [System.Windows.Controls.TextBlock]::new() + $nameTb.Text = $g.Name + $nameTb.FontSize = 13; $nameTb.FontWeight = [System.Windows.FontWeights]::SemiBold + $nameTb.Foreground = $fgBrush; $nameTb.TextWrapping = 'Wrap' + $idTb = [System.Windows.Controls.TextBlock]::new() + $idTb.Text = $g.Id + $idTb.FontSize = 11; $idTb.FontFamily = [System.Windows.Media.FontFamily]::new('Consolas') + $idTb.Foreground = $subBrush + $idTb.Margin = [System.Windows.Thickness]::new(0, 2, 0, 0); $idTb.TextWrapping = 'Wrap' + $info.Children.Add($nameTb) | Out-Null + $info.Children.Add($idTb) | Out-Null + [System.Windows.Controls.Grid]::SetColumn($info, 0) + + $removeBtn = [System.Windows.Controls.Button]::new() + try { $removeBtn.Style = $Window.FindResource('RoundedButton') } catch { } + $removeBtn.Height = 28; $removeBtn.Padding = [System.Windows.Thickness]::new(10, 2, 10, 2) + $removeBtn.Margin = [System.Windows.Thickness]::new(8, 0, 0, 0) + $removeBtn.VerticalAlignment = 'Center'; $removeBtn.Cursor = 'Hand' + $removeBtn.ToolTip = "Remove this target group" + $removeTb = [System.Windows.Controls.TextBlock]::new() + $removeTb.Text = [char]0xE711; $removeTb.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') + $removeTb.FontSize = 10; $removeTb.Foreground = $fgBrush + $removeBtn.Content = $removeTb + # Stash the group id on the button and use a plain (non-closure) handler: a GetNewClosure() + # scriptblock is rebound to a new session state that cannot see script-level functions. + $removeBtn.Tag = $g.Id + $removeBtn.Add_Click({ Remove-DATDeployTargetGroup -GroupId $this.Tag }) + [System.Windows.Controls.Grid]::SetColumn($removeBtn, 1) + + $grid.Children.Add($info) | Out-Null + $grid.Children.Add($removeBtn) | Out-Null + $row.Child = $grid + $list_DeployGroups.Children.Add($row) | Out-Null + } +} + +# Add a group to the target list (ignoring duplicates), persist and refresh the UI +function Add-DATDeployTargetGroup { + param ( + [Parameter(Mandatory)][AllowEmptyString()][string]$GroupId, + [Parameter(Mandatory)][AllowEmptyString()][string]$GroupName + ) + if ([string]::IsNullOrWhiteSpace($GroupId)) { return } + if ($script:DeployTargetGroups | Where-Object { $_.Id -eq $GroupId }) { + Write-DATActivityLog "Package Deployment: group '$GroupName' ($GroupId) is already a target" -Level Info + return + } + $displayName = if ([string]::IsNullOrWhiteSpace($GroupName)) { $GroupId } else { $GroupName } + $script:DeployTargetGroups.Add([pscustomobject]@{ Id = $GroupId; Name = $displayName }) + Save-DATDeployTargetGroups + Update-DATDeployGroupList + Write-DATActivityLog "Package Deployment: added custom target group '$displayName' ($GroupId)" -Level Info +} + +# Remove a group from the target list, persist and refresh the UI +function Remove-DATDeployTargetGroup { + param ([Parameter(Mandatory)][AllowEmptyString()][string]$GroupId) + if ([string]::IsNullOrWhiteSpace($GroupId)) { return } + $match = $script:DeployTargetGroups | Where-Object { $_.Id -eq $GroupId } | Select-Object -First 1 + if ($null -ne $match) { [void]$script:DeployTargetGroups.Remove($match) } + Save-DATDeployTargetGroups + Update-DATDeployGroupList + if ($script:DeployTargetGroups.Count -eq 0) { + Write-DATActivityLog "Package Deployment: all custom target groups removed (reverting to All Devices)" -Level Info + } } $btn_SearchDeployGroup.Add_Click({ @@ -18154,7 +18357,8 @@ $btn_SearchDeployGroup.Add_Click({ $txt_DeployGroupStatus.Visibility = 'Visible' $group = Invoke-DATGraphRequest -Uri "/groups/$($searchText.Trim())?`$select=id,displayName" -NoPagination if ($null -ne $group -and -not [string]::IsNullOrEmpty($group.id)) { - Set-DATDeployTargetGroup -GroupId $group.id -GroupName $group.displayName + Add-DATDeployTargetGroup -GroupId $group.id -GroupName $group.displayName + $txt_DeployGroupSearch.Text = '' $txt_DeployGroupStatus.Visibility = 'Collapsed' $cmb_DeployGroupResults.Visibility = 'Collapsed' } else { @@ -18196,13 +18400,13 @@ $btn_SearchDeployGroup.Add_Click({ # SelectedIndex here does not double-trigger the handler. $cmb_DeployGroupResults.SelectedIndex = 0 $only = $results[0] - Set-DATDeployTargetGroup -GroupId $only.id -GroupName $only.displayName - $txt_DeployGroupStatus.Text = "1 group found and set as the deployment target." + Add-DATDeployTargetGroup -GroupId $only.id -GroupName $only.displayName + $txt_DeployGroupStatus.Text = "1 group found and added to the deployment target list." } else { # Force no selection so a click on ANY item -- including the first -- # is a genuine index change that raises SelectionChanged. $cmb_DeployGroupResults.SelectedIndex = -1 - $txt_DeployGroupStatus.Text = "$($results.Count) group(s) found. Select one to set as the deployment target." + $txt_DeployGroupStatus.Text = "$($results.Count) group(s) found. Select one to add to the deployment target list." } } $script:DeployGroupSuppressSelection = $false @@ -18219,12 +18423,15 @@ $cmb_DeployGroupResults.Add_SelectionChanged({ if ($script:DeployGroupSuppressSelection) { return } $selected = $cmb_DeployGroupResults.SelectedItem if ($null -ne $selected -and $null -ne $selected.Tag) { - Set-DATDeployTargetGroup -GroupId $selected.Tag.Id -GroupName $selected.Tag.Name + Add-DATDeployTargetGroup -GroupId $selected.Tag.Id -GroupName $selected.Tag.Name } }) $btn_ClearDeployGroup.Add_Click({ - Set-DATDeployTargetGroup -GroupId '' -GroupName '' + $script:DeployTargetGroups.Clear() + Save-DATDeployTargetGroups + Update-DATDeployGroupList + Write-DATActivityLog "Package Deployment: all custom target groups removed (reverting to All Devices)" -Level Info $txt_DeployGroupSearch.Text = '' $txt_DeployGroupStatus.Visibility = 'Collapsed' $cmb_DeployGroupResults.Visibility = 'Collapsed' @@ -18800,13 +19007,17 @@ $cmb_HPDriverPackSource = $Window.FindName('cmb_HPDriverPackSource') $lbl_HPConcurrentTitle = $Window.FindName('lbl_HPConcurrentTitle') $lbl_HPConcurrentDesc = $Window.FindName('lbl_HPConcurrentDesc') $lbl_HPConcurrentHint = $Window.FindName('lbl_HPConcurrentHint') +$cmb_LatestDriverCadence = $Window.FindName('cmb_LatestDriverCadence') +$lbl_LatestCadenceTitle = $Window.FindName('lbl_LatestCadenceTitle') +$lbl_LatestCadenceDesc = $Window.FindName('lbl_LatestCadenceDesc') +$lbl_LatestCadenceHint = $Window.FindName('lbl_LatestCadenceHint') $cmb_HPDriverPackSource.Add_SelectionChanged({ $selected = $cmb_HPDriverPackSource.SelectedItem if ($selected) { $val = [string]$selected.Tag Set-DATRegistryValue -Name "HPDriverPackSource" -Value $val -Type String - Write-DATActivityLog "HP driver pack source set to $val" -Level Info + Write-DATActivityLog "Driver package build type set to $val" -Level Info # Enable/disable concurrent downloads based on selection $isSoftPaqs = ($val -eq 'SoftPaqs') $cmb_HPConcurrentDownloads.IsEnabled = $isSoftPaqs @@ -18814,6 +19025,15 @@ $cmb_HPDriverPackSource.Add_SelectionChanged({ $lbl_HPConcurrentTitle.Opacity = $opacity $lbl_HPConcurrentDesc.Opacity = $opacity $lbl_HPConcurrentHint.Opacity = $opacity + # Update cadence enables with Latest Drivers too + if ($null -ne $cmb_LatestDriverCadence) { + $cmb_LatestDriverCadence.IsEnabled = $isSoftPaqs + $lbl_LatestCadenceTitle.Opacity = $opacity + $lbl_LatestCadenceDesc.Opacity = $opacity + $lbl_LatestCadenceHint.Opacity = $opacity + } + # Surface the HP pre-req warning when Latest Drivers is chosen without HPCMSL + Update-DATBuildTypeWarning # Update the displayed driver version for HP rows already in the grid: # DriverPack mode shows the HP catalog version; SoftPaq mode shows a date stamp. @@ -18850,6 +19070,18 @@ $cmb_HPConcurrentDownloads.Add_SelectionChanged({ } }) +# Latest Drivers update cadence (Dell / HP / Lenovo) +if ($null -ne $cmb_LatestDriverCadence) { + $cmb_LatestDriverCadence.Add_SelectionChanged({ + $selected = $cmb_LatestDriverCadence.SelectedItem + if ($selected) { + $val = [string]$selected.Tag + Set-DATRegistryValue -Name "LatestDriverCadence" -Value $val -Type String + Write-DATActivityLog "Latest Drivers update cadence set to $val" -Level Info + } + }) +} + $cmb_IntuneChunkSize.Add_SelectionChanged({ $selected = $cmb_IntuneChunkSize.SelectedItem if ($selected) { @@ -25276,13 +25508,44 @@ function Show-DATReleaseNotesDialog { $scrollViewer.VerticalScrollBarVisibility = 'Auto' $scrollViewer.HorizontalScrollBarVisibility = 'Disabled' - $notesBlock = [System.Windows.Controls.TextBlock]::new() - $notesBlock.Text = $script:ReleaseNotesText - $notesBlock.TextWrapping = [System.Windows.TextWrapping]::Wrap - $notesBlock.FontSize = 12 - $notesBlock.Foreground = [System.Windows.Media.SolidColorBrush]::new( + # Render notes line-by-line so leading "-" bullets become blue accent dots; other lines stay as text. + $notesPanel = [System.Windows.Controls.StackPanel]::new() + $fgBrush = [System.Windows.Media.SolidColorBrush]::new( [System.Windows.Media.ColorConverter]::ConvertFromString($theme['WindowForeground'])) - $scrollViewer.Content = $notesBlock + $accentBrush = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($theme['AccentColor'])) + foreach ($rawLine in ($script:ReleaseNotesText -split "`r?`n")) { + $m = [regex]::Match($rawLine, '^(?\s*)-\s?(?.*)$') + if ($m.Success) { + $indent = $m.Groups['indent'].Value.Length + $row = [System.Windows.Controls.Grid]::new() + $c0 = [System.Windows.Controls.ColumnDefinition]::new(); $c0.Width = [System.Windows.GridLength]::Auto + $c1 = [System.Windows.Controls.ColumnDefinition]::new(); $c1.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) + $row.ColumnDefinitions.Add($c0); $row.ColumnDefinitions.Add($c1) + $dot = [System.Windows.Shapes.Ellipse]::new() + $dot.Width = 6; $dot.Height = 6; $dot.Fill = $accentBrush + $dot.VerticalAlignment = 'Top' + $dot.Margin = [System.Windows.Thickness]::new((6 + $indent * 6), 6, 8, 0) + [System.Windows.Controls.Grid]::SetColumn($dot, 0) + $txt = [System.Windows.Controls.TextBlock]::new() + $txt.Text = $m.Groups['text'].Value + $txt.TextWrapping = [System.Windows.TextWrapping]::Wrap + $txt.FontSize = 12; $txt.Foreground = $fgBrush + $txt.Margin = [System.Windows.Thickness]::new(0, 0, 0, 3) + [System.Windows.Controls.Grid]::SetColumn($txt, 1) + $row.Children.Add($dot) | Out-Null + $row.Children.Add($txt) | Out-Null + $notesPanel.Children.Add($row) | Out-Null + } else { + $tb = [System.Windows.Controls.TextBlock]::new() + $tb.Text = $rawLine + $tb.TextWrapping = [System.Windows.TextWrapping]::Wrap + $tb.FontSize = 12; $tb.Foreground = $fgBrush + $tb.Margin = [System.Windows.Thickness]::new(0, 0, 0, 2) + $notesPanel.Children.Add($tb) | Out-Null + } + } + $scrollViewer.Content = $notesPanel $mainPanel.Children.Add($scrollViewer) | Out-Null $border.Child = $mainPanel @@ -25448,16 +25711,20 @@ try { Write-Host "Disabled" -ForegroundColor DarkYellow } - # Restore Custom Deployment Target Group + # Restore Custom Deployment Target Group(s) Write-Host " Target Group : " -NoNewline -ForegroundColor DarkGray + $script:DeployTargetGroups.Clear() if (-not [string]::IsNullOrEmpty($savedConfig.DeployTargetGroupId)) { - $groupName = if (-not [string]::IsNullOrEmpty($savedConfig.DeployTargetGroupName)) { $savedConfig.DeployTargetGroupName } else { $savedConfig.DeployTargetGroupId } - $txt_DeployGroupSelectedName.Text = $groupName - $txt_DeployGroupSelectedId.Text = $savedConfig.DeployTargetGroupId - $panel_DeployGroupSelected.Visibility = 'Visible' - Write-Host "$groupName" -ForegroundColor Cyan + $savedIds = @($savedConfig.DeployTargetGroupId -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $savedNames = @("$($savedConfig.DeployTargetGroupName)" -split ';;') + for ($gi = 0; $gi -lt $savedIds.Count; $gi++) { + $gName = if ($gi -lt $savedNames.Count -and -not [string]::IsNullOrWhiteSpace($savedNames[$gi])) { $savedNames[$gi].Trim() } else { $savedIds[$gi] } + $script:DeployTargetGroups.Add([pscustomobject]@{ Id = $savedIds[$gi]; Name = $gName }) + } + Update-DATDeployGroupList + Write-Host "$(($script:DeployTargetGroups | ForEach-Object { $_.Name }) -join ', ')" -ForegroundColor Cyan } else { - $panel_DeployGroupSelected.Visibility = 'Collapsed' + Update-DATDeployGroupList Write-Host "All Devices (default)" -ForegroundColor DarkYellow } @@ -25766,6 +26033,21 @@ try { Write-Host "2 (Default)" -ForegroundColor DarkYellow } + # Restore Latest Drivers update cadence + Write-Host " Latest Cadence: " -NoNewline -ForegroundColor DarkGray + if ($null -ne $cmb_LatestDriverCadence) { + $savedCadence = if (-not [string]::IsNullOrEmpty($savedConfig.LatestDriverCadence)) { [string]$savedConfig.LatestDriverCadence } else { 'Off' } + foreach ($item in $cmb_LatestDriverCadence.Items) { + if ([string]$item.Tag -eq $savedCadence) { + $cmb_LatestDriverCadence.SelectedItem = $item + break + } + } + Write-Host "$savedCadence" -ForegroundColor White + } else { + Write-Host "Off (Default)" -ForegroundColor DarkYellow + } + # Restore Clean Temp on Exit Write-Host " Clean on Exit : " -NoNewline -ForegroundColor DarkGray if ($null -ne $savedConfig.CleanTempOnExit -and $savedConfig.CleanTempOnExit -eq 0) { @@ -26332,7 +26614,7 @@ if (Test-Path $logoPath) { # Read version from module manifest $manifestPath = Join-Path $AppRoot "Modules\DriverAutomationToolCore\DriverAutomationToolCore.psd1" -$script:versionString = "v10.2.3" +$script:versionString = "v10.2.4" if (Test-Path $manifestPath) { $manifestData = Import-PowerShellDataFile $manifestPath $ver = [version]$manifestData.ModuleVersion @@ -27631,4 +27913,178 @@ $script:UpgradeWatchTimer.Add_Tick({ }) $script:UpgradeWatchTimer.Start() +#region What's New highlighting (Tesla-style nav dots + "New" pills) +# Declarative manifest -- one entry per newly shipped feature. Add entries each release. +# Id : stable unique key persisted once the user has seen it (registry 'WhatsNewSeen', ;;-list, HKLM) +# Dot/Parent: nav-button dot to light up (Parent is the collapsible group's dot, for nested sub-items) +# Pill : the "New" badge element shown in the view +# Zone : the hover region that clears this feature (must be hovered individually) +# Controls : the section's interactive controls -- interacting (click/keyboard) with any of them, +# not just hovering the pill/title, also clears the feature +$script:WhatsNewFeatures = @( + [pscustomobject]@{ Id = 'lenovo-latest-10.2.4'; Dot = 'dot_CommonSettings'; Parent = ''; Pill = 'pill_LenovoLatest'; Zone = 'zone_LenovoLatest'; Controls = @('cmb_HPDriverPackSource') } + [pscustomobject]@{ Id = 'update-cadence-10.2.4'; Dot = 'dot_CommonSettings'; Parent = ''; Pill = 'pill_UpdateCadence'; Zone = 'zone_UpdateCadence'; Controls = @('cmb_LatestDriverCadence') } + [pscustomobject]@{ Id = 'multi-deploy-10.2.4'; Dot = 'dot_IntuneOptions'; Parent = 'dot_IntuneSettings'; Pill = 'pill_MultiDeploy'; Zone = 'zone_MultiDeploy'; Controls = @('txt_DeployGroupSearch', 'btn_SearchDeployGroup', 'cmb_DeployGroupResults', 'btn_ClearDeployGroup') } +) + +# Maps a wired element's x:Name to the feature id it clears, so plain (non-closure) handlers can +# resolve the feature from $this.Name without capturing loop variables (which do not close over +# reliably here) or clobbering a control's own Tag. +$script:WhatsNewClearByName = @{} + + +function Get-DATWhatsNewSeen { + $raw = (Get-ItemProperty -Path $global:RegPath -Name 'WhatsNewSeen' -ErrorAction SilentlyContinue).WhatsNewSeen + if ([string]::IsNullOrWhiteSpace($raw)) { return @() } + return @($raw -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) +} + +function Set-DATWhatsNewSeen { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Id) + if ([string]::IsNullOrWhiteSpace($Id)) { return } + $seen = @(Get-DATWhatsNewSeen) + if ($seen -contains $Id) { return } + Set-DATRegistryValue -Name 'WhatsNewSeen' -Value (($seen + $Id) -join ';;') -Type String +} + +# A nav dot lights up while any feature mapped to it (directly or via its parent group) is unseen. +function Update-DATWhatsNewDots { + $seen = @(Get-DATWhatsNewSeen) + $unseen = @($script:WhatsNewFeatures | Where-Object { $seen -notcontains $_.Id }) + $dotNames = @($script:WhatsNewFeatures | ForEach-Object { $_.Dot; $_.Parent } | Where-Object { $_ } | Select-Object -Unique) + foreach ($dotName in $dotNames) { + $dot = $Window.FindName($dotName) + if ($null -eq $dot) { continue } + $active = @($unseen | Where-Object { $_.Dot -eq $dotName -or $_.Parent -eq $dotName }) + $dot.Visibility = if ($active.Count -gt 0) { 'Visible' } else { 'Collapsed' } + } +} + +# Marks a feature seen and hides its pill. Idempotent: a no-op once already cleared, so it is safe +# to fire from high-frequency events (e.g. every keystroke in a section's text box). +function Clear-DATWhatsNewFeature { + param([Parameter(Mandatory)][AllowEmptyString()][string]$Id) + if ([string]::IsNullOrWhiteSpace($Id)) { return } + if (@(Get-DATWhatsNewSeen) -contains $Id) { return } + Set-DATWhatsNewSeen -Id $Id + $f = $script:WhatsNewFeatures | Where-Object { $_.Id -eq $Id } | Select-Object -First 1 + if ($f) { $p = $Window.FindName($f.Pill); if ($null -ne $p) { $p.Visibility = 'Collapsed' } } + Update-DATWhatsNewDots +} + +function Initialize-DATWhatsNew { + $seen = @(Get-DATWhatsNewSeen) + + # Clears the feature whose Zone is hovered (feature id carried on the Zone Tag). + $clearFromTag = { Clear-DATWhatsNewFeature -Id $this.Tag } + # Clears the feature a wired control belongs to, resolved from $this.Name via the shared map. + $clearFromName = { Clear-DATWhatsNewFeature -Id ([string]$script:WhatsNewClearByName[$this.Name]) } + + foreach ($feat in $script:WhatsNewFeatures) { + $isSeen = $seen -contains $feat.Id + $pill = $Window.FindName($feat.Pill) + if ($null -ne $pill) { $pill.Visibility = if ($isSeen) { 'Collapsed' } else { 'Visible' } } + if ($isSeen) { continue } + + # Hovering, or clicking, the title/pill row clears the feature. + $zone = $Window.FindName($feat.Zone) + if ($null -ne $zone) { + $zone.Tag = $feat.Id + $zone.Add_MouseEnter($clearFromTag) + $zone.Add_PreviewMouseLeftButtonDown($clearFromTag) + } + + # Interacting with the section's own controls (clicking, or keyboard input such as opening a + # dropdown or typing in a search box) clears it too -- these sit below the Zone, so a hover + # over them never reaches the Zone. Preview events are used so they fire for any child click + # and are never marked handled, leaving the control's own behaviour intact. Both are strictly + # user-initiated, so a programmatic config load (which raises SelectionChanged) cannot clear + # the badge prematurely. + foreach ($ctrlName in @($feat.Controls)) { + if ([string]::IsNullOrWhiteSpace($ctrlName)) { continue } + $ctrl = $Window.FindName($ctrlName) + if ($null -eq $ctrl) { continue } + $script:WhatsNewClearByName[$ctrlName] = $feat.Id + $ctrl.Add_PreviewMouseLeftButtonDown($clearFromName) + $ctrl.Add_PreviewKeyDown($clearFromName) + } + } + Update-DATWhatsNewDots +} + +try { Initialize-DATWhatsNew } catch { Write-DATActivityLog "What's New init failed: $($_.Exception.Message)" -Level Warn } + +# ---- "What's New" upgrade modal --------------------------------------------------------------- +# Shown once per version after an upgrade. Update this list each release, aligned with the +# "What's New & Fixed" changelog. Each entry renders as a bold category lead-in plus a description +# (no bullets), spaced apart. +$script:WhatsNewReleaseItems = @( + [pscustomobject]@{ Category = 'Latest Drivers -- Lenovo'; Text = 'Lenovo now supports building Latest Drivers packages from the per-model update catalog, joining Dell and HP. Choose it under Driver Package Build Type.' } + [pscustomobject]@{ Category = 'Update Cadence'; Text = 'A new cadence control throttles how often a Latest Drivers pack is re-evaluated on repeat or scheduled runs -- Off, Daily, Weekly or Monthly -- so an unchanged driver set is not rebuilt every time.' } + [pscustomobject]@{ Category = 'Multi-Group Deployment'; Text = 'Auto-deployed Intune packages can now target one or more specific Entra security groups (for example a pilot ring plus a broad ring) instead of only All Devices.' } +) + +function Get-DATWhatsNewModalShownVersion { + return (Get-ItemProperty -Path $global:RegPath -Name 'WhatsNewModalShownVersion' -ErrorAction SilentlyContinue).WhatsNewModalShownVersion +} + +function Show-DATWhatsNewModal { + $overlay = $Window.FindName('overlay_WhatsNew') + if ($null -eq $overlay) { return } + + $verLbl = $Window.FindName('txt_WhatsNewVersion') + if ($null -ne $verLbl) { $verLbl.Text = "Version $($global:ScriptRelease)" } + + $panel = $Window.FindName('panel_WhatsNewItems') + if ($null -ne $panel) { + $panel.Children.Clear() + foreach ($item in $script:WhatsNewReleaseItems) { + $block = [System.Windows.Controls.StackPanel]::new() + $block.Margin = [System.Windows.Thickness]::new(0, 0, 0, 16) # spacing between each feature/fix + + $cat = [System.Windows.Controls.TextBlock]::new() + $cat.Text = [string]$item.Category + $cat.FontSize = 14 + $cat.FontWeight = [System.Windows.FontWeights]::SemiBold + $cat.TextWrapping = 'Wrap' + $cat.Margin = [System.Windows.Thickness]::new(0, 0, 0, 3) + $cat.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'WindowForeground') + + $desc = [System.Windows.Controls.TextBlock]::new() + $desc.Text = [string]$item.Text + $desc.FontSize = 13 + $desc.TextWrapping = 'Wrap' + $desc.LineHeight = 19 + $desc.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'InputPlaceholder') + + [void]$block.Children.Add($cat) + [void]$block.Children.Add($desc) + [void]$panel.Children.Add($block) + } + } + + $overlay.Visibility = 'Visible' + # Persist as shown immediately so it never reappears for this version, even if the window is + # closed without pressing "Got it". + try { Set-DATRegistryValue -Name 'WhatsNewModalShownVersion' -Value ([string]$global:ScriptRelease) -Type String } catch {} +} + +function Show-DATWhatsNewModalIfUpgraded { + if ([string](Get-DATWhatsNewModalShownVersion) -ne [string]$global:ScriptRelease) { + Show-DATWhatsNewModal + } +} + +$btn_WhatsNewClose = $Window.FindName('btn_WhatsNewClose') +if ($null -ne $btn_WhatsNewClose) { + $btn_WhatsNewClose.Add_Click({ + $o = $Window.FindName('overlay_WhatsNew') + if ($null -ne $o) { $o.Visibility = 'Collapsed' } + }) +} + +try { Show-DATWhatsNewModalIfUpgraded } catch { Write-DATActivityLog "What's New modal failed: $($_.Exception.Message)" -Level Warn } +#endregion + + $Window.ShowDialog() | Out-Null diff --git a/Driver Automation Tool/UI/MainWindow.xaml b/Driver Automation Tool/UI/MainWindow.xaml index fca9926..eb84b22 100644 --- a/Driver Automation Tool/UI/MainWindow.xaml +++ b/Driver Automation Tool/UI/MainWindow.xaml @@ -934,7 +934,7 @@ Foreground="{DynamicResource AccentColor}" VerticalAlignment="Center" Margin="0,0,8,0"/> - @@ -1035,7 +1035,10 @@ - - + + + + + + @@ -3732,31 +3746,25 @@ - - - + + + - - - - - - - + + @@ -5548,6 +5556,143 @@ + + + + + + + + + + + + + SCCM Driver Packs are created from the OEM's SCCM / Configuration Manager driver package release, which is typically updated once a month. Due to the model support cadence defined by the OEM, these driver packages might not contain the latest driver updates. + + + For Dell, HP and Lenovo, selecting Latest Drivers forces the tool to use the latest individual driver sources -- the Dell Command Update catalog for Dell, the HP Client Management Script Library (CMSL) lookup for HP, or the Lenovo per-model update catalog for Lenovo. Other manufacturers continue to use their SCCM driver pack. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The Driver Automation Tool uses the HPCMSL PowerShell module to create driver and BIOS packages for HP devices, and it is required to build HP Latest Drivers packages. Without this module installed, HP will be unavailable as a manufacturer option. + + + + + + + + + + + + + + + + @@ -5708,79 +5853,6 @@ - - - - The Driver Automation Tool uses the HPCMSL PowerShell module to create driver and BIOS packages for HP devices. Without this module installed, HP will be unavailable as a manufacturer option. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -6814,7 +6886,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + From 2fc729c975af95eb36c0fd60c212c8214b483b16 Mon Sep 17 00:00:00 2001 From: Maurice Daly Date: Mon, 31 Aug 2026 13:18:44 +0100 Subject: [PATCH 2/4] Version 10.2.5 is live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OEM Support Panasonic support — added Panasonic as a supported manufacturer (catalog, model listing, packaging). Fujitsu support — added Fujitsu, including its session-gated catalog handling. ASUS support — ExpertBook/ExpertCenter commercial models via the DAT API catalog. Lenovo Custom Driver Pack detection — model now read from Win32_ComputerSystemProduct.Version and SKU from the first four characters of Win32_ComputerSystem.Model, matching the apply-driver script. (maurice-daly/DriverAutomationTool#921) - Intune package management — manage and delete existing BIOS/driver packages directly from the tool. - Force BIOS re-application — new -ForceDownload switch / SMSTSForceBIOSDownload task-sequence variable to download and flag the same BIOS version for flashing (e.g. recreating Dell recovery images). Opt-in; default unchanged. (MSEndpointMgr/ModernBIOSManagement#31) ASUS BIOS matching — each model now resolves its own BIOS version instead of every model collapsing onto the newest family BIOS (all showing 311). - Canonical manifest caching crash — fixed a Dell/Lenovo crash during canonical driver-manifest caching under PowerShell 5.1. (#913) Long-path pre-flight advisory — warns before Latest Drivers extraction when Windows long-path support is off and payloads (e.g. Intel DUPs) risk exceeding the path limit. (#912) - DCU graphics driver naming — Dell Command | Update graphics-driver revisions now canonicalise/collapse correctly. (#910) - Catalog OS matching — generic Windows 11 / win11 * catalog entries now appear for every Windows 11 build across all catalog-driven OEMs (10 vs 11 stay separated). - OEM Selections (Common Settings) — restrict which manufacturers are available app-wide via a toggle + multi-select. - Interface Scale (Common Settings) — 75–150% scaling slider plus auto-fit-to-screen for small/high-DPI displays. - Common Settings reorganised — the settings cards grouped into seven labelled sections. - Windows no longer float above other apps — modal dialogs stay above the tool's own window only, not the whole desktop. (maurice-daly/DriverAutomationTool#920) - Assorted UI tweaks and feature icons. - Shared desktop shortcut — the launcher now creates one shortcut on the public/all-users desktop instead of one per admin profile, cleans up stale per-user copies, and adds a CreateDesktopShortcut toggle / -NoShortcut switch. (maurice-daly/DriverAutomationTool#916) - Hardware inventory check — read-only WMI hardware-inventory class availability check. (#865) --- Data/DriverAutomationToolNotes.txt | 23 + Data/DriverAutomationToolRev.txt | 2 +- .../DriverAutomationToolCore.psd1 | 5 +- .../DriverAutomationToolCore.psm1 | 804 ++++++++++- .../Start-DriverAutomationTool.ps1 | 85 +- Driver Automation Tool/UI/MainApplication.ps1 | 875 ++++++++++-- Driver Automation Tool/UI/MainWindow.xaml | 1247 +++++++++++------ 7 files changed, 2425 insertions(+), 616 deletions(-) diff --git a/Data/DriverAutomationToolNotes.txt b/Data/DriverAutomationToolNotes.txt index dbe6fec..0e572b8 100644 --- a/Data/DriverAutomationToolNotes.txt +++ b/Data/DriverAutomationToolNotes.txt @@ -3,6 +3,29 @@ Release Notes IMPORTANT - DAT Downloads have been moved to GitHub - https://github.com/maurice-daly/DriverAutomationTool +Version 10.2.5 +What's New & Fixed + +- OEM Support +Panasonic support — added Panasonic as a supported manufacturer (catalog, model listing, packaging). +Fujitsu support — added Fujitsu, including its session-gated catalog handling. +ASUS support — ExpertBook/ExpertCenter commercial models via the DAT API catalog. +Lenovo Custom Driver Pack detection — model now read from Win32_ComputerSystemProduct.Version and SKU from the first four characters of Win32_ComputerSystem.Model, matching the apply-driver script. (maurice-daly/DriverAutomationTool#921) +- Intune package management — manage and delete existing BIOS/driver packages directly from the tool. +- Force BIOS re-application — new -ForceDownload switch / SMSTSForceBIOSDownload task-sequence variable to download and flag the same BIOS version for flashing (e.g. recreating Dell recovery images). Opt-in; default unchanged. (MSEndpointMgr/ModernBIOSManagement#31) +ASUS BIOS matching — each model now resolves its own BIOS version instead of every model collapsing onto the newest family BIOS (all showing 311). +- Canonical manifest caching crash — fixed a Dell/Lenovo crash during canonical driver-manifest caching under PowerShell 5.1. (#913) +Long-path pre-flight advisory — warns before Latest Drivers extraction when Windows long-path support is off and payloads (e.g. Intel DUPs) risk exceeding the path limit. (#912) +- DCU graphics driver naming — Dell Command | Update graphics-driver revisions now canonicalise/collapse correctly. (#910) +- Catalog OS matching — generic Windows 11 / win11 * catalog entries now appear for every Windows 11 build across all catalog-driven OEMs (10 vs 11 stay separated). +- OEM Selections (Common Settings) — restrict which manufacturers are available app-wide via a toggle + multi-select. +- Interface Scale (Common Settings) — 75–150% scaling slider plus auto-fit-to-screen for small/high-DPI displays. +- Common Settings reorganised — the settings cards grouped into seven labelled sections. +- Windows no longer float above other apps — modal dialogs stay above the tool's own window only, not the whole desktop. (maurice-daly/DriverAutomationTool#920) +- Assorted UI tweaks and feature icons. +- Shared desktop shortcut — the launcher now creates one shortcut on the public/all-users desktop instead of one per admin profile, cleans up stale per-user copies, and adds a CreateDesktopShortcut toggle / -NoShortcut switch. (maurice-daly/DriverAutomationTool#916) +- Hardware inventory check — read-only WMI hardware-inventory class availability check. (#865) + Version 10.2.4 What's New & Fixed - OEM Support — Dell and Lenovo now join HP with a Latest Drivers build type. Instead of the OEM's monthly SCCM driver pack, packages can be built from the newest individual drivers pulled straight from the Dell Command Update catalog (Dell) and the Lenovo per-model update catalog (Lenovo). Other manufacturers continue to use their SCCM driver pack. diff --git a/Data/DriverAutomationToolRev.txt b/Data/DriverAutomationToolRev.txt index 88c2cad..0fc9c3b 100644 --- a/Data/DriverAutomationToolRev.txt +++ b/Data/DriverAutomationToolRev.txt @@ -1 +1 @@ -10.2.4 \ No newline at end of file +10.2.5 \ No newline at end of file diff --git a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 index b534408..60c42ae 100644 --- a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 +++ b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'DriverAutomationToolCore.psm1' - ModuleVersion = '10.2.4.0' + ModuleVersion = '10.2.5.0' GUID = 'a3e0e746-8e3a-4c5b-b8d0-3b2e4f6a9c1d' Author = 'Maurice Daly' CompanyName = 'MSEndpointMgr' @@ -23,6 +23,7 @@ 'Start-DATModelProcessing', 'Connect-DATConfigMgr', 'Get-DATConfigMgrKnownModels', + 'Test-DATConfigMgrInventoryClasses', 'Get-DATSiteCode', 'Get-DATDistributionPoints', 'Get-DATDistributionPointGroups', @@ -57,6 +58,8 @@ 'Get-DATIntuneAppScriptMetadata', 'Update-DATIntuneAppRuleScript', 'Update-DATIntuneAppMetadata', + 'Set-DATIntuneAppDeploymentState', + 'Set-DATIntuneAppOSTarget', 'Get-DATIntuneAppAssignmentTargetKeys', 'Remove-DATIntuneAppAssignmentTargets', 'New-DATIntuneWin32App', diff --git a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 index e067fcd..b53a0c1 100644 --- a/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 +++ b/Driver Automation Tool/Modules/DriverAutomationToolCore/DriverAutomationToolCore.psm1 @@ -4,7 +4,7 @@ Organization: MSEndpointMgr / Patch My PC Filename: DriverAutomationToolCore.psm1 Purpose: Core functions for Driver Automation Tool v2.0 - Version: 10.2.4.0 + Version: 10.2.5.0 =========================================================================== #> @@ -37,8 +37,8 @@ if ($PSVersionTable.PSVersion.Major -le 5) { #region Variables -[version]$global:ScriptRelease = "10.2.4.0" -$global:ScriptBuildDate = "23-08-2026" +[version]$global:ScriptRelease = "10.2.5.0" +$global:ScriptBuildDate = "25-08-2026" $global:ReleaseNotesURL = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/DriverAutomationToolNotes.txt" $global:DATConfigUrl = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/refs/heads/master/Data/DATAPIConfig.json" $OEMLinksURL = "https://raw.githubusercontent.com/maurice-daly/DriverAutomationTool/master/Data/OEMLinks.xml" @@ -245,6 +245,8 @@ $script:DATTrustedPublisherCNs = @( 'Microsoft Windows*', 'Acer Incorporated', 'Acer Inc*', + 'ASUSTeK*', + 'Asus*', 'Fujitsu*', 'Toshiba*', 'Panasonic*', @@ -595,11 +597,54 @@ function Find-DATLenovoModelType { return $global:LenovoModelType } +function Get-DATCatalogOSMajor { + <# + .SYNOPSIS + Returns the Windows major version ('10' or '11') referenced by a catalog entry's + SupportedOS string, tolerating the many spellings present in the DAT driver catalog + (e.g. 'Windows 11', 'win11 25H2', 'Windows11', 'win11 *', 'Windows 11 64-bit, 24H2'). + Returns '' when no Windows major version can be determined. + #> + param ( + [string]$SupportedOS + ) + if ([string]::IsNullOrWhiteSpace($SupportedOS)) { return '' } + $normalized = $SupportedOS.ToLower() + if ($normalized -match 'win(?:dows)?\s*(1[01])') { return $Matches[1] } + if ($normalized -match '\b(1[01])\b') { return $Matches[1] } + return '' +} + +function Test-DATCatalogOSMatch { + <# + .SYNOPSIS + Determines whether a catalog entry's SupportedOS applies to the requested Windows + version. Matching is done on the Windows major version (10 vs 11) only, so an entry + tagged generically (e.g. 'Windows 11' or 'win11 *' with no specific build) is treated + as applying to every build of that version, and inconsistent spellings ('win11', + 'Windows11', 'Windows 11 64-bit, 25H2') no longer cause valid packages to be dropped. + .NOTES + Replaces the previous brittle "$SupportedOS -match 'Windows 11'" substring test, which + silently excluded any entry not spelled exactly "Windows 11..." -- the cause of ASUS + (and other catalog-driven OEM) models being omitted for builds where only a generic + or alternately-spelled catalog entry existed. + #> + param ( + [string]$SupportedOS, + [string]$WindowsVersion + ) + $targetMajor = ($WindowsVersion -replace '[^\d]', '') # '10' or '11' + if ([string]::IsNullOrEmpty($targetMajor)) { return $true } # no version constraint supplied + $entryMajor = Get-DATCatalogOSMajor -SupportedOS $SupportedOS + if ([string]::IsNullOrEmpty($entryMajor)) { return $false } # undeterminable -> exclude (as before) + return ($entryMajor -eq $targetMajor) +} + function Get-DATOEMModelInfo { [CmdletBinding()] param ( [Parameter(Position = 1)] - [ValidateSet('HP', 'Dell', 'Lenovo', 'Microsoft', 'Acer')] + [ValidateSet('HP', 'Dell', 'Lenovo', 'Microsoft', 'Acer', 'Panasonic', 'Fujitsu', 'ASUS')] [array]$RequiredOEMs, [Parameter(Position = 2)] [ValidateNotNullOrEmpty()] @@ -807,7 +852,7 @@ function Get-DATOEMModelInfo { if ($DATCatalog -and $DATCatalog.Count -gt 0) { $DATMSFiltered = $DATCatalog | Where-Object { $_.Manufacturer -eq 'Microsoft' -and - $_.SupportedOS -match $WindowsVersion -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and $_.SupportedArchitecture -eq $MSArchFilter } $DATMicrosoftModels = $DATMSFiltered | Group-Object -Property DisplayName @@ -898,7 +943,7 @@ function Get-DATOEMModelInfo { $datEntry = $AcerDATCatalog | Where-Object { $_.Manufacturer -eq 'Acer' -and $_.DisplayName -eq $Model -and - $_.SupportedOS -match $WindowsVersion -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and $_.SupportedArchitecture -eq $AcerArchFilter } | Select-Object -First 1 if ($datEntry -and -not [string]::IsNullOrEmpty($datEntry.Version)) { @@ -923,6 +968,138 @@ function Get-DATOEMModelInfo { Write-DATLogEntry -Value "[Error] - Acer model retrieval failed: $($_.Exception.Message)" -Severity 3 } } + "Panasonic" { + # Panasonic is API-catalog sourced via an XMLSource link, same shape as Acer. + $PanasonicXMLSource = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Panasonic" }).Link | Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 + $PanasonicXMLFile = [string]($PanasonicXMLSource | Split-Path -Leaf) + try { + Write-DATLogEntry -Value "[Panasonic] Catalog download path: $(Join-Path $global:TempDirectory $PanasonicXMLFile)" -Severity 1 + if (-not (Test-Path "$global:TempDirectory\$PanasonicXMLFile")) { + Invoke-DATContentDownload -DownloadURL $PanasonicXMLSource -DownloadDestination $global:TempDirectory + } + [xml]$global:PanasonicModelXML = Get-Content -Path (Join-Path $global:TempDirectory $PanasonicXMLFile) + $global:PanasonicModelDrivers = $global:PanasonicModelXML.ModelList.Model + if (-not ([string]::IsNullOrEmpty($WindowsBuild))) { + $PanasonicModels = ($global:PanasonicModelDrivers | Where-Object { + ($_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $("Win" + "$($WindowsVersion.Split(' ')[1])")) + } | Sort-Object).Name + } + # Load the DAT API driver catalog (preferred version source -- exposes a 'Version' field) + $PanasonicDATCatalog = $null + try { $PanasonicDATCatalog = Get-DATDriverCatalog } catch { + Write-DATLogEntry -Value "[Panasonic] DAT catalog unavailable for version lookup: $($_.Exception.Message)" -Severity 2 + } + $PanasonicArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + foreach ($Model in $PanasonicModels) { + $modelNode = $global:PanasonicModelDrivers | Where-Object { $_.Name -eq $Model } | Select-Object -First 1 + # Prefer the DAT API catalog Version field; fall back to the XML SCCM node date + $panasonicVersion = '' + if ($PanasonicDATCatalog) { + $datEntry = $PanasonicDATCatalog | Where-Object { + $_.Manufacturer -eq 'Panasonic' -and + $_.DisplayName -eq $Model -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and + $_.SupportedArchitecture -eq $PanasonicArchFilter + } | Select-Object -First 1 + if ($datEntry -and -not [string]::IsNullOrEmpty($datEntry.Version)) { + $panasonicVersion = $datEntry.Version + } + } + if ([string]::IsNullOrEmpty($panasonicVersion)) { + # Catalog-provided date from the matching SCCM node + $sccmNode = $modelNode.SCCM | Where-Object { $_.Version -eq $WindowsBuild -and $_.OS -eq $("Win" + "$($WindowsVersion.Split(' ')[1])") } | Select-Object -First 1 + $panasonicVersion = if ($sccmNode.date) { $sccmNode.date } else { '' } + } + $OEMSupportedModels += [PSCustomObject]@{ + OEM = "Panasonic" + Model = $Model + Baseboards = $Model + OS = $WindowsVersion + 'OS Build' = $WindowsBuild + Version = $panasonicVersion + } + } + } catch { + Write-DATLogEntry -Value "[Error] - Panasonic model retrieval failed: $($_.Exception.Message)" -Severity 3 + } + } + "Fujitsu" { + # Fujitsu is sourced entirely from the DAT API catalog (Manufacturer='Fujitsu'). + # There is no OEMLinks XML: the pre-built SCCM packs carry a session-gated servlet + # DownloadURL, a published hash and a comma-separated SupportedDevices model list. + try { + $FujitsuArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $FujitsuCatalog = Get-DATDriverCatalog + if ($null -eq $FujitsuCatalog -or @($FujitsuCatalog).Count -eq 0) { + Write-DATLogEntry -Value "[Fujitsu] DAT API catalog unavailable or empty -- no Fujitsu models loaded" -Severity 2 + } else { + $FujitsuEntries = $FujitsuCatalog | Where-Object { + $_.Manufacturer -eq 'Fujitsu' -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and + $_.SupportedArchitecture -eq $FujitsuArchFilter -and + -not [string]::IsNullOrEmpty($_.DownloadURL) + } + # Newest entry per model display name (packs are re-published quarterly). + foreach ($modelGroup in ($FujitsuEntries | Group-Object -Property DisplayName)) { + $latestEntry = $modelGroup.Group | Sort-Object { try { [datetime]$_.ReleaseDate } catch { [datetime]::MinValue } } -Descending | Select-Object -First 1 + $fujitsuVersion = if (-not [string]::IsNullOrEmpty($latestEntry.Version)) { $latestEntry.Version } + elseif (-not [string]::IsNullOrEmpty($latestEntry.ReleaseDate)) { $latestEntry.ReleaseDate } + else { '' } + $OEMSupportedModels += [PSCustomObject]@{ + OEM = "Fujitsu" + Model = $modelGroup.Name + Baseboards = if ($latestEntry.SupportedDevices) { $latestEntry.SupportedDevices } else { $modelGroup.Name } + OS = $WindowsVersion + 'OS Build' = $WindowsBuild + Version = $fujitsuVersion + DownloadURL = $latestEntry.DownloadURL + } + } + Write-DATLogEntry -Value "[Fujitsu] DAT catalog: $(@($FujitsuEntries | Group-Object -Property DisplayName).Count) model(s) found for $WindowsVersion $FujitsuArchFilter" -Severity 1 + } + } catch { + Write-DATLogEntry -Value "[Error] - Fujitsu model retrieval failed: $($_.Exception.Message)" -Severity 3 + } + } + "ASUS" { + # ASUS commercial (ExpertBook/ExpertCenter) SCCM driver packs are sourced from the + # DAT API catalog (Manufacturer='ASUS'). Unlike Fujitsu, the DownloadURL is a direct + # HTTPS .zip on dlcdnets.asus.com, so no session-gated servlet resolver is required -- + # the pre-resolved direct-file path in Invoke-DATOEMDownloadModule handles it. + try { + $AsusArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $AsusCatalog = Get-DATDriverCatalog + if ($null -eq $AsusCatalog -or @($AsusCatalog).Count -eq 0) { + Write-DATLogEntry -Value "[ASUS] DAT API catalog unavailable or empty -- no ASUS models loaded" -Severity 2 + } else { + $AsusEntries = $AsusCatalog | Where-Object { + $_.Manufacturer -eq 'ASUS' -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and + $_.SupportedArchitecture -eq $AsusArchFilter -and + -not [string]::IsNullOrEmpty($_.DownloadURL) + } + # Newest entry per model display name (packs are re-published per driver refresh). + foreach ($modelGroup in ($AsusEntries | Group-Object -Property DisplayName)) { + $latestEntry = $modelGroup.Group | Sort-Object { try { [datetime]$_.ReleaseDate } catch { [datetime]::MinValue } } -Descending | Select-Object -First 1 + $asusVersion = if (-not [string]::IsNullOrEmpty($latestEntry.Version)) { $latestEntry.Version } + elseif (-not [string]::IsNullOrEmpty($latestEntry.ReleaseDate)) { $latestEntry.ReleaseDate } + else { '' } + $OEMSupportedModels += [PSCustomObject]@{ + OEM = "ASUS" + Model = $modelGroup.Name + Baseboards = if ($latestEntry.SupportedDevices) { $latestEntry.SupportedDevices } else { $modelGroup.Name } + OS = $WindowsVersion + 'OS Build' = $WindowsBuild + Version = $asusVersion + DownloadURL = $latestEntry.DownloadURL + } + } + Write-DATLogEntry -Value "[ASUS] DAT catalog: $(@($AsusEntries | Group-Object -Property DisplayName).Count) model(s) found for $WindowsVersion $AsusArchFilter" -Severity 1 + } + } catch { + Write-DATLogEntry -Value "[Error] - ASUS model retrieval failed: $($_.Exception.Message)" -Severity 3 + } + } } } return [array]$OEMSupportedModels @@ -936,7 +1113,12 @@ function Invoke-DATContentDownload { [CmdletBinding()] param ( [ValidateNotNullOrEmpty()]$DownloadDestination, - [ValidateNotNullOrEmpty()]$DownloadURL + [ValidateNotNullOrEmpty()]$DownloadURL, + # Fujitsu-style session-gated packs: force the leaf file name (the servlet URL has no + # usable leaf), send a primed-session cookie header, and POST the form fields. + [string]$OverrideFileName, + [string]$CookieHeader, + [System.Collections.IDictionary]$PostData ) [Net.ServicePointManager]::SecurityProtocol = ( @@ -968,11 +1150,12 @@ function Invoke-DATContentDownload { } # Strip query strings from the leaf filename -- URLs like .zip?acerid=... produce invalid filenames on Windows - $leafName = $DownloadURL | Split-Path -Leaf + $leafName = if (-not [string]::IsNullOrEmpty($OverrideFileName)) { $OverrideFileName } else { $DownloadURL | Split-Path -Leaf } if ($leafName -match '\?') { $leafName = ($leafName -split '\?')[0] } $DownloadDestination = Join-Path -Path "$DownloadDestination" -ChildPath $leafName $DownloadSize = [long]0 + if (-not $PostData) { try { $proxyParams = Get-DATWebRequestProxy $DownloadState = Invoke-WebRequest -Uri $DownloadURL -Method Head -UseBasicParsing -TimeoutSec 30 @proxyParams @@ -986,6 +1169,7 @@ function Invoke-DATContentDownload { } catch { Write-DATLogEntry -Value "[Warning] - HEAD request failed, size unknown: $($_.Exception.Message)" -Severity 2 } + } # Skip if already downloaded: size matches, or size unknown but file exists (trust it) if (Test-Path -Path $DownloadDestination) { @@ -1128,7 +1312,7 @@ function Invoke-DATContentDownload { $curlProxyCfgFile = New-DATCurlProxyConfigFile # If HEAD request failed to get size, fall back to CURL headers - if ($DownloadSize -le 0) { + if ($DownloadSize -le 0 -and -not $PostData) { try { Write-DATLogEntry -Value "- Using CURL to obtain file size via response headers" -Severity 1 # Use -i (include headers) with a real GET request -- many CDNs don't return @@ -1167,6 +1351,13 @@ function Invoke-DATContentDownload { # Proxy server (no credentials) comes from Get-DATCurlProxyArgs; credentials come via --config (security fix #5) $CurlArgs = "--location --proto =https --max-redirs 5 --output `"$DownloadDestination`" --url `"$DownloadURL`" --dump-header `"$CurlHeaderDumpFile`" --connect-timeout 30 --retry 10 --retry-delay 60 --retry-max-time 600 --retry-connrefused $(Get-DATCurlProxyArgs)" if ($curlProxyCfgFile) { $CurlArgs = "--config `"$curlProxyCfgFile`" $CurlArgs" } + # Session-gated POST downloads (Fujitsu): carry the primed-session cookie and POST the + # overlay form fields. curl's cookie engine forwards Set-Cookie across the redirect chain. + if (-not [string]::IsNullOrEmpty($CookieHeader)) { $CurlArgs += " --cookie `"$CookieHeader`"" } + if ($PostData) { + $CurlArgs += " --user-agent `"Mozilla/5.0 (Windows NT 10.0; Win64; x64)`"" + foreach ($k in $PostData.Keys) { $CurlArgs += " --data-urlencode `"$k=$($PostData[$k])`"" } + } try { Set-DATRegistryValue -Name "RunningProcess" -Type String -Value "Curl" @@ -1303,6 +1494,37 @@ function Invoke-DATContentDownload { } } + # POST downloads (Fujitsu session-gated packs) cannot use the HttpClient GET loop below. + # When curl was unavailable or failed, fall back to a direct Invoke-WebRequest POST that + # carries the primed-session cookie and follows the redirect chain to the tokenised CDN URL. + if ($PostData) { + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + if (Test-Path -Path $DownloadDestination) { Remove-Item -Path $DownloadDestination -Force -ErrorAction SilentlyContinue } + Write-DATLogEntry -Value "- Downloading via Invoke-WebRequest POST (attempt $attempt/3)..." -Severity 1 + $proxyParams = Get-DATWebRequestProxy + if ($proxyParams -isnot [hashtable]) { $proxyParams = @{} } + $iwrHeaders = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } + if (-not [string]::IsNullOrEmpty($CookieHeader)) { $iwrHeaders['Cookie'] = $CookieHeader } + Invoke-WebRequest -Uri $DownloadURL -Method Post -Body $PostData -Headers $iwrHeaders ` + -MaximumRedirection 5 -OutFile $DownloadDestination -UseBasicParsing -TimeoutSec 0 @proxyParams + if (Test-Path -Path $DownloadDestination) { + $sz = (Get-Item -Path $DownloadDestination).Length + Set-DATRegistryValue -Name "BytesTransferred" -Value "$sz" -Type String + Set-DATRegistryValue -Name "DownloadSpeed" -Value "---" -Type String + } + Set-DATRegistryValue -Name "RunningState" -Value "Running" -Type String + Set-DATRegistryValue -Name "RunningMode" -Value "Download Completed" -Type String + return + } catch { + if ($attempt -ge 3) { throw } + Write-DATLogEntry -Value "[Warning] - POST download attempt $attempt failed: $($_.Exception.Message). Retrying in 30s..." -Severity 2 + Start-Sleep -Seconds 30 + } + } + return + } + # HttpClient download (primary if no CURL, fallback if CURL failed) $maxRetries = 10 $retryDelaySec = 60 @@ -1564,6 +1786,38 @@ function Get-DATHPMetaValue { return $null } +function Sort-DATCanonicalObjects { + <# + .SYNOPSIS + Orders objects deterministically by their canonical JSON using an ORDINAL comparer so that + identical package contents serialize -- and therefore hash -- identically on any machine, + culture or PowerShell edition. + .DESCRIPTION + The canonical key for each element is computed up front and the array is then ordered with + [System.Array]::Sort. This deliberately avoids "Sort-Object -Culture" with a calculated + property: that evaluates the key via ConvertTo-DATCanonicalJson, which itself runs a nested + "Sort-Object -Culture" to order object keys. On Windows PowerShell 5.1 that reentrancy + throws "Argument types do not match", which previously broke canonical manifest caching for + every Dell and Lenovo build (see issue #913). + #> + param([object[]]$InputObject = @()) + $count = $InputObject.Length + if ($count -le 1) { return , $InputObject } + + # Build the item and key arrays with explicit indexing. Do NOT use "@($collection)" or + # "[object[]]@($collection)" here: wrapping/casting a collection that contains IDictionary + # elements (the ordered hashtables used for drivers/components) throws "Argument types do not + # match" inside module scope. Callers therefore pass an already-materialized object[]. + $items = New-Object 'object[]' $count + $keys = New-Object 'object[]' $count + for ($i = 0; $i -lt $count; $i++) { + $items[$i] = $InputObject[$i] + $keys[$i] = [string](ConvertTo-DATCanonicalJson -InputObject $InputObject[$i]) + } + [System.Array]::Sort($keys, $items, [System.Collections.IComparer][System.StringComparer]::Ordinal) + return , $items +} + function New-DATDriverManifest { <# .SYNOPSIS @@ -1644,8 +1898,8 @@ function New-DATDriverManifest { # identical package contents serialize identically on any machine. Covers both SCCM # driver-pack builds and individual driver packs (all flow through this function). try { - $canonicalDrivers = @(@($drivers) | Sort-Object -Property @{ Expression = { ConvertTo-DATCanonicalJson -InputObject $_ } } -Culture ([System.Globalization.CultureInfo]::InvariantCulture)) - $canonicalComponents = @(@($Components) | Sort-Object -Property @{ Expression = { ConvertTo-DATCanonicalJson -InputObject $_ } } -Culture ([System.Globalization.CultureInfo]::InvariantCulture)) + $canonicalDrivers = @(Sort-DATCanonicalObjects -InputObject $drivers.ToArray()) + $canonicalComponents = @(Sort-DATCanonicalObjects -InputObject $Components) $canonicalContent = [ordered]@{ schema = 'DAT Driver Package manifest v1' oem = "$OEM".Trim() @@ -1707,14 +1961,16 @@ function Invoke-DATDriverFilePackaging { Set-DATRegistryValue -Name "RunningMode" -Value "Extracting" -Type String Write-DATLogEntry -Value "[$OEM] Extracting $Model drivers to $DriverFolder" -Severity 1 - # Proactive long-path advisory: once the full extraction root is known, warn if it is long - # (>120 chars) while Windows long path support is not enabled. Deep driver folders below this - # root can then push individual files past the 260-char MAX_PATH limit and be silently omitted - # from the WIM. This records the exact extraction path length and the disabled policy state so - # a later incomplete-WIM failure can be traced back to the path the user chose. + # Proactive long-path advisory (#912): applies to every OEM and build type, since all driver + # extraction/packaging flows through here. When Windows long path support is off, deeply nested + # driver files (notably vendor graphics payloads) can exceed the 260-char MAX_PATH even from a + # short temp root -- being omitted from the WIM and leaving staging content that headless cleanup + # cannot remove. Warn whenever the policy is disabled; escalate the wording when the extraction + # root is itself already long. $extractPathLen = $DriverFolder.Length - if ($extractPathLen -gt 120 -and -not (Test-DATLongPathsEnabled)) { - Write-DATLogEntry -Value "[$OEM] [Warning] - Full extraction path is $extractPathLen characters ('$DriverFolder') and Windows long path support (LongPathsEnabled) is not enabled. Deeply nested driver files may exceed the 260-character MAX_PATH limit and be omitted from the package. Use a shorter Temporary Storage Path or enable LongPathsEnabled." -Severity 2 -UpdateUI + if (-not (Test-DATLongPathsEnabled)) { + $lenNote = if ($extractPathLen -gt 120) { " The extraction root is already $extractPathLen characters ('$DriverFolder'), which further increases the risk." } else { '' } + Write-DATLogEntry -Value "[$OEM] [Warning] - Windows long path support (LongPathsEnabled) is not enabled. Deeply nested driver files may exceed the 260-character MAX_PATH limit -- being omitted from the package and leaving temporary files that cleanup cannot remove.$lenNote Enable LongPathsEnabled or use a shorter Temporary Storage Path." -Severity 2 -UpdateUI } if (Test-Path -Path $DriverFolder) { @@ -2957,6 +3213,22 @@ function Get-DATConfigMgrKnownModels { ModelProp = 'Model' NormalizeMake = 'Acer' NormalizeModel = $false + }, + @{ + OEM = 'Panasonic' + Query = "SELECT ResourceID, Manufacturer, Model FROM SMS_G_System_COMPUTER_SYSTEM WHERE Manufacturer LIKE 'Panasonic%'" + MakeProp = 'Manufacturer' + ModelProp = 'Model' + NormalizeMake = 'Panasonic' + NormalizeModel = $false + }, + @{ + OEM = 'Fujitsu' + Query = "SELECT ResourceID, Manufacturer, Model FROM SMS_G_System_COMPUTER_SYSTEM WHERE Manufacturer LIKE 'Fujitsu%'" + MakeProp = 'Manufacturer' + ModelProp = 'Model' + NormalizeMake = 'Fujitsu' + NormalizeModel = $false } ) @@ -3131,6 +3403,120 @@ function Get-DATConfigMgrKnownModels { } } +function Test-DATConfigMgrInventoryClasses { + <# + .SYNOPSIS + Read-only check of the ConfigMgr hardware inventory classes DAT relies on for model matching. + .DESCRIPTION + Inspects the site server's SMS provider (root/SMS/site_) for the inventory views that + back Known Model Lookup and driver/BIOS SystemSKU matching -- Win32_ComputerSystem + (SMS_G_System_COMPUTER_SYSTEM), MS_SystemInformation (SMS_G_System_MS_SYSTEMINFORMATION) and + Win32_BaseBoard (SMS_G_System_BASEBOARD). This never mutates client settings; it only reports + a tri-state per class so an administrator can decide whether to enable them: + + Ok -- view exists and at least one device reports the key property (green). + PropertyMissing -- view exists and has rows, but the key property is never populated (amber). + NoData -- view exists but no inventory has flowed yet / no matching devices (amber). + NotEnabled -- view does not exist, i.e. the class is not enabled in hardware inventory (red). + Error -- the query failed for another reason (red). + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)][string]$SiteServer, + [Parameter(Mandatory = $true)][string]$SiteCode, + [Parameter()][scriptblock]$OnProgress + ) + + $namespace = "root/SMS/site_$SiteCode" + + # Each required inventory class: friendly name, SMS_G_System_* view, and the key property DAT reads. + $requiredClasses = @( + [PSCustomObject]@{ DisplayName = 'Win32_ComputerSystem'; View = 'SMS_G_System_COMPUTER_SYSTEM'; Property = 'Manufacturer' } + [PSCustomObject]@{ DisplayName = 'MS_SystemInformation'; View = 'SMS_G_System_MS_SYSTEMINFORMATION'; Property = 'SystemSKU' } + [PSCustomObject]@{ DisplayName = 'Win32_BaseBoard'; View = 'SMS_G_System_BASEBOARD'; Property = 'Product' } + ) + + $cimSession = $null + $results = New-Object System.Collections.Generic.List[object] + + try { + if ($OnProgress) { & $OnProgress "Connecting to $SiteServer..." } + Write-DATLogEntry -Value "[Inventory Classes] Connecting CIM session to $SiteServer" -Severity 1 + $cimSession = New-DATCimSession -ComputerName $SiteServer + + foreach ($class in $requiredClasses) { + if ($OnProgress) { & $OnProgress "Checking $($class.DisplayName)..." } + $status = 'Error'; $detail = ''; $total = 0; $withValue = 0 + + try { + $rows = @(Invoke-DATRemoteQuery -CimSession $cimSession -ComputerName $SiteServer -Namespace $namespace ` + -Query "SELECT $($class.Property) FROM $($class.View)") + $total = $rows.Count + $withValue = @($rows | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.$($class.Property)) }).Count + + if ($total -eq 0) { + $status = 'NoData' + $detail = 'Class enabled, but no inventory data yet (or no matching devices).' + } elseif ($withValue -eq 0) { + $status = 'PropertyMissing' + $detail = "Class enabled, but the '$($class.Property)' property is not being collected." + } else { + $status = 'Ok' + $detail = "$withValue of $total device(s) reporting '$($class.Property)'." + } + } + catch { + $msg = $_.Exception.Message + # An absent SMS_G_System_* view means the class is not enabled in hardware inventory. + if ($msg -match 'Invalid class|not found|0x80041010') { + $status = 'NotEnabled' + $detail = 'Class is not enabled in client hardware inventory.' + } else { + $status = 'Error' + $detail = $msg + } + Write-DATLogEntry -Value "[Inventory Classes] $($class.DisplayName) ($($class.View)) check: $status -- $detail" -Severity 2 + } + + if ($status -eq 'Ok') { + Write-DATLogEntry -Value "[Inventory Classes] $($class.DisplayName): OK ($detail)" -Severity 1 + } + + $results.Add([PSCustomObject]@{ + DisplayName = $class.DisplayName + View = $class.View + Property = $class.Property + Status = $status + Detail = $detail + Total = $total + WithValue = $withValue + }) + } + } + catch { + Write-DATLogEntry -Value "[Inventory Classes] CIM session failed: $($_.Exception.Message)" -Severity 3 + throw + } + finally { + if ($cimSession) { + Remove-CimSession -CimSession $cimSession -ErrorAction SilentlyContinue + } + } + + $classArray = @($results) + $allOk = ($classArray.Count -gt 0) -and (@($classArray | Where-Object { $_.Status -ne 'Ok' }).Count -eq 0) + + if ($OnProgress) { + if ($allOk) { & $OnProgress "All required inventory classes are enabled and reporting." } + else { & $OnProgress "One or more required inventory classes need attention." } + } + + return [PSCustomObject]@{ + Classes = $classArray + AllOk = $allOk + } +} + function Get-DATDistributionPoints { param ( [Parameter(Mandatory = $true)][string]$SiteCode, @@ -3918,7 +4304,7 @@ function Get-DATLocalSystemTime { function Get-DATOEMDownloadLinks { [CmdletBinding()] param ( - [Parameter(Position = 1)][ValidateSet('HP', 'Dell', 'Lenovo', 'Microsoft', 'Acer')][array]$OEM, + [Parameter(Position = 1)][ValidateSet('HP', 'Dell', 'Lenovo', 'Microsoft', 'Acer', 'Panasonic', 'Fujitsu', 'ASUS')][array]$OEM, [Parameter(Position = 2)][string]$OS, [Parameter(Position = 3)][ValidateSet('x64', 'x86', 'Arm64')][string]$Architecture, [Parameter(Position = 4)][ValidateSet('driver', 'bios', 'all')][string]$DownloadType, @@ -6549,6 +6935,36 @@ function Invoke-DATConcurrentDriverDownload { return $present.ToArray() } +function Get-DcuNameSignature { + <# + .SYNOPSIS + Supersession-collapse key for Dell DCU driver DUP names. + .DESCRIPTION + Dell revises a driver's name by changing its chipset model list (which contains digits) + while keeping the vendor + function words, so digit-bearing tokens are dropped and the + remaining sorted alphabetic descriptor identifies the driver across revisions. + + Graphics DUPs are the exception: Dell also varies the GPU-family marketing tokens + (arc/iris/xe/uhd/radeon/...) and utility suffixes (software/command/center/application) + between revisions of the SAME display driver, so the digit-free signature alone leaves each + revision distinct (#910). For graphics names the signature is canonicalized to + " graphics" so revisions collapse, while keeping distinct GPU vendors (Intel iGPU vs + AMD/NVIDIA dGPU) as separate drivers. + #> + param([string]$Name) + if ([string]::IsNullOrWhiteSpace($Name)) { return '' } + $sigStopWords = @('and', 'the', 'of', 'for', 'with', 'to', 'plus', 'uwd', 'dch', 'a', 'an') + $toks = $Name -split '[\s/,()]+' | + ForEach-Object { $_.Trim().ToLowerInvariant() } | + Where-Object { $_ -and ($_ -notmatch '\d') -and ($sigStopWords -notcontains $_) } + $toks = @($toks | Sort-Object -Unique) + if ($toks -contains 'graphics' -or $toks -contains 'video' -or $toks -contains 'display') { + $vendor = @($toks | Where-Object { $_ -in @('intel', 'amd', 'nvidia') } | Select-Object -First 1) + if ($vendor) { return "$vendor graphics" } + } + return ($toks -join ' ') +} + function Invoke-DATDellLatestDriverPackage { <# .SYNOPSIS @@ -6614,17 +7030,8 @@ function Invoke-DATDellLatestDriverPackage { } return $null } - # Supersession-collapse key: sorted set of alphabetic-only words. Dell revises a driver's name - # by changing the chipset model list (which contains digits) while keeping the vendor + function - # words, so digit-bearing tokens are dropped and the remaining descriptor identifies the driver. - $sigStopWords = @('and', 'the', 'of', 'for', 'with', 'to', 'plus', 'uwd', 'dch', 'a', 'an') - function Get-DcuNameSignature { param([string]$Name) - if ([string]::IsNullOrWhiteSpace($Name)) { return '' } - $toks = $Name -split '[\s/,()]+' | - ForEach-Object { $_.Trim().ToLowerInvariant() } | - Where-Object { $_ -and ($_ -notmatch '\d') -and ($sigStopWords -notcontains $_) } - return (($toks | Sort-Object -Unique) -join ' ') - } + # Supersession-collapse signature is provided by the module-scope Get-DcuNameSignature helper + # (lifted out so it can be unit-tested independently -- see #910). # DCU osCode prefixes (Dell-specific; the arch comes from the separate osArch attribute). switch -Wildcard ($WindowsVersion) { @@ -7341,6 +7748,85 @@ function Invoke-DATLenovoLatestDriverPackage { return $buildVersion } +function Invoke-DATFujitsuDownload { + <# + .SYNOPSIS + Downloads a Fujitsu SCCM driver pack from its session-gated download servlet. + .DESCRIPTION + Fujitsu DownloadURLs are not static file links -- they are servlet endpoints + (Download.asp?SoftwareGUID=). Priming an ASPSESSIONID cookie alone is NOT + enough: the download is authorised per-file only after the session has opened the + file overlay (File_DownOverlay.asp), which grants a server-side download token. The + file is then fetched by POSTing the overlay's form (SoftwareGUID + Filename) to + Download.asp -- a GET returns an HTML error page. The POST 302-redirects through + StreamFileToBrowser.asp to the tokenised CDN URL (webdownloads*.ts.fujitsu.com/... + &Token=...), which streams the payload. + .PARAMETER DownloadUrl + The catalog DownloadURL (https://support.ts.fujitsu.com/Download/Download.asp?SoftwareGUID=...). + .PARAMETER OutFile + The full destination path (including file name) to stream the download to. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$DownloadUrl, + [Parameter(Mandatory)][string]$OutFile + ) + + # Enforce HTTPS and restrict to the Fujitsu support host (security: trusted download host). + $uriResult = $null + if (-not ([System.Uri]::TryCreate($DownloadUrl, [System.UriKind]::Absolute, [ref]$uriResult)) -or + $uriResult.Scheme -ne 'https') { + throw "Fujitsu download URL must use HTTPS: '$DownloadUrl'" + } + if ($uriResult.Host -notmatch '(^|\.)fujitsu\.com$') { + throw "Fujitsu download URL host not on the trusted allow-list: '$($uriResult.Host)'" + } + + # Extract the SoftwareGUID from the servlet URL query string. + $guid = ([regex]::Match($DownloadUrl, '(?i)SoftwareGUID=([0-9A-F\-]+)')).Groups[1].Value + if ([string]::IsNullOrEmpty($guid)) { + throw "Fujitsu download URL does not contain a SoftwareGUID: '$DownloadUrl'" + } + + $destDir = Split-Path -Path $OutFile -Parent + if (-not (Test-Path -Path $destDir)) { New-Item -Path $destDir -ItemType Directory -Force | Out-Null } + + $headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } + $proxyParams = Get-DATWebRequestProxy + $portalBase = "https://$($uriResult.Host)" + + # 1. Prime the portal session to obtain the ASPSESSIONID cookie. + $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + Write-DATLogEntry -Value "[Fujitsu] Priming portal session..." -Severity 1 + Invoke-WebRequest -Uri "$portalBase/Deskupdate/index.asp?lng=EU" ` + -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 60 @proxyParams | Out-Null + + # 2. Visit the file landing page to establish the GUID context in the session. + Invoke-WebRequest -Uri "$portalBase/IndexDownload.asp?SoftwareGUID=$guid" ` + -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 120 @proxyParams | Out-Null + + # 3. Open the file overlay -- this is the token-granting step that authorises the session to + # download this GUID and exposes the real Filename / AdlerSDBCheck the POST must send. + Write-DATLogEntry -Value "[Fujitsu] Requesting download authorisation token for $guid..." -Severity 1 + $overlay = Invoke-WebRequest -Uri "$portalBase/download/File_DownOverlay.asp?lng=EU&id=$guid&SupportOS=&IsSolution=&Produkt=&Version=37" ` + -WebSession $session -Headers $headers -UseBasicParsing -TimeoutSec 120 @proxyParams + $fileName = ([regex]::Match($overlay.Content, '(?i)name="Filename"\s+value="([^"]+)"')).Groups[1].Value + $adlerCheck = ([regex]::Match($overlay.Content, '(?i)name="AdlerSDBCheck"\s+value="([^"]*)"')).Groups[1].Value + if ([string]::IsNullOrEmpty($fileName)) { + throw "Fujitsu overlay did not return a download token/Filename for $guid -- the pack may have been withdrawn." + } + Write-DATLogEntry -Value "[Fujitsu] Authorised download of '$fileName'; streaming payload..." -Severity 1 + + # 4. Hand the tokenised POST to the shared curl download engine so the transfer shows the + # curl window, live progress and honours the Abort button -- exactly like other OEMs. + # The primed-session cookies are forwarded so StreamFileToBrowser.asp resolves the CDN URL. + $cookieHeader = (($session.Cookies.GetCookies([Uri]$portalBase) | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ') + $postData = [ordered]@{ SoftwareGUID = $guid; Filename = $fileName; AdlerSDBCheck = $adlerCheck } + Invoke-DATContentDownload -DownloadURL "$portalBase/Download/Download.asp" ` + -DownloadDestination $destDir -OverrideFileName (Split-Path -Path $OutFile -Leaf) ` + -CookieHeader $cookieHeader -PostData $postData +} + function Invoke-DATOEMDownloadModule { [CmdletBinding()] param ( @@ -8346,7 +8832,7 @@ New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -For $matchingEntry = $driverCatalog | Where-Object { $_.Manufacturer -eq 'Microsoft' -and $_.DisplayName -eq $Model -and - $_.SupportedOS -match $WindowsVersion -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and $_.SupportedArchitecture -eq $normalizedArch -and -not [string]::IsNullOrEmpty($_.DownloadURL) -and $_.DownloadURL -match '\.(msi|exe|cab|zip|wim)(\?|$)' @@ -8449,6 +8935,88 @@ New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -For throw "No matching Acer driver package found for $Model ($WinVer $WindowsBuild)" } } + "Panasonic" { + $PanasonicLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Panasonic" }).Link | + Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($PanasonicLink)) { throw "Panasonic catalog URL not found in OEM links" } + + $PanasonicFile = [string]($PanasonicLink | Split-Path -Leaf) + $PanasonicFilePath = Join-Path $TempDirectory $PanasonicFile + + if (-not (Test-Path $PanasonicFilePath)) { + Write-DATLogEntry -Value "[$OEM] Downloading Panasonic catalog..." -Severity 1 + Write-DATLogEntry -Value "[$OEM] Catalog download path: $PanasonicFilePath" -Severity 1 + Set-DATRegistryValue -Name "RunningMessage" -Value "Downloading Panasonic driver catalog..." -Type String + Invoke-CatalogDownload -Uri $PanasonicLink -OutFile $PanasonicFilePath + } else { + Write-DATLogEntry -Value "[$OEM] Using cached Panasonic catalog: $PanasonicFilePath" -Severity 1 + } + + [xml]$PanasonicModelXML = Get-Content -Path $PanasonicFilePath + $PanasonicDrivers = $PanasonicModelXML.ModelList.Model + $WinVer = "Win" + "$($WindowsVersion.Split(' ')[1])" + + Write-DATLogEntry -Value "[$OEM] Searching catalog for: Name='$Model' OS='$WinVer' Build='$WindowsBuild'" -Severity 1 + + $matchingModel = $PanasonicDrivers | Where-Object { + $_.Name -eq $Model -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer + } | Select-Object -First 1 + + if ($null -eq $matchingModel) { + # Fuzzy fallback -- partial name match + Write-DATLogEntry -Value "[$OEM] Exact match not found, attempting partial name match for '$Model'" -Severity 2 + $matchingModel = $PanasonicDrivers | Where-Object { + $_.Name -like "*$Model*" -and $_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer + } | Select-Object -First 1 + } + + if ($null -ne $matchingModel -and $null -ne $matchingModel.SCCM) { + $downloadURL = $matchingModel.SCCM.'#text' + if ($downloadURL -is [array]) { $downloadURL = $downloadURL[0] } + if ([string]::IsNullOrEmpty($downloadURL)) { $downloadURL = [string]$matchingModel.SCCM } + $downloadFileName = $downloadURL | Split-Path -Leaf + Write-DATLogEntry -Value "[$OEM] Found driver pack: $downloadFileName" -Severity 1 + Write-DATLogEntry -Value "[$OEM] Resolved download URL: $downloadURL" -Severity 1 + } else { + # Log all available models/builds to aid diagnostics + $available = $PanasonicDrivers | Where-Object { $_.SCCM.OS -eq $WinVer } | Select-Object -ExpandProperty Name -Unique + Write-DATLogEntry -Value "[$OEM] Available models for ${WinVer}: $($available -join ', ')" -Severity 2 + throw "No matching Panasonic driver package found for $Model ($WinVer $WindowsBuild)" + } + } + "Fujitsu" { + # Fujitsu packs are sourced from the DAT API catalog. The DownloadURL is a session-gated + # servlet (Download.asp?SoftwareGUID=...) which does not match the direct-file regex, so + # it is never accepted by the pre-resolved block above. Resolve it here from either the + # caller-provided CatalogDownloadURL or a fresh DAT catalog lookup. + if (-not [string]::IsNullOrEmpty($CatalogDownloadURL)) { + $downloadURL = $CatalogDownloadURL + if (-not [string]::IsNullOrEmpty($callerCatalogVersion)) { $catalogVersion = $callerCatalogVersion } + } else { + $FujitsuArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $FujitsuCatalog = Get-DATDriverCatalog + $matchingEntry = $FujitsuCatalog | Where-Object { + $_.Manufacturer -eq 'Fujitsu' -and + $_.DisplayName -eq $Model -and + (Test-DATCatalogOSMatch -SupportedOS $_.SupportedOS -WindowsVersion $WindowsVersion) -and + $_.SupportedArchitecture -eq $FujitsuArchFilter -and + -not [string]::IsNullOrEmpty($_.DownloadURL) + } | Sort-Object { try { [datetime]$_.ReleaseDate } catch { [datetime]::MinValue } } -Descending | Select-Object -First 1 + if ($null -eq $matchingEntry) { throw "No matching Fujitsu driver package found for $Model ($WindowsVersion $Architecture)" } + $downloadURL = $matchingEntry.DownloadURL + $catalogVersion = if (-not [string]::IsNullOrEmpty($matchingEntry.Version)) { $matchingEntry.Version } elseif (-not [string]::IsNullOrEmpty($matchingEntry.ReleaseDate)) { $matchingEntry.ReleaseDate } else { $callerCatalogVersion } + if (-not [string]::IsNullOrEmpty($matchingEntry.FileHash)) { + $catalogFileHash = $matchingEntry.FileHash + $catalogHashMethod = if (-not [string]::IsNullOrEmpty($matchingEntry.HashMethod)) { $matchingEntry.HashMethod } else { 'SHA256' } + } + } + # The servlet returns the real name via Content-Disposition, but the common download path + # needs a deterministic .zip file name for hashing/extraction. Build a safe one. + $safeFjModel = ConvertTo-DATSafePathSegment -Segment $Model + $downloadFileName = "Fujitsu_${safeFjModel}_$($WindowsVersion.Replace(' ',''))_$WindowsBuild.zip" + Write-DATLogEntry -Value "[$OEM] Resolved session-gated download URL: $downloadURL" -Severity 1 + Write-DATLogEntry -Value "[$OEM] Target file name: $downloadFileName" -Severity 1 + } default { throw "Unsupported OEM: $OEM" } @@ -8536,7 +9104,13 @@ New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -For Write-DATLogEntry -Value "[$OEM] Starting download: $downloadURL" -Severity 1 try { - Invoke-DATContentDownload -DownloadURL $downloadURL -DownloadDestination $DownloadDestination + if ($OEM -eq 'Fujitsu') { + # Fujitsu requires a primed portal session; the standard downloader would fetch the + # HTML error page. Stream to the pre-computed deterministic file name instead. + Invoke-DATFujitsuDownload -DownloadUrl $downloadURL -OutFile $downloadedFile + } else { + Invoke-DATContentDownload -DownloadURL $downloadURL -DownloadDestination $DownloadDestination + } if (-not (Test-Path $downloadedFile)) { Write-DATLogEntry -Value "[Warning] - Downloaded file not found after transfer (attempt $dlAttempt): $downloadedFile" -Severity 2 @@ -8580,7 +9154,11 @@ New-HPDriverPack -Platform "$PlatformID" -Os "$HPOS" -OSVer "$WindowsBuild" -For if ($dlAttempt -lt $maxDownloadAttempts) { continue } # Final attempt failed -- download anyway but warn Write-DATLogEntry -Value "[Warning] - Hash verification failed after $maxDownloadAttempts attempts. Re-downloading and proceeding without hash verification." -Severity 2 - Invoke-DATContentDownload -DownloadURL $downloadURL -DownloadDestination $DownloadDestination + if ($OEM -eq 'Fujitsu') { + Invoke-DATFujitsuDownload -DownloadUrl $downloadURL -OutFile $downloadedFile + } else { + Invoke-DATContentDownload -DownloadURL $downloadURL -DownloadDestination $DownloadDestination + } $downloadVerified = $false break } @@ -10388,6 +10966,155 @@ function Update-DATIntuneAppMetadata { } } +function Set-DATIntuneAppDeploymentState { + <# + .SYNOPSIS + Renames a DAT-created Win32 app's displayName prefix to move it between deployment states + (Production / Pilot / Retired) -- the Intune equivalent of the ConfigMgr package "Move to ..." + action. Only the displayName changes; installer content, rules and assignments are untouched. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$AppId, + [Parameter(Mandatory)][ValidateSet('Production', 'Pilot', 'Retired')][string]$State + ) + + $app = Invoke-DATGraphRequest -Uri "/deviceAppManagement/mobileApps/$AppId" -NoPagination + if ($null -eq $app) { throw "Intune app '$AppId' was not found." } + + $oldName = [string]$app.displayName + # Match the DAT driver/BIOS prefix with any existing state qualifier, e.g. "Drivers -", + # "Drivers Pilot -", "BIOS Retired -". + $prefixPattern = '^(Drivers|BIOS)(?:\s+(?:Pilot|Retired))?\s+-' + if ($oldName -notmatch $prefixPattern) { + return [PSCustomObject]@{ AppId = $AppId; Changed = $false; OldName = $oldName; NewName = $oldName; Reason = 'Name does not match a DAT driver/BIOS package pattern.' } + } + + $baseType = $Matches[1] + $newPrefix = switch ($State) { + 'Production' { "$baseType -" } + 'Pilot' { "$baseType Pilot -" } + 'Retired' { "$baseType Retired -" } + } + $newName = $oldName -replace $prefixPattern, $newPrefix + if ($newName -eq $oldName) { + return [PSCustomObject]@{ AppId = $AppId; Changed = $false; OldName = $oldName; NewName = $newName; Reason = "Already in $State state." } + } + + $patchBody = @{ "@odata.type" = "#microsoft.graph.win32LobApp"; displayName = $newName } + Invoke-DATGraphRequest -Uri "/deviceAppManagement/mobileApps/$AppId" -Method PATCH -Body $patchBody | Out-Null + Write-DATLogEntry -Value "[Intune] Moved app $AppId to ${State}: '$oldName' -> '$newName'" -Severity 1 + + return [PSCustomObject]@{ AppId = $AppId; Changed = $true; OldName = $oldName; NewName = $newName; Reason = '' } +} + +function Set-DATIntuneAppOSTarget { + <# + .SYNOPSIS + Re-targets a DAT-created driver Win32 app to a different Windows feature update. Unlike the + ConfigMgr package rename (name only), Intune enforces the build in the requirement rule, so + this renames the OS token in the displayName/description AND regenerates the requirement and + detection rule scripts for the new build. BIOS packages (OS-agnostic) and Dell driver packs + (major-OS only, no build gate) are skipped rather than changed. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)][string]$AppId, + [Parameter(Mandatory)][string]$NewOS + ) + + $app = Invoke-DATGraphRequest -Uri "/deviceAppManagement/mobileApps/$AppId" -NoPagination + if ($null -eq $app) { throw "Intune app '$AppId' was not found." } + $oldName = [string]$app.displayName + + $rules = @($app.rules) + $detRule = $rules | Where-Object { $_.'@odata.type' -match 'PowerShellScriptRule' -and $_.ruleType -eq 'detection' } | Select-Object -First 1 + $reqRule = $rules | Where-Object { $_.'@odata.type' -match 'PowerShellScriptRule' -and $_.ruleType -eq 'requirement' } | Select-Object -First 1 + $detText = if ($detRule -and $detRule.scriptContent) { [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($detRule.scriptContent)) } else { '' } + $reqText = if ($reqRule -and $reqRule.scriptContent) { [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($reqRule.scriptContent)) } else { '' } + + $meta = Get-DATIntuneAppScriptMetadata -DetectionScript $detText -RequirementScript $reqText + if ([string]::IsNullOrWhiteSpace($meta.OEM) -or [string]::IsNullOrWhiteSpace($meta.Model) -or [string]::IsNullOrWhiteSpace($meta.Baseboards)) { + throw "Could not read the package metadata (OEM / Model / Baseboards) from the existing scripts -- this may not be a Driver Automation Tool package." + } + + if ($meta.UpdateType -eq 'BIOS') { + return [PSCustomObject]@{ AppId = $AppId; Changed = $false; OldName = $oldName; NewName = $oldName; Reason = 'BIOS packages are OS-agnostic.' } + } + if ($meta.OEM -match 'Dell') { + return [PSCustomObject]@{ AppId = $AppId; Changed = $false; OldName = $oldName; NewName = $oldName; Reason = 'Dell driver packs are major-OS only (no build target).' } + } + + $osPattern = 'Windows\s+1[01]\s+\d{2}H[12]' + if ($oldName -notmatch $osPattern) { + return [PSCustomObject]@{ AppId = $AppId; Changed = $false; OldName = $oldName; NewName = $oldName; Reason = 'Package name has no Windows build to change.' } + } + $newName = $oldName -replace $osPattern, $NewOS + if ($newName -eq $oldName) { + return [PSCustomObject]@{ AppId = $AppId; Changed = $false; OldName = $oldName; NewName = $newName; Reason = "Already targets $NewOS." } + } + + # Architecture for the regenerated description (Arm64 vs x64), matching the metadata republish. + $archValue = [string]$app.applicableArchitectures + $arch = if (-not [string]::IsNullOrWhiteSpace($archValue) -and $archValue -match 'arm64' -and $archValue -notmatch 'x64') { 'Arm64' } else { 'x64' } + + # Regenerate both rule scripts against the new OS so the applicability build-gate matches. + $tmpReq = Join-Path $env:TEMP ("DAT_Req_{0}.ps1" -f ([Guid]::NewGuid().ToString('N'))) + $tmpDet = Join-Path $env:TEMP ("DAT_Det_{0}.ps1" -f ([Guid]::NewGuid().ToString('N'))) + try { + New-DATIntuneRequirementScript -OutputPath $tmpReq -OEM $meta.OEM -Model $meta.Model ` + -Baseboards $meta.Baseboards -OS $NewOS -Version $meta.Version ` + -UpdateType $meta.UpdateType -ReleaseDate $meta.ReleaseDate ` + -MaintenanceWindowsJson $meta.MaintenanceWindowsJson | Out-Null + New-DATIntuneDetectionScript -OutputPath $tmpDet -OEM $meta.OEM -Model $meta.Model ` + -Baseboards $meta.Baseboards -OS $NewOS -Version $meta.Version ` + -UpdateType $meta.UpdateType -ReleaseDate $meta.ReleaseDate | Out-Null + $reqB64 = ConvertTo-DATNoBomScriptBase64 -Path $tmpReq + $detB64 = ConvertTo-DATNoBomScriptBase64 -Path $tmpDet + } finally { + if (Test-Path $tmpReq) { Remove-Item $tmpReq -Force -ErrorAction SilentlyContinue } + if (Test-Path $tmpDet) { Remove-Item $tmpDet -Force -ErrorAction SilentlyContinue } + } + + # Graph requires the full rules set on a Win32 app PATCH, so both rules are always sent. + $newRules = @( + @{ + "@odata.type" = "#microsoft.graph.win32LobAppPowerShellScriptRule" + ruleType = "detection" + scriptContent = $detB64 + enforceSignatureCheck = $false + runAs32Bit = $false + }, + @{ + "@odata.type" = "#microsoft.graph.win32LobAppPowerShellScriptRule" + ruleType = "requirement" + scriptContent = $reqB64 + enforceSignatureCheck = $false + runAs32Bit = $false + runAsAccount = "system" + displayName = "DAT Model Requirement" + operationType = "string" + comparisonValue = "Requirement met" + operator = "equal" + } + ) + + $description = Get-DATIntunePackageDescription -OEM $meta.OEM -Model $meta.Model -OS $NewOS ` + -Architecture $arch -Baseboards $meta.Baseboards -Version $meta.Version ` + -ReleaseDate $meta.ReleaseDate -UpdateType $meta.UpdateType + + $patchBody = @{ + "@odata.type" = "#microsoft.graph.win32LobApp" + displayName = $newName + description = $description + rules = $newRules + } + Invoke-DATGraphRequest -Uri "/deviceAppManagement/mobileApps/$AppId" -Method PATCH -Body $patchBody | Out-Null + Write-DATLogEntry -Value "[Intune] Re-targeted app $AppId to ${NewOS}: '$oldName' -> '$newName' (rules regenerated)" -Severity 1 + + return [PSCustomObject]@{ AppId = $AppId; Changed = $true; OldName = $oldName; NewName = $newName; Reason = '' } +} + function Get-DATIntuneAssignmentTargetKey { <# .SYNOPSIS @@ -12938,6 +13665,8 @@ try {{ "Lenovo" {{ $oemMatch = ($manufacturer -match "Lenovo") }} "Microsoft" {{ $oemMatch = ($manufacturer -match "Microsoft") }} "Acer" {{ $oemMatch = ($manufacturer -match "Acer") }} + "Panasonic" {{ $oemMatch = ($manufacturer -match "Panasonic") }} + "Fujitsu" {{ $oemMatch = ($manufacturer -match "Fujitsu") }} default {{ $oemMatch = ($manufacturer -match $expectedOEM) }} }} @@ -13194,6 +13923,8 @@ try {{ "Lenovo" {{ $oemMatch = ($manufacturer -match "Lenovo") }} "Microsoft" {{ $oemMatch = ($manufacturer -match "Microsoft") }} "Acer" {{ $oemMatch = ($manufacturer -match "Acer") }} + "Panasonic" {{ $oemMatch = ($manufacturer -match "Panasonic") }} + "Fujitsu" {{ $oemMatch = ($manufacturer -match "Fujitsu") }} default {{ $oemMatch = ($manufacturer -match $expectedOEM) }} }} @@ -15386,9 +16117,14 @@ function Find-DATBiosPackage { if ($oemEntries.Count -gt 0) { # Find entries where any of the model's baseboards match any of the entry's SupportedDevices $matches = @() + # ASUS SupportedDevices is comma-delimited (full model name + short code, e.g. + # "ExpertBook B1402CBA,B1402CBA"), so it must split on comma as well as semicolon -- + # otherwise the whole value stays one token and never matches the model's code, and BIOS + # resolution fails for every ASUS model. Other OEMs use single/semicolon-delimited tokens. + $entryDeviceSplit = if ($OEM -eq 'ASUS') { '[;,]+' } else { ';' } foreach ($entry in $oemEntries) { if ([string]::IsNullOrEmpty($entry.SupportedDevices)) { continue } - $entryDevices = @($entry.SupportedDevices -split ';' | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ }) + $entryDevices = @($entry.SupportedDevices -split $entryDeviceSplit | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ }) foreach ($board in $modelBoards) { if ($board -in $entryDevices) { $matches += $entry; break } } diff --git a/Driver Automation Tool/Start-DriverAutomationTool.ps1 b/Driver Automation Tool/Start-DriverAutomationTool.ps1 index 8eca08b..763e809 100644 --- a/Driver Automation Tool/Start-DriverAutomationTool.ps1 +++ b/Driver Automation Tool/Start-DriverAutomationTool.ps1 @@ -6,7 +6,11 @@ [CmdletBinding()] param ( [ValidateSet('Dark', 'Light')] - [string]$Theme = 'Dark' + [string]$Theme = 'Dark', + + # Skip desktop shortcut creation/management for this launch (for admins who manage + # shortcuts manually). A persistent equivalent is the CreateDesktopShortcut registry value. + [switch]$NoShortcut ) $ErrorActionPreference = 'Stop' @@ -135,34 +139,65 @@ if (-not (Test-Path $RegPath)) { } New-ItemProperty -Path $RegPath -Name "InstallDirectory" -Value $AppRoot -PropertyType String -Force | Out-Null -# Create desktop shortcut (idempotent -- only creates if missing or pointing to wrong location) -$desktopPath = [Environment]::GetFolderPath('Desktop') -$shortcutPath = Join-Path $desktopPath 'Driver Automation Tool.lnk' +# Create the desktop shortcut on the PUBLIC (all-users) desktop, so a single shortcut is +# shared across every admin profile on a machine instead of one copy per user (#916). +# Honors the CreateDesktopShortcut registry toggle (default on) and the -NoShortcut switch +# for admins who manage shortcuts manually. +$shortcutName = 'Driver Automation Tool.lnk' $launcherPath = Join-Path $AppRoot 'Start-DriverAutomationTool.ps1' $iconPath = Join-Path $AppRoot 'Branding\DATLogo.ico' -$needsShortcut = $true -if (Test-Path $shortcutPath) { - $existing = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcutPath) - if ($existing.Arguments -like "*$launcherPath*") { - $needsShortcut = $false +$shortcutToggle = (Get-ItemProperty -Path $RegPath -Name 'CreateDesktopShortcut' -ErrorAction SilentlyContinue).CreateDesktopShortcut +$shortcutDisabled = $NoShortcut -or ($shortcutToggle -eq 0) + +if ($shortcutDisabled) { + Write-Host "Desktop shortcut management skipped (disabled)." -ForegroundColor DarkGray +} else { + $desktopPath = [Environment]::GetFolderPath('CommonDesktopDirectory') + $shortcutPath = Join-Path $desktopPath $shortcutName + + # Idempotent -- only create if missing or pointing to a different launcher + $needsShortcut = $true + if (Test-Path $shortcutPath) { + $existing = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcutPath) + if ($existing.Arguments -like "*$launcherPath*") { + $needsShortcut = $false + } } -} -if ($needsShortcut) { - $wshShell = New-Object -ComObject WScript.Shell - $shortcut = $wshShell.CreateShortcut($shortcutPath) - $shortcut.TargetPath = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" - $shortcut.Arguments = "-ExecutionPolicy Bypass -File `"$launcherPath`"" - $shortcut.WorkingDirectory = $AppRoot - $shortcut.Description = 'Driver Automation Tool' - if (Test-Path $iconPath) { - $shortcut.IconLocation = "$iconPath,0" + if ($needsShortcut) { + $wshShell = New-Object -ComObject WScript.Shell + $shortcut = $wshShell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + $shortcut.Arguments = "-ExecutionPolicy Bypass -File `"$launcherPath`"" + $shortcut.WorkingDirectory = $AppRoot + $shortcut.Description = 'Driver Automation Tool' + if (Test-Path $iconPath) { + $shortcut.IconLocation = "$iconPath,0" + } + $shortcut.Save() + # Set "Run as administrator" flag (byte 21, bit 0x20 in the .lnk binary) + $bytes = [System.IO.File]::ReadAllBytes($shortcutPath) + $bytes[21] = $bytes[21] -bor 0x20 + [System.IO.File]::WriteAllBytes($shortcutPath, $bytes) + Write-Host "Desktop shortcut created: $shortcutPath" -ForegroundColor Green + } + + # Clean up the current user's legacy per-user desktop shortcut left by pre-common-desktop + # versions. Only the current profile's copy is reachable; other admins' stale copies are + # removed the next time each of them launches. Guarded so we only delete a shortcut that + # points to this launcher, never an unrelated same-named shortcut. + try { + $userDesktop = [Environment]::GetFolderPath('Desktop') + $userShortcut = Join-Path $userDesktop $shortcutName + if ((Test-Path $userShortcut) -and ($userShortcut -ne $shortcutPath)) { + $userLnk = (New-Object -ComObject WScript.Shell).CreateShortcut($userShortcut) + if ($userLnk.Arguments -like "*$launcherPath*") { + Remove-Item -Path $userShortcut -Force -ErrorAction Stop + Write-Host "Removed legacy per-user desktop shortcut: $userShortcut" -ForegroundColor DarkYellow + } + } + } catch { + Write-Host "Could not remove legacy per-user shortcut: $($_.Exception.Message)" -ForegroundColor DarkYellow } - $shortcut.Save() - # Set "Run as administrator" flag (byte 21, bit 0x20 in the .lnk binary) - $bytes = [System.IO.File]::ReadAllBytes($shortcutPath) - $bytes[21] = $bytes[21] -bor 0x20 - [System.IO.File]::WriteAllBytes($shortcutPath, $bytes) - Write-Host "Desktop shortcut created: $shortcutPath" -ForegroundColor Green } # Launch the application diff --git a/Driver Automation Tool/UI/MainApplication.ps1 b/Driver Automation Tool/UI/MainApplication.ps1 index 13ff1f4..e04d0d9 100644 --- a/Driver Automation Tool/UI/MainApplication.ps1 +++ b/Driver Automation Tool/UI/MainApplication.ps1 @@ -436,7 +436,7 @@ $btn_FeedbackDown.Add_Click({ $dlg.Owner = $Window $dlg.Width = 460 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false $feedbackState.Dialog = $dlg @@ -1011,7 +1011,7 @@ function Show-DATConfirmDialog { $dlg.Owner = $Window $dlg.Width = 440 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -1165,7 +1165,7 @@ function Show-DATInfoDialog { } $dlg.Width = 440 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -1277,7 +1277,7 @@ function Show-DATInputDialog { } $dlg.Width = 440 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -1432,7 +1432,7 @@ function Show-DATProgressDialog { } $dlg.Width = 380 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -1649,7 +1649,7 @@ function Show-DATConnectivityWarningDialog { $dlg.Width = 560 $dlg.SizeToContent = 'Height' $dlg.MaxHeight = 600 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -1935,7 +1935,7 @@ function Show-DATConfirmDialog { $dlg.Owner = $Window $dlg.Width = 440 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -2100,7 +2100,7 @@ function Show-DATLoadingSourcesModal { $dlg.WindowStartupLocation = 'CenterScreen' } $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -2352,7 +2352,7 @@ function Show-DATLenovoFlashKilledModal { $dlg.WindowStartupLocation = 'CenterScreen' } $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -2585,7 +2585,7 @@ function Show-DATBuildFailuresDialog { $dlg.Background = [System.Windows.Media.Brushes]::Transparent $dlg.Width = 580 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false try { @@ -2893,7 +2893,7 @@ function Show-DATBuildSummaryDialog { $dlg.Owner = $Window $dlg.Width = 440 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -3221,7 +3221,7 @@ function Show-DATBiosNamePromptModal { $dlg.Background = [System.Windows.Media.Brushes]::Transparent $dlg.Width = 500 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false try { @@ -3490,7 +3490,7 @@ function Show-DATBugNoticeModal { $dlg.Background = [System.Windows.Media.Brushes]::Transparent $dlg.Width = 560 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false try { @@ -3690,7 +3690,7 @@ function Show-DATBiosNameRepairModal { $dlg.Width = 560 $dlg.MaxHeight = 600 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false try { @@ -4043,7 +4043,7 @@ function Show-DATCustomDriverDialog { $dlg.Owner = $Window $dlg.Width = 520 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -4316,7 +4316,7 @@ function Show-DATEntraGroupSearchDialog { $dlg.Width = 750 $dlg.SizeToContent = 'Height' $dlg.MaxHeight = 820 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -5148,7 +5148,7 @@ function Show-DATPackageRetentionModal { $dlg.Owner = $Window $dlg.Width = 520 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -5604,7 +5604,7 @@ function Show-DATCustomBuildCompleteDialog { $dlg.Owner = $Window $dlg.Width = 440 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -7481,6 +7481,9 @@ $script:OEMCheckboxes = @{ 'HP' = $Window.FindName('chk_OEM_HP') 'Lenovo' = $Window.FindName('chk_OEM_Lenovo') 'Microsoft' = $Window.FindName('chk_OEM_Microsoft') + 'Panasonic' = $Window.FindName('chk_OEM_Panasonic') + 'Fujitsu' = $Window.FindName('chk_OEM_Fujitsu') + 'ASUS' = $Window.FindName('chk_OEM_ASUS') } $script:OEMBorders = @{ 'Acer' = $Window.FindName('border_OEM_Acer') @@ -7488,6 +7491,9 @@ $script:OEMBorders = @{ 'HP' = $Window.FindName('border_OEM_HP') 'Lenovo' = $Window.FindName('border_OEM_Lenovo') 'Microsoft' = $Window.FindName('border_OEM_Microsoft') + 'Panasonic' = $Window.FindName('border_OEM_Panasonic') + 'Fujitsu' = $Window.FindName('border_OEM_Fujitsu') + 'ASUS' = $Window.FindName('border_OEM_ASUS') } function Get-DATSelectedOEMs { @@ -7813,6 +7819,8 @@ $grid_Models.Add_SelectionChanged({ $item = $grid_Models.SelectedItem -as [ModelItem] if ($null -eq $item) { $panel_ModelDetail.Visibility = 'Collapsed' + $split_ModelDetail.Visibility = 'Collapsed' + $row_ModelDetail.Height = [System.Windows.GridLength]::new(0) return } $txt_ModelDetail_OEM.Text = $item.OEM @@ -7874,6 +7882,19 @@ $grid_Models.Add_SelectionChanged({ $txt_ModelDetail_CustomPath.Text = if (-not [string]::IsNullOrEmpty($item.CustomDriverPath)) { $item.CustomDriverPath } else { 'None' } $panel_ModelDetail.Visibility = 'Visible' + $split_ModelDetail.Visibility = 'Visible' + # Default to Auto so the card is only as tall as its content; the splitter can still resize it. + if ($row_ModelDetail.Height.IsAbsolute -and $row_ModelDetail.Height.Value -le 0) { + $row_ModelDetail.Height = [System.Windows.GridLength]::Auto + } +}) + +# Close the model package detail card +$btn_ModelCloseDetail.Add_Click({ + $grid_Models.SelectedItem = $null + $panel_ModelDetail.Visibility = 'Collapsed' + $split_ModelDetail.Visibility = 'Collapsed' + $row_ModelDetail.Height = [System.Windows.GridLength]::new(0) }) # Context menu: Add Custom Drivers @@ -8190,7 +8211,7 @@ $btn_RefreshModels.Add_Click({ # SoftPaq mode uses a date stamp (real version is fingerprint-based). if (-not [string]::IsNullOrEmpty($entry.Version) -and $entry.Version -notmatch '^\d+H\d+$') { $hpPackVersion = $entry.Version } $displayVersion = if ($HPDriverPackSource -eq 'DriverPack' -and -not [string]::IsNullOrEmpty($hpPackVersion)) { $hpPackVersion } else { (Get-Date -Format 'ddMMyyyy') } - } elseif ($entry.Manufacturer -eq 'Acer') { + } elseif ($entry.Manufacturer -in @('Acer', 'Panasonic')) { $displayVersion = (Get-Date -Format 'ddMMyyyy') } elseif (-not [string]::IsNullOrEmpty($entry.Version) -and $entry.Version -notmatch '^\d+H\d+$') { $displayVersion = $entry.Version @@ -8248,7 +8269,7 @@ $btn_RefreshModels.Add_Click({ # SoftPaq mode uses a date stamp (real version is fingerprint-based). if (-not [string]::IsNullOrEmpty($entry.Version) -and $entry.Version -notmatch '^\d+H\d+$') { $hpPackVersion = $entry.Version } $displayVersion = if ($HPDriverPackSource -eq 'DriverPack' -and -not [string]::IsNullOrEmpty($hpPackVersion)) { $hpPackVersion } else { (Get-Date -Format 'ddMMyyyy') } - } elseif ($entry.Manufacturer -eq 'Acer') { + } elseif ($entry.Manufacturer -in @('Acer', 'Panasonic')) { # Acer uses current date as version (matches OEM XML method) $displayVersion = (Get-Date -Format 'ddMMyyyy') } elseif (-not [string]::IsNullOrEmpty($entry.Version) -and $entry.Version -notmatch '^\d+H\d+$') { @@ -8338,9 +8359,16 @@ $btn_RefreshModels.Add_Click({ } continue } - # Acer model names contain spaces (e.g. "TravelMate P214-42") -- don't split on whitespace - $devices = if ($bEntry.Manufacturer -eq 'Acer') { + # Acer/Panasonic model names contain spaces (e.g. "TravelMate P214-42") -- don't split on whitespace + $devices = if ($bEntry.Manufacturer -in @('Acer', 'Panasonic')) { @($bEntry.SupportedDevices.Trim().ToUpper()) + } elseif ($bEntry.Manufacturer -eq 'ASUS') { + # ASUS SupportedDevices is a comma-delimited pair of the full model name and + # its short model code (e.g. "ExpertBook B1402CBA,B1402CBA"). Split on comma/ + # semicolon only -- NOT whitespace -- otherwise the family word ("ExpertBook"/ + # "ExpertCenter") becomes a shared token that collides every model onto the + # latest-dated family BIOS (e.g. all ExpertBook models showing v311). + $bEntry.SupportedDevices -split '[,;]+' | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ } } else { $bEntry.SupportedDevices -split '[;\s]+' | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ } } @@ -8363,20 +8391,20 @@ $btn_RefreshModels.Add_Click({ } continue } - if ($model.OEM -eq 'Acer') { - # Acer BIOS SupportedDevices is the platform product code (e.g. + if ($model.OEM -in @('Acer', 'Panasonic')) { + # Acer/Panasonic BIOS SupportedDevices is the platform product code (e.g. # Trumpet_RBU), identical to the driver catalog -- so match on that # via Baseboards, exactly like the other OEMs. Fall back to the model # DisplayName only for any legacy entries keyed by name. $biosEntry = $null $boards = $model.Baseboards -split '[,;\s]+' | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ } foreach ($board in $boards) { - $biosEntry = $biosDeviceMap["Acer|$board"] + $biosEntry = $biosDeviceMap["$($model.OEM)|$board"] if ($null -ne $biosEntry) { break } } if ($null -eq $biosEntry) { # Fallback: DisplayName-keyed map (legacy entries without SupportedDevices) - $biosEntry = $biosNameMap["Acer|$($model.Model)"] + $biosEntry = $biosNameMap["$($model.OEM)|$($model.Model)"] } } else { $biosEntry = $null @@ -8846,6 +8874,128 @@ $btn_RefreshModels.Add_Click({ $LogQueue.Enqueue('[SOURCE:Acer:Error]') } } + "Panasonic" { + $PanasonicLink = ($OEMLinks.OEM.Manufacturer | Where-Object { $_.Name -match "Panasonic" }).Link | Where-Object { $_.Type -eq "XMLSource" } | Select-Object -ExpandProperty URL -First 1 + if ([string]::IsNullOrEmpty($PanasonicLink)) { + Write-Log "No Panasonic XMLSource URL found in OEM catalog." -Level Error + $LogQueue.Enqueue('[SOURCE:Panasonic:Error:No catalog URL]') + continue + } + Write-Log "Panasonic catalog URL: $PanasonicLink" + $PanasonicFile = [string]($PanasonicLink | Split-Path -Leaf) + try { + $PanasonicFilePath = Join-Path $TempDir $PanasonicFile + if (Test-CatalogFresh -FilePath $PanasonicFilePath) { + Write-Log "Using cached Panasonic catalog (less than 24h old)." + $LogQueue.Enqueue('[SOURCE:Panasonic:Cached]') + } else { + Write-Log "Downloading Panasonic model catalog..." + $proxyParams = Get-DATWebRequestProxy + Invoke-WebRequest -Uri $PanasonicLink -OutFile $PanasonicFilePath -UseBasicParsing -TimeoutSec $WebRequestTimeoutSec @proxyParams + } + [xml]$PanasonicModelXML = Get-Content -Path $PanasonicFilePath + $PanasonicDrivers = $PanasonicModelXML.ModelList.Model + $allPanasonicDriverModels = @() + foreach ($SingleOS in $OSList) { + $WindowsBuild = $($SingleOS).Split(" ")[2] + $WindowsVersion = $SingleOS.Replace(" $WindowsBuild", "").TrimEnd() + $WinVer = "Win" + "$($WindowsVersion.Split(' ')[1])" + $PanasonicModels = ($PanasonicDrivers | Where-Object { + ($_.SCCM.Version -eq $WindowsBuild -and $_.SCCM.OS -eq $WinVer) + } | Sort-Object).Name + $count = @($PanasonicModels).Count + Write-Log "Panasonic: Found $count matching models for $SingleOS." -Level Success + $allPanasonicDriverModels += @($PanasonicModels) + foreach ($Model in $PanasonicModels) { + $OEMSupportedModels += [PSCustomObject]@{ + OEM = "Panasonic" + Model = $Model + Baseboards = $Model + OS = $WindowsVersion + 'OS Build' = $WindowsBuild + Version = (Get-Date -Format 'ddMMyyyy') + } + } + } + + # Panasonic BIOS lives in the Panasonic XML catalog (not the JSON BIOS catalog). + # When BIOS or All is selected, include all Panasonic models from the XML catalog + # that don't already have a driver pack for any selected OS/build. + if ($PackageType -in @('BIOS', 'All', 'BIOS Pilot', 'All Pilot')) { + $existingPanasonicNames = @($allPanasonicDriverModels) | Select-Object -Unique + $allPanasonicNames = ($PanasonicDrivers | Where-Object { $_.Name -gt $null } | Sort-Object).Name | Select-Object -Unique + # Use the first OS for BIOS-only entries (BIOS is OS-agnostic) + $firstOS = $OSList | Select-Object -First 1 + $firstWindowsBuild = $($firstOS).Split(" ")[2] + $firstWindowsVersion = $firstOS.Replace(" $firstWindowsBuild", "").TrimEnd() + foreach ($extraModel in $allPanasonicNames) { + if ($extraModel -notin $existingPanasonicNames) { + $OEMSupportedModels += [PSCustomObject]@{ + OEM = "Panasonic" + Model = $extraModel + Baseboards = $extraModel + OS = $firstWindowsVersion + 'OS Build' = $firstWindowsBuild + Version = '' + BIOSOnly = $true + } + } + } + } + $uniqueCount = @($OEMSupportedModels | Where-Object { $_.OEM -eq 'Panasonic' } | Select-Object -Property Model -Unique).Count + Write-Log "Panasonic: $uniqueCount unique models across all selected OS versions." -Level Success + $LogQueue.Enqueue("[SOURCE:Panasonic:OK:$uniqueCount models]") + } catch { + Write-Log "Panasonic processing failed: $($_.Exception.Message)" -Level Error + $LogQueue.Enqueue('[SOURCE:Panasonic:Error]') + } + } + "Fujitsu" { + # Fujitsu has no OEMLinks XML -- it is sourced from the DAT API catalog. The + # DownloadURL is a session-gated servlet resolved at build time. + try { + $fujitsuCatalog = Get-DATDriverCatalog + if ($null -eq $fujitsuCatalog -or @($fujitsuCatalog).Count -eq 0) { + Write-Log "Fujitsu: DAT API catalog unavailable or empty." -Level Warn + $LogQueue.Enqueue('[SOURCE:Fujitsu:Error:Catalog unavailable]') + continue + } + foreach ($SingleOS in $OSList) { + $WindowsBuild = $($SingleOS).Split(" ")[2] + $WindowsVersion = $SingleOS.Replace(" $WindowsBuild", "").TrimEnd() + $FujitsuArchFilter = if ($Architecture -eq 'Arm64') { 'arm64' } else { 'x64' } + $fujitsuEntries = $fujitsuCatalog | Where-Object { + $_.Manufacturer -eq 'Fujitsu' -and + $_.SupportedOS -match $WindowsVersion -and + $_.SupportedArchitecture -eq $FujitsuArchFilter -and + -not [string]::IsNullOrEmpty($_.DownloadURL) + } + $count = @($fujitsuEntries | Group-Object -Property DisplayName).Count + Write-Log "Fujitsu: Found $count matching models for $SingleOS." -Level Success + foreach ($modelGroup in ($fujitsuEntries | Group-Object -Property DisplayName)) { + $latestEntry = $modelGroup.Group | Sort-Object { try { [datetime]$_.ReleaseDate } catch { [datetime]::MinValue } } -Descending | Select-Object -First 1 + $fujitsuVersion = if (-not [string]::IsNullOrEmpty($latestEntry.Version)) { $latestEntry.Version } + elseif (-not [string]::IsNullOrEmpty($latestEntry.ReleaseDate)) { $latestEntry.ReleaseDate } + else { '' } + $OEMSupportedModels += [PSCustomObject]@{ + OEM = "Fujitsu" + Model = $modelGroup.Name + Baseboards = if ($latestEntry.SupportedDevices) { $latestEntry.SupportedDevices } else { $modelGroup.Name } + OS = $WindowsVersion + 'OS Build' = $WindowsBuild + Version = $fujitsuVersion + DownloadURL = $latestEntry.DownloadURL + } + } + } + $uniqueCount = @($OEMSupportedModels | Where-Object { $_.OEM -eq 'Fujitsu' } | Select-Object -Property Model -Unique).Count + Write-Log "Fujitsu: $uniqueCount unique models across all selected OS versions." -Level Success + $LogQueue.Enqueue("[SOURCE:Fujitsu:OK:$uniqueCount models]") + } catch { + Write-Log "Fujitsu processing failed: $($_.Exception.Message)" -Level Error + $LogQueue.Enqueue('[SOURCE:Fujitsu:Error]') + } + } } } # end foreach ($OEM in $RequiredOEMs) @@ -8893,9 +9043,15 @@ $btn_RefreshModels.Add_Click({ } continue } - # Acer model names contain spaces (e.g. "TravelMate P214-42") -- don't split on whitespace - $devices = if ($entry.Manufacturer -eq 'Acer') { + # Acer/Panasonic model names contain spaces (e.g. "TravelMate P214-42") -- don't split on whitespace + $devices = if ($entry.Manufacturer -in @('Acer', 'Panasonic')) { @($entry.SupportedDevices.Trim().ToUpper()) + } elseif ($entry.Manufacturer -eq 'ASUS') { + # ASUS SupportedDevices is a comma-delimited pair of the full model name and its + # short model code (e.g. "ExpertBook B1402CBA,B1402CBA"). Split on comma/semicolon + # only -- NOT whitespace -- otherwise the family word ("ExpertBook"/"ExpertCenter") + # becomes a shared token that collides every model onto the latest-dated family BIOS. + $entry.SupportedDevices -split '[,;]+' | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ } } else { $entry.SupportedDevices -split '[;\s]+' | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ } } @@ -8926,12 +9082,12 @@ $btn_RefreshModels.Add_Click({ } continue } - if ($model.OEM -eq 'Acer') { - # Acer SupportedDevices == model name; look up by uppercased model name - $biosEntry = $biosDeviceMap["Acer|$($model.Model.ToUpper())"] + if ($model.OEM -in @('Acer', 'Panasonic')) { + # Acer/Panasonic SupportedDevices == model name; look up by uppercased model name + $biosEntry = $biosDeviceMap["$($model.OEM)|$($model.Model.ToUpper())"] if ($null -eq $biosEntry) { # Fallback: DisplayName-keyed map (legacy entries without SupportedDevices) - $biosEntry = $biosNameMap["Acer|$($model.Model)"] + $biosEntry = $biosNameMap["$($model.OEM)|$($model.Model)"] } } else { # Fast hashtable lookup for Dell/HP/Lenovo/Microsoft @@ -9249,6 +9405,8 @@ function ConvertTo-DATNormalizedMake { if ($m -match '^Lenovo$') { return 'Lenovo' } if ($m -match '^Microsoft') { return 'Microsoft' } if ($m -match '^Acer') { return 'Acer' } + if ($m -match '^(ASUS|ASUSTeK)') { return 'ASUS' } + if ($m -match '^Fujitsu') { return 'Fujitsu' } return $m } @@ -9304,7 +9462,8 @@ function Test-DATKnownDeviceMatch { name-based matching is used as a fallback. .NOTES Baseboard sources by OEM: - HP/Dell/Lenovo/Acer: Win32_BaseBoard.Product + HP/Dell/Acer: Win32_BaseBoard.Product + Lenovo: first 4 chars of Win32_ComputerSystem.Model (machine-type code) Microsoft Surface: MS_SystemInformation.SystemSKU #> param ( @@ -11443,12 +11602,14 @@ $arc_Mfr_HP = $Window.FindName('arc_Mfr_HP') $arc_Mfr_Lenovo = $Window.FindName('arc_Mfr_Lenovo') $arc_Mfr_Microsoft = $Window.FindName('arc_Mfr_Microsoft') $arc_Mfr_Acer = $Window.FindName('arc_Mfr_Acer') +$arc_Mfr_Panasonic = $Window.FindName('arc_Mfr_Panasonic') $arc_Mfr_Other = $Window.FindName('arc_Mfr_Other') $txt_MfrDellCount = $Window.FindName('txt_MfrDellCount') $txt_MfrHPCount = $Window.FindName('txt_MfrHPCount') $txt_MfrLenovoCount = $Window.FindName('txt_MfrLenovoCount') $txt_MfrMicrosoftCount = $Window.FindName('txt_MfrMicrosoftCount') $txt_MfrAcerCount = $Window.FindName('txt_MfrAcerCount') +$txt_MfrPanasonicCount = $Window.FindName('txt_MfrPanasonicCount') $txt_MfrOtherCount = $Window.FindName('txt_MfrOtherCount') # Intune chart controls @@ -11464,12 +11625,14 @@ $arc_IntuneMfr_HP = $Window.FindName('arc_IntuneMfr_HP') $arc_IntuneMfr_Lenovo = $Window.FindName('arc_IntuneMfr_Lenovo') $arc_IntuneMfr_Microsoft = $Window.FindName('arc_IntuneMfr_Microsoft') $arc_IntuneMfr_Acer = $Window.FindName('arc_IntuneMfr_Acer') +$arc_IntuneMfr_Panasonic = $Window.FindName('arc_IntuneMfr_Panasonic') $arc_IntuneMfr_Other = $Window.FindName('arc_IntuneMfr_Other') $txt_IntuneMfrDellCount = $Window.FindName('txt_IntuneMfrDellCount') $txt_IntuneMfrHPCount = $Window.FindName('txt_IntuneMfrHPCount') $txt_IntuneMfrLenovoCount = $Window.FindName('txt_IntuneMfrLenovoCount') $txt_IntuneMfrMicrosoftCount = $Window.FindName('txt_IntuneMfrMicrosoftCount') $txt_IntuneMfrAcerCount = $Window.FindName('txt_IntuneMfrAcerCount') +$txt_IntuneMfrPanasonicCount = $Window.FindName('txt_IntuneMfrPanasonicCount') $txt_IntuneMfrOtherCount = $Window.FindName('txt_IntuneMfrOtherCount') function New-DATDonutArc { @@ -11546,6 +11709,7 @@ function Update-DATManufacturerDonutChart { $txt_MfrLenovoCount.Text = ($Counts['Lenovo']).ToString('N0') $txt_MfrMicrosoftCount.Text = ($Counts['Microsoft']).ToString('N0') $txt_MfrAcerCount.Text = ($Counts['Acer']).ToString('N0') + $txt_MfrPanasonicCount.Text = ($Counts['Panasonic']).ToString('N0') $txt_MfrOtherCount.Text = ($Counts['Other']).ToString('N0') $total = ($Counts.Values | Measure-Object -Sum).Sum @@ -11555,6 +11719,7 @@ function Update-DATManufacturerDonutChart { 'Lenovo' = $arc_Mfr_Lenovo 'Microsoft' = $arc_Mfr_Microsoft 'Acer' = $arc_Mfr_Acer + 'Panasonic' = $arc_Mfr_Panasonic 'Other' = $arc_Mfr_Other } @@ -11564,7 +11729,7 @@ function Update-DATManufacturerDonutChart { } $startAngle = 0.0 - foreach ($key in @('Dell', 'HP', 'Lenovo', 'Microsoft', 'Acer', 'Other')) { + foreach ($key in @('Dell', 'HP', 'Lenovo', 'Microsoft', 'Acer', 'Panasonic', 'Other')) { $sweep = ($Counts[$key] / $total) * 360 $arcMap[$key].Data = New-DATDonutArc -StartAngle $startAngle -SweepAngle $sweep $startAngle += $sweep @@ -11606,6 +11771,7 @@ function Update-DATIntuneManufacturerDonutChart { $txt_IntuneMfrLenovoCount.Text = ($Counts['Lenovo']).ToString('N0') $txt_IntuneMfrMicrosoftCount.Text = ($Counts['Microsoft']).ToString('N0') $txt_IntuneMfrAcerCount.Text = ($Counts['Acer']).ToString('N0') + $txt_IntuneMfrPanasonicCount.Text = ($Counts['Panasonic']).ToString('N0') $txt_IntuneMfrOtherCount.Text = ($Counts['Other']).ToString('N0') $total = ($Counts.Values | Measure-Object -Sum).Sum @@ -11615,6 +11781,7 @@ function Update-DATIntuneManufacturerDonutChart { 'Lenovo' = $arc_IntuneMfr_Lenovo 'Microsoft' = $arc_IntuneMfr_Microsoft 'Acer' = $arc_IntuneMfr_Acer + 'Panasonic' = $arc_IntuneMfr_Panasonic 'Other' = $arc_IntuneMfr_Other } @@ -11624,7 +11791,7 @@ function Update-DATIntuneManufacturerDonutChart { } $startAngle = 0.0 - foreach ($key in @('Dell', 'HP', 'Lenovo', 'Microsoft', 'Acer', 'Other')) { + foreach ($key in @('Dell', 'HP', 'Lenovo', 'Microsoft', 'Acer', 'Panasonic', 'Other')) { $sweep = ($Counts[$key] / $total) * 360 $arcMap[$key].Data = New-DATDonutArc -StartAngle $startAngle -SweepAngle $sweep $startAngle += $sweep @@ -11652,6 +11819,7 @@ function Update-DATIntuneChartFromApps { 'Lenovo' = ($apps | Where-Object { $_.DisplayName -match '- Lenovo ' } | Measure-Object).Count 'Microsoft' = ($apps | Where-Object { $_.DisplayName -match '- Microsoft ' } | Measure-Object).Count 'Acer' = ($apps | Where-Object { $_.DisplayName -match '- Acer ' } | Measure-Object).Count + 'Panasonic' = ($apps | Where-Object { $_.DisplayName -match '- Panasonic ' } | Measure-Object).Count } $mfrCounts['Other'] = $total - ($mfrCounts.Values | Measure-Object -Sum).Sum @@ -11761,6 +11929,7 @@ function Invoke-DATConfigMgrConnect { 'Lenovo' = ($allPackages | Where-Object { $_.Name -match '- Lenovo ' } | Measure-Object).Count 'Microsoft' = ($allPackages | Where-Object { $_.Name -match '- Microsoft ' } | Measure-Object).Count 'Acer' = ($allPackages | Where-Object { $_.Name -match '- Acer ' } | Measure-Object).Count + 'Panasonic' = ($allPackages | Where-Object { $_.Name -match '- Panasonic ' } | Measure-Object).Count } $mfrCounts['Other'] = $packageCount - ($mfrCounts.Values | Measure-Object -Sum).Sum @@ -11884,6 +12053,9 @@ function Invoke-DATConfigMgrConnect { # grid rows (avoids overwriting the user's manual model selection). Invoke-DATConfigMgrKnownModelLookup -AutoSelectMatches:$false } + + # Inventory class check is a read-only diagnostic -- available whenever connected. + if ($null -ne $btn_CheckInventoryClasses) { $btn_CheckInventoryClasses.IsEnabled = $true } } else { $txt_SiteCode.Foreground = $Window.FindResource('StatusWarning') $txt_SiteCode.Text = "Connection failed - no site code returned." @@ -11919,6 +12091,9 @@ $txt_KnownModelsState = $Window.FindName('txt_KnownModelsState') $btn_ConfigMgrKnownModelLookup = $Window.FindName('btn_ConfigMgrKnownModelLookup') $btn_ConfigMgrViewModels = $Window.FindName('btn_ConfigMgrViewModels') $txt_ConfigMgrKnownModelStatus = $Window.FindName('txt_ConfigMgrKnownModelStatus') +$btn_CheckInventoryClasses = $Window.FindName('btn_CheckInventoryClasses') +$txt_InventoryClassSummary = $Window.FindName('txt_InventoryClassSummary') +$panel_InventoryClassStatus = $Window.FindName('panel_InventoryClassStatus') function Update-DATConfigMgrKnownModelSelection { <# @@ -12088,7 +12263,7 @@ function Show-DATConfigMgrKnownModelsDialog { $dlg.Owner = $Window $dlg.Width = 700 $dlg.Height = 550 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -12300,6 +12475,157 @@ $btn_ConfigMgrViewModels.Add_Click({ Show-DATConfigMgrKnownModelsDialog }) +function Show-DATInventoryClassResults { + param ($Result) + $panel_InventoryClassStatus.Children.Clear() + + $theme = Get-DATTheme -ThemeName $script:CurrentTheme + $mdl2 = [System.Windows.Media.FontFamily]::new('Segoe MDL2 Assets') + + foreach ($class in $Result.Classes) { + switch ($class.Status) { + 'Ok' { $glyph = [char]0xE73E; $colorKey = 'StatusSuccess'; $label = 'Enabled & reporting' } + 'PropertyMissing' { $glyph = [char]0xE7BA; $colorKey = 'StatusWarning'; $label = 'Enabled, property not collected' } + 'NoData' { $glyph = [char]0xE7BA; $colorKey = 'StatusWarning'; $label = 'Enabled, no data yet' } + 'NotEnabled' { $glyph = [char]0xE711; $colorKey = 'StatusError'; $label = 'Not enabled' } + default { $glyph = [char]0xE711; $colorKey = 'StatusError'; $label = 'Check failed' } + } + $brush = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($theme[$colorKey])) + + $row = [System.Windows.Controls.StackPanel]::new() + $row.Orientation = 'Horizontal' + $row.Margin = [System.Windows.Thickness]::new(0, 3, 0, 3) + + $icon = [System.Windows.Controls.TextBlock]::new() + $icon.Text = [string]$glyph + $icon.FontFamily = $mdl2 + $icon.FontSize = 14 + $icon.Foreground = $brush + $icon.VerticalAlignment = 'Top' + $icon.Margin = [System.Windows.Thickness]::new(0, 1, 8, 0) + [void]$row.Children.Add($icon) + + $text = [System.Windows.Controls.TextBlock]::new() + $text.TextWrapping = 'Wrap' + $text.FontSize = 12 + $text.MaxWidth = 560 + $text.Foreground = $brush + $namePart = [System.Windows.Documents.Run]::new("$($class.DisplayName) ($($class.Property)) -- $label") + $namePart.FontWeight = [System.Windows.FontWeights]::SemiBold + [void]$text.Inlines.Add($namePart) + if (-not [string]::IsNullOrWhiteSpace($class.Detail)) { + [void]$text.Inlines.Add([System.Windows.Documents.LineBreak]::new()) + $detailRun = [System.Windows.Documents.Run]::new($class.Detail) + $detailRun.Foreground = $Window.FindResource('InputPlaceholder') + [void]$text.Inlines.Add($detailRun) + } + [void]$row.Children.Add($text) + + [void]$panel_InventoryClassStatus.Children.Add($row) + } +} + +function Invoke-DATInventoryClassCheck { + if ([string]::IsNullOrEmpty($global:SiteCode) -or [string]::IsNullOrEmpty($global:SiteServer)) { + $txt_InventoryClassSummary.Text = 'Please connect to Configuration Manager first.' + $txt_InventoryClassSummary.Foreground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString( + (Get-DATTheme -ThemeName $script:CurrentTheme)['StatusError'])) + return + } + + $panel_InventoryClassStatus.Children.Clear() + $btn_CheckInventoryClasses.IsEnabled = $false + $txt_InventoryClassSummary.Foreground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString( + (Get-DATTheme -ThemeName $script:CurrentTheme)['StatusInfo'])) + $txt_InventoryClassSummary.Text = "Connecting to $($global:SiteServer)..." + Write-DATActivityLog "Starting hardware inventory class check via CIM on $($global:SiteServer)" -Level Info + + $siteServer = $global:SiteServer + $siteCode = $global:SiteCode + + $script:InventoryClassCheckState = [hashtable]::Synchronized(@{ + Status = 'Running' + Progress = "Connecting to $siteServer..." + Result = $null + Error = $null + }) + + $script:InventoryClassCheckPS = [powershell]::Create() + $script:InventoryClassCheckPS.AddScript({ + param ($CoreModulePath, $State, $Server, $Code) + Import-Module $CoreModulePath -Force + try { + $result = Test-DATConfigMgrInventoryClasses -SiteServer $Server -SiteCode $Code -OnProgress { + param ($msg) + $State.Progress = $msg + } + $State.Result = $result + $State.Status = 'Complete' + } + catch { + $State.Error = $_.Exception.Message + $State.Status = 'Failed' + } + }) + [void]$script:InventoryClassCheckPS.AddArgument($CoreModulePath) + [void]$script:InventoryClassCheckPS.AddArgument($script:InventoryClassCheckState) + [void]$script:InventoryClassCheckPS.AddArgument($siteServer) + [void]$script:InventoryClassCheckPS.AddArgument($siteCode) + + $script:InventoryClassCheckAsync = $script:InventoryClassCheckPS.BeginInvoke() + + $script:InventoryClassCheckTimer = New-Object System.Windows.Threading.DispatcherTimer + $script:InventoryClassCheckTimer.Interval = [TimeSpan]::FromMilliseconds(500) + $script:InventoryClassCheckTimer.Add_Tick({ + $state = $script:InventoryClassCheckState + $txt_InventoryClassSummary.Text = $state.Progress + + if ($state.Status -eq 'Complete') { + $script:InventoryClassCheckTimer.Stop() + $result = $state.Result + Show-DATInventoryClassResults -Result $result + + $colorKey = if ($result.AllOk) { 'StatusSuccess' } else { 'StatusWarning' } + $txt_InventoryClassSummary.Foreground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString( + (Get-DATTheme -ThemeName $script:CurrentTheme)[$colorKey])) + $txt_InventoryClassSummary.Text = if ($result.AllOk) { + 'All required inventory classes are enabled and reporting.' + } else { + 'One or more required inventory classes need attention (see below).' + } + Write-DATActivityLog "Hardware inventory class check complete (AllOk = $($result.AllOk))" -Level Success + foreach ($c in $result.Classes) { + Write-DATActivityLog " $($c.DisplayName) [$($c.View)]: $($c.Status) -- $($c.Detail)" -Level Info + } + + $btn_CheckInventoryClasses.IsEnabled = $true + $script:InventoryClassCheckPS.Dispose() + $script:InventoryClassCheckPS = $null + } + elseif ($state.Status -eq 'Failed') { + $script:InventoryClassCheckTimer.Stop() + $txt_InventoryClassSummary.Text = "Failed: $($state.Error)" + $txt_InventoryClassSummary.Foreground = [System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString( + (Get-DATTheme -ThemeName $script:CurrentTheme)['StatusError'])) + Write-DATActivityLog "Hardware inventory class check failed: $($state.Error)" -Level Error + + $btn_CheckInventoryClasses.IsEnabled = $true + $script:InventoryClassCheckPS.Dispose() + $script:InventoryClassCheckPS = $null + } + }) + $script:InventoryClassCheckTimer.Start() +} + +$btn_CheckInventoryClasses.Add_Click({ + Invoke-DATInventoryClassCheck +}) + #endregion Known Model Lookup #region Intune Known Model Lookup @@ -12549,7 +12875,7 @@ function Show-DATIntuneKnownModelsDialog { $dlg.Owner = $Window $dlg.Width = 700 $dlg.Height = 550 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -13174,7 +13500,7 @@ function Show-DATConsoleFolderBrowseDialog { $dlg.Owner = $Window $dlg.Width = 500 $dlg.Height = 480 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -13556,7 +13882,7 @@ function Show-DATConsoleFolderBrowseDialog { $inputDlg.Owner = $dlg $inputDlg.Width = 380 $inputDlg.SizeToContent = 'Height' - $inputDlg.Topmost = $true + $inputDlg.Topmost = $false $inputDlg.ResizeMode = 'NoResize' $inputDlg.ShowInTaskbar = $false @@ -14151,7 +14477,7 @@ function Update-DATCmPackageFilter { $item.Version -notlike "*$searchText*") { return $false } } return $true - } + }.GetNewClosure() } } @@ -14232,7 +14558,7 @@ $btn_CmDeleteSelected.Add_Click({ $script:cmDeleteModal.Owner = $Window $script:cmDeleteModal.Width = 440 $script:cmDeleteModal.SizeToContent = 'Height' - $script:cmDeleteModal.Topmost = $true + $script:cmDeleteModal.Topmost = $false $script:cmDeleteModal.ResizeMode = 'NoResize' $script:cmDeleteModal.ShowInTaskbar = $false @@ -14485,6 +14811,8 @@ $btn_CmDeleteSelected.Add_Click({ try { $script:cmDeletePS.Dispose(); $script:cmDeleteRunspace.Dispose() } catch {} $panel_PkgDetails.Visibility = 'Collapsed' + $split_CmDetail.Visibility = 'Collapsed' + $row_CmDetail.Height = [System.Windows.GridLength]::new(0) Invoke-DATPackageRefresh $delTotal = [int]$script:cmDeleteProgressBar.Maximum $delDeleted = $script:cmDeleteState.Deleted @@ -14525,10 +14853,20 @@ $txt_PkgDetailUpdated = $Window.FindName('txt_PkgDetailUpdated') $txt_PkgDetailContentStatus = $Window.FindName('txt_PkgDetailContentStatus') $btn_CmDeletePackage = $Window.FindName('btn_CmDeletePackage') +# Close the package detail card +$btn_CmCloseDetail.Add_Click({ + $grid_Packages.SelectedItem = $null + $panel_PkgDetails.Visibility = 'Collapsed' + $split_CmDetail.Visibility = 'Collapsed' + $row_CmDetail.Height = [System.Windows.GridLength]::new(0) +}) + $grid_Packages.Add_SelectionChanged({ $selected = $grid_Packages.SelectedItem if ($null -eq $selected -or [string]::IsNullOrEmpty($selected.PackageID)) { $panel_PkgDetails.Visibility = 'Collapsed' + $split_CmDetail.Visibility = 'Collapsed' + $row_CmDetail.Height = [System.Windows.GridLength]::new(0) $btn_CmReportIssue.IsEnabled = $false $btn_CmDeletePackage.IsEnabled = $false return @@ -14616,6 +14954,11 @@ $grid_Packages.Add_SelectionChanged({ $txt_PkgDetailContentStatus.Text = $statusText $panel_PkgDetails.Visibility = 'Visible' + $split_CmDetail.Visibility = 'Visible' + # Default to Auto so the card is only as tall as its content; the splitter can still resize it. + if ($row_CmDetail.Height.IsAbsolute -and $row_CmDetail.Height.Value -le 0) { + $row_CmDetail.Height = [System.Windows.GridLength]::Auto + } # Update Report Issue button text based on current state $make = if ($pkg.Manufacturer) { $pkg.Manufacturer } else { '' } @@ -14639,6 +14982,8 @@ $grid_Packages.Add_SelectionChanged({ } catch { Write-DATLogEntry -Value "[Warning] - Failed to load package details: $($_.Exception.Message)" -Severity 2 $panel_PkgDetails.Visibility = 'Collapsed' + $split_CmDetail.Visibility = 'Collapsed' + $row_CmDetail.Height = [System.Windows.GridLength]::new(0) } }) @@ -14730,6 +15075,8 @@ $btn_CmDeletePackage.Add_Click({ $txt_PkgStatus.Text = "Deleted: $($selected.Name)" $txt_PkgStatus.Visibility = 'Visible' $panel_PkgDetails.Visibility = 'Collapsed' + $split_CmDetail.Visibility = 'Collapsed' + $row_CmDetail.Height = [System.Windows.GridLength]::new(0) Invoke-DATPackageRefresh Show-DATInfoDialog -Title "Package Deleted" ` -Message "'$($selected.Name)' ($($selected.PackageID)) has been successfully removed from ConfigMgr." ` @@ -14765,7 +15112,7 @@ function Show-DATChangeOSTargetDialog { $dlg.Owner = $Window $dlg.Width = 460 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -15398,6 +15745,168 @@ $chk_UseDATAPICatalog.Add_Unchecked({ Write-DATLogEntry -Value "DAT API catalog mode disabled -- individual OEM catalog sources will be used" -Severity 1 }) +# -- OEM Selections (restrict supported manufacturers) -- +# When enabled, only the ticked OEMs are made available on the main-page manufacturer +# selector; every OEM-consuming path (dropdown, pre-flight source checks, refresh/build, +# scheduled tasks) funnels through Get-DATSelectedOEMs, which reads the main-page checkbox +# state -- so hiding/unchecking the disallowed boxes here restricts all of them at once. +$script:chk_RestrictOEMs = $Window.FindName('chk_RestrictOEMs') +$script:panel_OEMSelectionList = $Window.FindName('panel_OEMSelectionList') +$script:AllowOEMCheckboxes = @{ + 'Acer' = $Window.FindName('chk_AllowOEM_Acer') + 'ASUS' = $Window.FindName('chk_AllowOEM_ASUS') + 'Dell' = $Window.FindName('chk_AllowOEM_Dell') + 'Fujitsu' = $Window.FindName('chk_AllowOEM_Fujitsu') + 'HP' = $Window.FindName('chk_AllowOEM_HP') + 'Lenovo' = $Window.FindName('chk_AllowOEM_Lenovo') + 'Microsoft' = $Window.FindName('chk_AllowOEM_Microsoft') + 'Panasonic' = $Window.FindName('chk_AllowOEM_Panasonic') +} +# Guard so restore / programmatic changes don't fire persistence or refresh loops +$script:SuppressOEMRestrictionHandler = $false + +function Get-DATAllowedOEMs { + # Returns the set of OEMs the user is allowed to work with. Fail-open: when restriction + # is off, or on with nothing ticked, every supported OEM is returned. + $allKeys = @($script:OEMCheckboxes.Keys) + if ($script:chk_RestrictOEMs.IsChecked -ne $true) { return $allKeys } + $allowed = @() + foreach ($entry in $script:AllowOEMCheckboxes.GetEnumerator()) { + if ($entry.Value.IsChecked -eq $true) { $allowed += $entry.Key } + } + if ($allowed.Count -eq 0) { return $allKeys } + return $allowed +} + +function Update-DATOEMAvailability { + # Applies the allowed-OEM set to the main-page manufacturer control: OEMs that are not + # allowed are unchecked, hidden and disabled so they cannot be selected; allowed OEMs are + # shown and enabled. Called on startup and whenever the restriction setting changes. + $allowed = Get-DATAllowedOEMs + $prevSuppress = $script:SuppressModelRefresh + $script:SuppressModelRefresh = $true + foreach ($entry in $script:OEMCheckboxes.GetEnumerator()) { + $oem = $entry.Key + $chk = $entry.Value + $border = $script:OEMBorders[$oem] + if ($allowed -contains $oem) { + $chk.IsEnabled = $true + if ($null -ne $border) { $border.Visibility = 'Visible' } + } else { + if ($chk.IsChecked -eq $true) { $chk.IsChecked = $false } + $chk.IsEnabled = $false + if ($null -ne $border) { $border.Visibility = 'Collapsed' } + } + } + $script:SuppressModelRefresh = $prevSuppress + Update-DATOEMDisplayText + Update-DATOEMSelectionHighlight + # Grey out the OEM checkbox list unless the master toggle is on + if ($null -ne $script:panel_OEMSelectionList) { + $script:panel_OEMSelectionList.IsEnabled = ($script:chk_RestrictOEMs.IsChecked -eq $true) + } +} + +$persistOEMRestriction = { + if ($script:SuppressOEMRestrictionHandler) { return } + $restrictOn = ($script:chk_RestrictOEMs.IsChecked -eq $true) + Set-DATRegistryValue -Name "RestrictOEMs" -Value ([int]$restrictOn) -Type DWord + $allowedList = @() + foreach ($entry in $script:AllowOEMCheckboxes.GetEnumerator()) { + if ($entry.Value.IsChecked -eq $true) { $allowedList += $entry.Key } + } + Set-DATRegistryValue -Name "AllowedOEMs" -Value ($allowedList -join ',') -Type String + Update-DATOEMAvailability + # Keep the persisted main-page selection in sync with the now-available OEMs + Set-DATRegistryValue -Name "SelectedOEMs" -Value ((Get-DATSelectedOEMs) -join ',') -Type String + Write-DATLogEntry -Value "OEM restriction updated -- restrict=$restrictOn, allowed=[$($allowedList -join ',')]" -Severity 1 +} + +$script:chk_RestrictOEMs.Add_Checked($persistOEMRestriction) +$script:chk_RestrictOEMs.Add_Unchecked($persistOEMRestriction) +foreach ($chk in $script:AllowOEMCheckboxes.Values) { + $chk.Add_Checked($persistOEMRestriction) + $chk.Add_Unchecked($persistOEMRestriction) +} + +# -- Interface Scale -- +# Zoom the whole UI by applying a ScaleTransform (LayoutTransform, so layout reflows) to the +# root grid, and resize the window by the same factor clamped to the screen work area. Auto-fit +# picks the largest scale (<=100%) that fits the current display. +$script:root_ScaleHost = $Window.FindName('root_ScaleHost') +$script:chk_AutoFitScale = $Window.FindName('chk_AutoFitScale') +$script:sld_InterfaceScale = $Window.FindName('sld_InterfaceScale') +$script:txt_InterfaceScaleValue = $Window.FindName('txt_InterfaceScaleValue') +$script:BaseWindowWidth = 1200.0 +$script:BaseWindowHeight = 780.0 +$script:BaseWindowMinWidth = 1000.0 +$script:BaseWindowMinHeight = 650.0 +$script:MinInterfaceScale = 0.75 +$script:MaxInterfaceScale = 1.5 +$script:SuppressScaleHandler = $false + +function Get-DATAutoFitScale { + # Largest scale (capped at 1.0) at which the base window fits the current work area. + try { + $wa = [System.Windows.SystemParameters]::WorkArea + $fit = [Math]::Min($wa.Width / $script:BaseWindowWidth, $wa.Height / $script:BaseWindowHeight) + if ($fit -gt 1.0) { $fit = 1.0 } + if ($fit -lt $script:MinInterfaceScale) { $fit = $script:MinInterfaceScale } + return [Math]::Round($fit, 2) + } catch { return 1.0 } +} + +function Set-DATInterfaceScale { + param([double]$Scale) + if ($Scale -lt $script:MinInterfaceScale) { $Scale = $script:MinInterfaceScale } + if ($Scale -gt $script:MaxInterfaceScale) { $Scale = $script:MaxInterfaceScale } + if ($null -ne $script:root_ScaleHost) { + $script:root_ScaleHost.LayoutTransform = [System.Windows.Media.ScaleTransform]::new($Scale, $Scale) + } + try { + $wa = [System.Windows.SystemParameters]::WorkArea + $w = [Math]::Min($script:BaseWindowWidth * $Scale, $wa.Width) + $h = [Math]::Min($script:BaseWindowHeight * $Scale, $wa.Height) + $Window.MinWidth = [Math]::Min($script:BaseWindowMinWidth * $Scale, $w) + $Window.MinHeight = [Math]::Min($script:BaseWindowMinHeight * $Scale, $h) + $Window.Width = $w + $Window.Height = $h + } catch { } + if ($null -ne $script:txt_InterfaceScaleValue) { + $script:txt_InterfaceScaleValue.Text = ("{0}%" -f [int][Math]::Round($Scale * 100)) + } +} + +function Update-DATInterfaceScaleFromControls { + if ($script:chk_AutoFitScale.IsChecked -eq $true) { + $script:sld_InterfaceScale.IsEnabled = $false + $auto = Get-DATAutoFitScale + # Reflect the computed value on the slider without recursing into the change handlers + $prev = $script:SuppressScaleHandler + $script:SuppressScaleHandler = $true + try { $script:sld_InterfaceScale.Value = [int][Math]::Round($auto * 100) } finally { $script:SuppressScaleHandler = $prev } + Set-DATInterfaceScale -Scale $auto + } else { + $script:sld_InterfaceScale.IsEnabled = $true + Set-DATInterfaceScale -Scale ($script:sld_InterfaceScale.Value / 100.0) + } +} + +$persistInterfaceScale = { + if ($script:SuppressScaleHandler) { return } + Set-DATRegistryValue -Name "InterfaceScaleAuto" -Value ([int]($script:chk_AutoFitScale.IsChecked -eq $true)) -Type DWord + Set-DATRegistryValue -Name "InterfaceScale" -Value ([int][Math]::Round($script:sld_InterfaceScale.Value)) -Type DWord +} + +$script:chk_AutoFitScale.Add_Checked({ Update-DATInterfaceScaleFromControls; & $persistInterfaceScale }) +$script:chk_AutoFitScale.Add_Unchecked({ Update-DATInterfaceScaleFromControls; & $persistInterfaceScale }) +$script:sld_InterfaceScale.Add_ValueChanged({ + if ($script:SuppressScaleHandler) { return } + if ($script:chk_AutoFitScale.IsChecked -eq $true) { return } + Set-DATInterfaceScale -Scale ($script:sld_InterfaceScale.Value / 100.0) + & $persistInterfaceScale +}) + $btn_CopyTelemetryGuid = $Window.FindName('btn_CopyTelemetryGuid') $btn_CopyTelemetryGuid.Add_Click({ if (-not [string]::IsNullOrEmpty($txt_TelemetryGuid.Text)) { @@ -16704,10 +17213,28 @@ function Get-DATLocalDeviceInfo { try { $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop $bb = Get-CimInstance -ClassName Win32_BaseBoard -ErrorAction Stop - $txt_CustomMake.Text = ($cs.Manufacturer -replace '^\s+|\s+$','' -replace '\.$','') - $txt_CustomModel.Text = ($cs.Model -replace '^\s+|\s+$','' -replace '\.$','') - $txt_CustomBaseBoard.Text = ($bb.Product -replace '^\s+|\s+$','') - Write-DATActivityLog "Custom Driver Pack: WMI device info loaded -- $($cs.Manufacturer) $($cs.Model) ($($bb.Product))" -Level Info + $make = ($cs.Manufacturer -replace '^\s+|\s+$','' -replace '\.$','') + + if ($make -match 'Lenovo') { + # Lenovo is the exception: the friendly model name lives in + # Win32_ComputerSystemProduct.Version, while Win32_ComputerSystem.Model holds + # the machine-type code. The catalog/SystemSKU value is the first 4 characters + # of that machine-type code. This mirrors Get-ComputerData in + # Invoke-CMApplyDriverPackage.ps1 so custom packages match at deployment time. + $csp = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction Stop + $model = ($csp.Version -replace '^\s+|\s+$','' -replace '\.$','') + $rawModel = ($cs.Model -replace '^\s+|\s+$','') + $sku = if ($rawModel.Length -ge 4) { $rawModel.Substring(0, 4) } else { $rawModel } + $txt_CustomMake.Text = 'Lenovo' + $txt_CustomModel.Text = $model + $txt_CustomBaseBoard.Text = $sku + Write-DATActivityLog "Custom Driver Pack: WMI device info loaded -- Lenovo $model (SKU $sku)" -Level Info + } else { + $txt_CustomMake.Text = $make + $txt_CustomModel.Text = ($cs.Model -replace '^\s+|\s+$','' -replace '\.$','') + $txt_CustomBaseBoard.Text = ($bb.Product -replace '^\s+|\s+$','') + Write-DATActivityLog "Custom Driver Pack: WMI device info loaded -- $($cs.Manufacturer) $($cs.Model) ($($bb.Product))" -Level Info + } } catch { Write-DATActivityLog "Custom Driver Pack: Failed to read WMI -- $($_.Exception.Message)" -Level Warn $txt_CustomStatus.Text = "Could not read device information from WMI." @@ -17769,7 +18296,7 @@ $btn_BrowseCodeSigningCert.Add_Click({ $dlg.Width = 560 $dlg.MaxHeight = 500 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false $selState.Dialog = $dlg @@ -18675,7 +19202,7 @@ $btn_QueryFilters.Add_Click({ $dlg.Owner = $Window $dlg.Width = 620 $dlg.Height = 500 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false $qfState.Dialog = $dlg @@ -20155,7 +20682,8 @@ $btn_ShowToastPreview.Add_Click({ $previewWin.AllowsTransparency = $true $previewWin.Background = [System.Windows.Media.Brushes]::Transparent $previewWin.WindowStartupLocation = 'Manual' - $previewWin.Topmost = $true + $previewWin.Owner = $Window + $previewWin.Topmost = $false $previewWin.ResizeMode = 'NoResize' $previewWin.ShowInTaskbar = $false $previewWin.Width = 420 @@ -21053,8 +21581,9 @@ function Invoke-DATIntuneBulkAppProgress { #> param ( [array]$Apps, - [ValidateSet('RemoveAssignments','UpdateDetection','UpdateRequirement','UpdateMetadata','UpdateScopeTags')][string]$Operation, - [string[]]$RoleScopeTagIds = @() + [ValidateSet('RemoveAssignments','UpdateDetection','UpdateRequirement','UpdateMetadata','UpdateScopeTags','MoveToProduction','MoveToPilot','MoveToRetired','ChangeOSTarget')][string]$Operation, + [string[]]$RoleScopeTagIds = @(), + [string]$TargetOS = '' ) switch ($Operation) { @@ -21063,6 +21592,10 @@ function Invoke-DATIntuneBulkAppProgress { 'UpdateRequirement' { $titleIcon = [char]0xE90F; $titleText = 'Updating Requirement Script'; $doneLabel = 'Updated'; $doneSummary = 'script updated on' } 'UpdateMetadata' { $titleIcon = [char]0xE898; $titleText = 'Republishing Metadata'; $doneLabel = 'Updated'; $doneSummary = 'metadata republished on' } 'UpdateScopeTags' { $titleIcon = [char]0xE8D7; $titleText = 'Updating Scope Tags'; $doneLabel = 'Updated'; $doneSummary = 'scope tags updated on' } + 'MoveToProduction' { $titleIcon = [char]0xE7B8; $titleText = 'Moving to Production'; $doneLabel = 'Moved'; $doneSummary = 'moved to Production:' } + 'MoveToPilot' { $titleIcon = [char]0xE7B8; $titleText = 'Moving to Pilot'; $doneLabel = 'Moved'; $doneSummary = 'moved to Pilot:' } + 'MoveToRetired' { $titleIcon = [char]0xE7B8; $titleText = 'Moving to Retired'; $doneLabel = 'Moved'; $doneSummary = 'moved to Retired:' } + 'ChangeOSTarget' { $titleIcon = [char]0xE770; $titleText = 'Changing OS Target'; $doneLabel = 'Retargeted'; $doneSummary = 'retargeted:' } } $theme = Get-DATTheme -ThemeName $script:CurrentTheme @@ -21221,7 +21754,7 @@ function Invoke-DATIntuneBulkAppProgress { $script:BulkPS = [powershell]::Create() Add-DATCoreRunspaceBootstrap -PowerShell $script:BulkPS -CaptureIntuneAuthContext $script:BulkPS.AddScript({ - param ($State, $AppList, $Operation, $RoleScopeTagIds) + param ($State, $AppList, $Operation, $RoleScopeTagIds, $TargetOS) $scopeTags = @($RoleScopeTagIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) foreach ($app in $AppList) { $entry = @{ AppId = $app.AppId; DisplayName = $app.DisplayName; Success = $false; Error = ''; Count = 0 } @@ -21235,6 +21768,10 @@ function Invoke-DATIntuneBulkAppProgress { $applyTags = if ($scopeTags.Count -gt 0) { $scopeTags } else { @('0') } Set-DATIntuneAppScopeTags -AppId $app.AppId -RoleScopeTagIds $applyTags | Out-Null } + 'MoveToProduction' { $r = Set-DATIntuneAppDeploymentState -AppId $app.AppId -State 'Production'; $entry.Count = [int][bool]$r.Changed } + 'MoveToPilot' { $r = Set-DATIntuneAppDeploymentState -AppId $app.AppId -State 'Pilot'; $entry.Count = [int][bool]$r.Changed } + 'MoveToRetired' { $r = Set-DATIntuneAppDeploymentState -AppId $app.AppId -State 'Retired'; $entry.Count = [int][bool]$r.Changed } + 'ChangeOSTarget' { $r = Set-DATIntuneAppOSTarget -AppId $app.AppId -NewOS $TargetOS; $entry.Count = [int][bool]$r.Changed } } $entry.Success = $true } catch { @@ -21248,6 +21785,7 @@ function Invoke-DATIntuneBulkAppProgress { [void]$script:BulkPS.AddArgument($appList) [void]$script:BulkPS.AddArgument($Operation) [void]$script:BulkPS.AddArgument($RoleScopeTagIds) + [void]$script:BulkPS.AddArgument($TargetOS) $script:BulkAsync = $script:BulkPS.BeginInvoke() $script:BulkLastSeen = 0 @@ -21275,6 +21813,14 @@ function Invoke-DATIntuneBulkAppProgress { if ($label) { $label.Text = 'None' } Write-DATActivityLog "No assignments to remove for '$($entry.DisplayName)'" -Level Info } + } elseif ($Operation -in @('MoveToProduction','MoveToPilot','MoveToRetired','ChangeOSTarget')) { + if ([int]$entry.Count -gt 0) { + $script:BulkAnyChanges = $true + Write-DATActivityLog "$doneLabel '$($entry.DisplayName)'" -Level Success + } else { + if ($label) { $label.Text = 'No change'; $label.Foreground = $mutedBrush } + Write-DATActivityLog "No change for '$($entry.DisplayName)' (not applicable or already targeted)" -Level Info + } } else { $script:BulkAnyChanges = $true Write-DATActivityLog "$doneLabel '$($entry.DisplayName)'" -Level Success @@ -21320,6 +21866,10 @@ function Invoke-DATIntuneBulkAppProgress { if ($Operation -eq 'RemoveAssignments' -and $script:BulkAnyChanges) { try { Invoke-DATIntuneAppRefresh } catch { } } + # Refresh so renamed/retargeted display names are reflected in the grid. + if ($script:BulkAnyChanges -and ($Operation -in @('MoveToProduction','MoveToPilot','MoveToRetired','ChangeOSTarget'))) { + try { Invoke-DATIntuneAppRefresh } catch { } + } } }) $script:BulkTimer.Start() @@ -21467,7 +22017,7 @@ $ctx_UpdateRemoveFilter.Add_Click({ $filterDlg.Width = 520 $filterDlg.SizeToContent = 'Height' $filterDlg.MaxHeight = 420 - $filterDlg.Topmost = $true + $filterDlg.Topmost = $false $filterDlg.ResizeMode = 'NoResize' $filterDlg.ShowInTaskbar = $false @@ -22068,6 +22618,7 @@ function Update-DATIntuneAuthUI { $btn_VerifyIntunePermissions.Visibility = 'Visible' $grid_IntuneApps.IsEnabled = $true $btn_RefreshIntuneApps.IsEnabled = $true + if ($null -ne $cmb_IntunePkgAction) { $cmb_IntunePkgAction.IsEnabled = $true } $panel_AuthStatus.Visibility = 'Collapsed' $txt_IntunePkgStatus.Visibility = 'Collapsed' # Enable Intune known model lookup if toggle is on @@ -22120,6 +22671,7 @@ function Update-DATIntuneAuthUI { $btn_VerifyIntunePermissions.Visibility = 'Collapsed' $grid_IntuneApps.IsEnabled = $false $btn_RefreshIntuneApps.IsEnabled = $false + if ($null -ne $cmb_IntunePkgAction) { $cmb_IntunePkgAction.IsEnabled = $false } $btn_DeleteIntuneApp.IsEnabled = $false $btn_IntuneKnownModelLookup.IsEnabled = $false $txt_IntunePkgStatus.Text = 'Not connected to Intune. Please connect first.' @@ -22290,6 +22842,7 @@ $script:IntuneTokenTimer.Add_Tick({ (Get-DATTheme -ThemeName $script:CurrentTheme)['StatusWarning'])) $grid_IntuneApps.IsEnabled = $false $btn_RefreshIntuneApps.IsEnabled = $false + if ($null -ne $cmb_IntunePkgAction) { $cmb_IntunePkgAction.IsEnabled = $false } $btn_DeleteIntuneApp.IsEnabled = $false Write-DATActivityLog "Intune token expired" -Level Warn } @@ -22484,7 +23037,7 @@ function Update-DATIntuneAppFilter { if ($item.DisplayName -notlike "*$searchText*" -and $item.Publisher -notlike "*$searchText*") { return $false } } return $true - } + }.GetNewClosure() } # Intune package type filter @@ -22497,6 +23050,36 @@ $cmb_IntunePkgOS.Add_SelectionChanged({ Update-DATIntuneAppFilter }) # Intune apps search filter $txt_IntuneAppSearch.Add_TextChanged({ Update-DATIntuneAppFilter }) +# Intune package Action dropdown -- mirrors the ConfigMgr package "Action" combo. Operates on the +# checked packages and runs the change through the shared bulk-progress modal. +$cmb_IntunePkgAction = $Window.FindName('cmb_IntunePkgAction') +$cmb_IntunePkgAction.Add_SelectionChanged({ + $action = if ($null -ne $cmb_IntunePkgAction.SelectedItem) { $cmb_IntunePkgAction.SelectedItem.Content } else { $null } + if ([string]::IsNullOrEmpty($action)) { return } + + $checkedApps = @($script:IntuneAppsData | Where-Object { $_.Selected -eq $true }) + if ($checkedApps.Count -eq 0) { + $txt_IntunePkgStatus.Foreground = $Window.FindResource('StatusWarning') + $txt_IntunePkgStatus.Text = "No packages selected. Please check one or more packages first." + $txt_IntunePkgStatus.Visibility = 'Visible' + $cmb_IntunePkgAction.SelectedIndex = -1 + return + } + + switch ($action) { + 'Move to Production' { Invoke-DATIntuneBulkAppProgress -Apps $checkedApps -Operation 'MoveToProduction' } + 'Move to Pilot' { Invoke-DATIntuneBulkAppProgress -Apps $checkedApps -Operation 'MoveToPilot' } + 'Move to Retired' { Invoke-DATIntuneBulkAppProgress -Apps $checkedApps -Operation 'MoveToRetired' } + 'Change OS Target' { + $newOS = Show-DATChangeOSTargetDialog + if ([string]::IsNullOrEmpty($newOS)) { $cmb_IntunePkgAction.SelectedIndex = -1; return } + Invoke-DATIntuneBulkAppProgress -Apps $checkedApps -Operation 'ChangeOSTarget' -TargetOS $newOS + } + } + + $cmb_IntunePkgAction.SelectedIndex = -1 +}) + # Intune Select All / Select None $btn_IntunePkgSelectAll = $Window.FindName('btn_IntunePkgSelectAll') $btn_IntunePkgSelectNone = $Window.FindName('btn_IntunePkgSelectNone') @@ -23381,6 +23964,8 @@ $grid_IntuneApps.Add_SelectionChanged({ $selected = $grid_IntuneApps.SelectedItem if ($null -eq $selected) { $panel_IntuneAppDetail.Visibility = 'Collapsed' + $split_IntuneDetail.Visibility = 'Collapsed' + $row_IntuneDetail.Height = [System.Windows.GridLength]::new(0) $btn_IntuneReportIssue.IsEnabled = $false return } @@ -23391,6 +23976,11 @@ $grid_IntuneApps.Add_SelectionChanged({ $txt_Detail_Description.Text = $selected.Description $txt_Detail_Version.Text = $selected.Version $panel_IntuneAppDetail.Visibility = 'Visible' + $split_IntuneDetail.Visibility = 'Visible' + # Default to Auto so the card is only as tall as its content; the splitter can still resize it. + if ($row_IntuneDetail.Height.IsAbsolute -and $row_IntuneDetail.Height.Value -le 0) { + $row_IntuneDetail.Height = [System.Windows.GridLength]::Auto + } # Enable Report Issue only if telemetry is opted in $btn_IntuneReportIssue.IsEnabled = $chk_TelemetryOptOut.IsChecked -eq $true @@ -23473,6 +24063,8 @@ $btn_IntuneDeletePackage.Add_Click({ Remove-DATIntuneApp -AppId $selected.AppId Write-DATActivityLog "Deleted: $($selected.DisplayName)" -Level Success $panel_IntuneAppDetail.Visibility = 'Collapsed' + $split_IntuneDetail.Visibility = 'Collapsed' + $row_IntuneDetail.Height = [System.Windows.GridLength]::new(0) Invoke-DATIntuneAppRefresh Show-DATInfoDialog -Title "Application Deleted" ` -Message "'$($selected.DisplayName)' has been successfully removed from Intune." ` @@ -23482,6 +24074,14 @@ $btn_IntuneDeletePackage.Add_Click({ } }) +# Close the package detail panel +$btn_IntuneCloseDetail.Add_Click({ + $grid_IntuneApps.SelectedItem = $null + $panel_IntuneAppDetail.Visibility = 'Collapsed' + $split_IntuneDetail.Visibility = 'Collapsed' + $row_IntuneDetail.Height = [System.Windows.GridLength]::new(0) +}) + # Copy App ID from detail panel $btn_Detail_CopyId.Add_Click({ $id = $txt_Detail_AppId.Text @@ -23530,7 +24130,7 @@ $btn_DeleteIntuneApp.Add_Click({ $script:deleteModal.Owner = $Window $script:deleteModal.Width = 440 $script:deleteModal.SizeToContent = 'Height' - $script:deleteModal.Topmost = $true + $script:deleteModal.Topmost = $false $script:deleteModal.ResizeMode = 'NoResize' $script:deleteModal.ShowInTaskbar = $false @@ -24371,15 +24971,16 @@ function Restore-DATMaintenanceWindowSettings { function Import-DATLogEntries { $logPath = Join-Path -Path $global:LogDirectory -ChildPath "$global:ProductName.log" - $lst_LogEntries.Items.Clear() if (-not (Test-Path $logPath)) { + $lst_LogEntries.ItemsSource = $null $txt_LogStats.Text = "No log file found." return } $rawLines = Get-Content -Path $logPath -ErrorAction SilentlyContinue if ($null -eq $rawLines -or $rawLines.Count -eq 0) { + $lst_LogEntries.ItemsSource = $null $txt_LogStats.Text = "Log file is empty." return } @@ -24395,7 +24996,6 @@ function Import-DATLogEntries { $infoBg = [System.Windows.Media.ColorConverter]::ConvertFromString($themeColors['SidebarBackground']) $warnBg = [System.Windows.Media.ColorConverter]::ConvertFromString("#2D2A1A") $errorBg = [System.Windows.Media.ColorConverter]::ConvertFromString("#2D1A1E") - $dimColor = [System.Windows.Media.ColorConverter]::ConvertFromString($themeColors['InputPlaceholder']) if ($script:CurrentTheme -eq 'Light') { $warnBg = [System.Windows.Media.ColorConverter]::ConvertFromString("#FFF8E1") @@ -24408,7 +25008,6 @@ function Import-DATLogEntries { $infoFgBrush = [System.Windows.Media.SolidColorBrush]::new($infoColor); $infoFgBrush.Freeze() $warnFgBrush = [System.Windows.Media.SolidColorBrush]::new($warnColor); $warnFgBrush.Freeze() $errorFgBrush = [System.Windows.Media.SolidColorBrush]::new($errorColor); $errorFgBrush.Freeze() - $dimBrush = [System.Windows.Media.SolidColorBrush]::new($dimColor); $dimBrush.Freeze() # Severity icon brushes $infoIconBrush = [System.Windows.Media.SolidColorBrush]::new( @@ -24421,96 +25020,45 @@ function Import-DATLogEntries { [System.Windows.Media.ColorConverter]::ConvertFromString($themeColors['StatusError'])) $errorIconBrush.Freeze() + $infoIcon = [string][char]0xE946 + $warnIcon = [string][char]0xE7BA + $errorIcon = [string][char]0xEA39 + + # Build lightweight data objects; the virtualized ListBox realizes only visible rows. + $entries = [System.Collections.Generic.List[object]]::new() foreach ($line in $rawLines) { if ($line -match $cmtracePattern) { - $msg = $Matches['msg'] $timeRaw = $Matches['time'] - $dateRaw = $Matches['date'] $severity = $Matches['type'] # Parse time (take HH:mm:ss) $timePart = $timeRaw.Split('.')[0] if ($timePart.Length -gt 8) { $timePart = $timePart.Substring(0, 8) } - # Determine styling switch ($severity) { - '1' { $fgBrush = $infoFgBrush; $bgBrush = $infoBrush; $iconChar = [char]0xE946; $iconBrush = $infoIconBrush; $infoCount++ } - '2' { $fgBrush = $warnFgBrush; $bgBrush = $warnBrush; $iconChar = [char]0xE7BA; $iconBrush = $warnIconBrush; $warnCount++ } - '3' { $fgBrush = $errorFgBrush; $bgBrush = $errorBrush; $iconChar = [char]0xEA39; $iconBrush = $errorIconBrush; $errorCount++ } - default { $fgBrush = $infoFgBrush; $bgBrush = $infoBrush; $iconChar = [char]0xE946; $iconBrush = $infoIconBrush; $infoCount++ } - } - - # Build row - $grid = New-Object System.Windows.Controls.Grid - $col0 = New-Object System.Windows.Controls.ColumnDefinition; $col0.Width = [System.Windows.GridLength]::new(28) - $col1 = New-Object System.Windows.Controls.ColumnDefinition; $col1.Width = [System.Windows.GridLength]::new(80) - $col2 = New-Object System.Windows.Controls.ColumnDefinition; $col2.Width = [System.Windows.GridLength]::new(80) - $col3 = New-Object System.Windows.Controls.ColumnDefinition; $col3.Width = [System.Windows.GridLength]::new(1, [System.Windows.GridUnitType]::Star) - $grid.ColumnDefinitions.Add($col0) - $grid.ColumnDefinitions.Add($col1) - $grid.ColumnDefinitions.Add($col2) - $grid.ColumnDefinitions.Add($col3) + '2' { $fgBrush = $warnFgBrush; $bgBrush = $warnBrush; $iconChar = $warnIcon; $iconBrush = $warnIconBrush; $warnCount++ } + '3' { $fgBrush = $errorFgBrush; $bgBrush = $errorBrush; $iconChar = $errorIcon; $iconBrush = $errorIconBrush; $errorCount++ } + default { $fgBrush = $infoFgBrush; $bgBrush = $infoBrush; $iconChar = $infoIcon; $iconBrush = $infoIconBrush; $infoCount++ } + } + + $entries.Add([PSCustomObject]@{ + IconChar = $iconChar + IconBrush = $iconBrush + Time = $timePart + Date = $Matches['date'] + Message = $Matches['msg'] + Foreground = $fgBrush + Background = $bgBrush + }) + } + } - # Icon - $icon = New-Object System.Windows.Controls.TextBlock - $icon.Text = [string]$iconChar - $icon.FontFamily = New-Object System.Windows.Media.FontFamily("Segoe MDL2 Assets") - $icon.FontSize = 10 - $icon.Foreground = $iconBrush - $icon.VerticalAlignment = 'Center' - $icon.HorizontalAlignment = 'Center' - [System.Windows.Controls.Grid]::SetColumn($icon, 0) - $grid.Children.Add($icon) | Out-Null - - # Time - $tbTime = New-Object System.Windows.Controls.TextBlock - $tbTime.Text = $timePart - $tbTime.FontFamily = New-Object System.Windows.Media.FontFamily("Cascadia Code,Consolas,monospace") - $tbTime.FontSize = 11 - $tbTime.Foreground = $dimBrush - $tbTime.VerticalAlignment = 'Center' - $tbTime.Padding = [System.Windows.Thickness]::new(8, 0, 0, 0) - [System.Windows.Controls.Grid]::SetColumn($tbTime, 1) - $grid.Children.Add($tbTime) | Out-Null - - # Date - $tbDate = New-Object System.Windows.Controls.TextBlock - $tbDate.Text = $dateRaw - $tbDate.FontFamily = New-Object System.Windows.Media.FontFamily("Cascadia Code,Consolas,monospace") - $tbDate.FontSize = 11 - $tbDate.Foreground = $dimBrush - $tbDate.VerticalAlignment = 'Center' - $tbDate.Padding = [System.Windows.Thickness]::new(8, 0, 0, 0) - [System.Windows.Controls.Grid]::SetColumn($tbDate, 2) - $grid.Children.Add($tbDate) | Out-Null - - # Message - $tbMsg = New-Object System.Windows.Controls.TextBlock - $tbMsg.Text = $msg - $tbMsg.FontFamily = New-Object System.Windows.Media.FontFamily("Cascadia Code,Consolas,monospace") - $tbMsg.FontSize = 11 - $tbMsg.Foreground = $fgBrush - $tbMsg.TextWrapping = 'Wrap' - $tbMsg.VerticalAlignment = 'Center' - $tbMsg.Padding = [System.Windows.Thickness]::new(8, 0, 8, 0) - [System.Windows.Controls.Grid]::SetColumn($tbMsg, 3) - $grid.Children.Add($tbMsg) | Out-Null - - # ListBoxItem - $item = New-Object System.Windows.Controls.ListBoxItem - $item.Content = $grid - $item.Background = $bgBrush - $item.Padding = [System.Windows.Thickness]::new(0, 4, 0, 4) - $item.BorderThickness = [System.Windows.Thickness]::new(0) - $lst_LogEntries.Items.Add($item) | Out-Null - } - } - - $txt_LogStats.Text = "$($lst_LogEntries.Items.Count) entries | $infoCount info | $warnCount warnings | $errorCount errors" + $lst_LogEntries.ItemsSource = $entries + $txt_LogStats.Text = "$($entries.Count) entries | $infoCount info | $warnCount warnings | $errorCount errors" # Scroll to bottom (newest) - if ($lst_LogEntries.Items.Count -gt 0) { - $lst_LogEntries.ScrollIntoView($lst_LogEntries.Items[$lst_LogEntries.Items.Count - 1]) + if ($entries.Count -gt 0) { + $lst_LogEntries.ScrollIntoView($entries[$entries.Count - 1]) } } @@ -24933,7 +25481,7 @@ function Show-DATTelemetryConsentModal { $dlg.Owner = $Window $dlg.Width = 520 $dlg.SizeToContent = 'Height' - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -25414,7 +25962,7 @@ function Show-DATReleaseNotesDialog { $dlg.Owner = $Window $dlg.Width = 600 $dlg.Height = 520 - $dlg.Topmost = $true + $dlg.Topmost = $false $dlg.ResizeMode = 'NoResize' $dlg.ShowInTaskbar = $false @@ -25699,6 +26247,55 @@ try { Write-Host "Enabled (DAT API catalog)" -ForegroundColor Green } + # Restore OEM Selections (restrict supported manufacturers). + # Must run BEFORE the "Restore OEM selections" block below, which only re-checks + # main-page OEMs whose checkbox .IsEnabled -- disabling disallowed OEMs here keeps + # them out of the restored selection. + Write-Host " OEM Restrict : " -NoNewline -ForegroundColor DarkGray + $script:SuppressOEMRestrictionHandler = $true + try { + $restrictOn = ($null -ne $savedConfig.RestrictOEMs -and $savedConfig.RestrictOEMs -eq 1) + $script:chk_RestrictOEMs.IsChecked = $restrictOn + if (-not [string]::IsNullOrEmpty($savedConfig.AllowedOEMs)) { + $allowedSaved = @($savedConfig.AllowedOEMs -split ',' | ForEach-Object { $_.Trim() }) + foreach ($entry in $script:AllowOEMCheckboxes.GetEnumerator()) { + $entry.Value.IsChecked = ($allowedSaved -contains $entry.Key) + } + } + Update-DATOEMAvailability + if ($restrictOn) { + Write-Host "Enabled -- $((Get-DATAllowedOEMs) -join ', ')" -ForegroundColor Green + } else { + Write-Host "Disabled (all OEMs)" -ForegroundColor DarkYellow + } + } finally { + $script:SuppressOEMRestrictionHandler = $false + } + + # Restore Interface Scale. Applied before the window is shown so it opens at the + # correct size. Auto-fit defaults ON for new installs (no-op on displays large + # enough for the base 1200x780 window; shrinks to fit on smaller screens). + Write-Host " UI Scale : " -NoNewline -ForegroundColor DarkGray + $script:SuppressScaleHandler = $true + try { + $autoFit = if ($null -ne $savedConfig.InterfaceScaleAuto) { $savedConfig.InterfaceScaleAuto -eq 1 } else { $true } + $savedPct = if ($null -ne $savedConfig.InterfaceScale) { [int]$savedConfig.InterfaceScale } else { 100 } + if ($savedPct -lt 75) { $savedPct = 75 } + if ($savedPct -gt 150) { $savedPct = 150 } + $script:sld_InterfaceScale.Value = $savedPct + $script:chk_AutoFitScale.IsChecked = $autoFit + Update-DATInterfaceScaleFromControls + if ($autoFit) { + Write-Host "Auto-fit ($($script:txt_InterfaceScaleValue.Text))" -ForegroundColor Green + } else { + Write-Host "$($script:txt_InterfaceScaleValue.Text) (manual)" -ForegroundColor Green + } + } catch { + Write-Host "default (100%)" -ForegroundColor DarkYellow + } finally { + $script:SuppressScaleHandler = $false + } + # Restore Deploy All Devices Write-Host " Deploy All : " -NoNewline -ForegroundColor DarkGray if ($null -ne $savedConfig.DeployAllDevices -and $savedConfig.DeployAllDevices -eq 1) { @@ -26614,7 +27211,7 @@ if (Test-Path $logoPath) { # Read version from module manifest $manifestPath = Join-Path $AppRoot "Modules\DriverAutomationToolCore\DriverAutomationToolCore.psd1" -$script:versionString = "v10.2.4" +$script:versionString = "v10.2.5" if (Test-Path $manifestPath) { $manifestData = Import-PowerShellDataFile $manifestPath $ver = [version]$manifestData.ModuleVersion @@ -27654,7 +28251,7 @@ $Window.Add_ContentRendered({ $connDlg.Owner = $Window $connDlg.Width = 400 $connDlg.SizeToContent = 'Height' - $connDlg.Topmost = $true + $connDlg.Topmost = $false $connDlg.ResizeMode = 'NoResize' $connDlg.ShowInTaskbar = $false diff --git a/Driver Automation Tool/UI/MainWindow.xaml b/Driver Automation Tool/UI/MainWindow.xaml index eb84b22..417bbeb 100644 --- a/Driver Automation Tool/UI/MainWindow.xaml +++ b/Driver Automation Tool/UI/MainWindow.xaml @@ -1,4 +1,4 @@ - + + + + + + @@ -911,7 +983,7 @@ Background="{DynamicResource WindowBackground}" BorderBrush="{DynamicResource WindowBorder}" BorderThickness="1"> - + @@ -934,7 +1006,7 @@ Foreground="{DynamicResource AccentColor}" VerticalAlignment="Center" Margin="0,0,8,0"/> - @@ -1104,8 +1176,9 @@ - + + @@ -1187,6 +1260,18 @@ + + + + + + + + + + + + @@ -1805,14 +1914,48 @@ + + + - + - - - + + + + + + + + + + @@ -1885,10 +2028,11 @@ + - + @@ -1941,7 +2085,7 @@ - + @@ -2005,8 +2149,9 @@ - + + @@ -2085,6 +2230,8 @@ + + @@ -2139,14 +2286,24 @@ + + + - + + + @@ -2162,6 +2319,25 @@ Margin="8,0,0,0" IsEnabled="False"> + @@ -2201,6 +2377,7 @@ + @@ -2348,6 +2525,8 @@ StrokeStartLineCap="Flat" StrokeEndLineCap="Flat" Fill="Transparent"/> + @@ -2384,6 +2563,12 @@ + + + + + @@ -2496,6 +2681,25 @@ + + + + + + + + + + + @@ -2899,7 +3103,7 @@ - + @@ -2911,13 +3115,16 @@ Foreground="{DynamicResource WindowForeground}" Margin="0,0,0,12"> - + - + + + + - + @@ -2959,13 +3166,16 @@ Foreground="{DynamicResource WindowForeground}" Margin="0,0,0,12"> - + - + + + + - + @@ -2979,6 +3189,8 @@ StrokeStartLineCap="Flat" StrokeEndLineCap="Flat" Fill="Transparent"/> + @@ -3015,6 +3227,12 @@ + + + + + @@ -4675,8 +4893,9 @@ - + + @@ -4688,6 +4907,7 @@ + @@ -4696,6 +4916,8 @@ + @@ -4703,7 +4925,14 @@ - + @@ -5073,6 +5333,7 @@ + @@ -5408,6 +5669,62 @@ + + + + + + + + + + Adjust the overall size of the application interface. Use Auto-fit to let the tool pick the largest scale that fits your screen, or turn it off to set a custom scale. Helpful on small or high-resolution displays. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - Model catalogs and download URLs are sourced from the Driver Automation Tool API by default. If you prefer to download catalogs directly from individual OEM sources (Dell, HP, Lenovo, Acer, Microsoft), toggle this setting off. + Foreground="{DynamicResource WindowForeground}" Margin="0,0,0,12"> + - - The DAT API catalog is refreshed every 24 hours. When disabled, driver catalogs will be downloaded directly from each OEM during model refresh (Note: BIOS downloads will always use the API). + + + + + + + + + + + - + + - - - - - + + - - - - - - - - - - - - - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Removes all downloaded and extracted driver packages from the temporary storage path. This frees disk space without affecting packaged output. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Model catalogs and download URLs are sourced from the Driver Automation Tool API by default. If you prefer to download catalogs directly from individual OEM sources (Dell, HP, Lenovo, Acer, Microsoft), toggle this setting off. + + + The DAT API catalog is refreshed every 24 hours. When disabled, driver catalogs will be downloaded directly from each OEM during model refresh (Note: BIOS downloads will always use the API). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + By default, all supported hardware manufacturers are available throughout the tool. Enable this option to limit support to specific OEMs -- the manufacturer selector and the pre-flight source checks will then only include the OEMs you tick below. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5693,51 +6221,6 @@ - - - - - - - - Removes all downloaded and extracted driver packages from the temporary storage path. This frees disk space without affecting packaged output. - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -5768,80 +6251,115 @@ - + - + Foreground="{DynamicResource WindowForeground}" Margin="0,0,0,4"> + - - - - - - - - - - - - - - - - + + + + Select the engine used to create WIM packages. DISM is built into Windows but is single-threaded and slower. wimlib is an open-source alternative that supports multi-threaded compression for significantly faster packaging. 7-Zip also supports WIM creation if installed. + + + wimlib.net7-zip.org + - - + + + - + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + Controls compression when creating WIM packages. Fast (XPRESS) is recommended for most scenarios. Maximum (LZX) produces smaller files but is significantly slower. + + + + + + - - - - - - - - + + + + + + When enabled, the administrator can disable WIM compression for Configuration Manager, resulting in the package containing the full expanded driver package instead of a compressed WIM. It is recommended that WIM compression is used for faster task sequence deployments. + + + + + + + + + + + @@ -5954,182 +6472,131 @@ - + + + - + Foreground="{DynamicResource WindowForeground}" Margin="0,0,0,12"> + + - - - - Select the engine used to create WIM packages. DISM is built into Windows but is single-threaded and slower. wimlib is an open-source alternative that supports multi-threaded compression for significantly faster packaging. 7-Zip also supports WIM creation if installed. - - - wimlib.net7-zip.org - + + + + + + - - - - - - - - - - + + + + - - + + + + + + - + + - - + + + + + + + + - - - - - - - - - - - - - Controls compression when creating WIM packages. Fast (XPRESS) is recommended for most scenarios. Maximum (LZX) produces smaller files but is significantly slower. - - - - - - - - - - - - When enabled, the administrator can disable WIM compression for Configuration Manager, resulting in the package containing the full expanded driver package instead of a compressed WIM. It is recommended that WIM compression is used for faster task sequence deployments. - - - - - - - - - - - + + + + + + + + - + + + - + Foreground="{DynamicResource WindowForeground}" Margin="0,0,0,12"> + - + - - - - + - + - - + Margin="0,0,8,0"/> + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + @@ -6251,52 +6718,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -6367,49 +6789,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -6495,6 +6874,42 @@ + + + + + + + + + + + + + + + + + + + @@ -6886,7 +7301,7 @@ - Date: Mon, 31 Aug 2026 13:55:55 +0100 Subject: [PATCH 3/4] Version 10.2.5 is live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What's New & Fixed - OEM Support Panasonic support — added Panasonic as a supported manufacturer (catalog, model listing, packaging). Fujitsu support — added Fujitsu, including its session-gated catalog handling. ASUS support — ExpertBook/ExpertCenter commercial models via the DAT API catalog. Lenovo Custom Driver Pack detection — model now read from Win32_ComputerSystemProduct.Version and SKU from the first four characters of Win32_ComputerSystem.Model, matching the apply-driver script. (maurice-daly/DriverAutomationTool#921) - Intune package management — manage and delete existing BIOS/driver packages directly from the tool. - Force BIOS re-application — new -ForceDownload switch / SMSTSForceBIOSDownload task-sequence variable to download and flag the same BIOS version for flashing (e.g. recreating Dell recovery images). Opt-in; default unchanged. (MSEndpointMgr/ModernBIOSManagement#31) ASUS BIOS matching — each model now resolves its own BIOS version instead of every model collapsing onto the newest family BIOS (all showing 311). - Canonical manifest caching crash — fixed a Dell/Lenovo crash during canonical driver-manifest caching under PowerShell 5.1. (#913) Long-path pre-flight advisory — warns before Latest Drivers extraction when Windows long-path support is off and payloads (e.g. Intel DUPs) risk exceeding the path limit. (#912) - DCU graphics driver naming — Dell Command | Update graphics-driver revisions now canonicalise/collapse correctly. (#910) - Catalog OS matching — generic Windows 11 / win11 * catalog entries now appear for every Windows 11 build across all catalog-driven OEMs (10 vs 11 stay separated). - OEM Selections (Common Settings) — restrict which manufacturers are available app-wide via a toggle + multi-select. - Interface Scale (Common Settings) — 75–150% scaling slider plus auto-fit-to-screen for small/high-DPI displays. - Common Settings reorganised — the settings cards grouped into seven labelled sections. - Windows no longer float above other apps — modal dialogs stay above the tool's own window only, not the whole desktop. (maurice-daly/DriverAutomationTool#920) - Assorted UI tweaks and feature icons. - Shared desktop shortcut — the launcher now creates one shortcut on the public/all-users desktop instead of one per admin profile, cleans up stale per-user copies, and adds a CreateDesktopShortcut toggle / -NoShortcut switch. (maurice-daly/DriverAutomationTool#916) - Hardware inventory check — read-only WMI hardware-inventory class availability check. (#865) --- Driver Automation Tool/UI/MainApplication.ps1 | 11 +++- Driver Automation Tool/UI/MainWindow.xaml | 56 ++++++++++++++----- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/Driver Automation Tool/UI/MainApplication.ps1 b/Driver Automation Tool/UI/MainApplication.ps1 index e04d0d9..c04a23f 100644 --- a/Driver Automation Tool/UI/MainApplication.ps1 +++ b/Driver Automation Tool/UI/MainApplication.ps1 @@ -28522,6 +28522,10 @@ $script:WhatsNewFeatures = @( [pscustomobject]@{ Id = 'lenovo-latest-10.2.4'; Dot = 'dot_CommonSettings'; Parent = ''; Pill = 'pill_LenovoLatest'; Zone = 'zone_LenovoLatest'; Controls = @('cmb_HPDriverPackSource') } [pscustomobject]@{ Id = 'update-cadence-10.2.4'; Dot = 'dot_CommonSettings'; Parent = ''; Pill = 'pill_UpdateCadence'; Zone = 'zone_UpdateCadence'; Controls = @('cmb_LatestDriverCadence') } [pscustomobject]@{ Id = 'multi-deploy-10.2.4'; Dot = 'dot_IntuneOptions'; Parent = 'dot_IntuneSettings'; Pill = 'pill_MultiDeploy'; Zone = 'zone_MultiDeploy'; Controls = @('txt_DeployGroupSearch', 'btn_SearchDeployGroup', 'cmb_DeployGroupResults', 'btn_ClearDeployGroup') } + [pscustomobject]@{ Id = 'oem-selections-10.2.5'; Dot = 'dot_CommonSettings'; Parent = ''; Pill = 'pill_OEMSelections'; Zone = 'zone_OEMSelections'; Controls = @('chk_RestrictOEMs') } + [pscustomobject]@{ Id = 'interface-scale-10.2.5'; Dot = 'dot_CommonSettings'; Parent = ''; Pill = 'pill_InterfaceScale'; Zone = 'zone_InterfaceScale'; Controls = @('chk_AutoFitScale', 'sld_InterfaceScale') } + [pscustomobject]@{ Id = 'new-oems-10.2.5'; Dot = 'dot_ModelSelection'; Parent = ''; Pill = 'pill_NewOEMs'; Zone = 'zone_NewOEMs'; Controls = @('btn_OEMToggle') } + [pscustomobject]@{ Id = 'intune-pkgmgmt-10.2.5'; Dot = 'dot_IntunePackageMgmt'; Parent = 'dot_IntuneSettings'; Pill = 'pill_IntunePkgMgmt'; Zone = 'zone_IntunePkgMgmt'; Controls = @('nav_IntunePackageMgmt') } ) # Maps a wired element's x:Name to the feature id it clears, so plain (non-closure) handlers can @@ -28616,9 +28620,10 @@ try { Initialize-DATWhatsNew } catch { Write-DATActivityLog "What's New init fai # "What's New & Fixed" changelog. Each entry renders as a bold category lead-in plus a description # (no bullets), spaced apart. $script:WhatsNewReleaseItems = @( - [pscustomobject]@{ Category = 'Latest Drivers -- Lenovo'; Text = 'Lenovo now supports building Latest Drivers packages from the per-model update catalog, joining Dell and HP. Choose it under Driver Package Build Type.' } - [pscustomobject]@{ Category = 'Update Cadence'; Text = 'A new cadence control throttles how often a Latest Drivers pack is re-evaluated on repeat or scheduled runs -- Off, Daily, Weekly or Monthly -- so an unchanged driver set is not rebuilt every time.' } - [pscustomobject]@{ Category = 'Multi-Group Deployment'; Text = 'Auto-deployed Intune packages can now target one or more specific Entra security groups (for example a pilot ring plus a broad ring) instead of only All Devices.' } + [pscustomobject]@{ Category = 'OEM Selections'; Text = 'Restrict the tool to specific manufacturers from Common Settings. Turn on "Restrict to selected OEMs" and tick the vendors you support -- the model grid, manufacturer selector and pre-flight checks then show only those OEMs. Off by default (all OEMs available).' } + [pscustomobject]@{ Category = 'Interface Scale'; Text = 'Resize the whole application from Common Settings. Use auto-fit to scale to smaller or high-DPI screens automatically, or set a custom 75-150% scale with the slider.' } + [pscustomobject]@{ Category = 'More Manufacturers'; Text = 'ASUS, Panasonic and Fujitsu commercial models are now supported alongside Dell, HP, Lenovo, Microsoft and Acer -- select them from the OEM dropdown on the Model Selection page.' } + [pscustomobject]@{ Category = 'Intune Package Management'; Text = 'A dedicated Intune Package Management view lists your published driver and BIOS packages and lets you review and remove them without leaving the tool.' } ) function Get-DATWhatsNewModalShownVersion { diff --git a/Driver Automation Tool/UI/MainWindow.xaml b/Driver Automation Tool/UI/MainWindow.xaml index 417bbeb..dc9da1b 100644 --- a/Driver Automation Tool/UI/MainWindow.xaml +++ b/Driver Automation Tool/UI/MainWindow.xaml @@ -1074,7 +1074,10 @@ Foreground="{DynamicResource InputPlaceholder}"/>