From 5aaa1ad7326969826c69116b897d38710b197f7e Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:57:15 -0700 Subject: [PATCH] Refresh Windows SKU dynamically after fallback image selection Previously, when a requested Windows SKU was not found in the provided ISO/ESD and the user manually selected a fallback image, the script kept the original (stale) `$WindowsSKU`. This caused downstream features like FFU file naming, VHDX cache metadata, and cumulative update planning to enforce logic against the wrong edition. - Refactored `Get-Index` into `Get-WindowsImageSelection` to return rich image metadata (including EditionId and InstallationType) instead of just the image index. - Added `Get-ResolvedWindowsSKUFromImage` to resolve raw image metadata back into the repository's native friendly SKU vocabulary. - Added `Get-WindowsTargetRuntimeState` to centralize and recalculate dependent variables (`installationType`, `WindowsVersion`, LTSC flags) after the SKU updates mid-flight. --- FFUDevelopment/BuildFFUVM.ps1 | 227 ++++++++++++++++++++++++++-------- 1 file changed, 178 insertions(+), 49 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 4eeb9f1..02d45a2 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -587,6 +587,55 @@ public static extern uint GetPrivateProfileSection( '@ Add-Type -MemberDefinition $definition -Namespace Win32 -Name Kernel32 -PassThru | Out-Null +function Get-WindowsTargetRuntimeState { + param( + [Parameter(Mandatory = $true)] + [int]$WindowsRelease, + + [Parameter(Mandatory = $true)] + [string]$WindowsSKU, + + [Parameter(Mandatory = $true)] + [string]$CurrentWindowsVersion, + + [Parameter(Mandatory = $true)] + [bool]$UpdateLatestCU + ) + + $localInstallationType = if ($WindowsSKU -like 'Standard*' -or $WindowsSKU -like 'Datacenter*') { 'Server' } else { 'Client' } + $localWindowsVersion = $CurrentWindowsVersion + $localIsLTSC = $false + + if ($localInstallationType -eq 'Server') { + switch ($WindowsRelease) { + 2016 { $localWindowsVersion = '1607' } + 2019 { $localWindowsVersion = '1809' } + 2022 { $localWindowsVersion = '21H2' } + 2025 { $localWindowsVersion = '24H2' } + } + } + + if ($WindowsSKU -like '*LTS*') { + switch ($WindowsRelease) { + 2016 { $localWindowsVersion = '1607' } + 2019 { $localWindowsVersion = '1809' } + 2021 { $localWindowsVersion = '21H2' } + 2024 { $localWindowsVersion = '24H2' } + } + $localIsLTSC = $true + } + + $localIsWindows10LtscClient = ($localInstallationType -eq 'Client') -and ($WindowsRelease -in 2016, 2019, 2021) -and $localIsLTSC + + return [pscustomobject]@{ + InstallationType = $localInstallationType + WindowsVersion = $localWindowsVersion + IsLTSC = $localIsLTSC + IsWindows10LtscClient = $localIsWindows10LtscClient + InstallLatestCuInVm = ($UpdateLatestCU -and $localIsWindows10LtscClient) + } +} + #Check if Hyper-V feature is installed (requires only checks the module) $osInfo = Get-CimInstance -ClassName win32_OperatingSystem $isServer = $osInfo.Caption -match 'server' @@ -648,31 +697,13 @@ if (-not $UnattendFolder) { $UnattendFolder = "$FFUDevelopmentPath\Unattend" } if (-not $AutopilotFolder) { $AutopilotFolder = "$FFUDevelopmentPath\Autopilot" } if (-not $PEDriversFolder) { $PEDriversFolder = "$FFUDevelopmentPath\PEDrivers" } if (-not $VHDXCacheFolder) { $VHDXCacheFolder = "$FFUDevelopmentPath\VHDXCache" } -if (-not $installationType) { $installationType = if ($WindowsSKU -like "Standard*" -or $WindowsSKU -like "Datacenter*") { 'Server' } else { 'Client' } } -if ($installationType -eq 'Server') { - #Map $WindowsRelease to $WindowsVersion for Windows Server - switch ($WindowsRelease) { - 2016 { $WindowsVersion = '1607' } - 2019 { $WindowsVersion = '1809' } - 2022 { $WindowsVersion = '21H2' } - 2025 { $WindowsVersion = '24H2' } - } -} if (-not $AppListPath) { $AppListPath = "$AppsPath\AppList.json" } - -if ($WindowsSKU -like "*LTS*") { - switch ($WindowsRelease) { - 2016 { $WindowsVersion = '1607' } - 2019 { $WindowsVersion = '1809' } - 2021 { $WindowsVersion = '21H2' } - 2024 { $WindowsVersion = '24H2' } - } - $isLTSC = $true -} - -# Determine runtime LTSC CU handling flags -$isWindows10LtscClient = ($installationType -eq 'Client') -and ($WindowsRelease -in 2016, 2019, 2021) -and ($WindowsSKU -like '*LTS*') -$installLatestCuInVm = ($UpdateLatestCU -and $isWindows10LtscClient) +$windowsTargetRuntimeState = Get-WindowsTargetRuntimeState -WindowsRelease $WindowsRelease -WindowsSKU $WindowsSKU -CurrentWindowsVersion $WindowsVersion -UpdateLatestCU:$UpdateLatestCU +$installationType = $windowsTargetRuntimeState.InstallationType +$WindowsVersion = $windowsTargetRuntimeState.WindowsVersion +$isLTSC = $windowsTargetRuntimeState.IsLTSC +$isWindows10LtscClient = $windowsTargetRuntimeState.IsWindows10LtscClient +$installLatestCuInVm = $windowsTargetRuntimeState.InstallLatestCuInVm $refreshAppsIsoForLtscCu = $false # Set the log path for the common logger @@ -2423,13 +2454,76 @@ function Get-WimFromISO { return $wimPath } -function Get-Index { +function Get-ResolvedWindowsSKUFromImage { + param( + [Parameter(Mandatory = $true)] + [string]$EditionId, + + [string]$InstallationType, + + [string]$ImageName, + + [Parameter(Mandatory = $true)] + [int]$WindowsRelease + ) + + $normalizedInstallationType = if ([string]::IsNullOrWhiteSpace($InstallationType)) { '' } else { $InstallationType.Trim() } + + switch ($EditionId) { + 'Core' { return 'Home' } + 'CoreN' { return 'Home N' } + 'CoreSingleLanguage' { return 'Home Single Language' } + 'Education' { return 'Education' } + 'EducationN' { return 'Education N' } + 'Professional' { return 'Pro' } + 'ProfessionalN' { return 'Pro N' } + 'ProfessionalEducation' { return 'Pro Education' } + 'ProfessionalEducationN' { return 'Pro Education N' } + 'ProfessionalWorkstation' { return 'Pro for Workstations' } + 'ProfessionalWorkstationN' { return 'Pro N for Workstations' } + 'Enterprise' { return 'Enterprise' } + 'EnterpriseN' { return 'Enterprise N' } + 'EnterpriseS' { + if ($WindowsRelease -eq 2016 -or $ImageName -match 'LTSB') { + return 'Enterprise 2016 LTSB' + } + return 'Enterprise LTSC' + } + 'EnterpriseSN' { + if ($WindowsRelease -eq 2016 -or $ImageName -match 'LTSB') { + return 'Enterprise N 2016 LTSB' + } + return 'Enterprise N LTSC' + } + 'IoTEnterpriseS' { return 'IoT Enterprise LTSC' } + 'IoTEnterpriseSN' { return 'IoT Enterprise N LTSC' } + 'ServerStandard' { + if ($normalizedInstallationType -eq 'Server') { + return 'Standard (Desktop Experience)' + } + return 'Standard' + } + 'ServerDatacenter' { + if ($normalizedInstallationType -eq 'Server') { + return 'Datacenter (Desktop Experience)' + } + return 'Datacenter' + } + } + + return $null +} + +function Get-WindowsImageSelection { param( [Parameter(Mandatory = $true)] [string]$WindowsImagePath, [Parameter(Mandatory = $true)] - [string]$WindowsSKU + [string]$WindowsSKU, + + [Parameter(Mandatory = $true)] + [int]$WindowsRelease ) # Get the available indexes in the WIM/ESD @@ -2514,25 +2608,26 @@ function Get-Index { } } + # Build per-index metadata (EditionId, InstallationType, resolved SKU) once for deterministic matching and fallback prompts. + $imageMetadata = @(foreach ($imageIndex in $imageIndexes) { + try { + $details = Get-WindowsImage -ImagePath $WindowsImagePath -Index $imageIndex.ImageIndex + [pscustomobject]@{ + ImageIndex = $details.ImageIndex + ImageName = $details.ImageName + ImageSize = $details.ImageSize + EditionId = $details.EditionId + InstallationType = $details.InstallationType + ResolvedWindowsSKU = Get-ResolvedWindowsSKUFromImage -EditionId $details.EditionId -InstallationType $details.InstallationType -ImageName $details.ImageName -WindowsRelease $WindowsRelease + } + } + catch { + $null + } + }) | Where-Object { $null -ne $_ } + # If we can map SKU -> EditionId, attempt a non-interactive match if ($editionIdCandidates.Count -gt 0) { - # Build per-index metadata (EditionId, InstallationType) to match deterministically - $imageMetadata = @(foreach ($imageIndex in $imageIndexes) { - try { - $details = Get-WindowsImage -ImagePath $WindowsImagePath -Index $imageIndex.ImageIndex - [pscustomobject]@{ - ImageIndex = $details.ImageIndex - ImageName = $details.ImageName - ImageSize = $details.ImageSize - EditionId = $details.EditionId - InstallationType = $details.InstallationType - } - } - catch { - $null - } - }) | Where-Object { $null -ne $_ } - # Match by EditionId first $imageMatches = $imageMetadata | Where-Object { $_.EditionId -in $editionIdCandidates } @@ -2547,14 +2642,17 @@ function Get-Index { # If multiple matches remain, pick the largest image (Desktop Experience tends to be larger) if ($imageMatches.Count -gt 0) { $bestMatch = $imageMatches | Sort-Object -Property ImageSize -Descending | Select-Object -First 1 - WriteLog "Selected Windows image index $($bestMatch.ImageIndex) (SKU='$WindowsSKU', EditionId='$($bestMatch.EditionId)', InstallationType='$($bestMatch.InstallationType)'): $($bestMatch.ImageName)" - return $bestMatch.ImageIndex + WriteLog "Selected Windows image index $($bestMatch.ImageIndex) (RequestedSKU='$WindowsSKU', ResolvedSKU='$($bestMatch.ResolvedWindowsSKU)', EditionId='$($bestMatch.EditionId)', InstallationType='$($bestMatch.InstallationType)'): $($bestMatch.ImageName)" + return $bestMatch } } # Final fallback: prompt the user to select an ImageName # Look for the numbers 10, 11, 2016, 2019, 2022+ in the ImageName - $relevantImageIndexes = $imageIndexes | Where-Object { ($_.ImageName -match "(10|11|2016|2019|202\d)") } + $relevantImageIndexes = @($imageMetadata | Where-Object { $_.ImageName -match "(10|11|2016|2019|202\d)" }) + if ($relevantImageIndexes.Count -eq 0) { + $relevantImageIndexes = $imageMetadata + } WriteLog "No matching image index found for SKU '$WindowsSKU' in '$WindowsImagePath'. Prompting user to select an ImageName." @@ -2575,8 +2673,8 @@ function Get-Index { $selectedImage = $relevantImageIndexes[$inputValue - 1] if ($selectedImage) { - WriteLog "User selected Windows image index $($selectedImage.ImageIndex) (SKU='$WindowsSKU'): $($selectedImage.ImageName)" - return $selectedImage.ImageIndex + WriteLog "User selected Windows image index $($selectedImage.ImageIndex) (RequestedSKU='$WindowsSKU', ResolvedSKU='$($selectedImage.ResolvedWindowsSKU)'): $($selectedImage.ImageName)" + return $selectedImage } else { Write-Host "Invalid selection, please try again." @@ -6844,7 +6942,38 @@ try { } #If index not specified by user, try and find based on WindowsSKU if (-not($index) -and ($WindowsSKU)) { - $index = Get-Index -WindowsImagePath $wimPath -WindowsSKU $WindowsSKU + $requestedWindowsSKU = $WindowsSKU + $previousInstallationType = $installationType + $previousWindowsVersion = $WindowsVersion + $previousIsLTSC = [bool]$isLTSC + $windowsImageSelection = Get-WindowsImageSelection -WindowsImagePath $wimPath -WindowsSKU $WindowsSKU -WindowsRelease $WindowsRelease + $index = $windowsImageSelection.ImageIndex + + if (-not [string]::IsNullOrWhiteSpace($windowsImageSelection.ResolvedWindowsSKU)) { + $WindowsSKU = $windowsImageSelection.ResolvedWindowsSKU + $windowsTargetRuntimeState = Get-WindowsTargetRuntimeState -WindowsRelease $WindowsRelease -WindowsSKU $WindowsSKU -CurrentWindowsVersion $WindowsVersion -UpdateLatestCU:$UpdateLatestCU + $installationType = $windowsTargetRuntimeState.InstallationType + $WindowsVersion = $windowsTargetRuntimeState.WindowsVersion + $isLTSC = $windowsTargetRuntimeState.IsLTSC + $isWindows10LtscClient = $windowsTargetRuntimeState.IsWindows10LtscClient + $installLatestCuInVm = $windowsTargetRuntimeState.InstallLatestCuInVm + + if ($requestedWindowsSKU -ne $WindowsSKU) { + WriteLog "Resolved WindowsSKU from '$requestedWindowsSKU' to '$WindowsSKU' based on image selection '$($windowsImageSelection.ImageName)'." + } + + if (($previousInstallationType -ne $installationType) -or ($previousWindowsVersion -ne $WindowsVersion) -or ($previousIsLTSC -ne [bool]$isLTSC)) { + WriteLog "Updated Windows target state after image selection: InstallationType='$installationType', WindowsVersion='$WindowsVersion', IsLTSC='$isLTSC'." + } + + if (($InstallApps -eq $false) -and ($installLatestCuInVm -eq $true)) { + WriteLog 'You have selected to update Defender, Malicious Software Removal Tool, OneDrive, Edge, or the latest Windows 10 LTSB/LTSC cumulative update, however you are setting InstallApps to false. These updates require the InstallApps variable to be set to true. Please set InstallApps to true and try again.' + throw "InstallApps variable must be set to `$true to update Defender, OneDrive, Edge, MSRT, or the latest Windows 10 LTSB/LTSC cumulative update" + } + } + else { + WriteLog "Could not resolve a friendly WindowsSKU for selected image '$($windowsImageSelection.ImageName)'. Continuing with requested SKU '$WindowsSKU'." + } } $vhdxDisk = New-ScratchVhdx -VhdxPath $VHDXPath -SizeBytes $disksize -LogicalSectorSizeBytes $LogicalSectorSizeBytes