diff --git a/docs/src/content/docs/guides/automation.mdx b/docs/src/content/docs/guides/automation.mdx index f44efae938..4dc87775ac 100644 --- a/docs/src/content/docs/guides/automation.mdx +++ b/docs/src/content/docs/guides/automation.mdx @@ -40,6 +40,12 @@ This is useful for: - Reusing a known-good baseline after reinstalling Windows - Standardizing deployments for labs, workstations, or personal setups +:::caution[Keep exported configurations current] +Exported configurations contain the WinUtil catalog keys that existed when the file was created. If a later WinUtil version removes or renames one of those keys, the import is rejected before any current selections are changed. PowerShell reports the stale entry as `Unknown selection key ''`. + +To recover, compare the reported key with the current files in the [WinUtil configuration catalog](https://github.com/ChrisTitusTech/winutil/tree/main/config). Remove or replace the stale key in your JSON file, or create and export a new configuration with the current WinUtil version, then run the import again. Re-export long-lived baselines after catalog changes so they remain compatible. +::: + :::note Run the command in an elevated PowerShell session so WinUtil can apply system-level changes. ::: diff --git a/functions/private/Update-WinUtilSelections.ps1 b/functions/private/Update-WinUtilSelections.ps1 index e5efe31949..aff293d4f2 100644 --- a/functions/private/Update-WinUtilSelections.ps1 +++ b/functions/private/Update-WinUtilSelections.ps1 @@ -1,4 +1,19 @@ -function Update-WinUtilSelections ($flatJson) { +function Update-WinUtilSelections { + param( + [Parameter(Mandatory)] + [string[]]$flatJson, + + [switch]$Replace + ) + + $nextSelections = @{ + selectedApps = [System.Collections.Generic.List[string]]::new() + selectedTweaks = [System.Collections.Generic.List[string]]::new() + selectedToggles = [System.Collections.Generic.List[string]]::new() + selectedFeatures = [System.Collections.Generic.List[string]]::new() + selectedAppx = [System.Collections.Generic.List[string]]::new() + } + foreach ($cbkey in $flatJson) { $listName = switch -Regex ($cbkey) { @@ -9,6 +24,45 @@ function Update-WinUtilSelections ($flatJson) { '^WPFAppx' { 'selectedAppx' } } - $sync.$listName.Add($cbkey) + if (-not $listName) { + throw "Unsupported selection key '$cbkey'." + } + + $isKnownSelection = switch ($listName) { + 'selectedApps' { + $sync.configs.applicationsHashtable.ContainsKey($cbkey) + } + 'selectedTweaks' { + $null -ne $sync.configs.tweaks.PSObject.Properties[$cbkey] + } + 'selectedToggles' { + $null -ne $sync.configs.tweaks.PSObject.Properties[$cbkey] + } + 'selectedFeatures' { + $null -ne $sync.configs.feature.PSObject.Properties[$cbkey] + } + 'selectedAppx' { + $sync.configs.appxHashtable.ContainsKey($cbkey) + } + } + + if (-not $isKnownSelection) { + throw "Unknown selection key '$cbkey'." + } + + $nextSelections[$listName].Add($cbkey) + } + + if ($Replace) { + foreach ($listName in $nextSelections.Keys) { + $sync[$listName] = $nextSelections[$listName] + } + return + } + + foreach ($listName in $nextSelections.Keys) { + foreach ($cbkey in $nextSelections[$listName]) { + $sync.$listName.Add($cbkey) + } } } diff --git a/functions/public/Invoke-WPFImpex.ps1 b/functions/public/Invoke-WPFImpex.ps1 index 18218ef76c..b3c0ae16dd 100644 --- a/functions/public/Invoke-WPFImpex.ps1 +++ b/functions/public/Invoke-WPFImpex.ps1 @@ -84,15 +84,9 @@ function Invoke-WPFImpex { return } - # Clear all existing selections before importing so the import replaces - # the current state rather than merging with it - $sync.selectedAppx = [System.Collections.Generic.List[string]]::new() - $sync.selectedApps = [System.Collections.Generic.List[string]]::new() - $sync.selectedTweaks = [System.Collections.Generic.List[string]]::new() - $sync.selectedToggles = [System.Collections.Generic.List[string]]::new() - $sync.selectedFeatures = [System.Collections.Generic.List[string]]::new() - - Update-WinUtilSelections -flatJson $flattenedJson + # Build and validate every imported selection before replacing the current + # state. This keeps a malformed config from leaving partial selections behind. + Update-WinUtilSelections -flatJson $flattenedJson -Replace if ($sync.Form) { Reset-WPFCheckBoxes -doToggles $true diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 index 5223601a77..1f2809f002 100644 --- a/pester/ui-state.Tests.ps1 +++ b/pester/ui-state.Tests.ps1 @@ -48,12 +48,17 @@ namespace System.Windows.Controls public class WrapPanel { - public global::Windows.Visibility Visibility { get; set; } + public object Visibility { get; set; } } public class StackPanel { - public System.Collections.ArrayList Children { get; } = new System.Collections.ArrayList(); + public System.Collections.ArrayList Children { get; private set; } + + public StackPanel() + { + Children = new System.Collections.ArrayList(); + } } } "@ @@ -61,6 +66,7 @@ namespace System.Windows.Controls . (Join-Path $script:repoRoot "functions\private\Update-WinUtilSelections.ps1") . (Join-Path $script:repoRoot "functions\private\Reset-WPFCheckBoxes.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFImpex.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFGetInstalled.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFSelectedCheckboxesUpdate.ps1") . (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1") @@ -127,6 +133,16 @@ namespace System.Windows.Controls Content = "Git" } } + appxHashtable = @{ + WPFAppxExample = [pscustomobject]@{} + } + tweaks = [pscustomobject]@{ + WPFTweaksTelemetry = [pscustomobject]@{} + WPFToggleDarkMode = [pscustomobject]@{} + } + feature = [pscustomobject]@{ + WPFFeatureSandbox = [pscustomobject]@{} + } } WPFselectedAppsButton = [pscustomobject]@{ Content = "" @@ -174,6 +190,106 @@ Describe "Update-WinUtilSelections" { @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") @($script:sync.selectedAppx) | Should -Be @("WPFAppxExample") } + + It "replaces selections only after every imported key is validated" { + $script:sync.selectedApps.Add("WPFInstallExisting") + $script:sync.selectedTweaks.Add("WPFTweaksExisting") + + Update-WinUtilSelections -flatJson @( + "WPFInstallGit", + "WPFFeatureSandbox" + ) -Replace + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + @($script:sync.selectedTweaks) | Should -Be @() + @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") + } + + It "preserves existing selections when an imported key is unsupported" { + $script:sync.selectedApps.Add("WPFInstallExisting") + $script:sync.selectedTweaks.Add("WPFTweaksExisting") + + { + Update-WinUtilSelections -flatJson @( + "WPFInstallGit", + "NotAWinUtilKey" + ) -Replace + } | Should -Throw "Unsupported selection key 'NotAWinUtilKey'." + + @($script:sync.selectedApps) | Should -Be @("WPFInstallExisting") + @($script:sync.selectedTweaks) | Should -Be @("WPFTweaksExisting") + @($script:sync.selectedFeatures) | Should -Be @() + } + + It "preserves existing selections when an imported key is not in the current catalog" { + $script:sync.selectedApps.Add("WPFInstallExisting") + + { + Update-WinUtilSelections -flatJson @( + "WPFInstallGit", + "WPFInstallUnknown" + ) -Replace + } | Should -Throw "Unknown selection key 'WPFInstallUnknown'." + + @($script:sync.selectedApps) | Should -Be @("WPFInstallExisting") + @($script:sync.selectedTweaks) | Should -Be @() + @($script:sync.selectedFeatures) | Should -Be @() + } +} + +Describe "Invoke-WPFImpex import selection state" { + BeforeEach { + New-WinUtilUiStateTestContext + $script:sync.Form = [pscustomobject]@{} + + Mock Reset-WPFCheckBoxes { } + Mock Write-Error { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + + It "replaces selections and resets the UI after a valid import" { + $script:sync.selectedApps.Add("WPFInstallExisting") + $script:sync.selectedTweaks.Add("WPFTweaksExisting") + $configPath = Join-Path $TestDrive "valid-config.json" + @( + "WPFInstallGit", + "WPFFeatureSandbox" + ) | ConvertTo-Json | Set-Content -LiteralPath $configPath + + Invoke-WPFImpex -type "import" -Config $configPath + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + @($script:sync.selectedTweaks) | Should -Be @() + @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter { + $doToggles -eq $true + } + Should -Invoke -CommandName Write-Error -Times 0 -Exactly + } + + It "preserves selections and does not reset the UI after an invalid import" { + $script:sync.selectedApps.Add("WPFInstallExisting") + $script:sync.selectedTweaks.Add("WPFTweaksExisting") + $configPath = Join-Path $TestDrive "invalid-config.json" + @( + "WPFInstallGit", + "WPFInstallUnknown" + ) | ConvertTo-Json | Set-Content -LiteralPath $configPath + + Invoke-WPFImpex -type "import" -Config $configPath + + @($script:sync.selectedApps) | Should -Be @("WPFInstallExisting") + @($script:sync.selectedTweaks) | Should -Be @("WPFTweaksExisting") + @($script:sync.selectedFeatures) | Should -Be @() + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 0 -Exactly + Should -Invoke -CommandName Write-Error -Times 1 -Exactly -ParameterFilter { + $Message -like "An error occurred while importing: *Unknown selection key 'WPFInstallUnknown'.*" + } + } } Describe "Invoke-WPFSelectedCheckboxesUpdate" {