diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index ff3246c..72d39f6 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -419,9 +419,7 @@ if (Get-Module -Name 'FFU.Common.Drivers' -ErrorAction SilentlyContinue) { Remove-Module -Name 'FFU.Common.Drivers' -Force } # Import the required 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" +Import-Module "$PSScriptRoot\common" -Force # If a config file is specified and it exists, load it if ($ConfigFile -and (Test-Path -Path $ConfigFile)) { diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1 index 017a0ca..9d81f6f 100644 --- a/FFUDevelopment/BuildFFUVM_UI.ps1 +++ b/FFUDevelopment/BuildFFUVM_UI.ps1 @@ -37,14 +37,14 @@ $UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTM $script:uiState = [PSCustomObject]@{ Window = $null; Controls = @{ - featureCheckBoxes = @{}; # Moved from script scope - UpdateInstallAppsBasedOnUpdates = $null # Placeholder for the scriptblock + featureCheckBoxes = @{}; + UpdateInstallAppsBasedOnUpdates = $null }; Data = @{ allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new(); appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new(); - versionData = $null; # Will be initialized later - vmSwitchMap = @{} # To store Hyper-V switch to IP mapping + versionData = $null; + vmSwitchMap = @{} }; Flags = @{ installAppsForcedByUpdates = $false; @@ -65,9 +65,9 @@ if (Get-Module -Name 'FFUUI.Core' -ErrorAction SilentlyContinue) { Remove-Module -Name 'FFUUI.Core' -Force } # Import the common core module first for logging -Import-Module "$PSScriptRoot\common\FFU.Common.Core.psm1" +Import-Module "$PSScriptRoot\FFU.Common" -Force # Import the Core UI Logic Module -Import-Module "$PSScriptRoot\FFUUI.Core\FFUUI.Core.psm1" +Import-Module "$PSScriptRoot\FFUUI.Core" -Force # Set the log path for the common logger (for UI operations) Set-CommonCoreLogPath -Path $script:uiState.LogFilePath @@ -92,8 +92,6 @@ if ($script:uiState.Flags.originalLongPathsValue -ne 1) { } catch { WriteLog "Error setting LongPathsEnabled registry key: $($_.Exception.Message). Long path issues might persist." - # Optionally show a warning to the user if this fails? - # [System.Windows.MessageBox]::Show("Could not enable long path support. Some operations might fail.", "Warning", "OK", "Warning") } } else { @@ -245,23 +243,20 @@ function ConvertTo-StandardizedDriverModel { # Lenovo specific handling if ($Make -eq 'Lenovo') { - # RawDriverObject.Model is "ProductName (MachineType)" from Get-LenovoDriversModelList - # RawDriverObject.ProductName is "ProductName" - # RawDriverObject.MachineType is "MachineType" - $modelDisplay = $RawDriverObject.Model # This is already "ProductName (MachineType)" + $modelDisplay = $RawDriverObject.Model $productName = $RawDriverObject.ProductName $machineType = $RawDriverObject.MachineType - $id = $RawDriverObject.MachineType # Use MachineType as a more specific ID for Lenovo backend operations if needed + $id = $RawDriverObject.MachineType } return [PSCustomObject]@{ IsSelected = $false Make = $Make - Model = $modelDisplay # Primary display string, used as identifier in ListView + Model = $modelDisplay Link = $link - Id = $id # Technical/unique identifier (e.g., MachineType for Lenovo) - ProductName = $productName # Specific for Lenovo - MachineType = $machineType # Specific for Lenovo + Id = $id + ProductName = $productName + MachineType = $machineType Version = "" # Placeholder Type = "" # Placeholder Size = "" # Placeholder @@ -353,7 +348,6 @@ function Filter-DriverModels { WriteLog "Filtering models with text: '$filterText'" # Filter the full list based on the Model property (case-insensitive) - # Use -match for potentially better performance or stick with -like # Ensure the result is always an array, even if only one item matches $filteredModels = @($State.Data.allDriverModels | Where-Object { $_.Model -like "*$filterText*" }) @@ -614,12 +608,6 @@ function Import-DriversJson { } } -# Some default values -$defaultFFUPrefix = "_FFU" - -# -------------------------------------------------------------------------- - - #Remove old log file if found if (Test-Path -Path $script:uiState.LogFilePath) { Remove-item -Path $script:uiState.LogFilePath -Force @@ -633,7 +621,7 @@ function Update-WindowsReleaseCombo { [psobject]$State ) - if (-not $State.Controls.cmbWindowsRelease) { return } # Ensure combo exists + if (-not $State.Controls.cmbWindowsRelease) { return } $oldSelectedItemValue = $null if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) { @@ -676,8 +664,8 @@ function Update-WindowsVersionCombo { [psobject]$State ) - $combo = $State.Controls.cmbWindowsVersion # Use script-scoped variable - if (-not $combo) { return } # Ensure combo exists + $combo = $State.Controls.cmbWindowsVersion + if (-not $combo) { return } # Get available versions and default from the helper module $versionData = Get-AvailableWindowsVersions -SelectedRelease $selectedRelease -IsoPath $isoPath -State $State @@ -691,7 +679,7 @@ function Update-WindowsVersionCombo { $combo.SelectedItem = $versionData.DefaultVersion } elseif ($versionData.Versions.Count -gt 0) { - $combo.SelectedIndex = 0 # Fallback to first item if default isn't valid + $combo.SelectedIndex = 0 } else { $combo.SelectedIndex = -1 # No items available @@ -704,8 +692,6 @@ function Update-WindowsSkuCombo { [Parameter(Mandatory = $true)] [psobject]$State ) - # This function no longer takes parameters. - # It derives the selected release value and display name from the cmbWindowsRelease ComboBox. $skuCombo = $State.Controls.cmbWindowsSKU if (-not $skuCombo) { @@ -896,457 +882,6 @@ function Update-WingetVersionFields { }) } - -# Add a function to create a sortable list view for Winget search results -function Add-SortableColumn { - param( - [System.Windows.Controls.GridView]$gridView, - [string]$header, - [string]$binding, - [int]$width = 'Auto', - [bool]$isCheckbox = $false, - [System.Windows.HorizontalAlignment]$headerHorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch - ) - - $column = New-Object System.Windows.Controls.GridViewColumn - $commonPadding = New-Object System.Windows.Thickness(5, 2, 5, 2) - - $headerControl = New-Object System.Windows.Controls.GridViewColumnHeader - $headerControl.Tag = $binding # Used for sorting - - if ($isCheckbox) { - # Cell template for a column of checkboxes - $cellTemplate = New-Object System.Windows.DataTemplate - $gridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid]) - - $checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox]) - $checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding("IsSelected"))) - $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center) - $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) - - $checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] { - param($eventSourceLocal, $eventArgsLocal) - # Sync logic would be needed here if this column had a header checkbox - }) - $gridFactory.AppendChild($checkBoxFactory) - $cellTemplate.VisualTree = $gridFactory - $column.CellTemplate = $cellTemplate - # $column.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Center # REMOVED - } - else { - # For regular text columns - $headerControl.HorizontalContentAlignment = $headerHorizontalAlignment - $headerControl.Content = $header - - $headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock]) - $headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, $header) - $headerTextBlockPadding = New-Object System.Windows.Thickness($commonPadding.Left, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom) - $headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding) - $headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) - - $headerDataTemplate = New-Object System.Windows.DataTemplate - $headerDataTemplate.VisualTree = $headerTextElementFactory - $headerControl.ContentTemplate = $headerDataTemplate - - $cellTemplate = New-Object System.Windows.DataTemplate - $textBlockFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock]) - $textBlockFactory.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, (New-Object System.Windows.Data.Binding($binding))) - # Adjust left padding to 0 for cell text to align with header text - $cellTextBlockPadding = New-Object System.Windows.Thickness(0, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom) - $textBlockFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $cellTextBlockPadding) - $textBlockFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Left) - $textBlockFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) - - $cellTemplate.VisualTree = $textBlockFactory - $column.CellTemplate = $cellTemplate - # $column.HorizontalContentAlignment = $headerHorizontalAlignment # REMOVED - } - - $column.Header = $headerControl - - if ($width -ne 'Auto') { - $column.Width = $width - } - - $gridView.Columns.Add($column) -} - -# Function to sort ListView items -function Invoke-ListViewSort { - param( - [System.Windows.Controls.ListView]$listView, - [string]$property - ) - - # Toggle sort direction if clicking the same column - if ($script:uiState.Flags.lastSortProperty -eq $property) { - $script:uiState.Flags.lastSortAscending = -not $script:uiState.Flags.lastSortAscending - } - else { - $script:uiState.Flags.lastSortAscending = $true - } - $script:uiState.Flags.lastSortProperty = $property - - # Get items from ItemsSource or Items collection - $currentItemsSource = $listView.ItemsSource - $itemsToSort = @() - if ($null -ne $currentItemsSource) { - $itemsToSort = @($currentItemsSource) - } - else { - $itemsToSort = @($listView.Items) - } - - if ($itemsToSort.Count -eq 0) { - return - } - - $selectedItems = @($itemsToSort | Where-Object { $_.IsSelected }) - $unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected }) - - # Define the primary sort criterion - $primarySortDefinition = @{ - Expression = { - $val = $_.$property - if ($null -eq $val) { '' } else { $val } - } - Ascending = $script:uiState.Flags.lastSortAscending - } - - $sortCriteria = [System.Collections.Generic.List[hashtable]]::new() - $sortCriteria.Add($primarySortDefinition) - - # Determine secondary sort property based on the ListView - $secondarySortPropertyName = $null - if ($listView.Name -eq 'lstDriverModels') { - $secondarySortPropertyName = "Model" - } - elseif ($listView.Name -eq 'lstWingetResults') { - $secondarySortPropertyName = "Name" - } - elseif ($listView.Name -eq 'lstAppsScriptVariables') { - if ($property -eq "Key") { - $secondarySortPropertyName = "Value" - } - elseif ($property -eq "Value") { - $secondarySortPropertyName = "Key" - } - else { - # Default secondary sort for IsSelected or other properties - $secondarySortPropertyName = "Key" - } - } - - if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) { - $itemsHaveSecondaryProperty = $false - if ($unselectedItems.Count -gt 0) { - if ($null -ne $unselectedItems[0].PSObject.Properties[$secondarySortPropertyName]) { - $itemsHaveSecondaryProperty = $true - } - } - elseif ($selectedItems.Count -gt 0) { - if ($null -ne $selectedItems[0].PSObject.Properties[$secondarySortPropertyName]) { - $itemsHaveSecondaryProperty = $true - } - } - - if ($itemsHaveSecondaryProperty) { - # Create a scriptblock for the secondary sort expression dynamically - $expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName") - - $secondarySortDefinition = @{ - Expression = { - $val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_ - if ($null -eq $val) { '' } else { $val } - } - Ascending = $true # Secondary sort always ascending - } - $sortCriteria.Add($secondarySortDefinition) - } - } - - $sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray() - # Ensure $sortedUnselected is not null before attempting to add its range - if ($null -eq $sortedUnselected) { - $sortedUnselected = @() - } - - # Combine sorted items: selected items first, then sorted unselected items - $newSortedList = [System.Collections.Generic.List[object]]::new() - $newSortedList.AddRange($selectedItems) - $newSortedList.AddRange($sortedUnselected) - - # Set the new sorted list as the ItemsSource - # Try nulling out ItemsSource first to force a more complete refresh - $listView.ItemsSource = $null - $listView.ItemsSource = $newSortedList.ToArray() -} - -# Function to add a selectable GridViewColumn with a "Select All" header CheckBox -function Add-SelectableGridViewColumn { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [System.Windows.Controls.ListView]$ListView, - [Parameter(Mandatory)] - [string]$HeaderCheckBoxScriptVariableName, - [Parameter(Mandatory)] - [double]$ColumnWidth, - [string]$IsSelectedPropertyName = "IsSelected" - ) - - # Ensure the ListView has a GridView - if ($null -eq $ListView.View -or -not ($ListView.View -is [System.Windows.Controls.GridView])) { - WriteLog "Add-SelectableGridViewColumn: ListView '$($ListView.Name)' does not have a GridView or View is null. Cannot add column." - # Optionally, create a new GridView if one doesn't exist, though XAML usually defines it. - # $ListView.View = New-Object System.Windows.Controls.GridView - return - } - $gridView = $ListView.View - - # Create the "Select All" CheckBox for the header - $headerCheckBox = New-Object System.Windows.Controls.CheckBox - $headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center - # Store an object containing the IsSelectedPropertyName and the ListView's Name in the Tag - $headerTagObject = [PSCustomObject]@{ - PropertyName = $IsSelectedPropertyName - ListViewName = $ListView.Name - } - $headerCheckBox.Tag = $headerTagObject - # Removed debug WriteLog for storing tag data - - $headerCheckBox.Add_Checked({ - param($senderCheckBoxLocal, $eventArgsCheckedLocal) - - $tagData = $senderCheckBoxLocal.Tag - if ($null -eq $tagData -or -not $tagData.PSObject.Properties['PropertyName'] -or -not $tagData.PSObject.Properties['ListViewName']) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - Tag data on header checkbox is missing, null, or malformed. Aborting HeaderChecked event." - return - } - - $localPropertyName = $tagData.PropertyName - $localListViewName = $tagData.ListViewName - # Removed debug WriteLog for HeaderChecked event fired - - if ([string]::IsNullOrEmpty($localPropertyName)) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - PropertyName from Tag is null or empty in HeaderChecked event for ListView '$localListViewName'. Aborting." - return - } - if ([string]::IsNullOrEmpty($localListViewName)) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - ListViewName from Tag is null or empty in HeaderChecked event. Aborting." - return - } - - $actualListView = $script:uiState.Controls[$localListViewName] - if ($null -eq $actualListView) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - ListView control '$localListViewName' not found in window during HeaderChecked event. Aborting." - return - } - # Removed debug WriteLog for successfully finding ListView in HeaderChecked - - $collectionToUpdate = $null - if ($null -ne $actualListView.ItemsSource) { - $collectionToUpdate = $actualListView.ItemsSource - } - elseif ($actualListView.HasItems) { - $collectionToUpdate = $actualListView.Items - } - - if ($null -ne $collectionToUpdate) { - foreach ($item in $collectionToUpdate) { - try { - $item.($localPropertyName) = $true - } - catch { - WriteLog "Error setting '$localPropertyName' to true for item in $($actualListView.Name): $($_.Exception.Message)" - } - } - $actualListView.Items.Refresh() - WriteLog "Header checkbox for $($actualListView.Name) checked. All items' '$localPropertyName' set to true." - } - }) - $headerCheckBox.Add_Unchecked({ - param($senderCheckBoxLocal, $eventArgsUncheckedLocal) - - $tagData = $senderCheckBoxLocal.Tag - if ($null -eq $tagData -or -not $tagData.PSObject.Properties['PropertyName'] -or -not $tagData.PSObject.Properties['ListViewName']) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - Tag data on header checkbox is missing, null, or malformed. Aborting HeaderUnchecked event." - return - } - - $localPropertyName = $tagData.PropertyName - $localListViewName = $tagData.ListViewName - # Removed debug WriteLog for HeaderUnchecked event fired - - if ([string]::IsNullOrEmpty($localPropertyName)) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - PropertyName from Tag is null or empty in HeaderUnchecked event for ListView '$localListViewName'. Aborting." - return - } - if ([string]::IsNullOrEmpty($localListViewName)) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - ListViewName from Tag is null or empty in HeaderUnchecked event. Aborting." - return - } - - $actualListView = $script:uiState.Controls[$localListViewName] - if ($null -eq $actualListView) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - ListView control '$localListViewName' not found in window during HeaderUnchecked event. Aborting." - return - } - # Removed debug WriteLog for successfully finding ListView in HeaderUnchecked - - # Only proceed if the uncheck was initiated by the user (IsChecked is explicitly false) - if ($senderCheckBoxLocal.IsChecked -eq $false) { - $collectionToUpdate = $null - if ($null -ne $actualListView.ItemsSource) { - $collectionToUpdate = $actualListView.ItemsSource - } - elseif ($actualListView.HasItems) { - $collectionToUpdate = $actualListView.Items - } - - if ($null -ne $collectionToUpdate) { - foreach ($item in $collectionToUpdate) { - try { - $item.($localPropertyName) = $false - } - catch { - WriteLog "Error setting '$localPropertyName' to false for item in $($actualListView.Name): $($_.Exception.Message)" - } - } - $actualListView.Items.Refresh() - WriteLog "Header checkbox for $($actualListView.Name) unchecked by user. All items' '$localPropertyName' set to false." - } - } - }) - - # Store the header checkbox in a script-scoped variable - Set-Variable -Name $HeaderCheckBoxScriptVariableName -Value $headerCheckBox -Scope Script -Force - WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in script variable '$HeaderCheckBoxScriptVariableName'." - - # Create the GridViewColumn - $selectableColumn = New-Object System.Windows.Controls.GridViewColumn - $selectableColumn.Header = $headerCheckBox - $selectableColumn.Width = $ColumnWidth - - # Create the CellTemplate for item CheckBoxes - $cellTemplate = New-Object System.Windows.DataTemplate - - # Use a Border to ensure CheckBox centers and stretches - $borderFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Border]) - $borderFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch) - $borderFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch) - - $checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox]) - $checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding($IsSelectedPropertyName))) - $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center) - $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) - - # Create an object to store both the header checkbox name and the ListView name - $tagObject = [PSCustomObject]@{ - HeaderCheckboxName = $HeaderCheckBoxScriptVariableName - ListViewName = $ListView.Name # Store the name of the ListView - } - # Store this object in the Tag of each item checkbox - $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::TagProperty, $tagObject) - - # Add handler to update the header checkbox state when an item checkbox is clicked - $checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] { - param($eventSourceLocal, $eventArgsLocal) - - $itemCheckBox = $eventSourceLocal -as [System.Windows.Controls.CheckBox] - if ($null -eq $itemCheckBox) { - WriteLog "Add-SelectableGridViewColumn: CRITICAL - Event source in item checkbox click handler is not a CheckBox." - return - } - - $tagData = $itemCheckBox.Tag - if ($null -eq $tagData -or -not $tagData.PSObject.Properties['HeaderCheckboxName'] -or -not $tagData.PSObject.Properties['ListViewName']) { - WriteLog "Add-SelectableGridViewColumn: Error - Tag data on itemCheckBox is missing or malformed." - return - } - - $headerCheckboxNameFromTag = $tagData.HeaderCheckboxName - $listViewNameFromTag = $tagData.ListViewName - - WriteLog "Add-SelectableGridViewColumn: Item Click. ListView: '$listViewNameFromTag', HeaderChkName: '$headerCheckboxNameFromTag'" - - if ([string]::IsNullOrEmpty($headerCheckboxNameFromTag)) { - WriteLog "Add-SelectableGridViewColumn: Error - Header checkbox name from Tag is null or empty for ListView '$listViewNameFromTag'." - return - } - if ([string]::IsNullOrEmpty($listViewNameFromTag)) { - WriteLog "Add-SelectableGridViewColumn: Error - ListView name from Tag is null or empty." - return - } - - # Retrieve the actual ListView control using its name stored in the Tag - $targetListView = $script:uiState.Controls[$listViewNameFromTag] - if ($null -eq $targetListView) { - WriteLog "Add-SelectableGridViewColumn: Error - Could not find ListView control named '$listViewNameFromTag'." - return - } - - $headerChk = Get-Variable -Name $headerCheckboxNameFromTag -Scope Script -ValueOnly -ErrorAction SilentlyContinue - if ($null -ne $headerChk) { - Update-SelectAllHeaderCheckBoxState -ListView $targetListView -HeaderCheckBox $headerChk - } - else { - WriteLog "Add-SelectableGridViewColumn: Error - Could not retrieve script variable for header checkbox named '$headerCheckboxNameFromTag' for ListView '$listViewNameFromTag'." - } - }) - - $borderFactory.AppendChild($checkBoxFactory) - $cellTemplate.VisualTree = $borderFactory - $selectableColumn.CellTemplate = $cellTemplate - - # Insert the new column at the beginning of the GridView - $gridView.Columns.Insert(0, $selectableColumn) - WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'." -} - -# Function to update the IsChecked state of a "Select All" header CheckBox -function Update-SelectAllHeaderCheckBoxState { - param( - [Parameter(Mandatory)] - [System.Windows.Controls.ListView]$ListView, - [Parameter(Mandatory)] - [System.Windows.Controls.CheckBox]$HeaderCheckBox - ) - - $collectionToInspect = $null - if ($null -ne $ListView.ItemsSource) { - $collectionToInspect = @($ListView.ItemsSource) - } - elseif ($ListView.HasItems) { - # Check if Items collection has items and ItemsSource is null - $collectionToInspect = @($ListView.Items) - } - - # If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty) - if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) { - $HeaderCheckBox.IsChecked = $false - return - } - - $selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count - $totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected - - if ($totalItemCount -eq 0) { - # Handle empty list case specifically - $HeaderCheckBox.IsChecked = $false - } - elseif ($selectedCount -eq $totalItemCount) { - $HeaderCheckBox.IsChecked = $true - } - elseif ($selectedCount -eq 0) { - $HeaderCheckBox.IsChecked = $false - } - else { - # Indeterminate state - $HeaderCheckBox.IsChecked = $null - } -} - # Function to update priorities sequentially in a ListView function Update-ListViewPriorities { param( @@ -1459,11 +994,6 @@ function Update-CopyButtonState { } } -# -------------------------------------------------------------------------- -# SECTION: Parallel Processing -# -------------------------------------------------------------------------- - -# -------------------------------------------------------------------------- $window.Add_Loaded({ # Pass the state object to all initialization functions @@ -1492,7 +1022,7 @@ $window.Add_Loaded({ param($eventSource, $e) $header = $e.OriginalSource if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) { - Invoke-ListViewSort -listView $script:uiState.Controls.lstDriverModels -property $header.Tag + Invoke-ListViewSort -listView $script:uiState.Controls.lstDriverModels -property $header.Tag -State $script:uiState } } ) @@ -1543,7 +1073,7 @@ $window.Add_Loaded({ param($eventSource, $e) $header = $e.OriginalSource if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) { - Invoke-ListViewSort -listView $script:uiState.Controls.lstAppsScriptVariables -property $header.Tag + Invoke-ListViewSort -listView $script:uiState.Controls.lstAppsScriptVariables -property $header.Tag -State $script:uiState } } ) @@ -2285,7 +1815,7 @@ $window.Add_Loaded({ param($eventSource, $e) $header = $e.OriginalSource if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) { - Invoke-ListViewSort -listView $script:uiState.Controls.lstWingetResults -property $header.Tag + Invoke-ListViewSort -listView $script:uiState.Controls.lstWingetResults -property $header.Tag -State $script:uiState } } ) @@ -2301,15 +1831,6 @@ $window.Add_Loaded({ $script:uiState.Controls.txtWingetSearch.Text = "" if ($script:uiState.Controls.txtStatus) { $script:uiState.Controls.txtStatus.Text = "Cleared all applications from the list" } }) - # -------------------------------------------------------------------------- - # SECTION: Background Task Management (Using ForEach-Object -Parallel) - # -------------------------------------------------------------------------- - # Modules (UI_Helpers, BackgroundTasks) and Scripts (WingetFunctions) are imported/dot-sourced - # directly into the main script scope. ForEach-Object -Parallel automatically handles - # module/variable availability in the parallel threads. - # UI updates are handled by calling helper functions directly on the main UI thread - # after the parallel processing completes. - # -------------------------------------------------------------------------- $script:uiState.Controls.btnDownloadSelected.Add_Click({ param($buttonSender, $clickEventArgs) diff --git a/FFUDevelopment/common/FFU.Common.Core.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Core.psm1 similarity index 98% rename from FFUDevelopment/common/FFU.Common.Core.psm1 rename to FFUDevelopment/FFU.Common/FFU.Common.Core.psm1 index b32b8e0..1079e27 100644 --- a/FFUDevelopment/common/FFU.Common.Core.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Core.psm1 @@ -248,4 +248,4 @@ function Start-BitsTransferWithRetry { throw $lastError } -Export-ModuleMember -Function Set-CommonCoreLogPath, WriteLog, Invoke-Process, Start-BitsTransferWithRetry \ No newline at end of file +Export-ModuleMember -Function * \ No newline at end of file diff --git a/FFUDevelopment/common/FFU.Common.Drivers.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 similarity index 96% rename from FFUDevelopment/common/FFU.Common.Drivers.psm1 rename to FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 index b425cc9..8e4e1cd 100644 --- a/FFUDevelopment/common/FFU.Common.Drivers.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 @@ -3,8 +3,8 @@ #Requires -Modules Dism -# Import the common core module for logging and process invocation -Import-Module "$PSScriptRoot\FFU.Common.Core.psm1" +# # Import the common core module for logging and process invocation +# Import-Module "$PSScriptRoot\FFU.Common.Core.psm1" # -------------------------------------------------------------------------- # SECTION: Driver Compression Function diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 new file mode 100644 index 0000000..304b9e6 --- /dev/null +++ b/FFUDevelopment/FFU.Common/FFU.Common.Parallel.psm1 @@ -0,0 +1,478 @@ +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 the module DIRECTORIES needed by the parallel threads + $commonModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFU.Common" + $uiCoreModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFUUI.Core" + + # 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['_commonModulePath'] = $commonModulePathForJob + $jobScopeVariables['_uiCoreModulePath'] = $uiCoreModulePathForJob + $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 modules needed for the task + Import-Module $localJobArgs['_commonModulePath'] -Force + Import-Module $localJobArgs['_uiCoreModulePath'] -Force + + # 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'] + } + + # 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 +} + +Export-ModuleMember -Function Invoke-ParallelProcessing \ No newline at end of file diff --git a/FFUDevelopment/common/FFU.Common.Winget.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 similarity index 99% rename from FFUDevelopment/common/FFU.Common.Winget.psm1 rename to FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 index dbc3996..6654b80 100644 --- a/FFUDevelopment/common/FFU.Common.Winget.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 @@ -1,5 +1,5 @@ -# Import the common core module for logging -Import-Module "$PSScriptRoot\FFU.Common.Core.psm1" +# # Import the common core module for logging +# Import-Module "$PSScriptRoot\FFU.Common.Core.psm1" function Get-Application { [CmdletBinding()] diff --git a/FFUDevelopment/FFU.Common/FFU.Common.psd1 b/FFUDevelopment/FFU.Common/FFU.Common.psd1 new file mode 100644 index 0000000..bb40165 --- /dev/null +++ b/FFUDevelopment/FFU.Common/FFU.Common.psd1 @@ -0,0 +1,134 @@ +# +# Module manifest for module 'FFU.Common' +# +# Generated by: Richard Balsley +# +# Generated on: 6/11/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'FFU.Common.Core.psm1' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '7dac2b8f-e65a-4997-961e-7a5ef5161901' + +# Author of this module +Author = 'Richard Balsley' + +# Company or vendor of this module +CompanyName = 'Unknown' + +# Copyright statement for this module +Copyright = '(c) Richard Balsley. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM.ps1 build script.' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +NestedModules = @('FFU.Common.Drivers.psm1', + 'FFU.Common.Winget.psm1', + 'FFU.Common.Parallel.psm1') + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = '*' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = '*' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psd1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psd1 new file mode 100644 index 0000000..ae730e4 --- /dev/null +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psd1 @@ -0,0 +1,132 @@ +# +# Module manifest for module 'FFUUI.Core' +# +# Generated by: Richard Balsley +# +# Generated on: 6/11/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'FFUUI.Core.psm1' + +# Version number of this module. +ModuleVersion = '0.0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '826c5868-c452-48a9-a3d8-9ff7fea54feb' + +# Author of this module +Author = 'Richard Balsley' + +# Company or vendor of this module +CompanyName = 'Unknown' + +# Copyright statement for this module +Copyright = '(c) Richard Balsley. All rights reserved.' + +# Description of the functionality provided by this module +Description = 'Core UI logic for the FFU Builder application.' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +RequiredModules = @('..\FFU.Common\FFU.Common.psd1') + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +NestedModules = @('FFUUI.Shared.psm1') + +# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. +FunctionsToExport = '*' + +# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. +CmdletsToExport = '*' + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = '*' + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + # LicenseUri = '' + + # A URL to the main website for this project. + # ProjectUri = '' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# HelpInfo URI of this module +# HelpInfoURI = '' + +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' + +} + diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 index 878560f..3cf0f94 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 @@ -3,19 +3,16 @@ #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" +# # 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", @@ -164,15 +161,6 @@ $script:windowsReleaseSkuMap = @{ # Note: LTSC 2016 and LTSC 2019 SKUs are now conditionally returned by Get-AvailableSkusForRelease } -# -------------------------------------------------------------------------- -# 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 { @@ -257,8 +245,7 @@ function Get-WindowsSettingsDefaults { DefaultMediaType = "Consumer" DefaultOptionalFeatures = "" DefaultProductKey = "" - AllowedFeatures = $script:allowedFeatures # Return the list - # SkuList will now be populated dynamically based on Windows Release + AllowedFeatures = $script:allowedFeatures AllowedLanguages = $script:allowedLangs AllowedArchitectures = @('x86', 'x64', 'arm64') AllowedMediaTypes = @('Consumer', 'Business') @@ -301,7 +288,7 @@ function Get-AvailableWindowsVersions { } if (-not $State.Defaults.GeneralDefaults.WindowsVersionMap.ContainsKey($SelectedRelease)) { - return $result # Return empty/disabled state + return $result } $validVersions = $State.Defaults.GeneralDefaults.WindowsVersionMap[$SelectedRelease] @@ -329,9 +316,9 @@ function Get-AvailableWindowsVersions { $result.DefaultVersion = "24H2" } elseif ($validVersions.Count -gt 0) { - $result.DefaultVersion = $validVersions[0] # Default to first in list otherwise + $result.DefaultVersion = $validVersions[0] } - $result.IsEnabled = $true # Combo should be enabled + $result.IsEnabled = $true } return $result @@ -473,8 +460,7 @@ function Get-GeneralDefaults { } } -# 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 to get the list of Dell models from the catalog using XML streaming function Get-DellDriversModelList { [CmdletBinding()] param( @@ -494,7 +480,7 @@ function Get-DellDriversModelList { $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 + $reader = $null try { # Check if the Dell catalog XML exists and is recent @@ -604,7 +590,6 @@ function Get-DellDriversModelList { 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" @@ -737,9 +722,9 @@ function Get-LenovoDriversModelList { # 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 + Model = $displayModel + ProductName = $productName + MachineType = $machineType }) } else { @@ -757,8 +742,6 @@ function Get-LenovoDriversModelList { 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 } @@ -777,17 +760,15 @@ function Save-LenovoDriversTask { [Parameter(Mandatory = $true)] [string]$UserAgent, [Parameter()] # Made optional - [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null + [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, [Parameter()] - [bool]$CompressToWim = $false # New parameter for compression + [bool]$CompressToWim = $false ) # 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 + $identifier = $DriverItemData.Model $machineType = $DriverItemData.MachineType $make = "Lenovo" - # $identifier = "$($modelName) ($($machineType))" # No longer needed, use Model directly $status = "Starting..." $success = $false @@ -1103,7 +1084,7 @@ function Save-LenovoDriversTask { } } else { - $status = "Completed" # Final status if not compressing + $status = "Completed" } # --- End Compression --- @@ -1133,7 +1114,6 @@ function Save-LenovoDriversTask { } # 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 ( @@ -1203,7 +1183,6 @@ function Get-HPDriversModelList { $modelList.Add([PSCustomObject]@{ Make = $Make Model = $modelName - # Add other properties like SystemID if needed later, but keep it simple for now }) } } @@ -1218,8 +1197,6 @@ function Get-HPDriversModelList { } 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 @@ -1242,173 +1219,6 @@ function Get-USBDrives { } } -# -------------------------------------------------------------------------- -# 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 # -------------------------------------------------------------------------- @@ -1560,10 +1370,7 @@ function Confirm-WingetInstallationUI { # 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. + # Attempt to install/update Winget CLI and module $installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback # Re-check status after attempt @@ -1680,11 +1487,6 @@ function Start-WingetAppDownloadTask { # 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) { @@ -2110,18 +1912,6 @@ function Start-CopyBYOApplicationTask { # 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 { @@ -3138,589 +2928,7 @@ function Save-HPDriversTask { 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: UI Configuration # -------------------------------------------------------------------------- @@ -3992,32 +3200,4 @@ function Initialize-UIControls { # -------------------------------------------------------------------------- # Export only the functions intended for public use by the UI script -Export-ModuleMember -Function Get-UIConfig, -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, -Get-AvailableSkusForRelease, -Initialize-UIControls +Export-ModuleMember -Function * diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Shared.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Shared.psm1 new file mode 100644 index 0000000..fbea0aa --- /dev/null +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Shared.psm1 @@ -0,0 +1,618 @@ +# 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." + } +} + +# 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 }) +} + +# Add a function to create a sortable list view +function Add-SortableColumn { + param( + [System.Windows.Controls.GridView]$gridView, + [string]$header, + [string]$binding, + [int]$width = 'Auto', + [bool]$isCheckbox = $false, + [System.Windows.HorizontalAlignment]$headerHorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch + ) + + $column = New-Object System.Windows.Controls.GridViewColumn + $commonPadding = New-Object System.Windows.Thickness(5, 2, 5, 2) + + $headerControl = New-Object System.Windows.Controls.GridViewColumnHeader + $headerControl.Tag = $binding # Used for sorting + + if ($isCheckbox) { + # Cell template for a column of checkboxes + $cellTemplate = New-Object System.Windows.DataTemplate + $gridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid]) + + $checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox]) + $checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding("IsSelected"))) + $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center) + $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + $checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] { + param($eventSourceLocal, $eventArgsLocal) + # Sync logic would be needed here if this column had a header checkbox + }) + $gridFactory.AppendChild($checkBoxFactory) + $cellTemplate.VisualTree = $gridFactory + $column.CellTemplate = $cellTemplate + } + else { + # For regular text columns + $headerControl.HorizontalContentAlignment = $headerHorizontalAlignment + $headerControl.Content = $header + + $headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock]) + $headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, $header) + $headerTextBlockPadding = New-Object System.Windows.Thickness($commonPadding.Left, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom) + $headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding) + $headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + $headerDataTemplate = New-Object System.Windows.DataTemplate + $headerDataTemplate.VisualTree = $headerTextElementFactory + $headerControl.ContentTemplate = $headerDataTemplate + + $cellTemplate = New-Object System.Windows.DataTemplate + $textBlockFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock]) + $textBlockFactory.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, (New-Object System.Windows.Data.Binding($binding))) + # Adjust left padding to 0 for cell text to align with header text + $cellTextBlockPadding = New-Object System.Windows.Thickness(0, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom) + $textBlockFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $cellTextBlockPadding) + $textBlockFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Left) + $textBlockFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + $cellTemplate.VisualTree = $textBlockFactory + $column.CellTemplate = $cellTemplate + } + + $column.Header = $headerControl + + if ($width -ne 'Auto') { + $column.Width = $width + } + + $gridView.Columns.Add($column) +} + +# Function to add a selectable GridViewColumn with a "Select All" header CheckBox +function Add-SelectableGridViewColumn { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Windows.Controls.ListView]$ListView, + [Parameter(Mandatory)] + [string]$HeaderCheckBoxScriptVariableName, + [Parameter(Mandatory)] + [double]$ColumnWidth, + [string]$IsSelectedPropertyName = "IsSelected" + ) + + # Ensure the ListView has a GridView + if ($null -eq $ListView.View -or -not ($ListView.View -is [System.Windows.Controls.GridView])) { + WriteLog "Add-SelectableGridViewColumn: ListView '$($ListView.Name)' does not have a GridView or View is null. Cannot add column." + return + } + $gridView = $ListView.View + + # Create the "Select All" CheckBox for the header + $headerCheckBox = New-Object System.Windows.Controls.CheckBox + $headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + + # MODIFICATION: Store the actual ListView object in the header's Tag + $headerTagObject = [PSCustomObject]@{ + PropertyName = $IsSelectedPropertyName + ListViewControl = $ListView # Store the object itself + } + $headerCheckBox.Tag = $headerTagObject + + $headerCheckBox.Add_Checked({ + param($senderCheckBoxLocal, $eventArgsCheckedLocal) + $tagData = $senderCheckBoxLocal.Tag + $localPropertyName = $tagData.PropertyName + $actualListView = $tagData.ListViewControl # Get the control directly from the tag + + $collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items } + if ($null -ne $collectionToUpdate) { + foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true } + $actualListView.Items.Refresh() + } + }) + + $headerCheckBox.Add_Unchecked({ + param($senderCheckBoxLocal, $eventArgsUncheckedLocal) + if ($senderCheckBoxLocal.IsChecked -eq $false) { + $tagData = $senderCheckBoxLocal.Tag + $localPropertyName = $tagData.PropertyName + $actualListView = $tagData.ListViewControl # Get the control directly from the tag + + $collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items } + if ($null -ne $collectionToUpdate) { + foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false } + $actualListView.Items.Refresh() + } + } + }) + + Set-Variable -Name $HeaderCheckBoxScriptVariableName -Value $headerCheckBox -Scope Script -Force + WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in script variable '$HeaderCheckBoxScriptVariableName'." + + $selectableColumn = New-Object System.Windows.Controls.GridViewColumn + $selectableColumn.Header = $headerCheckBox + $selectableColumn.Width = $ColumnWidth + + $cellTemplate = New-Object System.Windows.DataTemplate + $borderFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Border]) + $borderFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch) + $borderFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch) + + $checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox]) + $checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding($IsSelectedPropertyName))) + $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center) + $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + # MODIFICATION: Store the actual ListView object in the item checkbox's Tag + $tagObject = [PSCustomObject]@{ + HeaderCheckboxName = $HeaderCheckBoxScriptVariableName + ListViewControl = $ListView # Store the object itself + } + $checkBoxFactory.SetValue([System.Windows.FrameworkElement]::TagProperty, $tagObject) + + $checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] { + param($eventSourceLocal, $eventArgsLocal) + $itemCheckBox = $eventSourceLocal -as [System.Windows.Controls.CheckBox] + $tagData = $itemCheckBox.Tag + + $headerCheckboxNameFromTag = $tagData.HeaderCheckboxName + $targetListView = $tagData.ListViewControl # Get the control directly from the tag + + WriteLog "Add-SelectableGridViewColumn: Item Click. ListView: '$($targetListView.Name)', HeaderChkName: '$headerCheckboxNameFromTag'" + + $headerChk = Get-Variable -Name $headerCheckboxNameFromTag -Scope Script -ValueOnly -ErrorAction SilentlyContinue + if ($null -ne $headerChk) { + Update-SelectAllHeaderCheckBoxState -ListView $targetListView -HeaderCheckBox $headerChk + } + else { + WriteLog "Add-SelectableGridViewColumn: Error - Could not retrieve script variable for header checkbox named '$headerCheckboxNameFromTag'." + } + }) + + $borderFactory.AppendChild($checkBoxFactory) + $cellTemplate.VisualTree = $borderFactory + $selectableColumn.CellTemplate = $cellTemplate + + $gridView.Columns.Insert(0, $selectableColumn) + WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'." +} + +# Function to update the IsChecked state of a "Select All" header CheckBox +function Update-SelectAllHeaderCheckBoxState { + param( + [Parameter(Mandatory)] + [System.Windows.Controls.ListView]$ListView, + [Parameter(Mandatory)] + [System.Windows.Controls.CheckBox]$HeaderCheckBox + ) + + $collectionToInspect = $null + if ($null -ne $ListView.ItemsSource) { + $collectionToInspect = @($ListView.ItemsSource) + } + elseif ($ListView.HasItems) { + # Check if Items collection has items and ItemsSource is null + $collectionToInspect = @($ListView.Items) + } + + # If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty) + if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) { + $HeaderCheckBox.IsChecked = $false + return + } + + $selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count + $totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected + + if ($totalItemCount -eq 0) { + # Handle empty list case specifically + $HeaderCheckBox.IsChecked = $false + } + elseif ($selectedCount -eq $totalItemCount) { + $HeaderCheckBox.IsChecked = $true + } + elseif ($selectedCount -eq 0) { + $HeaderCheckBox.IsChecked = $false + } + else { + # Indeterminate state + $HeaderCheckBox.IsChecked = $null + } +} + +# Function to sort ListView items +function Invoke-ListViewSort { + param( + [System.Windows.Controls.ListView]$listView, + [string]$property, + [PSCustomObject]$State + ) + + # Toggle sort direction if clicking the same column + if ($State.Flags.lastSortProperty -eq $property) { + $State.Flags.lastSortAscending = -not $State.Flags.lastSortAscending + } + else { + $State.Flags.lastSortAscending = $true + } + $State.Flags.lastSortProperty = $property + + # Get items from ItemsSource or Items collection + $currentItemsSource = $listView.ItemsSource + $itemsToSort = @() + if ($null -ne $currentItemsSource) { + $itemsToSort = @($currentItemsSource) + } + else { + $itemsToSort = @($listView.Items) + } + + if ($itemsToSort.Count -eq 0) { + return + } + + $selectedItems = @($itemsToSort | Where-Object { $_.IsSelected }) + $unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected }) + + # Define the primary sort criterion + $primarySortDefinition = @{ + Expression = { + $val = $_.$property + if ($null -eq $val) { '' } else { $val } + } + Ascending = $State.Flags.lastSortAscending + } + + $sortCriteria = [System.Collections.Generic.List[hashtable]]::new() + $sortCriteria.Add($primarySortDefinition) + + # Determine secondary sort property based on the ListView + $secondarySortPropertyName = $null + if ($listView.Name -eq 'lstDriverModels') { + $secondarySortPropertyName = "Model" + } + elseif ($listView.Name -eq 'lstWingetResults') { + $secondarySortPropertyName = "Name" + } + elseif ($listView.Name -eq 'lstAppsScriptVariables') { + if ($property -eq "Key") { + $secondarySortPropertyName = "Value" + } + elseif ($property -eq "Value") { + $secondarySortPropertyName = "Key" + } + else { + # Default secondary sort for IsSelected or other properties + $secondarySortPropertyName = "Key" + } + } + + if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) { + $itemsHaveSecondaryProperty = $false + if ($unselectedItems.Count -gt 0) { + if ($null -ne $unselectedItems[0].PSObject.Properties[$secondarySortPropertyName]) { + $itemsHaveSecondaryProperty = $true + } + } + elseif ($selectedItems.Count -gt 0) { + if ($null -ne $selectedItems[0].PSObject.Properties[$secondarySortPropertyName]) { + $itemsHaveSecondaryProperty = $true + } + } + + if ($itemsHaveSecondaryProperty) { + # Create a scriptblock for the secondary sort expression dynamically + $expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName") + + $secondarySortDefinition = @{ + Expression = { + $val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_ + if ($null -eq $val) { '' } else { $val } + } + Ascending = $true # Secondary sort always ascending + } + $sortCriteria.Add($secondarySortDefinition) + } + } + + $sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray() + # Ensure $sortedUnselected is not null before attempting to add its range + if ($null -eq $sortedUnselected) { + $sortedUnselected = @() + } + + # Combine sorted items: selected items first, then sorted unselected items + $newSortedList = [System.Collections.Generic.List[object]]::new() + $newSortedList.AddRange($selectedItems) + $newSortedList.AddRange($sortedUnselected) + + # Set the new sorted list as the ItemsSource + # Try nulling out ItemsSource first to force a more complete refresh + $listView.ItemsSource = $null + $listView.ItemsSource = $newSortedList.ToArray() +} + +# -------------------------------------------------------------------------- +# SECTION: Modern Folder Picker +# -------------------------------------------------------------------------- + +# 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) +} + +Export-ModuleMember -Function * \ No newline at end of file