Files
FFU/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1
T
rbalsleyMSFT c8042c6a75 Add Drivers.json path configuration to UI and core logic
- Introduced a new TextBox and Button in the UI for specifying the Drivers.json file path.
- Updated the UI layout to accommodate the new Drivers.json path input.
- Enhanced the core logic to set a default path for Drivers.json based on the Drivers folder.
- Implemented functionality to browse and select the Drivers.json file, allowing users to create or select an existing file.
2025-05-29 13:26:03 -07:00

3616 lines
188 KiB
PowerShell

# FFU UI Core Logic Module
# Contains non-UI specific helper functions, data retrieval, and core processing logic.
#Requires -Modules BitsTransfer
# Import shared modules
Import-Module "$PSScriptRoot\..\common\FFU.Common.Core.psm1"
Import-Module "$PSScriptRoot\..\common\FFU.Common.Winget.psm1"
Import-Module "$PSScriptRoot\..\common\FFU.Common.Drivers.psm1"
# --------------------------------------------------------------------------
# SECTION: Module Variables (Static Data & State)
# --------------------------------------------------------------------------
# Mutex for log file access is now in FFU.Common.Core.psm1
# Static data moved from UI_Helpers
$script:allowedFeatures = @(
"AppServerClient", "Client-DeviceLockdown", "Client-EmbeddedBootExp", "Client-EmbeddedLogon",
"Client-EmbeddedShellLauncher", "Client-KeyboardFilter", "Client-ProjFS", "Client-UnifiedWriteFilter",
"Containers", "Containers-DisposableClientVM", "Containers-HNS", "Containers-SDN", "DataCenterBridging",
"DirectoryServices-ADAM-Client", "DirectPlay", "HostGuardian", "HypervisorPlatform", "IIS-ApplicationDevelopment",
"IIS-ApplicationInit", "IIS-ASP", "IIS-ASPNET45", "IIS-BasicAuthentication", "IIS-CertProvider",
"IIS-CGI", "IIS-ClientCertificateMappingAuthentication", "IIS-CommonHttpFeatures", "IIS-CustomLogging",
"IIS-DefaultDocument", "IIS-DirectoryBrowsing", "IIS-DigestAuthentication", "IIS-ESP", "IIS-FTPServer",
"IIS-FTPExtensibility", "IIS-FTPSvc", "IIS-HealthAndDiagnostics", "IIS-HostableWebCore", "IIS-HttpCompressionDynamic",
"IIS-HttpCompressionStatic", "IIS-HttpErrors", "IIS-HttpLogging", "IIS-HttpRedirect", "IIS-HttpTracing",
"IIS-IPSecurity", "IIS-IIS6ManagementCompatibility", "IIS-IISCertificateMappingAuthentication",
"IIS-ISAPIExtensions", "IIS-ISAPIFilter", "IIS-LoggingLibraries", "IIS-ManagementConsole", "IIS-ManagementService",
"IIS-ManagementScriptingTools", "IIS-Metabase", "IIS-NetFxExtensibility", "IIS-NetFxExtensibility45",
"IIS-ODBCLogging", "IIS-Performance", "IIS-RequestFiltering", "IIS-RequestMonitor", "IIS-Security", "IIS-ServerSideIncludes",
"IIS-StaticContent", "IIS-URLAuthorization", "IIS-WebDAV", "IIS-WebServer", "IIS-WebServerManagementTools",
"IIS-WebServerRole", "IIS-WebSockets", "LegacyComponents", "MediaPlayback", "Microsoft-Hyper-V", "Microsoft-Hyper-V-All",
"Microsoft-Hyper-V-Hypervisor", "Microsoft-Hyper-V-Management-Clients", "Microsoft-Hyper-V-Management-PowerShell",
"Microsoft-Hyper-V-Services", "Microsoft-Windows-Subsystem-Linux", "MSMQ-ADIntegration", "MSMQ-Container", "MSMQ-DCOMProxy",
"MSMQ-HTTP", "MSMQ-Multicast", "MSMQ-Server", "MSMQ-Triggers", "MultiPoint-Connector", "MultiPoint-Connector-Services",
"MultiPoint-Tools", "NetFx3", "NetFx4-AdvSrvs", "NetFx4Extended-ASPNET45", "NFS-Administration", "Printing-Foundation-Features",
"Printing-Foundation-InternetPrinting-Client", "Printing-Foundation-LPDPrintService", "Printing-Foundation-LPRPortMonitor",
"Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "SearchEngine-Client-Package",
"ServicesForNFS-ClientOnly", "SimpleTCP", "SMB1Protocol", "SMB1Protocol-Client", "SMB1Protocol-Deprecation",
"SMB1Protocol-Server", "SmbDirect", "TFTP", "TelnetClient", "TIFFIFilter", "VirtualMachinePlatform", "WAS-ConfigurationAPI",
"WAS-NetFxEnvironment", "WAS-ProcessModel", "WAS-WindowsActivationService", "WCF-HTTP-Activation", "WCF-HTTP-Activation45",
"WCF-MSMQ-Activation45", "WCF-MSMQ-Activation", "WCF-NonHTTP-Activation", "WCF-Pipe-Activation45", "WCF-Services45",
"WCF-TCP-Activation45", "WCF-TCP-PortSharing45", "Windows-Defender-ApplicationGuard",
"Windows-Defender-Default-Definitions", "Windows-Identity-Foundation", "WindowsMediaPlayer", "WorkFolders-Client"
)
$script:skuList = @(
'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro',
'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations',
'Pro N for Workstations', 'Enterprise', 'Enterprise N', 'Standard',
'Standard (Desktop Experience)', 'Datacenter', 'Datacenter (Desktop Experience)'
)
$script:allowedLangs = @(
'ar-sa', 'bg-bg', 'cs-cz', 'da-dk', 'de-de', 'el-gr', 'en-gb', 'en-us', 'es-es', 'es-mx', 'et-ee',
'fi-fi', 'fr-ca', 'fr-fr', 'he-il', 'hr-hr', 'hu-hu', 'it-it', 'ja-jp', 'ko-kr', 'lt-lt', 'lv-lv',
'nb-no', 'nl-nl', 'pl-pl', 'pt-br', 'pt-pt', 'ro-ro', 'ru-ru', 'sk-sk', 'sl-si', 'sr-latn-rs',
'sv-se', 'th-th', 'tr-tr', 'uk-ua', 'zh-cn', 'zh-tw'
)
$script:allWindowsReleases = @(
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 },
[PSCustomObject]@{ Display = "Windows Server 2016"; Value = 2016 },
[PSCustomObject]@{ Display = "Windows Server 2019"; Value = 2019 },
[PSCustomObject]@{ Display = "Windows Server 2022"; Value = 2022 },
[PSCustomObject]@{ Display = "Windows Server 2025"; Value = 2025 }
)
$script:mctWindowsReleases = @(
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 }
)
$script:windowsVersionMap = @{
10 = @("22H2")
11 = @("22H2", "23H2", "24H2")
2016 = @("1607")
2019 = @("1809")
2022 = @("21H2")
2025 = @("24H2")
}
# --------------------------------------------------------------------------
# SECTION: Logging Function (Moved from UI_Helpers)
# --------------------------------------------------------------------------
# WriteLog function has been moved to FFU.Common.Core.psm1
# All WriteLog calls in this module will now use the common WriteLog.
# --------------------------------------------------------------------------
# SECTION: Data Retrieval Functions (Moved from UI_Helpers & BuildFFUVM_UI)
# --------------------------------------------------------------------------
# Function to get VM Switch names and associated IP addresses (Moved from UI_Helpers)
function Get-VMSwitchData {
[CmdletBinding()]
param() # No parameters needed
$switchMap = @{}
$switchNames = @()
try {
# Attempt to get Hyper-V virtual switches
# SilentlyContinue is used as Hyper-V role might not be installed
$allSwitches = Get-VMSwitch -ErrorAction SilentlyContinue
if ($null -ne $allSwitches) {
foreach ($sw in $allSwitches) {
# Construct a pattern to find the network adapter associated with the vSwitch
# The adapter name often includes the vSwitch name in parentheses
$adapterNamePattern = "*($($sw.Name))*"
# Attempt to find the network adapter associated with the vSwitch
# Select-Object -First 1 ensures we only get one adapter if multiple match (unlikely but possible)
$netAdapter = Get-NetAdapter -Name $adapterNamePattern -ErrorAction SilentlyContinue | Select-Object -First 1
if ($netAdapter) {
# Get IPv4 addresses for the found adapter's interface index
$netIPs = Get-NetIPAddress -InterfaceIndex $netAdapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
# Filter out Automatic Private IP Addressing (APIPA) addresses (169.254.x.x)
# and select the first valid IP found.
$validIP = $netIPs | Where-Object { $_.IPAddress -notlike '169.254.*' -and $_.IPAddress } | Select-Object -First 1
if ($validIP) {
# Store the valid IP address in the map with the switch name as the key
$switchMap[$sw.Name] = $validIP.IPAddress
# Log the found IP address for debugging/information using WriteLog
WriteLog "Found IP $($validIP.IPAddress) for vSwitch '$($sw.Name)' (Adapter: $($netAdapter.Name)). Adding to list."
# Add the switch name to the list ONLY if a valid IP was found
$switchNames += $sw.Name
}
else {
# Log if no valid non-APIPA IP was found for the adapter
WriteLog "No valid non-APIPA IPv4 address found for vSwitch '$($sw.Name)' (Adapter: $($netAdapter.Name)). Skipping from list."
# Do NOT add $sw.Name to $switchNames
}
}
else {
# Log if no matching network adapter was found for the vSwitch
WriteLog "Could not find a network adapter matching pattern '$adapterNamePattern' for vSwitch '$($sw.Name)'. Skipping from list."
# Do NOT add $sw.Name to $switchNames
}
}
}
else {
# Log if no vSwitches were found at all (Hyper-V might be disabled or not installed)
WriteLog "No Hyper-V virtual switches found on this system."
}
}
catch {
# Log any unexpected errors during the process
WriteLog "Error occurred while getting VM Switch data: $($_.Exception.Message)"
# Optionally re-throw or handle the error appropriately depending on requirements
# For UI stability, we might just log and return empty/partial data
}
# Return a custom object containing both the list of switch names and the map of names to IP addresses
return [PSCustomObject]@{
SwitchNames = $switchNames
SwitchMap = $switchMap
}
}
# Function to return the default settings and static lists (Moved from UI_Helpers)
function Get-WindowsSettingsDefaults {
[CmdletBinding()]
param()
return [PSCustomObject]@{
DefaultISOPath = ""
DefaultWindowsArch = "x64"
DefaultWindowsLang = "en-us"
DefaultWindowsSKU = "Pro"
DefaultMediaType = "Consumer"
DefaultOptionalFeatures = ""
DefaultProductKey = ""
AllowedFeatures = $script:allowedFeatures # Return the list
SkuList = $script:skuList
AllowedLanguages = $script:allowedLangs
AllowedArchitectures = @('x86', 'x64', 'arm64')
AllowedMediaTypes = @('Consumer', 'Business')
}
}
# Function to get the appropriate list of Windows Releases based on ISO path (Moved from UI_Helpers)
function Get-AvailableWindowsReleases {
[CmdletBinding()]
param(
[string]$IsoPath
)
if ([string]::IsNullOrEmpty($IsoPath)) {
return $script:mctWindowsReleases
}
else {
return $script:allWindowsReleases
}
}
# Function to get available Windows Versions for a given release and ISO path (Moved from UI_Helpers)
function Get-AvailableWindowsVersions {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int]$SelectedRelease,
[string]$IsoPath
)
$result = [PSCustomObject]@{
Versions = @()
DefaultVersion = $null
IsEnabled = $false
}
if (-not $script:windowsVersionMap.ContainsKey($SelectedRelease)) {
return $result # Return empty/disabled state
}
$validVersions = $script:windowsVersionMap[$SelectedRelease]
if ([string]::IsNullOrEmpty($IsoPath)) {
# Logic for when no ISO is specified (MCT scenario)
switch ($SelectedRelease) {
10 { $result.DefaultVersion = "22H2" }
11 { $result.DefaultVersion = "24H2" }
# Server versions typically require an ISO, but handle just in case
2016 { $result.DefaultVersion = "1607" }
2019 { $result.DefaultVersion = "1809" }
2022 { $result.DefaultVersion = "21H2" }
2025 { $result.DefaultVersion = "24H2" }
default { $result.DefaultVersion = $validVersions[0] }
}
$result.Versions = @($result.DefaultVersion) # Only the default is available/relevant
$result.IsEnabled = $false # Combo should be disabled
}
else {
# Logic for when an ISO is specified
$result.Versions = $validVersions
# Set default selection logic (e.g., latest for Win11)
if ($SelectedRelease -eq 11 -and $validVersions -contains "24H2") {
$result.DefaultVersion = "24H2"
}
elseif ($validVersions.Count -gt 0) {
$result.DefaultVersion = $validVersions[0] # Default to first in list otherwise
}
$result.IsEnabled = $true # Combo should be enabled
}
return $result
}
# Function to return general default settings for various UI elements (Moved from UI_Helpers)
function Get-GeneralDefaults {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$FFUDevelopmentPath # Base path needed to derive other paths
)
# Derive paths based on the main development path
$appsPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "Apps"
$driversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "Drivers"
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
$officePath = Join-Path -Path $appsPath -ChildPath "Office"
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
return [PSCustomObject]@{
# Build Tab Defaults
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
FFUCaptureLocation = $ffuCapturePath
ShareName = "FFUCaptureShare"
Username = "ffu_user"
BuildUSBDriveEnable = $false
CompactOS = $false
Optimize = $false
AllowVHDXCaching = $false
CreateCaptureMedia = $false
CreateDeploymentMedia = $false
AllowExternalHardDiskMedia = $false
PromptExternalHardDiskMedia = $false
SelectSpecificUSBDrives = $false
CopyAutopilot = $false # New
CopyUnattend = $false # New
CopyPPKG = $false # New
CleanupAppsISO = $false
CleanupCaptureISO = $false
CleanupDeployISO = $false
CleanupDrivers = $false
RemoveFFU = $false
RemoveApps = $false
RemoveUpdates = $false
# Hyper-V Settings Defaults
VMHostIPAddress = "" # Requires user input
DiskSizeGB = 30
MemoryGB = 4
Processors = 4
VMLocation = $vmLocationPath
VMNamePrefix = "_FFU"
LogicalSectorSize = 512
# Updates Tab Defaults
UpdateLatestCU = $false
UpdateLatestNet = $false
UpdateLatestDefender = $false
UpdateEdge = $false
UpdateOneDrive = $false
UpdateLatestMSRT = $false
UpdateLatestMicrocode = $false # Added for UpdateLatestMicrocode UI control
UpdatePreviewCU = $false
# Applications Tab Defaults
InstallApps = $false
ApplicationPath = $appsPath
AppListJsonPath = $appListJsonPath
InstallWingetApps = $false
BringYourOwnApps = $false
# M365 Apps/Office Tab Defaults
InstallOffice = $false
OfficePath = $officePath
CopyOfficeConfigXML = $false
OfficeConfigXMLFilePath = "" # Requires user input
# Drivers Tab Defaults
DriversFolder = $driversPath
PEDriversFolder = $peDriversPath
DriversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
DownloadDrivers = $false
InstallDrivers = $false
CopyDrivers = $false
CopyPEDrivers = $false
UpdateADK = $true # Added for UpdateADK UI control
}
}
# Function to get the list of Dell models from the catalog using XML streaming (Moved from UI_Helpers)
# Depends on private functions: Start-BitsTransferWithRetry, Invoke-Process
function Get-DellDriversModelList {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
[Parameter(Mandatory = $true)]
[string]$Make # Should be 'Dell'
)
# Define Dell specific drivers folder and catalog file names
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
$catalogUrl = if ($WindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
$uniqueModelNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$reader = $null # Initialize reader variable
try {
# Check if the Dell catalog XML exists and is recent
$downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
WriteLog "Dell Catalog XML found: $dellCatalogXML"
$dellCatalogCreationTime = (Get-Item $dellCatalogXML).CreationTime
WriteLog "Dell Catalog XML Creation time: $dellCatalogCreationTime"
# Check if the XML file is less than 7 days old
if (((Get-Date) - $dellCatalogCreationTime).TotalDays -lt 7) {
WriteLog "Using existing Dell Catalog XML (less than 7 days old): $dellCatalogXML"
$downloadCatalog = $false
}
else {
WriteLog "Existing Dell Catalog XML is older than 7 days: $dellCatalogXML"
}
}
else {
WriteLog "Dell Catalog XML not found: $dellCatalogXML"
}
if ($downloadCatalog) {
WriteLog "Attempting to download and extract Dell Catalog for Get-DellDriversModelList..."
# Ensure Dell drivers folder exists
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
WriteLog "Creating Dell drivers folder: $dellDriversFolder"
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
}
# Check URL accessibility
try {
$request = [System.Net.WebRequest]::Create($catalogUrl)
$request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
}
catch { throw "Dell Catalog URL '$catalogUrl' not accessible: $($_.Exception.Message)" }
# Remove existing files before download if they exist
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $dellCabFile"
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
WriteLog "Dell Catalog cab file downloaded to $dellCabFile"
WriteLog "Extracting Dell Catalog cab file '$dellCabFile' to '$dellCatalogXML'"
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
WriteLog "Dell Catalog cab file extracted to $dellCatalogXML"
# Delete the CAB file after extraction
WriteLog "Deleting Dell Catalog CAB file: $dellCabFile"
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
}
# Ensure the XML file exists before trying to read it
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
throw "Dell Catalog XML file '$dellCatalogXML' not found after download/check attempt."
}
# Use XmlReader for streaming from the XML file
$settings = New-Object System.Xml.XmlReaderSettings
$settings.IgnoreWhitespace = $true
$settings.IgnoreComments = $true
# $settings.DtdProcessing = [System.Xml.DtdProcessing]::Ignore # Optional
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
WriteLog "Starting XML stream parsing for Dell models from '$dellCatalogXML'..."
$isDriverComponent = $false
$isModelElement = $false
$modelDepth = -1 # Track depth to handle nested elements if needed
# Read through the XML stream node by node
while ($reader.Read()) {
switch ($reader.NodeType) {
([System.Xml.XmlNodeType]::Element) {
switch ($reader.Name) {
'SoftwareComponent' { $isDriverComponent = $false } # Reset flag
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $isDriverComponent = $true } }
'Model' { if ($isDriverComponent) { $isModelElement = $true; $modelDepth = $reader.Depth } }
}
}
([System.Xml.XmlNodeType]::CDATA) {
if ($isModelElement -and $isDriverComponent) {
$modelName = $reader.Value.Trim()
if (-not [string]::IsNullOrWhiteSpace($modelName)) { $uniqueModelNames.Add($modelName) | Out-Null }
$isModelElement = $false # Reset after reading CDATA
}
}
([System.Xml.XmlNodeType]::EndElement) {
switch ($reader.Name) {
'SoftwareComponent' { $isDriverComponent = $false; $isModelElement = $false; $modelDepth = -1 }
'Model' { if ($reader.Depth -eq $modelDepth) { $isModelElement = $false; $modelDepth = -1 } }
}
}
}
} # End while ($reader.Read())
WriteLog "Finished XML stream parsing. Found $($uniqueModelNames.Count) unique Dell models."
}
catch {
WriteLog "Error getting Dell models: $($_.Exception.ToString())" # Log full exception
throw "Failed to retrieve Dell models. Check log for details." # Re-throw for UI handling
}
finally {
# Ensure the reader is closed and disposed
if ($null -ne $reader) {
$reader.Dispose()
}
# REMOVED: Cleanup of temp folder - XML is kept in DriversFolder
# Ensure CAB file is deleted even if extraction failed but download succeeded
if (Test-Path -Path $dellCabFile) {
WriteLog "Cleaning up downloaded Dell CAB file: $dellCabFile"
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
}
}
# Convert HashSet to sorted list of PSCustomObjects
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($modelName in ($uniqueModelNames | Sort-Object)) {
$models.Add([PSCustomObject]@{
Make = $Make
Model = $modelName
# Link is not applicable here like for Microsoft
})
}
return $models
}
# Function to get the list of Microsoft Surface models
function Get-MicrosoftDriversModelList {
[CmdletBinding()]
param(
[hashtable]$Headers, # Pass necessary headers
[string]$UserAgent # Pass UserAgent
)
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
$models = @()
try {
WriteLog "Getting Surface driver information from $url"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
# Use passed-in UserAgent and Headers
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Complete"
WriteLog "Parsing web content for models and download links"
$html = $webContent.Content
$divPattern = '<div[^>]*class="selectable-content-options__option-content(?: ocHidden)?"[^>]*>(.*?)</div>'
$divMatches = [regex]::Matches($html, $divPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($divMatch in $divMatches) {
$divContent = $divMatch.Groups[1].Value
$tablePattern = '<table[^>]*>(.*?)</table>'
$tableMatches = [regex]::Matches($divContent, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($tableMatch in $tableMatches) {
$tableContent = $tableMatch.Groups[1].Value
$rowPattern = '<tr[^>]*>(.*?)</tr>'
$rowMatches = [regex]::Matches($tableContent, $rowPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellPattern = '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>'
$cellMatches = [regex]::Matches($rowContent, $cellPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -ge 2) {
$modelName = ($cellMatches[0].Groups[1].Value).Trim()
$secondTdContent = $cellMatches[1].Groups[1].Value.Trim()
# $linkPattern = '<a[^>]+href="([^"]+)"[^>]*>'
# Change linkPattern to match https://www.microsoft.com/download/details.aspx?id=
$linkPattern = '<a[^>]+href="(https://www\.microsoft\.com/download/details\.aspx\?id=\d+)"[^>]*>'
$linkMatch = [regex]::Match($secondTdContent, $linkPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($linkMatch.Success) {
$modelLink = $linkMatch.Groups[1].Value
}
else {
continue
}
$models += [PSCustomObject]@{
Make = 'Microsoft'
Model = $modelName
Link = $modelLink
}
}
}
}
}
WriteLog "Parsing complete. Found $($models.Count) models."
return $models
}
catch {
WriteLog "Error getting Microsoft models: $($_.Exception.Message)"
throw "Failed to retrieve Microsoft Surface models."
}
}
# Function to get the list of Lenovo models using the PSREF API
function Get-LenovoDriversModelList {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ModelSearchTerm, # User input for model/machine type
[Parameter(Mandatory = $true)]
[hashtable]$Headers,
[Parameter(Mandatory = $true)]
[string]$UserAgent
)
WriteLog "Querying Lenovo PSREF API for model/machine type: $ModelSearchTerm"
$url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($ModelSearchTerm))"
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
try {
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
$response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent -ErrorAction Stop
$VerbosePreference = $OriginalVerbosePreference
WriteLog "PSREF API query complete."
$jsonResponse = $response.Content | ConvertFrom-Json
if ($null -ne $jsonResponse.data -and $jsonResponse.data.Count -gt 0) {
foreach ($item in $jsonResponse.data) {
$productName = $item.ProductName
$machineTypes = $item.MachineType -split " / " # Split if multiple machine types are listed
foreach ($machineTypeRaw in $machineTypes) {
$machineType = $machineTypeRaw.Trim()
# Only add if machine type is not empty
if (-not [string]::IsNullOrWhiteSpace($machineType)) {
# Create the combined display string
$displayModel = "$productName ($machineType)"
# Add each combination as a separate entry
$models.Add([PSCustomObject]@{
Make = 'Lenovo'
Model = $displayModel # Combined string for display
ProductName = $productName # Original product name stored separately if needed
MachineType = $machineType # Machine type needed for catalog URL
})
}
else {
WriteLog "Skipping entry for product '$productName' due to missing machine type."
}
}
}
WriteLog "Found $($models.Count) potential model/machine type combinations for '$ModelSearchTerm'."
}
else {
WriteLog "No models found matching '$ModelSearchTerm' in Lenovo PSREF."
}
}
catch {
WriteLog "Error querying Lenovo PSREF API: $($_.Exception.Message)"
# Return empty list on error
}
# Return the list (sorting might be done in the UI layer if needed)
return $models
}
# Function to download and extract drivers for a specific Lenovo model (Background Task)
function Save-LenovoDriversTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Model (ProductName) and MachineType
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[hashtable]$Headers,
[Parameter(Mandatory = $true)]
[string]$UserAgent,
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false # New parameter for compression
)
# The Model property from the UI already contains the combined "ProductName (MachineType)" string
$identifier = $DriverItemData.Model
# We still need the machine type for the catalog URL
$machineType = $DriverItemData.MachineType
$make = "Lenovo"
# $identifier = "$($modelName) ($($machineType))" # No longer needed, use Model directly
$status = "Starting..."
$success = $false
# Define paths
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
# Use the identifier (which contains the model name and machine type) and sanitize it for the path
$modelPath = Join-Path -Path $makeDriversPath -ChildPath ($identifier -replace '[\\/:"*?<>|]', '_')
$tempDownloadPath = Join-Path -Path $makeDriversPath -ChildPath "_TEMP_$($machineType)_$($PID)" # Temp folder for catalog/package XMLs
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking..." }
try {
# 1. Check if drivers already exist for this model (final destination)
if (Test-Path -Path $modelPath -PathType Container) {
$folderSize = (Get-ChildItem -Path $modelPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$status = "Already downloaded"
WriteLog "Drivers for '$identifier' already exist in '$modelPath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $true }
}
else {
WriteLog "Driver folder '$modelPath' for '$identifier' exists but is empty/small. Re-downloading."
}
}
# Ensure base directories exist
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
if (-not (Test-Path -Path $tempDownloadPath)) { New-Item -Path $tempDownloadPath -ItemType Directory -Force | Out-Null }
# 2. Construct and Download Catalog URL
$modelRelease = $machineType + "_Win" + $WindowsRelease
$catalogUrl = "https://download.lenovo.com/catalog/$modelRelease.xml"
$lenovoCatalogXML = Join-Path -Path $tempDownloadPath -ChildPath "$modelRelease.xml"
$status = "Downloading Catalog..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
WriteLog "Downloading Lenovo Driver catalog for '$identifier' from $catalogUrl"
# Check URL accessibility first
try {
$request = [System.Net.WebRequest]::Create($catalogUrl); $request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
}
catch { throw "Lenovo Driver catalog URL is not accessible: $catalogUrl. Error: $($_.Exception.Message)" }
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $lenovoCatalogXML
WriteLog "Catalog download Complete: $lenovoCatalogXML"
# 3. Parse Catalog and Process Packages
$status = "Parsing Catalog..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
[xml]$xmlContent = Get-Content -Path $lenovoCatalogXML -Encoding UTF8
$packages = @($xmlContent.packages.package) # Ensure it's an array
$totalPackages = $packages.Count
$processedPackages = 0
WriteLog "Found $totalPackages packages in catalog for '$identifier'."
foreach ($package in $packages) {
$processedPackages++
$category = $package.category
$packageUrl = $package.location # URL to the package's *XML* file
# Skip BIOS/Firmware based on category
if ($category -like 'BIOS*' -or $category -like 'Firmware*') {
WriteLog "($processedPackages/$totalPackages) Skipping BIOS/Firmware package: $category"
continue
}
# Sanitize category for path
$categoryClean = $category -replace '[\\/:"*?<>|]', '_'
if ($categoryClean -eq 'Motherboard Devices Backplanes core chipset onboard video PCIe switches') {
$categoryClean = 'Motherboard Devices' # Shorten long category name
}
$packageName = [System.IO.Path]::GetFileName($packageUrl)
$packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName
$baseURL = $packageUrl -replace [regex]::Escape($packageName), "" # Base URL for the driver file
$status = "($processedPackages/$totalPackages) Getting package info..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Download the package XML
WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl"
try {
Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to download package XML '$packageUrl'. Skipping. Error: $($_.Exception.Message)"
continue # Skip this package
}
# Load and parse the package XML
[xml]$packageXmlContent = Get-Content -Path $packageXMLPath -Encoding UTF8
$packageType = $packageXmlContent.Package.PackageType.type
$packageTitleRaw = $packageXmlContent.Package.title.InnerText
# Filter out non-driver packages (Type 2 = Driver)
if ($packageType -ne 2) {
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' (Type: $packageType) - Not a driver."
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
continue
}
# Sanitize title for folder name
$packageTitle = $packageTitleRaw -replace '[\\/:"*?<>|]', '_' -replace ' - .*', ''
# Extract driver file name and extract command
$driverFileName = $null
$extractCommand = $null
try {
$driverFileName = $packageXmlContent.Package.Files.Installer.File.Name
$extractCommand = $packageXmlContent.Package.ExtractCommand
}
catch {
WriteLog "($processedPackages/$totalPackages) Error parsing package XML '$packageXMLPath' for file name/command. Skipping. Error: $($_.Exception.Message)"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
continue
}
# Skip if essential info is missing
if ([string]::IsNullOrWhiteSpace($driverFileName) -or [string]::IsNullOrWhiteSpace($extractCommand)) {
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' - Missing driver file name or extract command in XML."
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
continue
}
# Construct paths
$driverUrl = $baseURL + $driverFileName
$categoryPath = Join-Path -Path $modelPath -ChildPath $categoryClean
$downloadFolder = Join-Path -Path $categoryPath -ChildPath $packageTitle # Final destination subfolder
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverFileName -replace '\.exe$', '') # Extract to subfolder named after exe
# Check if already extracted
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
WriteLog "($processedPackages/$totalPackages) Driver '$packageTitleRaw' already extracted to '$extractFolder'. Skipping."
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
continue
}
}
# Ensure download folder exists
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
}
# Download the driver .exe
$status = "($processedPackages/$totalPackages) Downloading $packageTitle..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath"
try {
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath
WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName"
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to download driver '$driverUrl'. Skipping. Error: $($_.Exception.Message)"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
continue # Skip this driver
}
# --- Extraction Logic ---
$status = "($processedPackages/$totalPackages) Extracting $packageTitle..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Always use a temporary extraction path to avoid long path issues
$originalExtractFolder = $extractFolder # Store the originally intended final path
$extractionSucceeded = $false
$tempExtractBase = $null # Initialize
# Create randomized number for use with temp folder name
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
$tempExtractBase = Join-Path $env:TEMP "LenovoDriverExtract_$randomNumber"
$extractFolder = Join-Path $tempExtractBase ($driverFileName -replace '\.exe$', '') # Actual temp extraction folder
WriteLog "($processedPackages/$totalPackages) Using temporary extraction path: $extractFolder"
# Ensure the base temp directory exists
if (-not (Test-Path -Path $tempExtractBase)) {
New-Item -Path $tempExtractBase -ItemType Directory -Force | Out-Null
}
# Ensure the target temporary extraction folder exists
if (-not (Test-Path -Path $extractFolder)) {
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
}
# Modify the extract command to point to the temporary folder
$modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`""
WriteLog "($processedPackages/$totalPackages) Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
try {
Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null
WriteLog "($processedPackages/$totalPackages) Driver extracted to temporary path: $extractFolder"
$extractionSucceeded = $true
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to extract driver '$driverFilePath' to temporary path. Skipping. Error: $($_.Exception.Message)"
# Don't delete the downloaded exe yet if extraction fails
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
# Clean up temp folder if extraction failed
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
}
continue # Skip further processing for this driver
}
# --- Post-Extraction Handling (Move from Temp to Final Destination) ---
if ($extractionSucceeded) {
WriteLog "($processedPackages/$totalPackages) Performing post-extraction move from temp to final destination..."
try {
# Ensure the *original* final destination folder exists and is empty
if (Test-Path -Path $originalExtractFolder) {
WriteLog "($processedPackages/$totalPackages) Clearing existing final destination folder: $originalExtractFolder"
Get-ChildItem -Path $originalExtractFolder -Recurse | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
else {
WriteLog "($processedPackages/$totalPackages) Creating final destination folder: $originalExtractFolder"
New-Item -Path $originalExtractFolder -ItemType Directory -Force | Out-Null
}
# Get all items (files and folders) directly inside the temp extraction folder
$extractedItems = Get-ChildItem -Path $extractFolder -ErrorAction Stop
foreach ($item in $extractedItems) {
$itemName = $item.Name
$finalDestinationPath = $null
# Check if it's a directory containing 'Liteon'
if ($item.PSIsContainer -and $itemName -like '*Liteon*') {
# Rename Liteon folders with a random number suffix
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
$finalFolderName = "Liteon_$randomNumber"
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $finalFolderName
WriteLog "($processedPackages/$totalPackages) Moving Liteon folder '$itemName' to '$finalDestinationPath'"
}
else {
# For other files/folders, move them directly
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $itemName
WriteLog "($processedPackages/$totalPackages) Moving item '$itemName' to '$finalDestinationPath'"
}
# Perform the move
try {
Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to move item '$($item.FullName)' to '$finalDestinationPath'. Error: $($_.Exception.Message)"
# Decide if this should stop the whole process or just skip this item
# For now, we'll log and continue, but mark overall success as false
$extractionSucceeded = $false
}
} # End foreach ($item in $extractedItems)
if ($extractionSucceeded) {
WriteLog "($processedPackages/$totalPackages) All driver contents moved successfully from temp to final destination."
}
else {
WriteLog "($processedPackages/$totalPackages) Some driver contents failed to move. Check logs."
}
}
catch {
WriteLog "($processedPackages/$totalPackages) Error during post-extraction move: $($_.Exception.Message). Files might remain in temp."
$extractionSucceeded = $false # Mark as failed for cleanup logic below
}
finally {
# Clean up the base temporary directory regardless of move success/failure
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
WriteLog "($processedPackages/$totalPackages) Cleaning up temporary extraction base: $tempExtractBase"
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
# --- Final Cleanup ---
# Delete the downloaded .exe only if extraction AND move were successful
if ($extractionSucceeded) {
WriteLog "($processedPackages/$totalPackages) Deleting driver installation file: $driverFilePath"
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
}
else {
WriteLog "($processedPackages/$totalPackages) Keeping driver installation file due to extraction/move failure: $driverFilePath"
}
# Always delete the package XML
WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
} # End foreach package
# --- Compress to WIM if requested (after all drivers processed) ---
if ($CompressToWim) {
$status = "Compressing..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
$wimFileName = "$($identifier).wim" # Use sanitized identifier for filename
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
try {
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -ErrorAction Stop
if ($compressResult) {
WriteLog "Compression successful for '$identifier'."
$status = "Completed & Compressed"
}
else {
WriteLog "Compression failed for '$identifier'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
}
}
catch {
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
}
}
else {
$status = "Completed" # Final status if not compressing
}
# --- End Compression ---
$success = $true # Mark success as download/extract was okay
}
catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())" # Log full exception string
$success = $false
# Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Ensure return object is created even on error
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success }
}
finally {
# Clean up the main catalog XML and temp folder
WriteLog "Cleaning up temporary download folder: $tempDownloadPath"
Remove-Item -Path $tempDownloadPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Enqueue the final status (success or error) before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Return the final status
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success }
}
# Function to get the list of HP models from the PlatformList.xml
# Depends on private functions: Start-BitsTransferWithRetry, Invoke-Process
function Get-HPDriversModelList {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$Make # Expected to be 'HP'
)
WriteLog "Getting HP driver model list..."
$hpDriversFolder = Join-Path -Path $DriversFolder -ChildPath $Make
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
$platformListCab = Join-Path -Path $hpDriversFolder -ChildPath "platformList.cab"
$platformListXml = Join-Path -Path $hpDriversFolder -ChildPath "PlatformList.xml"
$modelList = [System.Collections.Generic.List[PSCustomObject]]::new()
try {
# Ensure HP drivers folder exists
if (-not (Test-Path -Path $hpDriversFolder)) {
WriteLog "Creating HP Drivers folder: $hpDriversFolder"
New-Item -Path $hpDriversFolder -ItemType Directory -Force | Out-Null
}
# Download PlatformList.cab if it doesn't exist or is outdated (e.g., older than 7 days)
if (-not (Test-Path -Path $platformListCab) -or ((Get-Date) - (Get-Item $platformListCab).LastWriteTime).TotalDays -gt 7) {
WriteLog "Downloading $platformListUrl to $platformListCab"
# Use the private helper function for download with retry
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
WriteLog "PlatformList.cab download complete."
# Force extraction if downloaded
if (Test-Path -Path $platformListXml) {
Remove-Item -Path $platformListXml -Force
}
}
else {
WriteLog "Using existing PlatformList.cab found at $platformListCab"
}
# Extract PlatformList.xml if it doesn't exist
if (-not (Test-Path -Path $platformListXml)) {
WriteLog "Expanding $platformListCab to $platformListXml"
# Use the private helper function for process invocation
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
WriteLog "PlatformList.xml extraction complete."
}
else {
WriteLog "Using existing PlatformList.xml found at $platformListXml"
}
# Parse the PlatformList.xml using XmlReader for efficiency
WriteLog "Parsing PlatformList.xml to extract HP models..."
$settings = New-Object System.Xml.XmlReaderSettings
$settings.Async = $false # Ensure synchronous reading
$reader = [System.Xml.XmlReader]::Create($platformListXml, $settings)
$uniqueModels = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
while ($reader.Read()) {
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') {
# Read the inner content of the Platform node
$platformReader = $reader.ReadSubtree()
while ($platformReader.Read()) {
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $platformReader.Name -eq 'ProductName') {
$modelName = $platformReader.ReadElementContentAsString()
if (-not [string]::IsNullOrWhiteSpace($modelName) -and $uniqueModels.Add($modelName)) {
# Add to list only if it's a new unique model
$modelList.Add([PSCustomObject]@{
Make = $Make
Model = $modelName
# Add other properties like SystemID if needed later, but keep it simple for now
})
}
}
}
$platformReader.Close()
}
}
$reader.Close()
WriteLog "Successfully parsed $($modelList.Count) unique HP models from PlatformList.xml."
}
catch {
WriteLog "Error getting HP driver model list: $($_.Exception.Message)"
# Optionally re-throw or return an empty list/error object
# For now, just return the potentially partially populated list or empty list
}
# Sort the list alphabetically by Model name before returning
return $modelList | Sort-Object -Property Model
}
# Function to get USB Drives (Moved from BuildFFUVM_UI.ps1)
function Get-USBDrives {
Get-WmiObject Win32_DiskDrive | Where-Object {
($_.MediaType -eq 'Removable Media' -or $_.MediaType -eq 'External hard disk media')
} | ForEach-Object {
$size = [math]::Round($_.Size / 1GB, 2)
$serialNumber = if ($_.SerialNumber) { $_.SerialNumber.Trim() } else { "N/A" }
@{
IsSelected = $false
Model = $_.Model.Trim()
SerialNumber = $serialNumber
Size = $size
DriveIndex = $_.Index
}
}
}
# --------------------------------------------------------------------------
# SECTION: Modern Folder Picker (Moved from BuildFFUVM_UI.ps1)
# --------------------------------------------------------------------------
# 1) Define a C# class that uses the correct GUIDs for IFileDialog, IFileOpenDialog, and FileOpenDialog,
# while omitting conflicting "GetResults/GetSelectedItems" from IFileDialog.
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class ModernFolderBrowser
{
// Flags for IFileDialog
[Flags]
private enum FileDialogOptions : uint
{
OverwritePrompt = 0x00000002,
StrictFileTypes = 0x00000004,
NoChangeDir = 0x00000008,
PickFolders = 0x00000020,
ForceFileSystem = 0x00000040,
AllNonStorageItems = 0x00000080,
NoValidate = 0x00000100,
AllowMultiSelect = 0x00000200,
PathMustExist = 0x00000800,
FileMustExist = 0x00001000,
CreatePrompt = 0x00002000,
ShareAware = 0x00004000,
NoReadOnlyReturn = 0x00008000,
NoTestFileCreate = 0x00010000,
DontAddToRecent = 0x02000000,
ForceShowHidden = 0x10000000
}
// IFileDialog (GUID from Windows SDK)
// - Omitting GetResults / GetSelectedItems to avoid overshadow.
[ComImport]
[Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileDialog
{
[PreserveSig]
int Show(IntPtr parent);
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
void SetFileTypeIndex(uint iFileType);
void GetFileTypeIndex(out uint piFileType);
void Advise(IntPtr pfde, out uint pdwCookie);
void Unadvise(uint dwCookie);
void SetOptions(FileDialogOptions fos);
void GetOptions(out FileDialogOptions pfos);
void SetDefaultFolder(IShellItem psi);
void SetFolder(IShellItem psi);
void GetFolder(out IShellItem ppsi);
void GetCurrentSelection(out IShellItem ppsi);
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetFileName(out IntPtr pszName);
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
void GetResult(out IShellItem ppsi);
void AddPlace(IShellItem psi, int fdap);
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
void Close(int hr);
void SetClientGuid(ref Guid guid);
void ClearClientData();
void SetFilter(IntPtr pFilter);
// NOTE: We intentionally do NOT define GetResults and GetSelectedItems here,
// because they cause overshadow warnings in IFileOpenDialog.
}
// IFileOpenDialog extends IFileDialog by adding 2 new methods with the same name,
// which otherwise cause overshadow warnings. We'll define them only here.
[ComImport]
[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog : IFileDialog
{
// These two come after the parent's vtable:
void GetResults(out IntPtr ppenum);
void GetSelectedItems(out IntPtr ppsai);
}
// The coclass for creating an IFileOpenDialog
[ComImport]
[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
private class FileOpenDialog
{
}
// IShellItem
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
void GetParent(out IShellItem ppsi);
void GetDisplayName(uint sigdnName, out IntPtr ppszName);
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
void Compare(IShellItem psi, uint hint, out int piOrder);
}
private const uint SIGDN_FILESYSPATH = 0x80058000;
public static string ShowDialog(string title, IntPtr parentHandle)
{
// Create COM dialog instance
IFileOpenDialog dialog = (IFileOpenDialog)(new FileOpenDialog());
// Get current options
FileDialogOptions opts;
dialog.GetOptions(out opts);
// Add flags for picking folders
opts |= FileDialogOptions.PickFolders | FileDialogOptions.PathMustExist | FileDialogOptions.ForceFileSystem;
dialog.SetOptions(opts);
// Set title
if (!string.IsNullOrEmpty(title))
{
dialog.SetTitle(title);
}
// Show the dialog
int hr = dialog.Show(parentHandle);
// 0 = S_OK. 1 or 0x800704C7 often means user canceled. Return null if so.
if (hr != 0)
{
if ((uint)hr == 0x800704C7 || hr == 1)
{
return null; // Canceled
}
else
{
Marshal.ThrowExceptionForHR(hr);
}
}
// Retrieve the selection (IShellItem)
IShellItem shellItem;
dialog.GetResult(out shellItem);
if (shellItem == null) return null;
// Convert to file system path
IntPtr pszPath = IntPtr.Zero;
shellItem.GetDisplayName(SIGDN_FILESYSPATH, out pszPath);
if (pszPath == IntPtr.Zero) return null;
string folderPath = Marshal.PtrToStringAuto(pszPath);
Marshal.FreeCoTaskMem(pszPath);
return folderPath;
}
}
"@ -Language CSharp
# 2) Define a PowerShell function that invokes our C# wrapper
function Show-ModernFolderPicker {
param(
[string]$Title = "Select a folder"
)
# For a simple test, pass IntPtr.Zero as the parent window handle
return [ModernFolderBrowser]::ShowDialog($Title, [IntPtr]::Zero)
}
# --------------------------------------------------------------------------
# SECTION: Winget Management Functions
# --------------------------------------------------------------------------
function Search-WingetPackagesPublic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Query
)
WriteLog "Searching Winget packages with query: '$Query'"
try {
# Call the shared Find-WinGetPackage function
$results = Find-WinGetPackage -Query $Query -ErrorAction Stop
WriteLog "Found $($results.Count) packages matching query '$Query'."
return $results
}
catch {
WriteLog "Error during Winget search: $($_.Exception.Message)"
# Return an empty array or throw, depending on desired UI behavior
return @()
}
}
function Test-WingetCLI {
[CmdletBinding()]
param()
$minVersion = [version]"1.8.1911"
# Check Winget CLI
$wingetCmd = Get-Command -Name winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
return @{
Version = "Not installed"
Status = "Not installed - Install from Microsoft Store"
}
}
# Get and check version
$wingetVersion = & winget.exe --version
if ($wingetVersion -match 'v?(\d+\.\d+.\d+)') {
$version = [version]$matches[1]
if ($version -lt $minVersion) {
return @{
Version = $version.ToString()
Status = "Update required - Install from Microsoft Store"
}
}
return @{
Version = $version.ToString()
Status = $version.ToString()
}
}
return @{
Version = "Unknown"
Status = "Version check failed"
}
}
function Install-WingetComponents {
[CmdletBinding()]
param(
# Add parameter to accept a script block for UI updates
[Parameter(Mandatory)]
[scriptblock]$UiUpdateCallback
)
$minVersion = [version]"1.8.1911"
$module = $null
try {
# Check and update PowerShell Module
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
if (-not $module -or $module.Version -lt $minVersion) {
WriteLog "Winget module needs install/update. Attempting..."
# Invoke the callback provided by the UI script to update status
# Note: We don't have the CLI version readily available here, pass a placeholder or adjust if needed.
& $UiUpdateCallback "Checking..." "Installing..."
# Store and modify PSGallery trust setting temporarily if needed
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
if ($PSGalleryTrust -eq 'Untrusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
}
# Install/Update the module
Install-Module -Name Microsoft.WinGet.Client -Force -Repository 'PSGallery'
# Restore original PSGallery trust setting
if ($PSGalleryTrust -eq 'Untrusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
}
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction Stop
}
return $module
}
catch {
Write-Error "Failed to install/update Winget PowerShell module: $_"
throw
}
}
# Winget Module Check Function (UI Version)
# Performs checks, triggers install if needed, and reports status back to the UI.
function Confirm-WingetInstallationUI {
[CmdletBinding()]
param(
# Callback for intermediate UI updates (e.g., "Installing...")
[Parameter(Mandatory)]
[scriptblock]$UiUpdateCallback
)
$minVersion = [version]"1.8.1911"
$result = [PSCustomObject]@{
Success = $false
Message = ""
CliVersion = "Unknown"
ModuleVersion = "Unknown"
NeedsUpdate = $false
UpdateAttempted = $false
}
try {
# Initial Check
WriteLog "Confirm-WingetInstallationUI: Starting checks..."
$cliStatus = Test-WingetCLI
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$result.CliVersion = $cliStatus.Version
$result.ModuleVersion = if ($null -ne $module) { $module.Version.ToString() } else { "Not installed" }
# Use callback for initial status display
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
# Determine if install/update is needed
$needsCliUpdate = $cliStatus.Status -notmatch '^\d+\.\d+\.\d+$' -or ([version]$cliStatus.Version -lt $minVersion)
$needsModuleUpdate = ($null -eq $module) -or ([version]$module.Version -lt $minVersion)
$result.NeedsUpdate = $needsCliUpdate -or $needsModuleUpdate
if ($result.NeedsUpdate) {
WriteLog "Confirm-WingetInstallationUI: Update needed. CLI Needs Update: $needsCliUpdate, Module Needs Update: $needsModuleUpdate"
$result.UpdateAttempted = $true
# Use callback to indicate installation attempt
& $UiUpdateCallback $result.CliVersion "Installing/Updating..."
# Call Install-WingetComponents (which also uses the callback internally)
# Note: Install-WingetComponents currently only installs the module.
# CLI installation/update might need separate handling or integration here if desired.
# For now, we focus on the module install triggered by this check.
$installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback
# Re-check status after attempt
WriteLog "Confirm-WingetInstallationUI: Re-checking status after update attempt..."
$cliStatus = Test-WingetCLI
$result.CliVersion = $cliStatus.Version
$result.ModuleVersion = if ($null -ne $installedModule) { $installedModule.Version } else { "Install Failed" }
# Use callback for final status display after update attempt
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
# Check if update was successful
$cliOk = $cliStatus.Status -match '^\d+\.\d+\.\d+$' -and ([version]$cliStatus.Version -ge $minVersion)
$moduleOk = ($null -ne $installedModule) -and ([version]$installedModule.Version -ge $minVersion)
$result.Success = $cliOk -and $moduleOk
$result.Message = if ($result.Success) { "Winget components installed/updated successfully." } else { "Winget component installation/update failed or is incomplete." }
WriteLog "Confirm-WingetInstallationUI: Update attempt finished. Success: $($result.Success). Message: $($result.Message)"
}
else {
# Already up-to-date
$result.Success = $true
$result.Message = "Winget components are up-to-date."
WriteLog "Confirm-WingetInstallationUI: Components already up-to-date."
}
}
catch {
$result.Success = $false
$result.Message = "Error during Winget check/install: $($_.Exception.Message)"
WriteLog "Confirm-WingetInstallationUI: Error - $($result.Message)"
# Use callback to show error state
& $UiUpdateCallback $result.CliVersion "Error"
}
return $result
}
# Function to handle downloading a winget application (Modified for ForEach-Object -Parallel)
function Start-WingetAppDownloadTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
[Parameter(Mandatory = $true)]
[string]$AppListJsonPath,
[Parameter(Mandatory = $true)]
[string]$AppsPath, # Pass necessary paths
[Parameter(Mandatory = $true)]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath,
[Parameter(Mandatory = $true)]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
)
$appName = $ApplicationItemData.Name
$appId = $ApplicationItemData.Id
$source = $ApplicationItemData.Source
$status = "Checking..." # Initial local status
$resultCode = -1 # Default to error/unknown
# Initial status update
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
# WriteLog "Apps Path: $($AppsPath)"
# WriteLog "AppList JSON Path: $($AppListJsonPath)"
# WriteLog "Windows Architecture: $($WindowsArch)"
# WriteLog "Orchestration Path: $($OrchestrationPath)"
try {
# Define paths
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
$appFound = $false # Flag to track if the app is found locally
# WriteLog "UserAppList Path: $($userAppListPath)"
# WriteLog "Checking for existing app in UserAppList.json and content folder."
# 1. Check UserAppList.json and content
if (Test-Path -Path $userAppListPath) {
# WriteLog "UserAppList.json found at $($userAppListPath). Checking for app entry."
try {
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
$userAppEntry = $userAppListContent | Where-Object { $_.Name -eq $appName }
if ($userAppEntry) {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Not Downloaded: App in $userAppListPath and found in $appFolder"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' in $userAppListPath and content exists in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
else {
$appFound = $true
$status = "Error: App in '$userAppListPath' but content missing/small in '$appFolder'. Copy content or remove from UserAppList.json."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
else {
$appFound = $true
$status = "Error: App in '$userAppListPath' but content folder '$appFolder' not found. Copy content or remove from UserAppList.json."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
}
catch {
WriteLog "Warning: Could not read or parse '$userAppListPath'. Error: $($_.Exception.Message)"
}
}
# 2. Check previous Winget download
if (-not $appFound) {
# Set environment variable for Get-Application checks (if needed by sub-functions)
# Set environment variables needed by Get-Application if called within this scope
# Note: ForEach-Object -Parallel handles variable scoping differently than Runspaces.
# Ensure Get-Application correctly accesses these if needed, potentially via $using: scope
# or by passing them as parameters if Get-Application # 2. Check previous Winget download and WinGetWin32Apps.json for duplicate entries
if (-not $appFound) {
$wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json"
if (Test-Path -Path $wingetWin32jsonFile) {
try {
$wingetAppsJson = Get-Content -Path $wingetWin32jsonFile -Raw | ConvertFrom-Json
# Check if app already exists in WinGetWin32Apps.json
$existingWin32Entry = $wingetAppsJson | Where-Object { $_.Name -eq $appName }
if ($existingWin32Entry) {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Not Downloaded: App already in $wingetWin32jsonFile and found in $appFolder"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' in WinGetWin32Apps.json and content exists in '$appFolder'. Skipping download to prevent duplicate entry."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
}
else {
# App entry exists in WinGetWin32Apps.json but folder is missing
$appFound = $true
$status = "Error: App in '$wingetWin32jsonFile' but content folder '$appFolder' not found. Remove entry from WinGetWin32Apps.json or restore content."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
}
catch {
WriteLog "Warning: Could not read or parse '$wingetWin32jsonFile'. Error: $($_.Exception.Message)"
}
}
}
# For now, assuming Get-Application uses $global variables set in the main script or $using: scope.
# $global:AppsPath = $AppsPath # Potentially redundant if set globally before parallel call
# $global:WindowsArch = $WindowsArch # Potentially redundant
# $global:orchestrationPath = $OrchestrationPath # Potentially redundant
$wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json"
if (Test-Path -Path $wingetWin32jsonFile) {
try {
$wingetAppsJson = Get-Content -Path $wingetWin32jsonFile -Raw | ConvertFrom-Json
$wingetApp = $wingetAppsJson | Where-Object { $_.Name -eq $appName }
if ($wingetApp) {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Not Downloaded: App in $wingetWin32jsonFile and found in $appFolder"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' via WinGetWin32Apps.json and content exists in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
}
}
}
catch {
WriteLog "Warning: Could not read or parse '$wingetWin32jsonFile'. Error: $($_.Exception.Message)"
}
}
}
# Check MSStore folder
if (-not $appFound -and (Test-Path -Path "$AppsPath\MSStore" -PathType Container)) {
$appFolder = Join-Path -Path "$AppsPath\MSStore" -ChildPath $appName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Already downloaded (MSStore)"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' content in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
}
}
# 3. If not found locally, add to AppList.json and download
if (-not $appFound) {
# Add to AppList.json
$appListContent = $null
$appListDir = Split-Path -Path $AppListJsonPath -Parent
if (-not (Test-Path -Path $appListDir -PathType Container)) {
New-Item -Path $appListDir -ItemType Directory -Force | Out-Null
}
if (Test-Path -Path $AppListJsonPath) {
try {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if (-not $appListContent.PSObject.Properties['apps']) {
$appListContent = @{ apps = @() }
}
}
catch {
WriteLog "Warning: Could not read or parse '$AppListJsonPath'. Creating new structure. Error: $($_.Exception.Message)"
$appListContent = @{ apps = @() }
}
}
else {
$appListContent = @{ apps = @() }
}
$appExistsInAppList = $false
if ($appListContent.apps) {
foreach ($app in $appListContent.apps) {
if ($app.id -eq $appId) {
$appExistsInAppList = $true
break
}
}
}
if (-not $appExistsInAppList) {
$newApp = @{ name = $appName; id = $appId; source = $source }
if (-not ($appListContent.apps -is [array])) { $appListContent.apps = @() }
$appListContent.apps += $newApp
try {
# Use a lock to prevent race conditions when writing to the same file
$lockName = "AppListJsonLock"
$lock = New-Object System.Threading.Mutex($false, $lockName)
try {
$lock.WaitOne() | Out-Null
# Re-read content inside lock to ensure latest version
if (Test-Path -Path $AppListJsonPath) {
$currentAppListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if (-not ($currentAppListContent.apps | Where-Object { $_.id -eq $appId })) {
$currentAppListContent.apps += $newApp
$currentAppListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Added '$appName' to '$AppListJsonPath'."
}
else {
WriteLog "'$appName' already exists in '$AppListJsonPath' (checked inside lock)."
}
}
else {
# File doesn't exist, write the initial content
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Created '$AppListJsonPath' and added '$appName'."
}
}
finally {
$lock.ReleaseMutex()
$lock.Dispose()
}
}
catch {
WriteLog "Error saving '$AppListJsonPath'. Error: $($_.Exception.Message)"
$status = "Error saving AppList.json"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
else {
WriteLog "'$appName' already exists in '$AppListJsonPath'."
}
# Proceed with download
$status = "Downloading..."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
# Ensure variables needed by Get-Application are accessible
# (Assuming they are available via $using: scope or global scope from main script)
# $global:AppsPath = $AppsPath # Potentially redundant
# $global:WindowsArch = $WindowsArch # Potentially redundant
# $global:orchestrationPath = $OrchestrationPath # Potentially redundant"
WriteLog "Orchestration Path: $($OrchestrationPath)"
if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) {
New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null
}
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
if ($source -eq "winget" -and -not (Test-Path -Path $win32Folder -PathType Container)) {
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
}
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
if ($source -eq "msstore" -and -not (Test-Path -Path $storeAppsFolder -PathType Container)) {
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
}
try {
# Call Get-Application (ensure it's available via dot-sourcing and uses $global:LogFile)
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -ErrorAction Stop
# Determine status based on result code
switch ($resultCode) {
0 { $status = "Downloaded successfully" }
1 { $status = "Error: No win32 app installers were found" }
2 { $status = "Silent install switch could not be found. Did not download." }
default { $status = "Downloaded with status: $resultCode" } # Should not happen with current Get-Application
}
# Remove app from AppList.json if silent install switch could not be found (resultCode 2)
if ($resultCode -eq 2) {
try {
if (Test-Path -Path $AppListJsonPath) {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if ($appListContent.apps) {
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
$appListContent.apps = $filteredApps
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to missing silent install switch."
}
}
}
catch {
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
}
}
}
catch {
$status = "Error: $($_.Exception.Message)"
WriteLog "Download error for $($appName): $($_.Exception.Message)"
$resultCode = 1 # Indicate error
# Enqueue error status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
# Remove app from AppList.json if publisher does not support download
if ($_.Exception.Message -match "does not support downloads by the publisher") {
try {
if (Test-Path -Path $AppListJsonPath) {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if ($appListContent.apps) {
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
$appListContent.apps = $filteredApps
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to publisher download restriction."
}
}
}
catch {
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
}
}
}
} # End if (-not $appFound)
}
catch {
$status = "Error: $($_.Exception.Message)"
WriteLog "Unexpected error in Start-WingetAppDownloadTask for $($appName): $($_.Exception.Message)"
$resultCode = 1 # Indicate error
# Enqueue error status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
finally {
# Ensure status is not empty before returning
if ([string]::IsNullOrEmpty($status)) {
$status = "Error: Unknown failure" # Provide a default error status
WriteLog "Status was empty for $appName ($appId), setting to default error."
if ($resultCode -ne 0 -and $resultCode -ne 1 -and $resultCode -ne 2) {
$resultCode = -1 # Ensure resultCode reflects an error if status was empty
}
# Enqueue the final (error) status if it was previously empty
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
elseif ($resultCode -ne 0) {
# Enqueue the final status if it's an error (already set in try/catch)
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
else {
# Enqueue the final success status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
}
# Prepare the return object as a Hashtable
$returnObject = @{ Id = $appId; Status = $status; ResultCode = $resultCode }
# Return the final status and result code as a Hashtable
return $returnObject
}
# Function to copy a single BYO application (Modified for ForEach-Object -Parallel)
function Start-CopyBYOApplicationTask {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
[Parameter(Mandatory)]
[string]$AppsPath, # Pass necessary path
[Parameter(Mandatory = $true)]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
# REMOVED: UI-related parameters
)
$priority = $ApplicationItemData.Priority
$appName = $ApplicationItemData.Name
$commandLine = $ApplicationItemData.CommandLine
$arguments = $ApplicationItemData.Arguments
$sourcePath = $ApplicationItemData.Source
$status = "Starting..." # Initial local status
$success = $false
# Initial status update
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
if ([string]::IsNullOrWhiteSpace($AppsPath)) {
$status = "Error: Apps Path not set"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Copy error for $($appName): Apps Path not set."
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
if ([string]::IsNullOrWhiteSpace($sourcePath)) {
$status = "No source specified"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
# This isn't an error, just nothing to do. Consider it success.
$success = $true
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
if (-not (Test-Path -Path $sourcePath -PathType Container)) {
$status = "Error: Source path not found"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Copy error for $($appName): Source path '$sourcePath' not found."
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
$win32BasePath = Join-Path -Path $AppsPath -ChildPath "Win32"
$destinationPath = Join-Path -Path $win32BasePath -ChildPath $appName
try {
# Check destination
if (Test-Path -Path $destinationPath -PathType Container) {
$folderSize = (Get-ChildItem -Path $destinationPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$status = "Already copied"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Skipping copy for $($appName): Destination '$destinationPath' exists and has content."
$success = $true
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
else {
WriteLog "Destination '$destinationPath' exists but is empty/small. Proceeding with copy."
}
}
# Ensure base directory exists
if (-not (Test-Path -Path $win32BasePath -PathType Container)) {
New-Item -Path $win32BasePath -ItemType Directory -Force | Out-Null
WriteLog "Created directory: $win32BasePath"
}
# Perform the copy
$status = "Copying..."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Copying '$sourcePath' to '$destinationPath'..."
Copy-Item -Path $sourcePath -Destination $destinationPath -Recurse -Force -ErrorAction Stop
$status = "Copied successfully"
$success = $true
WriteLog "Successfully copied '$appName' to '$destinationPath'."
# ------------------------------------------------------------------
# Update (or create) UserAppList.json with the copied application
# ------------------------------------------------------------------
try {
WriteLog "Updating UserAppList.json for '$appName'..."
$userAppListPath = Join-Path -Path $AppsPath -ChildPath 'UserAppList.json'
# Build the new entry
$newEntry = [pscustomobject]@{
Priority = $priority
Name = $appName
CommandLine = $commandLine
Arguments = $arguments
Source = $sourcePath
}
# Load existing list if present, ensuring it's always an array
if (Test-Path -Path $userAppListPath) {
try {
# Attempt to load and ensure it's an array
$appList = @(Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json -ErrorAction Stop)
}
catch {
WriteLog "Warning: Could not parse '$userAppListPath' or it's not a valid JSON array. Initializing as empty array. Error: $($_.Exception.Message)"
$appList = @() # Initialize as empty array on error
}
}
else {
$appList = @() # Initialize as empty array if file doesn't exist
}
# Ensure $appList is an array even if ConvertFrom-Json returned $null or a single object somehow
if ($null -eq $appList -or $appList -isnot [array]) {
# If it was a single object, wrap it in an array. Otherwise, start fresh.
$appList = if ($null -ne $appList) { @($appList) } else { @() }
}
# Skip adding if an entry with the same Name already exists
if (-not ($appList | Where-Object { $_.Name -eq $newEntry.Name })) {
# Now $appList is guaranteed to be an array, so += is safe
$appList += $newEntry
# Sort by Priority before saving
$sortedAppList = $appList | Sort-Object Priority
$sortedAppList | ConvertTo-Json -Depth 10 | Set-Content -Path $userAppListPath -Encoding UTF8
WriteLog "Added '$($newEntry.Name)' to '$userAppListPath'."
}
else {
WriteLog "'$appName' already exists in '$userAppListPath'."
}
}
catch {
WriteLog "Failed to update UserAppList.json for '$appName': $($_.Exception.Message)"
}
}
catch {
$errorMessage = $_.Exception.Message
$status = "Error: $($errorMessage)"
WriteLog "Copy error for $($appName): $($errorMessage)"
$success = $false
# Enqueue error status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
}
# Enqueue final success status if applicable
if ($success) {
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
}
# Return the final status
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
# Helper function to enqueue progress updates to the UI thread
function Invoke-ProgressUpdate {
param(
[Parameter(Mandatory)]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue,
[Parameter(Mandatory)]
[string]$Identifier,
[Parameter(Mandatory)]
[string]$Status
)
$ProgressQueue.Enqueue(@{ Identifier = $Identifier; Status = $Status })
}
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Pass data, not the UI object
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[hashtable]$Headers, # Pass necessary headers
[Parameter(Mandatory = $true)]
[string]$UserAgent, # Pass UserAgent
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false # New parameter for compression
# REMOVED: UI-related parameters
)
$modelName = $DriverItemData.Model
$modelLink = $DriverItemData.Link
$make = $DriverItemData.Make
$status = "Getting download link..." # Initial local status
$success = $false
# Initial status update
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
try {
# Check if drivers already exist for this model
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
if (Test-Path -Path $modelPath -PathType Container) {
$folderSize = (Get-ChildItem -Path $modelPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$status = "Already downloaded"
WriteLog "Drivers for '$modelName' already exist in '$modelPath'."
# Enqueue this status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Return success immediately
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $true }
}
else {
# Status is not set to error here, just log and continue
WriteLog "Driver folder '$modelPath' for '$modelName' exists but is empty or very small. Re-downloading."
# Allow the process to continue to re-download
}
}
### GET THE DOWNLOAD LINK
$status = "Getting download link..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Getting download page content for $modelName from $modelLink"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
# Use passed-in UserAgent and Headers
$downloadPageContent = Invoke-WebRequest -Uri $modelLink -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Complete"
$status = "Parsing download page..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Parsing download page for file"
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
if ($scriptMatch.Success) {
$scriptContent = $scriptMatch.Groups[1].Value
# $downloadFilePattern = '"name":"(.*?)",.*?"url":"(.*?)"'
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$win10Link = $null
$win10FileName = $null
$win11Link = $null
$win11FileName = $null
# Iterate through all matches to find potential Win10 and Win11 links
foreach ($downloadFile in $downloadFileMatches) {
$currentFileName = $downloadFile.Groups[1].Value
$fileUrl = $downloadFile.Groups[2].Value
if ($currentFileName -match "Win10") {
$win10Link = $fileUrl
$win10FileName = $currentFileName
WriteLog "Found Win10 link: $win10FileName"
}
elseif ($currentFileName -match "Win11") {
$win11Link = $fileUrl
$win11FileName = $currentFileName
WriteLog "Found Win11 link: $win11FileName"
}
}
# Decision logic to select the appropriate download link
$downloadLink = $null
$fileName = $null
$downloadedVersion = $null # Track which version we are actually downloading
if ($WindowsRelease -eq 10 -and $win10Link) {
$downloadLink = $win10Link
$fileName = $win10FileName
$downloadedVersion = 10
WriteLog "Exact match found for Win10."
}
elseif ($WindowsRelease -eq 11 -and $win11Link) {
$downloadLink = $win11Link
$fileName = $win11FileName
$downloadedVersion = 11
WriteLog "Exact match found for Win11."
}
elseif (-not $win10Link -and $win11Link) {
# Only Win11 available, regardless of $WindowsRelease
$downloadLink = $win11Link
$fileName = $win11FileName
$downloadedVersion = 11
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win11 driver."
}
elseif ($win10Link -and -not $win11Link) {
# Only Win10 available, regardless of $WindowsRelease
$downloadLink = $win10Link
$fileName = $win10FileName
$downloadedVersion = 10
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win10 driver."
}
# If both Win10 and Win11 links exist, but neither matches $WindowsRelease, $downloadLink remains $null.
### DOWNLOAD AND EXTRACT
if ($downloadLink) {
WriteLog "Selected Download Link for $modelName (Actual: Windows $downloadedVersion): $downloadLink"
$status = "Downloading (Win$downloadedVersion)..." # Update status message
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Create directories
if (-not (Test-Path -Path $DriversFolder)) {
WriteLog "Creating Drivers folder: $DriversFolder"
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
}
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
if (-Not (Test-Path -Path $modelPath)) {
WriteLog "Creating model folder: $modelPath"
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
}
else {
WriteLog "Model folder already exists: $modelPath"
}
### DOWNLOAD
$filePath = Join-Path -Path $makeDriversPath -ChildPath ($fileName)
WriteLog "Downloading $modelName driver file to $filePath"
# Use Start-BitsTransferWithRetry
Start-BitsTransferWithRetry -Source $downloadLink -Destination $filePath
WriteLog "Download complete"
$fileExtension = [System.IO.Path]::GetExtension($filePath).ToLower()
### EXTRACT
if ($fileExtension -eq ".msi") {
$status = "Extracting MSI..." # Set initial status
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Loop indefinitely to wait for mutex and handle MSIExec exit codes by catching errors
while ($true) {
$mutexClear = $false
# 1. Check Mutex
try {
$Mutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
$Mutex.Dispose()
$status = "Waiting for MSIExec..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Another MSIExec installer is running (Mutex Held). Waiting 5 seconds before rechecking for $modelName..."
Start-Sleep -Seconds 5
continue # Go back to start of while loop to re-check mutex
}
catch [System.Threading.WaitHandleCannotBeOpenedException] {
# Mutex is clear, proceed to extraction attempt
WriteLog "Mutex clear. Proceeding with MSI extraction attempt for $modelName."
$status = "Extracting MSI..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$mutexClear = $true
}
catch {
# Handle other potential errors when checking the mutex
WriteLog "Warning: Error checking MSIExec mutex for $($modelName): $_. Proceeding with caution."
$status = "Extracting MSI (Mutex Error)..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$mutexClear = $true # Proceed despite mutex error
}
# 2. Attempt Extraction (only if mutex was clear or error occurred during check)
if ($mutexClear) {
WriteLog "Extracting MSI file to $modelPath"
$arguments = "/a `"$($filePath)`" /qn TARGETDIR=`"$($modelPath)`""
try {
# Use Invoke-Process. It will throw an error for any non-zero exit code.
Invoke-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait $true -ErrorAction Stop | Out-Null
# If Invoke-Process succeeded (didn't throw), extraction is complete.
WriteLog "Extraction complete for $modelName (Exit Code 0)."
break # Success, exit the while loop
}
catch {
# Catch errors thrown by Invoke-Process
$errorMessage = $_.Exception.Message
if ($errorMessage -match 'Process exited with code 1618') {
# Specific handling for MSIExec busy error (1618)
WriteLog "MSIExec collision detected (Exit Code 1618) for $modelName. Retrying after wait..."
$status = "Waiting (MSI Collision)..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
Start-Sleep -Seconds 5 # Wait before retrying
continue # Go back to start of while loop to re-check mutex/retry
}
else {
# Handle other errors from Invoke-Process (e.g., file not found, permissions, other exit codes)
WriteLog "Error during MSI extraction process for $($modelName): $errorMessage"
throw # Re-throw the original exception to be caught by the outer try/catch
}
}
} # End if ($mutexClear)
} # End while ($true) - Loop runs until break or throw
}
elseif ($fileExtension -eq ".zip") {
$status = "Extracting ZIP..." # Set status before extraction
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Extracting ZIP file to $modelPath"
$ProgressPreference = 'SilentlyContinue'
Expand-Archive -Path $filePath -DestinationPath $modelPath -Force
$ProgressPreference = 'Continue'
WriteLog "Extraction complete"
}
else {
WriteLog "Unsupported file type: $fileExtension"
throw "Unsupported file type: $fileExtension"
}
# Remove downloaded file
$status = "Cleaning up..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Removing $filePath"
Remove-Item -Path $filePath -Force
WriteLog "Cleanup complete." # Changed log message slightly
# --- Compress to WIM if requested ---
if ($CompressToWim) {
$status = "Compressing..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$wimFileName = "$($modelName).wim"
# Corrected WIM path: WIM file should be next to the model folder, not inside it.
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
try {
# Use the function from the imported common module
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop
if ($compressResult) {
WriteLog "Compression successful for '$modelName'."
$status = "Completed & Compressed"
}
else {
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
# Don't mark overall success as false, download/extract succeeded
}
}
catch {
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
# Don't mark overall success as false
}
}
else {
$status = "Completed" # Final status if not compressing
}
# --- End Compression ---
$success = $true # Mark success as download/extract was okay
} # End if/elseif for .msi/.zip
else {
WriteLog "No suitable download link found for Windows $WindowsRelease (or fallback) for model $modelName."
$status = "Error: No Win$($WindowsRelease)/Fallback link"
$success = $false
}
}
else {
WriteLog "Failed to parse the download page for the driver file for model $modelName."
$status = "Error: Parse failed"
$success = $false
}
}
catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)"
$success = $false
# Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure return object is created even on error
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success }
}
# Enqueue the final status (success or error) before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Return the final status (this is still used by Receive-Job for final confirmation)
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success }
}
# Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel)
function Save-DellDriversTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Model property
[Parameter(Mandatory = $true)]
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
[Parameter(Mandatory = $true)]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[string]$DellCatalogXmlPath, # Path to the *existing* central XML catalog file
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false # New parameter for compression
# REMOVED: UI-related parameters, Catalog download/extract params
)
$modelName = $DriverItemData.Model
$make = "Dell" # Hardcoded for this task
$status = "Starting..." # Initial local status
$success = $false
# Initial status update
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
try {
# 1. Check if drivers already exist for this model (final destination)
if (Test-Path -Path $modelPath -PathType Container) {
$folderSize = (Get-ChildItem -Path $modelPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$status = "Already downloaded"
WriteLog "Drivers for '$modelName' already exist in '$modelPath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $true }
}
else {
WriteLog "Driver folder '$modelPath' for '$modelName' exists but is empty/small. Re-downloading."
}
}
# 2. REMOVED: Download and Extract Catalog - This is now done centrally in the UI script
# 3. Parse the *EXISTING* XML and Find Drivers for *this specific model*
$status = "Finding drivers..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Check if the provided XML path exists
if (-not (Test-Path -Path $DellCatalogXmlPath -PathType Leaf)) {
throw "Dell Catalog XML file not found at specified path: $DellCatalogXmlPath"
}
WriteLog "Parsing existing Dell Catalog XML for model '$modelName' from: $DellCatalogXmlPath"
[xml]$xmlContent = Get-Content -Path $DellCatalogXmlPath
# Check if manifest and baseLocation exist before accessing
if ($null -eq $xmlContent.manifest -or $null -eq $xmlContent.manifest.baseLocation) {
throw "Invalid Dell Catalog XML format: Missing 'manifest' or 'baseLocation' element in '$DellCatalogXmlPath'."
}
$baseLocation = "https://" + $xmlContent.manifest.baseLocation + "/"
$latestDrivers = @{} # Hashtable to store latest drivers for this model
# Ensure SoftwareComponent is iterable
$softwareComponents = @($xmlContent.Manifest.SoftwareComponent | Where-Object { $_.ComponentType.value -eq "DRVR" })
$modelSpecificDriversFound = $false
WriteLog "Searching $($softwareComponents.Count) DRVR components in '$DellCatalogXmlPath' for model '$modelName'..."
foreach ($component in $softwareComponents) {
# Check if SupportedSystems and Brand exist
if ($null -eq $component.SupportedSystems -or $null -eq $component.SupportedSystems.Brand) { continue }
# Ensure Model is iterable
$componentModels = @($component.SupportedSystems.Brand.Model)
if ($null -eq $componentModels) { continue }
$modelMatch = $false
foreach ($item in $componentModels) {
# Check if Display and its CDATA section exist before accessing
if ($null -ne $item.Display -and $null -ne $item.Display.'#cdata-section' -and $item.Display.'#cdata-section'.Trim() -eq $modelName) {
$modelMatch = $true
break
}
}
if ($modelMatch) {
# Model matches, now check OS compatibility
$validOS = $null
if ($null -ne $component.SupportedOperatingSystems) {
# Ensure OperatingSystem is always an array/collection
$osList = @($component.SupportedOperatingSystems.OperatingSystem)
if ($null -ne $osList) {
if ($WindowsRelease -le 11) {
# Client OS check
$validOS = $osList | Where-Object { $_.osArch -eq $WindowsArch } | Select-Object -First 1
}
else {
# Server OS check
$osCodePattern = switch ($WindowsRelease) {
2016 { "W14" } # Note: Dell uses W14 for Server 2016
2019 { "W19" }
2022 { "W22" }
2025 { "W25" }
default { "W22" } # Fallback, adjust as needed
}
$validOS = $osList | Where-Object { ($_.osArch -eq $WindowsArch) -and ($_.osCode -match $osCodePattern) } | Select-Object -First 1
}
}
}
if ($validOS) {
$modelSpecificDriversFound = $true # Mark that we found at least one relevant driver component
$driverPath = $component.path
$downloadUrl = $baseLocation + $driverPath
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
# Check if Name, Display, and CDATA exist
$name = "UnknownDriver" # Default name
if ($null -ne $component.Name -and $null -ne $component.Name.Display -and $null -ne $component.Name.Display.'#cdata-section') {
$name = $component.Name.Display.'#cdata-section'
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
}
# Check if Category, Display, and CDATA exist
$category = "Uncategorized" # Default category
if ($null -ne $component.Category -and $null -ne $component.Category.Display -and $null -ne $component.Category.Display.'#cdata-section') {
$category = $component.Category.Display.'#cdata-section'
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
}
$version = [version]"0.0" # Default version
if ($null -ne $component.vendorVersion) {
try { $version = [version]$component.vendorVersion } catch { WriteLog "Warning: Could not parse version '$($component.vendorVersion)' for driver '$name'. Using 0.0." }
}
$namePrefix = ($name -split '-')[0] # Group by prefix within category
# Store the latest version for each category/prefix combination
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
Name = $name
DownloadUrl = $downloadUrl
DriverFileName = $driverFileName
Version = $version
Category = $category
}
}
}
} # End if ($modelMatch)
} # End foreach ($component in $softwareComponents)
if (-not $modelSpecificDriversFound) {
$status = "No drivers found for OS"
WriteLog "No drivers found for model '$modelName' matching Windows Release '$WindowsRelease' and Arch '$WindowsArch' in '$DellCatalogXmlPath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Consider this success as the process completed, just no drivers to download
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $true }
}
# 4. Download and Extract Found Drivers (Logic remains largely the same)
$totalDriversToProcess = ($latestDrivers.Values | ForEach-Object { $_.Values.Count } | Measure-Object -Sum).Sum
$driversProcessed = 0
WriteLog "Found $totalDriversToProcess latest driver packages to download for $modelName."
# Ensure base directories exist before loop
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
foreach ($category in $latestDrivers.Keys) {
foreach ($driver in $latestDrivers[$category].Values) {
$driversProcessed++
$status = "Downloading $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$downloadFolder = Join-Path -Path $modelPath -ChildPath $driver.Category
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName
$extractFolder = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName.TrimEnd($driver.DriverFileName[-4..-1])
# Check if already extracted (more robust check)
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
WriteLog "Driver already extracted: $($driver.Name) in $extractFolder. Skipping."
continue # Skip to next driver
}
}
# Check if download file exists but extraction folder doesn't or is empty
if (Test-Path -Path $driverFilePath -PathType Leaf) {
WriteLog "Download file $($driver.DriverFileName) exists, but extraction folder '$extractFolder' is missing or empty. Will attempt extraction."
# Proceed to extraction logic below
}
else {
# Download the driver
WriteLog "Downloading driver: $($driver.Name) ($($driver.DriverFileName))"
if (-not (Test-Path -Path $downloadFolder)) {
WriteLog "Creating download folder: $downloadFolder"
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
}
WriteLog "Downloading from: $($driver.DownloadUrl) to $driverFilePath"
try {
Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath
WriteLog "Driver downloaded: $($driver.DriverFileName)"
}
catch {
WriteLog "Failed to download driver: $($driver.DownloadUrl). Error: $($_.Exception.Message). Skipping."
# Update status for this specific driver failure? Maybe too granular.
continue # Skip to next driver
}
}
# Extract the driver
$status = "Extracting $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure extraction folder exists before attempting extraction
if (-not (Test-Path -Path $extractFolder)) {
WriteLog "Creating extraction folder: $extractFolder"
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
}
# Dell uses /e to extact the entire DUP while /drivers to extract only the drivers
# In many cases /drivers will extract drivers for mutliple OS versions
# Which can cause many duplicate files and bloat your driver folder
# /e seems to be better and only extracts what is necessary and has less issues
# We will default to using /e, but will fall back to /drivers if content cannot be found
$arguments = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
$altArguments = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
$extractionSuccess = $false
try {
# Handle special cases (Chipset/Network) - Check if OS is Server
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem # Get OS info within the task scope
$isServer = $osInfo.Caption -match 'server'
# Chipset drivers may require killing child processes in some cases
if ($driver.Category -eq "Chipset") {
WriteLog "Extracting Chipset driver: $driverFilePath $arguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
Start-Sleep -Seconds 5 # Allow time for extraction
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
# Attempt to gracefully close child process if needed (logic from original script)
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
if ($childProcesses) {
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
WriteLog "Stopping child process for Chipset driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
# Network drivers on client OS may require killing child processes
elseif ($driver.Category -eq "Network" -and -not $isServer) {
WriteLog "Extracting Network driver: $driverFilePath $arguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
Start-Sleep -Seconds 5
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
if (-not $process.HasExited) {
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
if ($childProcesses) {
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
WriteLog "Stopping child process for Network driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
}
else {
WriteLog "Extracting driver: $driverFilePath $arguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
}
# Verify extraction (check if folder has content)
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
$extractionSuccess = $true
WriteLog "Extraction successful (Method 1) for $driverFilePath $arguments"
}
}
# If primary extraction failed or folder is empty, try alternative
if (-not $extractionSuccess) {
# $arguments = "/s /e=`"$extractFolder`""
# $altArguments = "/s /drivers=`"$extractFolder`""
WriteLog "Extraction with $arguments failed or resulted in empty folder for $driverFilePath. Retrying with $altArguments"
# Clean up potentially empty folder before retrying
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null # Recreate empty folder
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
# Verify extraction again
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
$extractionSuccess = $true
WriteLog "Extraction successful (Method 2) for $driverFilePath $altArguments"
}
}
}
}
catch {
WriteLog "Error during extraction process for $($driver.DriverFileName): $($_.Exception.Message). Trying alternative method."
# Try alternative method on any error during the first attempt block
try {
if (Test-Path -Path $extractFolder) {
# Clean up before retry if needed
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
# $arguments = "/s /e=`"$extractFolder`""
# $altArguments = "/s /drivers=`"$extractFolder`""
WriteLog "Extracting driver (Method 2): $driverFilePath $altArguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
# Verify extraction again
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
$extractionSuccess = $true
WriteLog "Extraction successful (Method 2) for $driverFilePath."
}
}
}
catch {
WriteLog "Alternative extraction method also failed for $($driver.DriverFileName): $($_.Exception.Message)."
# Extraction failed completely
}
}
# Cleanup downloaded file only if extraction was successful
if ($extractionSuccess) {
WriteLog "Deleting driver file: $driverFilePath"
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
WriteLog "Driver file deleted: $driverFilePath"
}
else {
WriteLog "Extraction failed for $($driver.DriverFileName). Downloaded file kept at $driverFilePath for inspection."
# Update status to indicate partial failure?
}
} # End foreach ($driver in $latestDrivers)
} # End foreach ($category in $latestDrivers)
# --- Compress to WIM if requested (after all drivers processed) ---
if ($CompressToWim) {
$status = "Compressing..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$wimFileName = "$($modelName).wim"
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
try {
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop
if ($compressResult) {
WriteLog "Compression successful for '$modelName'."
$status = "Completed & Compressed"
}
else {
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
}
}
catch {
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
}
}
else {
$status = "Completed" # Final status if not compressing
}
# --- End Compression ---
$success = $true # Mark success as download/extract was okay
}
catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Dell drivers for $($modelName): $($_.Exception.ToString())" # Log full exception string
$success = $false
# Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure return object is created even on error
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success }
}
# REMOVED: Finally block that cleaned up temp catalog files
# Enqueue the final status (success or error) before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Return the final status
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success }
}
# Function to download and extract drivers for a specific HP model (Designed for ForEach-Object -Parallel)
function Save-HPDriversTask {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Make, Model
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[ValidateSet("x64", "x86", "ARM64")]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[ValidateSet(10, 11)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[string]$WindowsVersion, # e.g., 22H2, 23H2, etc.
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false # New parameter for compression
)
$modelName = $DriverItemData.Model
$make = $DriverItemData.Make # Should be 'HP'
$identifier = $modelName # Unique identifier for progress updates
$hpDriversBaseFolder = Join-Path -Path $DriversFolder -ChildPath $make # Changed variable name for clarity
$platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml"
$modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath ($modelName -replace '[\\/:"*?<>|]', '_') # Sanitize model name for folder path
$finalStatus = "" # Initialize final status
$successState = $true # Assume success unless an operation fails
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking HP drivers for $modelName..." }
# Ensure the base HP folder exists
if (-not (Test-Path -Path $hpDriversBaseFolder -PathType Container)) {
try {
New-Item -Path $hpDriversBaseFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
WriteLog "Created base HP driver folder: $hpDriversBaseFolder"
}
catch {
$errMsg = "Failed to create base HP driver folder '$hpDriversBaseFolder': $($_.Exception.Message)"
WriteLog $errMsg
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Error: Create HP dir failed" }
return [PSCustomObject]@{ Identifier = $identifier; Status = "Error: Create HP dir failed"; Success = $false }
}
}
# Check if drivers already exist for this model
if (Test-Path -Path $modelSpecificFolder -PathType Container) {
WriteLog "HP drivers for '$identifier' already exist in '$modelSpecificFolder'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found existing HP drivers for $identifier. Verifying..." }
if ($CompressToWim) {
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim" # WIM in base HP folder, next to model folder
if (Test-Path -Path $wimFilePath -PathType Leaf) {
$finalStatus = "Already downloaded (WIM exists)"
WriteLog "WIM file $wimFilePath already exists for $identifier."
}
else {
WriteLog "WIM file $wimFilePath not found for $identifier. Attempting compression of existing folder '$modelSpecificFolder'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing HP drivers for $identifier..." }
try {
Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
$finalStatus = "Already downloaded & Compressed"
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
}
catch {
$errMsgForLog = "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
WriteLog $errMsgForLog
$finalStatus = "Already downloaded (Compression failed: $($_.Exception.Message.Split([Environment]::NewLine)[0]))"
# $successState = false # Keep true if folder exists, compression is secondary
}
}
}
else {
# Not compressing
$finalStatus = "Already downloaded"
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
return [PSCustomObject]@{
Identifier = $identifier
Status = $finalStatus
Success = $successState
}
}
# If folder does not exist, proceed with download and extraction
WriteLog "HP drivers for '$identifier' not found locally. Starting download process..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading HP drivers for $identifier..." }
try {
# Ensure PlatformList.xml exists (it should have been downloaded by Get-HPDriversModelList)
if (-not (Test-Path -Path $platformListXml)) {
# Attempt to download/extract it again if missing
WriteLog "PlatformList.xml not found for HP task, attempting download/extract..."
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
$platformListCab = Join-Path -Path $hpDriversBaseFolder -ChildPath "platformList.cab"
# Base folder already checked/created
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
if (Test-Path -Path $platformListXml) { Remove-Item -Path $platformListXml -Force }
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
WriteLog "PlatformList.xml download/extract complete for HP task."
if (-not (Test-Path -Path $platformListXml)) {
throw "Failed to obtain PlatformList.xml for HP driver task."
}
}
# Parse PlatformList.xml to find SystemID and OSReleaseID for the specific model
WriteLog "Parsing $platformListXml for model '$modelName' details..."
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.ProductName.'#text' -match "^$([regex]::Escape($modelName))$" } | Select-Object -First 1
if ($null -eq $platformNode) {
throw "Model '$modelName' not found in PlatformList.xml."
}
$systemID = $platformNode.SystemID
# --- OS Node Selection with Fallback Logic ---
$selectedOSNode = $null
$selectedOSVersion = $null
$selectedOSRelease = $WindowsRelease # Start with the requested release
# Complete list of Windows 11 feature-update versions (newest to oldest)
$win11Versions = @(
"24H2", "23H2", "22H2", "21H2"
)
# Complete list of Windows 10 feature-update versions (newest to oldest)
$win10Versions = @(
"22H2", "21H2", "21H1", "20H2", "2004", "1909", "1903", "1809", "1803", "1709", "1703", "1607", "1511", "1507"
)
# Helper function to find a matching OS node for a given release and version list
function Find-MatchingOSNode {
param(
[int]$ReleaseToSearch,
[array]$VersionsToSearch
)
$osNodesForRelease = $platformNode.OS | Where-Object {
($ReleaseToSearch -eq 11 -and $_.IsWindows11 -contains 'true') -or
($ReleaseToSearch -eq 10 -and ($null -eq $_.IsWindows11 -or $_.IsWindows11 -notcontains 'true'))
}
if ($null -eq $osNodesForRelease) { return $null }
foreach ($version in $VersionsToSearch) {
foreach ($osNode in $osNodesForRelease) {
$releaseIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
if ($releaseIDs -contains $version.ToLower()) {
return @{ Node = $osNode; Version = $version }
}
}
}
return $null
}
# 1. Attempt Exact Match (Requested Release and Version)
WriteLog "Attempting to find exact match for Win$($WindowsRelease) ($($WindowsVersion))..."
$exactMatchResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch @($WindowsVersion)
if ($null -ne $exactMatchResult) {
$selectedOSNode = $exactMatchResult.Node
$selectedOSVersion = $exactMatchResult.Version
WriteLog "Exact match found: Win$($selectedOSRelease) ($($selectedOSVersion))."
}
else {
WriteLog "Exact match not found for Win$($WindowsRelease) ($($WindowsVersion))."
# 2. Fallback: Same Release, Other Versions (Newest First)
WriteLog "Attempting fallback within Win$($WindowsRelease)..."
$versionsForCurrentRelease = if ($WindowsRelease -eq 11) { $win11Versions } else { $win10Versions }
$fallbackVersions = $versionsForCurrentRelease | Where-Object { $_ -ne $WindowsVersion }
$fallbackResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch $fallbackVersions
if ($null -ne $fallbackResult) {
$selectedOSNode = $fallbackResult.Node
$selectedOSVersion = $fallbackResult.Version
WriteLog "Fallback successful within Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
}
else {
WriteLog "Fallback within Win$($WindowsRelease) unsuccessful."
# 3. Fallback: Other Release, Versions (Newest First)
$otherRelease = if ($WindowsRelease -eq 11) { 10 } else { 11 }
WriteLog "Attempting fallback to Win$($otherRelease)..."
$versionsForOtherRelease = if ($otherRelease -eq 11) { $win11Versions } else { $win10Versions }
$otherFallbackResult = Find-MatchingOSNode -ReleaseToSearch $otherRelease -VersionsToSearch $versionsForOtherRelease
if ($null -ne $otherFallbackResult) {
$selectedOSNode = $otherFallbackResult.Node
$selectedOSVersion = $otherFallbackResult.Version
$selectedOSRelease = $otherRelease
WriteLog "Fallback successful to Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
}
else {
WriteLog "Fallback to Win$($otherRelease) also failed."
}
}
}
if ($null -eq $selectedOSNode) {
$allAvailableVersions = @()
if ($platformNode.OS) {
foreach ($osNode in $platformNode.OS) {
$osRel = if ($osNode.IsWindows11 -contains 'true') { 11 } else { 10 }
$relIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
foreach ($id in $relIDs) { $allAvailableVersions += "Win$($osRel) $($id)" }
}
}
$availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', '
if ([string]::IsNullOrWhiteSpace($availableVersionsString)) { $availableVersionsString = "None" }
throw "Could not find any suitable OS driver pack for model '$modelName' matching requested or fallback versions (Win$($WindowsRelease) $WindowsVersion). Available: $availableVersionsString"
}
$osReleaseIdFileName = $selectedOSNode.OSReleaseIdFileName -replace 'H', 'h'
WriteLog "Using SystemID: $systemID and OS Info: Win$($selectedOSRelease) ($($selectedOSVersion)) for '$modelName'"
$archSuffix = $WindowsArch -replace "^x", ""
$modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())"
$driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab"
$driverCabFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.cab" # Store in base HP folder
$driverXmlFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.xml" # Store in base HP folder
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading driver index..." }
WriteLog "Downloading HP Driver cab from $driverCabUrl to $driverCabFile"
Start-BitsTransferWithRetry -Source $driverCabUrl -Destination $driverCabFile -ErrorAction Stop
WriteLog "Expanding HP Driver cab $driverCabFile to $driverXmlFile"
if (Test-Path -Path $driverXmlFile) { Remove-Item -Path $driverXmlFile -Force }
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$driverCabFile`"", "`"$driverXmlFile`"") -ErrorAction Stop | Out-Null
WriteLog "Parsing driver XML $driverXmlFile"
[xml]$driverXmlContent = Get-Content -Path $driverXmlFile -Raw -Encoding UTF8 -ErrorAction Stop
$updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' }
$totalDrivers = ($updates | Measure-Object).Count
$downloadedCount = 0
WriteLog "Found $totalDrivers driver updates for $modelName."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found $totalDrivers drivers. Downloading..." }
if (-not (Test-Path -Path $modelSpecificFolder)) {
New-Item -Path $modelSpecificFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
foreach ($update in $updates) {
$driverName = $update.Name -replace '[\\/:"*?<>|]', '_'
$category = $update.Category -replace '[\\/:"*?<>|]', '_'
$version = $update.Version -replace '[\\/:"*?<>|]', '_'
$driverUrl = "https://$($update.URL)"
$driverFileName = Split-Path -Path $driverUrl -Leaf
$downloadFolder = Join-Path -Path $modelSpecificFolder -ChildPath $category
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', ''))
$downloadedCount++
$progressMsg = "($downloadedCount/$totalDrivers) Downloading $driverName..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
WriteLog "$progressMsg URL: $driverUrl"
if (Test-Path -Path $extractFolder) {
WriteLog "Driver already extracted to $extractFolder, skipping download."
continue
}
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -Path $downloadFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
WriteLog "Downloading driver to: $driverFilePath"
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop
WriteLog "Driver downloaded: $driverFilePath"
WriteLog "Creating extraction folder: $extractFolder"
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
$arguments = "/s /e /f `"$extractFolder`""
WriteLog "Extracting driver $driverFilePath with args: $arguments"
#DEBUG
# wrap $driverFilePath in quotes to handle spaces
# $driverFilePath = "`"$driverFilePath`""
WriteLog "Running HP Driver Extraction Command: $driverFilePath $arguments"
Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -ErrorAction Stop | Out-Null
# Start-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait -NoNewWindow -ErrorAction Stop | Out-Null
WriteLog "Driver extracted to: $extractFolder"
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
WriteLog "Deleted driver installer: $driverFilePath"
}
Remove-Item -Path $driverCabFile, $driverXmlFile -Force -ErrorAction SilentlyContinue
WriteLog "Cleaned up driver cab and xml files for $modelName"
$finalStatus = "Completed"
if ($CompressToWim) {
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing..." }
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim"
WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..."
try {
Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
WriteLog "Compression successful for '$identifier'."
$finalStatus = "Completed & Compressed"
}
catch {
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
$finalStatus = "Completed (Compression Failed)"
}
}
$successState = $true
}
catch {
$errorMessage = "Error saving HP drivers for $($modelName): $($_.Exception.Message)"
WriteLog $errorMessage
$finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])"
$successState = $false
if (Test-Path -Path $modelSpecificFolder -PathType Container) {
WriteLog "Attempting to remove partially created folder $modelSpecificFolder due to error."
Remove-Item -Path $modelSpecificFolder -Recurse -Force -ErrorAction SilentlyContinue
}
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
return [PSCustomObject]@{ Identifier = $identifier; Status = $finalStatus; Success = $successState }
}
# Function to update status of a specific item in a ListView
function Update-ListViewItemStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$WindowObject, # Changed type to [object]
[Parameter(Mandatory)]
[object]$ListView, # Changed type to [object]
[Parameter(Mandatory)]
[string]$IdentifierProperty,
[Parameter(Mandatory)]
[string]$IdentifierValue,
[Parameter(Mandatory)]
[string]$StatusProperty,
[Parameter(Mandatory)]
[string]$StatusValue
)
# Ensure we are in UI mode and objects are of correct WPF types
if ($WindowObject -is [System.Windows.Window] -and $ListView -is [System.Windows.Controls.ListView]) {
# Directly update UI elements as this function is now called on the UI thread
try {
$itemToUpdate = $ListView.Items | Where-Object { $_.$IdentifierProperty -eq $IdentifierValue } | Select-Object -First 1
if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change
}
else {
# Log if item not found (for debugging)
WriteLog "Update-ListViewItemStatus: Item with $IdentifierProperty '$IdentifierValue' not found in ListView."
}
}
catch {
WriteLog "Update-ListViewItemStatus: Error updating ListView: $($_.Exception.Message)"
}
} # End of if ($WindowObject -is [System.Windows.Window]...)
else {
# Log if called in non-UI mode or with incorrect types (should not happen if Invoke-ParallelProcessing $isUiMode is correct)
WriteLog "Update-ListViewItemStatus: Skipped UI update for $IdentifierValue due to non-UI mode or incorrect object types."
}
}
# Function to update overall progress bar and status text label
function Update-OverallProgress {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$WindowObject, # Changed type to [object]
[Parameter(Mandatory)]
[int]$CompletedCount,
[Parameter(Mandatory)]
[int]$TotalCount,
[Parameter(Mandatory)]
[string]$StatusText,
[Parameter(Mandatory)]
[string]$ProgressBarName,
[Parameter(Mandatory)]
[string]$StatusLabelName
)
# Ensure we are in UI mode and WindowObject is of correct WPF type
if ($WindowObject -is [System.Windows.Window]) {
# Directly update UI elements as this function is now called on the UI thread
try {
# Find controls by name using the $WindowObject
$pb = $WindowObject.FindName($ProgressBarName)
$lbl = $WindowObject.FindName($StatusLabelName)
if ($null -eq $pb) {
WriteLog "Update-OverallProgress: ProgressBar '$ProgressBarName' not found."
return
}
if ($null -eq $lbl) {
WriteLog "Update-OverallProgress: StatusLabel '$StatusLabelName' not found."
return
}
# Update the progress bar
if ($TotalCount -gt 0) {
$percentComplete = ($CompletedCount / $TotalCount) * 100
$pb.Value = $percentComplete
}
else {
$pb.Value = 0
}
# Update the status label
$lbl.Text = $StatusText
}
catch {
WriteLog "Update-OverallProgress: Error updating progress: $($_.Exception.Message)"
}
} # End of if ($WindowObject -is [System.Windows.Window])
else {
# Log if called in non-UI mode or with incorrect types
WriteLog "Update-OverallProgress: Skipped UI update ($StatusText) due to non-UI mode or incorrect WindowObject type."
}
}
# Reusable function to invoke parallel processing with UI updates
function Invoke-ParallelProcessing {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[array]$ItemsToProcess,
[Parameter(Mandatory = $false)]
[object]$ListViewControl = $null, # Changed type to [object]
[Parameter(Mandatory = $false)]
[string]$IdentifierProperty = 'Identifier',
[Parameter(Mandatory = $false)]
[string]$StatusProperty = 'Status',
[Parameter(Mandatory)]
[ValidateSet('WingetDownload', 'CopyBYO', 'DownloadDriverByMake')]
[string]$TaskType,
[Parameter()]
[hashtable]$TaskArguments = @{},
[Parameter(Mandatory = $false)]
[string]$CompletedStatusText = "Completed",
[Parameter(Mandatory = $false)]
[string]$ErrorStatusPrefix = "Error: ",
[Parameter(Mandatory = $false)]
[object]$WindowObject = $null, # Changed type to [object]
[Parameter(Mandatory = $false)]
[string]$MainThreadLogPath = $null # New parameter for the log path
)
# Check if running in UI mode by verifying the types of the passed objects
$isUiMode = ($null -ne $WindowObject -and $WindowObject -is [System.Windows.Window] -and $null -ne $ListViewControl -and $ListViewControl -is [System.Windows.Controls.ListView])
if ($isUiMode) {
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items in ListView '$($ListViewControl.Name)'."
}
else {
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items (non-UI mode)."
}
$resultsCollection = [System.Collections.Generic.List[object]]::new()
$jobs = @()
$results = @() # Store results from jobs
$totalItems = $ItemsToProcess.Count
$processedCount = 0
# Create a thread-safe queue for intermediate progress updates
$progressQueue = New-Object System.Collections.Concurrent.ConcurrentQueue[hashtable]
# Define common paths locally within this function's scope
$coreModulePath = $MyInvocation.MyCommand.Module.Path
$coreModuleDirectory = Split-Path -Path $coreModulePath -Parent
$ffuDevelopmentRoot = Split-Path -Path $coreModuleDirectory -Parent
# Paths to other modules needed by the parallel threads
$commonCoreModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "common\FFU.Common.Core.psm1"
$commonWingetModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "common\FFU.Common.Winget.psm1"
$commonDriversModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "common\FFU.Common.Drivers.psm1"
# Use the explicitly passed MainThreadLogPath for the parallel jobs.
# If not provided (e.g., older calls or direct module use without this param), it might be null.
# The parallel job's Set-CommonCoreLogPath will handle null/empty paths by warning.
$currentLogFilePathForJob = $MainThreadLogPath
$jobScopeVariables = $TaskArguments.Clone()
$jobScopeVariables['_thisCoreModulePath'] = $coreModulePath # Path to FFUUI.Core.psm1 itself
$jobScopeVariables['_commonCoreModulePath'] = $commonCoreModulePathForJob
$jobScopeVariables['_commonWingetModulePath'] = $commonWingetModulePathForJob
$jobScopeVariables['_commonDriversModulePath'] = $commonDriversModulePathForJob
$jobScopeVariables['_currentLogFilePathForJob'] = $currentLogFilePathForJob # Pass the determined log path
$jobScopeVariables['_progressQueue'] = $progressQueue
# The $TaskScriptBlock parameter is already a local variable in this scope
# Initial UI update needs to happen *before* starting the jobs
# Update all items to a static "Processing..." status
if ($isUiMode) {
# Use the new $isUiMode flag
foreach ($item in $ItemsToProcess) {
$identifierValue = $item.$IdentifierProperty
$initialStaticStatus = "Queued..."
try {
# Update the UI on the main thread to show the item is being queued for processing
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifierValue -StatusProperty $StatusProperty -StatusValue $initialStaticStatus
})
}
catch {
WriteLog "Error setting initial status for item '$identifierValue': $($_.Exception.Message)"
}
}
}
# Queue items and start jobs using the pipeline and $using:
try {
# $jobScopeVariables and $TaskType are local here
# Inside the -Parallel scriptblock, we access them with $using:
$jobs = $ItemsToProcess | ForEach-Object -Parallel {
# Access the current item via pipeline variable $_
$currentItem = $_
# Access the combined arguments hashtable from the calling scope using $using:
$localJobArgs = $using:jobScopeVariables
# Access the task type string from the calling scope using $using:
$localTaskType = $using:TaskType
# Access the progress queue using $using:
$localProgressQueue = $localJobArgs['_progressQueue']
# Initialize result hashtable
$taskResult = $null
$resultIdentifier = $null
$resultStatus = "Error: Task type '$localTaskType' not recognized"
$resultCode = 1 # Default to error
try {
# Import the common core module first
Import-Module $localJobArgs['_commonCoreModulePath']
# Set the log path for this parallel thread
Set-CommonCoreLogPath -Path $localJobArgs['_currentLogFilePathForJob']
# Set other global variables if tasks rely on them (prefer passing as parameters)
$global:AppsPath = $localJobArgs['AppsPath']
$global:WindowsArch = $localJobArgs['WindowsArch']
if ($localJobArgs.ContainsKey('OrchestrationPath')) {
$global:OrchestrationPath = $localJobArgs['OrchestrationPath']
}
# Import other necessary modules. Their WriteLog calls will use the path set above.
Import-Module $localJobArgs['_thisCoreModulePath'] # FFUUI.Core.psm1
Import-Module $localJobArgs['_commonWingetModulePath']
Import-Module $localJobArgs['_commonDriversModulePath']
# Execute the appropriate background task based on $localTaskType
switch ($localTaskType) {
'WingetDownload' {
# Pass the progress queue to the task function
$taskResult = Start-WingetAppDownloadTask -ApplicationItemData $currentItem `
-AppListJsonPath $localJobArgs['AppListJsonPath'] `
-AppsPath $localJobArgs['AppsPath'] `
-WindowsArch $localJobArgs['WindowsArch'] `
-OrchestrationPath $localJobArgs['OrchestrationPath'] `
-ProgressQueue $localProgressQueue
if ($null -ne $taskResult) {
$resultIdentifier = $taskResult.Id
$resultStatus = $taskResult.Status
$resultCode = $taskResult.ResultCode
}
else {
$resultIdentifier = $currentItem.Id # Fallback
$resultStatus = "Error: WingetDownload task returned null"
$resultCode = 1
WriteLog $resultStatus
}
}
'CopyBYO' {
# Pass the progress queue to the task function
$taskResult = Start-CopyBYOApplicationTask -ApplicationItemData $currentItem `
-AppsPath $localJobArgs['AppsPath'] `
-ProgressQueue $localProgressQueue
if ($null -ne $taskResult) {
$resultIdentifier = $taskResult.Name
$resultStatus = $taskResult.Status
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
}
else {
$resultIdentifier = $currentItem.Name # Fallback
$resultStatus = "Error: CopyBYO task returned null"
$resultCode = 1
WriteLog $resultStatus
}
}
'DownloadDriverByMake' {
$make = $currentItem.Make
# Ensure $resultIdentifier is set before the switch, using the main IdentifierProperty
# This is crucial if a Make is unsupported or a task fails to return a result.
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
switch ($make) {
'Microsoft' {
$taskResult = Save-MicrosoftDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-Headers $localJobArgs['Headers'] `
-UserAgent $localJobArgs['UserAgent'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim']
}
'Dell' {
# DellCatalogXmlPath might be null if catalog prep failed; Save-DellDriversTask should handle this.
$taskResult = Save-DellDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsArch $localJobArgs['WindowsArch'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-DellCatalogXmlPath $localJobArgs['DellCatalogXmlPath'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim']
}
'HP' {
$taskResult = Save-HPDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsArch $localJobArgs['WindowsArch'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-WindowsVersion $localJobArgs['WindowsVersion'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim']
}
'Lenovo' {
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-Headers $localJobArgs['Headers'] `
-UserAgent $localJobArgs['UserAgent'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim']
}
default {
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
WriteLog $unsupportedMakeMessage
$resultStatus = $unsupportedMakeMessage
$resultCode = 1
# $resultIdentifier is already set from $currentItem.$($using:IdentifierProperty)
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
# $taskResult remains null, handled below
}
}
# Consolidate result handling for 'DownloadDriverByMake'
if ($null -ne $taskResult) {
# $resultIdentifier is already $currentItem.$($using:IdentifierProperty)
# We use the task's returned Model/Identifier for logging/status if needed,
# but the primary identifier for UI updates should be consistent.
$taskSpecificIdentifier = $null
if ($taskResult.PSObject.Properties.Name -contains 'Model') { $taskSpecificIdentifier = $taskResult.Model }
elseif ($taskResult.PSObject.Properties.Name -contains 'Identifier') { $taskSpecificIdentifier = $taskResult.Identifier }
$resultStatus = $taskResult.Status
if ($taskResult.PSObject.Properties.Name -contains 'Success') {
# Dell, Microsoft, Lenovo
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
}
elseif ($taskResult.Status -like 'Completed*') {
# HP success
$resultCode = 0
}
elseif ($taskResult.Status -like 'Error*') {
# HP error
$resultCode = 1
}
else {
# Default for HP if status is unexpected, or if 'Success' property is missing but status isn't 'Completed*' or 'Error*'
WriteLog "Unexpected status or missing 'Success' property from task for '$taskSpecificIdentifier': $($taskResult.Status)"
$resultCode = 1 # Assume error
}
}
elseif ($make -in ('Microsoft', 'Dell', 'HP', 'Lenovo')) {
# This means a specific Make case was hit, but $taskResult was unexpectedly null
$nullTaskResultMessage = "Error: Task for Make '$make' returned null."
WriteLog $nullTaskResultMessage
$resultStatus = $nullTaskResultMessage
$resultCode = 1
# $resultIdentifier is already set
}
# If it was an unsupported Make, $resultStatus and $resultCode are already set from the 'default' case.
}
Default {
# This handles unknown $localTaskType values
$resultStatus = "Error: Task type '$localTaskType' not recognized"
$resultCode = 1
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
}
else {
$resultIdentifier = "UnknownItem"
}
WriteLog "Error in parallel job: Unknown TaskType '$localTaskType' provided for item '$resultIdentifier'."
}
}
}
catch {
# Catch errors within the parallel task execution
$resultStatus = "Error: $($_.Exception.Message)"
$resultCode = 1
# Try to get an identifier
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
}
else {
$resultIdentifier = "UnknownItemOnError"
}
WriteLog "Exception during parallel task '$localTaskType' for item '$resultIdentifier': $($_.Exception.ToString())"
# Enqueue the error status from the catch block
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
}
# Return a consistent hashtable structure (final result)
return @{
Identifier = $resultIdentifier
Status = $resultStatus # Return the final status
ResultCode = $resultCode
}
} -ThrottleLimit 5 -AsJob
}
catch {
# Catch errors during the *creation* of the parallel jobs (e.g., module loading in main thread failed)
WriteLog "Error initiating ForEach-Object -Parallel: $($_.Exception.Message)"
# Update all items to show a general startup error
$errorStatus = "$ErrorStatusPrefix Failed to start processing"
foreach ($item in $ItemsToProcess) {
$identifier = $item.$IdentifierProperty
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { # Use $WindowObject
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifier -StatusProperty $StatusProperty -StatusValue $errorStatus # Pass $WindowObject
})
}
# Exit the function as processing cannot proceed
return
}
# Check if any jobs failed to start immediately (e.g., module loading issues within the job)
$failedJobs = $jobs | Where-Object { $_.State -eq 'Failed' -and $_.JobStateInfo.Reason }
foreach ($failedJob in $failedJobs) {
WriteLog "Job $($failedJob.Id) failed to start or failed early: $($failedJob.JobStateInfo.Reason)"
# We don't easily know which item failed here without more complex mapping
# Update overall status maybe?
$processedCount++
}
# Filter out jobs that failed immediately
$jobs = $jobs | Where-Object { $_.State -ne 'Failed' }
# Process job results and intermediate status updates without blocking the UI thread
while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty) {
# Continue while jobs are running OR queue has messages
# 1. Process intermediate status updates from the queue
$statusUpdate = $null
while ($progressQueue.TryDequeue([ref]$statusUpdate)) {
if ($null -ne $statusUpdate) {
$intermediateIdentifier = $statusUpdate.Identifier
$intermediateStatus = $statusUpdate.Status
if ($isUiMode) {
# Use the new $isUiMode flag
# Update the UI with the intermediate status
try {
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus
})
}
catch {
WriteLog "Error setting intermediate status for item '$intermediateIdentifier': $($_.Exception.Message)"
}
}
else {
# Log intermediate status if not in UI mode
WriteLog "Intermediate Status for '$intermediateIdentifier': $intermediateStatus"
}
}
}
# 2. Check for completed jobs
$completedJobs = $jobs | Where-Object { $_.State -in 'Completed', 'Failed', 'Stopped' }
if ($completedJobs) {
foreach ($completedJob in $completedJobs) {
$finalIdentifier = "UnknownJob" # Placeholder if we can't get result
$finalStatus = "$ErrorStatusPrefix Job $($completedJob.Id) ended unexpectedly"
$finalResultCode = 1 # Assume error
if ($completedJob.State -eq 'Failed') {
WriteLog "Job $($completedJob.Id) failed: $($completedJob.Error)"
# Try to get identifier from job name if possible (less reliable)
# $finalIdentifier = ... logic to parse job name or map ID ...
$finalStatus = "$ErrorStatusPrefix Job Failed"
$processedCount++ # Count failed job as processed
}
elseif ($completedJob.HasMoreData) {
# Receive final results specifically from the completed job
$jobResults = $completedJob | Receive-Job
foreach ($result in $jobResults) {
# Should only be one result per job in this setup
if ($null -ne $result -and $result -is [hashtable] -and $result.ContainsKey('Identifier')) {
$finalIdentifier = $result.Identifier
$status = $result.Status # This is the FINAL status returned by the task
$finalResultCode = $result.ResultCode
# Determine final status text based on the result code
if ($finalResultCode -eq 0) {
# Assuming 0 means success
# Use the specific status returned by the successful job
# This handles cases like "Already downloaded" correctly
$finalStatus = $status
}
else {
$finalStatus = "$($ErrorStatusPrefix)$($status)" # Use status from result for error message
}
$processedCount++
}
else {
WriteLog "Warning: Received unexpected final job result format: $($result | Out-String)"
$finalStatus = "$ErrorStatusPrefix Invalid Result Format"
$processedCount++ # Count as processed to avoid loop issues
}
# Add the received result (even if format was unexpected, for logging)
if ($null -ne $result) { $resultsCollection.Add($result) }
break # Only process first result from this job
}
}
else {
# Job completed but had no data
if ($completedJob.State -ne 'Failed') {
WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data."
# $finalIdentifier = ... logic to parse job name or map ID ...
$finalStatus = "$ErrorStatusPrefix No Result Data"
$processedCount++
}
# If it was 'Failed', it was handled above
}
# Update the specific item in the ListView with its FINAL status
if ($isUiMode) {
# Use the new $isUiMode flag
try {
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus
})
}
catch {
WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)"
}
# Update overall progress after processing a job's results
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
})
}
else {
# Log final status if not in UI mode
WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)"
}
# Remove the completed/failed job from the list and clean it up
$jobs = $jobs | Where-Object { $_.Id -ne $completedJob.Id }
Remove-Job -Job $completedJob -Force -ErrorAction SilentlyContinue
} # End foreach completedJob
} # End if ($completedJobs)
# 3. Allow UI events to process and sleep briefly
if ($isUiMode) {
# Use the new $isUiMode flag
# Only sleep if jobs are still running AND the queue is empty (to avoid delaying UI updates)
if ($jobs.Count -gt 0 -and $progressQueue.IsEmpty) {
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
Start-Sleep -Milliseconds 100
}
elseif (-not $progressQueue.IsEmpty) {
# If queue has messages, process them immediately without sleeping
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
}
}
else {
# Non-UI mode, just sleep if jobs are running
if ($jobs.Count -gt 0) {
Start-Sleep -Milliseconds 100
}
}
# If jobs are done AND queue is empty, the loop condition will terminate
} # End while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty)
# Final cleanup of any remaining jobs (shouldn't be necessary with this loop logic, but good practice)
if ($jobs.Count -gt 0) {
WriteLog "Cleaning up $($jobs.Count) remaining jobs after loop exit."
Remove-Job -Job $jobs -Force -ErrorAction SilentlyContinue
}
if ($isUiMode) {
# Use the new $isUiMode flag
WriteLog "Invoke-ParallelProcessing finished for ListView '$($ListViewControl.Name)'."
# Final overall progress update
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processing complete. Processed $processedCount of $totalItems." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
})
}
else {
WriteLog "Invoke-ParallelProcessing finished (non-UI mode). Processed $processedCount of $totalItems."
}
# Return all collected final results from jobs
return $resultsCollection
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
# Export only the functions intended for public use by the UI script
Export-ModuleMember -Function Get-VMSwitchData,
Get-WindowsSettingsDefaults,
Get-AvailableWindowsReleases,
Get-AvailableWindowsVersions,
Get-GeneralDefaults,
Get-DellDriversModelList,
Get-HPDriversModelList,
Get-MicrosoftDriversModelList,
Get-LenovoDriversModelList,
Get-USBDrives,
Show-ModernFolderPicker,
Test-WingetCLI,
Install-WingetComponents,
Confirm-WingetInstallationUI,
Search-WingetPackagesPublic,
Start-WingetAppDownloadTask,
Start-CopyBYOApplicationTask,
Save-MicrosoftDriversTask,
Save-DellDriversTask,
Save-HPDriversTask,
Save-LenovoDriversTask,
Invoke-ProgressUpdate,
Invoke-ParallelProcessing,
Update-ListViewItemStatus,
Update-OverallProgress,
Compress-DriverFolderToWim