From d9c0c9c68ee1769230c9789b5c7cb84bcff4d642 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:15:00 -0700 Subject: [PATCH] Adds exit-code overrides and UI for winget apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-app control for additional accepted exit codes and ignoring non‑zero exit codes to improve handling of installers with nonstandard returns. Exposes editable fields in the app list UI, persists them across search defaults, import/export, and pre-download save, and applies overrides during app resolution to honor configured behavior. --- .../FFU.Common/FFU.Common.Winget.psm1 | 24 ++++-- .../FFUUI.Core/FFUUI.Core.Config.psm1 | 16 ++-- .../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 69 ++++++++++++++++ .../FFUUI.Core/FFUUI.Core.Winget.psm1 | 78 ++++++++++++++----- 4 files changed, 157 insertions(+), 30 deletions(-) diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 index 5ab9b2d..ae5c509 100644 --- a/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Winget.psm1 @@ -426,12 +426,16 @@ function Get-Apps { $overrideMap = @{} foreach ($app in $apps.apps) { if ($app.source -in @('winget', 'msstore')) { - $hasCmd = ($app.PSObject.Properties['CommandLine'] -and -not [string]::IsNullOrWhiteSpace($app.CommandLine)) - $hasArgs = ($app.PSObject.Properties['Arguments'] -and -not [string]::IsNullOrWhiteSpace($app.Arguments)) - if ($hasCmd -or $hasArgs) { + $hasCmd = ($app.PSObject.Properties['CommandLine'] -and -not [string]::IsNullOrWhiteSpace($app.CommandLine)) + $hasArgs = ($app.PSObject.Properties['Arguments'] -and -not [string]::IsNullOrWhiteSpace($app.Arguments)) + $hasAdd = ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes)) + $hasIgnore = ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) + if ($hasCmd -or $hasArgs -or $hasAdd -or $hasIgnore) { $overrideMap[$app.name] = @{ - CommandLine = if ($hasCmd) { $app.CommandLine } else { $null } - Arguments = if ($hasArgs) { $app.Arguments } else { $null } + CommandLine = if ($hasCmd) { $app.CommandLine } else { $null } + Arguments = if ($hasArgs) { $app.Arguments } else { $null } + AdditionalExitCodes = if ($hasAdd) { $app.AdditionalExitCodes } else { $null } + IgnoreNonZeroExitCodes = if ($hasIgnore) { [bool]$app.IgnoreNonZeroExitCodes } else { $null } } } } @@ -455,6 +459,16 @@ function Get-Apps { $entry.Arguments = $ov.Arguments $changed = $true } + if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) { + WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)" + $entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force + $changed = $true + } + if ($ov.ContainsKey('IgnoreNonZeroExitCodes') -and $null -ne $ov.IgnoreNonZeroExitCodes) { + WriteLog "Override (AppList.json) IgnoreNonZeroExitCodes for $($entry.Name)" + $entry | Add-Member -NotePropertyName IgnoreNonZeroExitCodes -NotePropertyValue ([bool]$ov.IgnoreNonZeroExitCodes) -Force + $changed = $true + } } } if ($changed) { diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 index 68f536b..9d7ba3d 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Config.psm1 @@ -885,13 +885,15 @@ function Import-ConfigSupplementalAssets { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } } $appsBuffer.Add([PSCustomObject]@{ - IsSelected = $true - Name = $appInfo.name - Id = $appInfo.id - Version = "" - Source = $appInfo.source - Architecture = $arch - DownloadStatus = "" + IsSelected = $true + Name = $appInfo.name + Id = $appInfo.id + Version = "" + Source = $appInfo.source + Architecture = $arch + AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" } + IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false } + DownloadStatus = "" }) } $State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray() diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 index 21771f8..d870a96 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 @@ -446,6 +446,75 @@ function Initialize-DynamicUIElements { $wingetGridView.Columns.Add($archColumn) # --- END: Add Architecture Column --- + # --- START: Add Additional Exit Codes Column --- + $exitCodesColumn = New-Object System.Windows.Controls.GridViewColumn + $exitCodesHeader = New-Object System.Windows.Controls.GridViewColumnHeader + $exitCodesHeader.Tag = "AdditionalExitCodes" + $exitCodesHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left + + $exitHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock]) + $exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Additional Exit Codes") + $exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5,2,5,2))) + $exitHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + $exitHeaderTemplate = New-Object System.Windows.DataTemplate + $exitHeaderTemplate.VisualTree = $exitHeaderTextFactory + $exitCodesHeader.ContentTemplate = $exitHeaderTemplate + + $exitCodesColumn.Header = $exitCodesHeader + $exitCodesColumn.Width = 140 + + $exitCodesCellTemplate = New-Object System.Windows.DataTemplate + $exitCodesTextBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBox]) + $exitBinding = New-Object System.Windows.Data.Binding("AdditionalExitCodes") + $exitBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay + $exitCodesTextBoxFactory.SetBinding([System.Windows.Controls.TextBox]::TextProperty, $exitBinding) + $exitCodesCellTemplate.VisualTree = $exitCodesTextBoxFactory + $exitCodesColumn.CellTemplate = $exitCodesCellTemplate + $wingetGridView.Columns.Add($exitCodesColumn) + # --- END: Add Additional Exit Codes Column --- + + # --- START: Add Ignore Non-Zero Exit Codes Column --- + $ignoreColumn = New-Object System.Windows.Controls.GridViewColumn + $ignoreHeader = New-Object System.Windows.Controls.GridViewColumnHeader + $ignoreHeader.Tag = "IgnoreNonZeroExitCodes" + $ignoreHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left + + $ignoreHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock]) + $ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Ignore Exit Codes") + $ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5,2,5,2))) + $ignoreHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + $ignoreHeaderTemplate = New-Object System.Windows.DataTemplate + $ignoreHeaderTemplate.VisualTree = $ignoreHeaderTextFactory + $ignoreHeader.ContentTemplate = $ignoreHeaderTemplate + + $ignoreColumn.Header = $ignoreHeader + $ignoreColumn.Width = 140 + + $ignoreCellTemplate = New-Object System.Windows.DataTemplate + + # Center the checkbox in the cell + $ignoreCellGridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid]) + $ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch) + $ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch) + + $ignoreCheckFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox]) + $ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center) + $ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center) + + $ignoreBinding = New-Object System.Windows.Data.Binding("IgnoreNonZeroExitCodes") + $ignoreBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay + $ignoreCheckFactory.SetBinding([System.Windows.Controls.Primitives.ToggleButton]::IsCheckedProperty, $ignoreBinding) + + # Build the visual tree: Grid -> CheckBox + $ignoreCellGridFactory.AppendChild($ignoreCheckFactory) + $ignoreCellTemplate.VisualTree = $ignoreCellGridFactory + + $ignoreColumn.CellTemplate = $ignoreCellTemplate + $wingetGridView.Columns.Add($ignoreColumn) + # --- END: Add Ignore Non-Zero Exit Codes Column --- + Add-SortableColumn -gridView $wingetGridView -header "Download Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left $State.Controls.lstWingetResults.AddHandler( [System.Windows.Controls.GridViewColumnHeader]::ClickEvent, diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1 index 0505e7b..91e1058 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Winget.psm1 @@ -98,10 +98,12 @@ function Save-WingetList { $appList = @{ apps = @($selectedApps | ForEach-Object { [ordered]@{ - name = (ConvertTo-SafeName -Name $_.Name) - id = $_.Id - source = $_.Source.ToLower() - architecture = $_.Architecture + name = (ConvertTo-SafeName -Name $_.Name) + id = $_.Id + source = $_.Source.ToLower() + architecture = $_.Architecture + AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" } + IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false } } }) } @@ -148,13 +150,15 @@ function Import-WingetList { foreach ($appInfo in $importedAppsData.apps) { $arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } } $newAppListForItemsSource.Add([PSCustomObject]@{ - IsSelected = $true # Imported apps are marked as selected - Name = $appInfo.name - Id = $appInfo.id - Version = "" # Will be populated when searching or if data exists - Source = $appInfo.source - Architecture = $arch - DownloadStatus = "" + IsSelected = $true # Imported apps are marked as selected + Name = $appInfo.name + Id = $appInfo.id + Version = "" # Will be populated when searching or if data exists + Source = $appInfo.source + Architecture = $arch + AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" } + IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false } + DownloadStatus = "" }) } } @@ -191,13 +195,15 @@ function Search-WingetPackagesPublic { $output = $results | ForEach-Object -Parallel { $arch = if ($_.Source -eq 'msstore') { 'NA' } else { $using:DefaultArchitecture } [PSCustomObject]@{ - IsSelected = [bool]$false - Name = [string]$_.Name - Id = [string]$_.Id - Version = [string]$_.Version - Source = [string]$_.Source - Architecture = [string]$arch - DownloadStatus = [string]::Empty + IsSelected = [bool]$false + Name = [string]$_.Name + Id = [string]$_.Id + Version = [string]$_.Version + Source = [string]$_.Source + Architecture = [string]$arch + AdditionalExitCodes = [string]::Empty + IgnoreNonZeroExitCodes = [bool]$false + DownloadStatus = [string]::Empty } } -ThrottleLimit 20 WriteLog "Winget search completed. Created $($output.Count) output objects." @@ -724,6 +730,42 @@ function Invoke-WingetDownload { # Select only necessary properties before passing to Invoke-ParallelProcessing $itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed + + # Before downloading, persist the selected apps to AppList.json including exit-code fields (parity with Save-WingetList) + try { + # Determine AppList.json path; default if empty + if ([string]::IsNullOrWhiteSpace($localAppListJsonPath)) { + $localAppListJsonPath = Join-Path -Path $localAppsPath -ChildPath "AppList.json" + $taskArguments.AppListJsonPath = $localAppListJsonPath + WriteLog "AppListJsonPath was empty. Defaulting to: $localAppListJsonPath" + } + + # Build apps payload from current selection, preserving AdditionalExitCodes/IgnoreNonZeroExitCodes + $appListToSave = @{ + apps = @($selectedApps | ForEach-Object { + [ordered]@{ + name = (ConvertTo-SafeName -Name $_.Name) + id = $_.Id + source = $_.Source.ToLower() + architecture = $_.Architecture + AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" } + IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false } + } + }) + } + + # Ensure destination directory exists and write AppList.json + $destDir = Split-Path -Parent $localAppListJsonPath + if (-not (Test-Path -LiteralPath $destDir)) { + [void][System.IO.Directory]::CreateDirectory($destDir) + } + $appListToSave | ConvertTo-Json -Depth 10 | Set-Content -Path $localAppListJsonPath -Encoding UTF8 + WriteLog "Persisted AppList.json with selected apps and exit-code fields to: $localAppListJsonPath" + } + catch { + WriteLog "Warning: Failed to persist AppList.json prior to download. Error: $($_.Exception.Message)" + } + # Invoke the centralized parallel processing function # Pass task type and task-specific arguments Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `