# 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 # New # 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 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 DownloadDrivers = $false InstallDrivers = $false CopyDrivers = $false CopyPEDrivers = $false } } # 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 = ']*class="selectable-content-options__option-content(?: ocHidden)?"[^>]*>(.*?)' $divMatches = [regex]::Matches($html, $divPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) foreach ($divMatch in $divMatches) { $divContent = $divMatch.Groups[1].Value $tablePattern = ']*>(.*?)' $tableMatches = [regex]::Matches($divContent, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) foreach ($tableMatch in $tableMatches) { $tableContent = $tableMatch.Groups[1].Value $rowPattern = ']*>(.*?)' $rowMatches = [regex]::Matches($tableContent, $rowPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) foreach ($rowMatch in $rowMatches) { $rowContent = $rowMatch.Groups[1].Value $cellPattern = ']*>\s*(?:]*>)?(.*?)(?:

)?\s*' $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 = ']+href="([^"]+)"[^>]*>' # Change linkPattern to match https://www.microsoft.com/download/details.aspx?id= $linkPattern = ']+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 = '