Add FFU.Common and FFUUI.Core module manifests and shared UI functions

- Created module manifest for FFU.Common with initial version 0.0.1.
- Created module manifest for FFUUI.Core with initial version 0.0.1.
- Implemented shared UI functions in FFUUI.Shared.psm1, including:
  - Update-ListViewItemStatus: Updates the status of items in a ListView.
  - Update-OverallProgress: Updates a progress bar and status label.
  - Invoke-ProgressUpdate: Enqueues progress updates to the UI thread.
  - Add-SortableColumn: Adds sortable columns to a ListView.
  - Add-SelectableGridViewColumn: Adds a selectable column with a "Select All" checkbox.
  - Update-SelectAllHeaderCheckBoxState: Updates the state of the header checkbox.
  - Invoke-ListViewSort: Sorts ListView items based on specified properties.
  - Show-ModernFolderPicker: Displays a modern folder picker dialog.
This commit is contained in:
rbalsleyMSFT
2025-06-11 20:50:51 -07:00
parent e4201aebff
commit 9282b4231e
10 changed files with 1406 additions and 1345 deletions
+1 -3
View File
@@ -419,9 +419,7 @@ if (Get-Module -Name 'FFU.Common.Drivers' -ErrorAction SilentlyContinue) {
Remove-Module -Name 'FFU.Common.Drivers' -Force Remove-Module -Name 'FFU.Common.Drivers' -Force
} }
# Import the required modules # Import the required modules
Import-Module "$PSScriptRoot\common\FFU.Common.Core.psm1" Import-Module "$PSScriptRoot\common" -Force
Import-Module "$PSScriptRoot\common\FFU.Common.Winget.psm1"
Import-Module "$PSScriptRoot\common\FFU.Common.Drivers.psm1"
# If a config file is specified and it exists, load it # If a config file is specified and it exists, load it
if ($ConfigFile -and (Test-Path -Path $ConfigFile)) { if ($ConfigFile -and (Test-Path -Path $ConfigFile)) {
+19 -498
View File
@@ -37,14 +37,14 @@ $UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTM
$script:uiState = [PSCustomObject]@{ $script:uiState = [PSCustomObject]@{
Window = $null; Window = $null;
Controls = @{ Controls = @{
featureCheckBoxes = @{}; # Moved from script scope featureCheckBoxes = @{};
UpdateInstallAppsBasedOnUpdates = $null # Placeholder for the scriptblock UpdateInstallAppsBasedOnUpdates = $null
}; };
Data = @{ Data = @{
allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new(); allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new();
appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new(); appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
versionData = $null; # Will be initialized later versionData = $null;
vmSwitchMap = @{} # To store Hyper-V switch to IP mapping vmSwitchMap = @{}
}; };
Flags = @{ Flags = @{
installAppsForcedByUpdates = $false; installAppsForcedByUpdates = $false;
@@ -65,9 +65,9 @@ if (Get-Module -Name 'FFUUI.Core' -ErrorAction SilentlyContinue) {
Remove-Module -Name 'FFUUI.Core' -Force Remove-Module -Name 'FFUUI.Core' -Force
} }
# Import the common core module first for logging # 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 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 the log path for the common logger (for UI operations)
Set-CommonCoreLogPath -Path $script:uiState.LogFilePath Set-CommonCoreLogPath -Path $script:uiState.LogFilePath
@@ -92,8 +92,6 @@ if ($script:uiState.Flags.originalLongPathsValue -ne 1) {
} }
catch { catch {
WriteLog "Error setting LongPathsEnabled registry key: $($_.Exception.Message). Long path issues might persist." 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 { else {
@@ -245,23 +243,20 @@ function ConvertTo-StandardizedDriverModel {
# Lenovo specific handling # Lenovo specific handling
if ($Make -eq 'Lenovo') { if ($Make -eq 'Lenovo') {
# RawDriverObject.Model is "ProductName (MachineType)" from Get-LenovoDriversModelList $modelDisplay = $RawDriverObject.Model
# RawDriverObject.ProductName is "ProductName"
# RawDriverObject.MachineType is "MachineType"
$modelDisplay = $RawDriverObject.Model # This is already "ProductName (MachineType)"
$productName = $RawDriverObject.ProductName $productName = $RawDriverObject.ProductName
$machineType = $RawDriverObject.MachineType $machineType = $RawDriverObject.MachineType
$id = $RawDriverObject.MachineType # Use MachineType as a more specific ID for Lenovo backend operations if needed $id = $RawDriverObject.MachineType
} }
return [PSCustomObject]@{ return [PSCustomObject]@{
IsSelected = $false IsSelected = $false
Make = $Make Make = $Make
Model = $modelDisplay # Primary display string, used as identifier in ListView Model = $modelDisplay
Link = $link Link = $link
Id = $id # Technical/unique identifier (e.g., MachineType for Lenovo) Id = $id
ProductName = $productName # Specific for Lenovo ProductName = $productName
MachineType = $machineType # Specific for Lenovo MachineType = $machineType
Version = "" # Placeholder Version = "" # Placeholder
Type = "" # Placeholder Type = "" # Placeholder
Size = "" # Placeholder Size = "" # Placeholder
@@ -353,7 +348,6 @@ function Filter-DriverModels {
WriteLog "Filtering models with text: '$filterText'" WriteLog "Filtering models with text: '$filterText'"
# Filter the full list based on the Model property (case-insensitive) # 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 # Ensure the result is always an array, even if only one item matches
$filteredModels = @($State.Data.allDriverModels | Where-Object { $_.Model -like "*$filterText*" }) $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 #Remove old log file if found
if (Test-Path -Path $script:uiState.LogFilePath) { if (Test-Path -Path $script:uiState.LogFilePath) {
Remove-item -Path $script:uiState.LogFilePath -Force Remove-item -Path $script:uiState.LogFilePath -Force
@@ -633,7 +621,7 @@ function Update-WindowsReleaseCombo {
[psobject]$State [psobject]$State
) )
if (-not $State.Controls.cmbWindowsRelease) { return } # Ensure combo exists if (-not $State.Controls.cmbWindowsRelease) { return }
$oldSelectedItemValue = $null $oldSelectedItemValue = $null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) { if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
@@ -676,8 +664,8 @@ function Update-WindowsVersionCombo {
[psobject]$State [psobject]$State
) )
$combo = $State.Controls.cmbWindowsVersion # Use script-scoped variable $combo = $State.Controls.cmbWindowsVersion
if (-not $combo) { return } # Ensure combo exists if (-not $combo) { return }
# Get available versions and default from the helper module # Get available versions and default from the helper module
$versionData = Get-AvailableWindowsVersions -SelectedRelease $selectedRelease -IsoPath $isoPath -State $State $versionData = Get-AvailableWindowsVersions -SelectedRelease $selectedRelease -IsoPath $isoPath -State $State
@@ -691,7 +679,7 @@ function Update-WindowsVersionCombo {
$combo.SelectedItem = $versionData.DefaultVersion $combo.SelectedItem = $versionData.DefaultVersion
} }
elseif ($versionData.Versions.Count -gt 0) { elseif ($versionData.Versions.Count -gt 0) {
$combo.SelectedIndex = 0 # Fallback to first item if default isn't valid $combo.SelectedIndex = 0
} }
else { else {
$combo.SelectedIndex = -1 # No items available $combo.SelectedIndex = -1 # No items available
@@ -704,8 +692,6 @@ function Update-WindowsSkuCombo {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[psobject]$State [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 $skuCombo = $State.Controls.cmbWindowsSKU
if (-not $skuCombo) { 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 to update priorities sequentially in a ListView
function Update-ListViewPriorities { function Update-ListViewPriorities {
param( param(
@@ -1459,11 +994,6 @@ function Update-CopyButtonState {
} }
} }
# --------------------------------------------------------------------------
# SECTION: Parallel Processing
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
$window.Add_Loaded({ $window.Add_Loaded({
# Pass the state object to all initialization functions # Pass the state object to all initialization functions
@@ -1492,7 +1022,7 @@ $window.Add_Loaded({
param($eventSource, $e) param($eventSource, $e)
$header = $e.OriginalSource $header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) { 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) param($eventSource, $e)
$header = $e.OriginalSource $header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) { 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) param($eventSource, $e)
$header = $e.OriginalSource $header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) { 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 = "" $script:uiState.Controls.txtWingetSearch.Text = ""
if ($script:uiState.Controls.txtStatus) { $script:uiState.Controls.txtStatus.Text = "Cleared all applications from the list" } 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({ $script:uiState.Controls.btnDownloadSelected.Add_Click({
param($buttonSender, $clickEventArgs) param($buttonSender, $clickEventArgs)
@@ -248,4 +248,4 @@ function Start-BitsTransferWithRetry {
throw $lastError throw $lastError
} }
Export-ModuleMember -Function Set-CommonCoreLogPath, WriteLog, Invoke-Process, Start-BitsTransferWithRetry Export-ModuleMember -Function *
@@ -3,8 +3,8 @@
#Requires -Modules Dism #Requires -Modules Dism
# Import the common core module for logging and process invocation # # Import the common core module for logging and process invocation
Import-Module "$PSScriptRoot\FFU.Common.Core.psm1" # Import-Module "$PSScriptRoot\FFU.Common.Core.psm1"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# SECTION: Driver Compression Function # SECTION: Driver Compression Function
@@ -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
@@ -1,5 +1,5 @@
# Import the common core module for logging # # Import the common core module for logging
Import-Module "$PSScriptRoot\FFU.Common.Core.psm1" # Import-Module "$PSScriptRoot\FFU.Common.Core.psm1"
function Get-Application { function Get-Application {
[CmdletBinding()] [CmdletBinding()]
+134
View File
@@ -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 = ''
}
+132
View File
@@ -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 = ''
}
+18 -838
View File
@@ -3,19 +3,16 @@
#Requires -Modules BitsTransfer #Requires -Modules BitsTransfer
# Import shared modules # # Import shared modules
Import-Module "$PSScriptRoot\..\common\FFU.Common.Core.psm1" # Import-Module "$PSScriptRoot\..\common\FFU.Common.Core.psm1"
Import-Module "$PSScriptRoot\..\common\FFU.Common.Winget.psm1" # Import-Module "$PSScriptRoot\..\common\FFU.Common.Winget.psm1"
Import-Module "$PSScriptRoot\..\common\FFU.Common.Drivers.psm1" # Import-Module "$PSScriptRoot\..\common\FFU.Common.Drivers.psm1"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# SECTION: Module Variables (Static Data & State) # 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 = @( $script:allowedFeatures = @(
"AppServerClient", "Client-DeviceLockdown", "Client-EmbeddedBootExp", "Client-EmbeddedLogon", "AppServerClient", "Client-DeviceLockdown", "Client-EmbeddedBootExp", "Client-EmbeddedLogon",
"Client-EmbeddedShellLauncher", "Client-KeyboardFilter", "Client-ProjFS", "Client-UnifiedWriteFilter", "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 # 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 to get VM Switch names and associated IP addresses (Moved from UI_Helpers)
function Get-VMSwitchData { function Get-VMSwitchData {
@@ -257,8 +245,7 @@ function Get-WindowsSettingsDefaults {
DefaultMediaType = "Consumer" DefaultMediaType = "Consumer"
DefaultOptionalFeatures = "" DefaultOptionalFeatures = ""
DefaultProductKey = "" DefaultProductKey = ""
AllowedFeatures = $script:allowedFeatures # Return the list AllowedFeatures = $script:allowedFeatures
# SkuList will now be populated dynamically based on Windows Release
AllowedLanguages = $script:allowedLangs AllowedLanguages = $script:allowedLangs
AllowedArchitectures = @('x86', 'x64', 'arm64') AllowedArchitectures = @('x86', 'x64', 'arm64')
AllowedMediaTypes = @('Consumer', 'Business') AllowedMediaTypes = @('Consumer', 'Business')
@@ -301,7 +288,7 @@ function Get-AvailableWindowsVersions {
} }
if (-not $State.Defaults.GeneralDefaults.WindowsVersionMap.ContainsKey($SelectedRelease)) { if (-not $State.Defaults.GeneralDefaults.WindowsVersionMap.ContainsKey($SelectedRelease)) {
return $result # Return empty/disabled state return $result
} }
$validVersions = $State.Defaults.GeneralDefaults.WindowsVersionMap[$SelectedRelease] $validVersions = $State.Defaults.GeneralDefaults.WindowsVersionMap[$SelectedRelease]
@@ -329,9 +316,9 @@ function Get-AvailableWindowsVersions {
$result.DefaultVersion = "24H2" $result.DefaultVersion = "24H2"
} }
elseif ($validVersions.Count -gt 0) { 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 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) # Function to get the list of Dell models from the catalog using XML streaming
# Depends on private functions: Start-BitsTransferWithRetry, Invoke-Process
function Get-DellDriversModelList { function Get-DellDriversModelList {
[CmdletBinding()] [CmdletBinding()]
param( 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" } $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) $uniqueModelNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$reader = $null # Initialize reader variable $reader = $null
try { try {
# Check if the Dell catalog XML exists and is recent # Check if the Dell catalog XML exists and is recent
@@ -604,7 +590,6 @@ function Get-DellDriversModelList {
if ($null -ne $reader) { if ($null -ne $reader) {
$reader.Dispose() $reader.Dispose()
} }
# REMOVED: Cleanup of temp folder - XML is kept in DriversFolder
# Ensure CAB file is deleted even if extraction failed but download succeeded # Ensure CAB file is deleted even if extraction failed but download succeeded
if (Test-Path -Path $dellCabFile) { if (Test-Path -Path $dellCabFile) {
WriteLog "Cleaning up downloaded Dell CAB file: $dellCabFile" WriteLog "Cleaning up downloaded Dell CAB file: $dellCabFile"
@@ -737,9 +722,9 @@ function Get-LenovoDriversModelList {
# Add each combination as a separate entry # Add each combination as a separate entry
$models.Add([PSCustomObject]@{ $models.Add([PSCustomObject]@{
Make = 'Lenovo' Make = 'Lenovo'
Model = $displayModel # Combined string for display Model = $displayModel
ProductName = $productName # Original product name stored separately if needed ProductName = $productName
MachineType = $machineType # Machine type needed for catalog URL MachineType = $machineType
}) })
} }
else { else {
@@ -757,8 +742,6 @@ function Get-LenovoDriversModelList {
WriteLog "Error querying Lenovo PSREF API: $($_.Exception.Message)" WriteLog "Error querying Lenovo PSREF API: $($_.Exception.Message)"
# Return empty list on error # Return empty list on error
} }
# Return the list (sorting might be done in the UI layer if needed)
return $models return $models
} }
@@ -777,17 +760,15 @@ function Save-LenovoDriversTask {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$UserAgent, [string]$UserAgent,
[Parameter()] # Made optional [Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
[Parameter()] [Parameter()]
[bool]$CompressToWim = $false # New parameter for compression [bool]$CompressToWim = $false
) )
# The Model property from the UI already contains the combined "ProductName (MachineType)" string # The Model property from the UI already contains the combined "ProductName (MachineType)" string
$identifier = $DriverItemData.Model $identifier = $DriverItemData.Model
# We still need the machine type for the catalog URL
$machineType = $DriverItemData.MachineType $machineType = $DriverItemData.MachineType
$make = "Lenovo" $make = "Lenovo"
# $identifier = "$($modelName) ($($machineType))" # No longer needed, use Model directly
$status = "Starting..." $status = "Starting..."
$success = $false $success = $false
@@ -1103,7 +1084,7 @@ function Save-LenovoDriversTask {
} }
} }
else { else {
$status = "Completed" # Final status if not compressing $status = "Completed"
} }
# --- End Compression --- # --- End Compression ---
@@ -1133,7 +1114,6 @@ function Save-LenovoDriversTask {
} }
# Function to get the list of HP models from the PlatformList.xml # Function to get the list of HP models from the PlatformList.xml
# Depends on private functions: Start-BitsTransferWithRetry, Invoke-Process
function Get-HPDriversModelList { function Get-HPDriversModelList {
[CmdletBinding()] [CmdletBinding()]
param ( param (
@@ -1203,7 +1183,6 @@ function Get-HPDriversModelList {
$modelList.Add([PSCustomObject]@{ $modelList.Add([PSCustomObject]@{
Make = $Make Make = $Make
Model = $modelName Model = $modelName
# Add other properties like SystemID if needed later, but keep it simple for now
}) })
} }
} }
@@ -1218,8 +1197,6 @@ function Get-HPDriversModelList {
} }
catch { catch {
WriteLog "Error getting HP driver model list: $($_.Exception.Message)" 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 # 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 # SECTION: Winget Management Functions
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -1560,10 +1370,7 @@ function Confirm-WingetInstallationUI {
# Use callback to indicate installation attempt # Use callback to indicate installation attempt
& $UiUpdateCallback $result.CliVersion "Installing/Updating..." & $UiUpdateCallback $result.CliVersion "Installing/Updating..."
# Call Install-WingetComponents (which also uses the callback internally) # Attempt to install/update Winget CLI and module
# Note: Install-WingetComponents currently only installs the module.
# CLI installation/update might need separate handling or integration here if desired.
# For now, we focus on the module install triggered by this check.
$installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback $installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback
# Re-check status after attempt # Re-check status after attempt
@@ -1680,11 +1487,6 @@ function Start-WingetAppDownloadTask {
# 2. Check previous Winget download # 2. Check previous Winget download
if (-not $appFound) { 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) { if (-not $appFound) {
$wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json" $wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json"
if (Test-Path -Path $wingetWin32jsonFile) { if (Test-Path -Path $wingetWin32jsonFile) {
@@ -2110,18 +1912,6 @@ function Start-CopyBYOApplicationTask {
# Return the final status # Return the final status
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success } 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 to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask { function Save-MicrosoftDriversTask {
@@ -3138,589 +2928,7 @@ function Save-HPDriversTask {
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
return [PSCustomObject]@{ Identifier = $identifier; Status = $finalStatus; Success = $successState } 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 # SECTION: UI Configuration
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -3992,32 +3200,4 @@ function Initialize-UIControls {
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Export only the functions intended for public use by the UI script # Export only the functions intended for public use by the UI script
Export-ModuleMember -Function Get-UIConfig, Export-ModuleMember -Function *
Get-VMSwitchData,
Get-WindowsSettingsDefaults,
Get-AvailableWindowsReleases,
Get-AvailableWindowsVersions,
Get-GeneralDefaults,
Get-DellDriversModelList,
Get-HPDriversModelList,
Get-MicrosoftDriversModelList,
Get-LenovoDriversModelList,
Get-USBDrives,
Show-ModernFolderPicker,
Test-WingetCLI,
Install-WingetComponents,
Confirm-WingetInstallationUI,
Search-WingetPackagesPublic,
Start-WingetAppDownloadTask,
Start-CopyBYOApplicationTask,
Save-MicrosoftDriversTask,
Save-DellDriversTask,
Save-HPDriversTask,
Save-LenovoDriversTask,
Invoke-ProgressUpdate,
Invoke-ParallelProcessing,
Update-ListViewItemStatus,
Update-OverallProgress,
Compress-DriverFolderToWim,
Get-AvailableSkusForRelease,
Initialize-UIControls
+618
View File
@@ -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 *