From 4a2d8e63ea7cdc0901539f23459634c75d5c3c12 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:48:34 -0700 Subject: [PATCH 01/10] Add flexible device naming options to Unattend delivery Introduces new parameters and UI controls to give users more choice over device naming when applying an Unattend.xml file. Users can now specify a device name, use a static or template-based name with the `%serial%` variable, or continue using a list of prefixes. The UI is updated with a new Device Naming expander to guide the user through the options and clearly indicate the requirements for each mode, ensuring that mutually exclusive options like Copy Unattend and Inject Unattend are not selected together. Documentation is updated to reflect the new functionality. --- FFUDevelopment/BuildFFUVM.ps1 | 224 +++++++++++++- FFUDevelopment/BuildFFUVM_UI.ps1 | 54 ++++ FFUDevelopment/BuildFFUVM_UI.xaml | 46 ++- .../FFUUI.Core/FFUUI.Core.Config.psm1 | 22 ++ .../FFUUI.Core/FFUUI.Core.Handlers.psm1 | 274 ++++++++++++++++++ .../FFUUI.Core/FFUUI.Core.Initialize.psm1 | 16 + .../FFUUI.Core/FFUUI.Core.Shared.psm1 | 3 + FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 | 6 + .../WinPEDeployFFUFiles/ApplyFFU.ps1 | 122 ++++++-- FFUDevelopment/config/Sample_default.json | Bin 6162 -> 6506 bytes docs/build.md | 32 +- docs/parameters_reference.md | 8 +- docs/quickstart.md | 18 +- 13 files changed, 764 insertions(+), 61 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 5055dbb..bb61540 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -72,6 +72,18 @@ When set to $true, will copy the provisioning package from the $FFUDevelopmentPa .PARAMETER CopyUnattend When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is $false. +.PARAMETER DeviceNamingMode +Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Supported values are Legacy, None, Template, and Prefixes. + +.PARAMETER DeviceNameTemplate +Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when CopyUnattend is used. + +.PARAMETER DeviceNamePrefixes +Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. + +.PARAMETER DeviceNamePrefixesPath +Path to the source prefixes file used for legacy copy or when DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. + .PARAMETER CreateDeploymentMedia When set to $true, this will create WinPE deployment media for use when deploying to a physical device. @@ -407,6 +419,11 @@ param( [bool]$AllowVHDXCaching, [bool]$CopyPPKG, [bool]$CopyUnattend, + [ValidateSet('Legacy', 'None', 'Template', 'Prefixes')] + [string]$DeviceNamingMode = 'Legacy', + [string]$DeviceNameTemplate, + [string[]]$DeviceNamePrefixes, + [string]$DeviceNamePrefixesPath, [bool]$CopyAutopilot, [bool]$CompactOS = $true, [bool]$CleanupDeployISO = $true, @@ -505,6 +522,79 @@ if ($ConfigFile -and (Test-Path -Path $ConfigFile)) { } } +function Get-UnattendSourcePath { + param( + [Parameter(Mandatory = $true)] + [string]$UnattendFolder, + [Parameter(Mandatory = $true)] + [string]$WindowsArch + ) + + $archSuffix = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } + return Join-Path $UnattendFolder "unattend_$archSuffix.xml" +} + +function Test-UnattendHasComputerNameElement { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + [xml]$unattendXml = Get-Content -Path $Path + foreach ($component in $unattendXml.unattend.settings.component) { + if ($component.ComputerName) { + return $true + } + } + + return $false +} + +function Save-StagedUnattendFile { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath, + [Parameter(Mandatory = $true)] + [ValidateSet('Legacy', 'None', 'Template', 'Prefixes')] + [string]$DeviceNamingMode, + [string]$DeviceNameTemplate + ) + + if ($DeviceNamingMode -in @('Legacy', 'Prefixes')) { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + [xml]$unattendXml = Get-Content -Path $SourcePath + $computerNameComponent = $null + foreach ($component in $unattendXml.unattend.settings.component) { + if ($component.ComputerName) { + $computerNameComponent = $component + break + } + } + + if ($null -eq $computerNameComponent) { + if ($DeviceNamingMode -eq 'None') { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + throw "ComputerName element not found in unattend source file: $SourcePath" + } + + if ($DeviceNamingMode -eq 'None') { + $computerNameComponent.ComputerName = '*' + } + elseif ($DeviceNamingMode -eq 'Template') { + $computerNameComponent.ComputerName = $DeviceNameTemplate + } + + $unattendXml.Save($DestinationPath) +} + $vmSwitchWasExplicitlyBound = $PSBoundParameters.ContainsKey('VMSwitchName') $enableVmNetworkingWasExplicitlyBound = $PSBoundParameters.ContainsKey('EnableVMNetworking') if (-not $EnableVMNetworking -and $vmSwitchWasExplicitlyBound -and -not $enableVmNetworkingWasExplicitlyBound) { @@ -512,6 +602,52 @@ if (-not $EnableVMNetworking -and $vmSwitchWasExplicitlyBound -and -not $enableV WriteLog 'EnableVMNetworking not explicitly set. Enabling VM networking because -VMSwitchName was supplied on the command line.' } +$normalizedDeviceNameTemplate = if ($null -ne $DeviceNameTemplate) { $DeviceNameTemplate.Trim() } else { $null } +$effectiveDeviceNamePrefixes = @($DeviceNamePrefixes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +$resolvedDeviceNamePrefixesPath = if ([string]::IsNullOrWhiteSpace($DeviceNamePrefixesPath)) { + Join-Path (Join-Path $FFUDevelopmentPath 'Unattend') 'prefixes.txt' +} +else { + $DeviceNamePrefixesPath +} + +if (($DeviceNamingMode -eq 'Prefixes') -and ($effectiveDeviceNamePrefixes.Count -eq 0) -and (Test-Path -Path $resolvedDeviceNamePrefixesPath -PathType Leaf)) { + $effectiveDeviceNamePrefixes = @(Get-Content -Path $resolvedDeviceNamePrefixesPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + WriteLog "Loaded device name prefixes from $resolvedDeviceNamePrefixesPath" +} + +if ($CopyUnattend -and $InjectUnattend) { + throw 'CopyUnattend and InjectUnattend cannot both be set to `$true. Select only one unattend delivery method.' +} + +if ($DeviceNamingMode -eq 'Template') { + if ([string]::IsNullOrWhiteSpace($normalizedDeviceNameTemplate)) { + throw 'DeviceNamingMode Template requires DeviceNameTemplate.' + } + + $templateWithoutSupportedVariables = $normalizedDeviceNameTemplate -replace '(?i)%serial%', '' + if ($templateWithoutSupportedVariables -match '%') { + throw 'Only the %serial% device name variable is supported.' + } + + if (-not ($CopyUnattend -or $InjectUnattend)) { + throw 'DeviceNamingMode Template requires either CopyUnattend or InjectUnattend.' + } + + if ($InjectUnattend -and (-not $CopyUnattend) -and $normalizedDeviceNameTemplate -match '(?i)%serial%') { + throw 'The %serial% device name variable is only supported when CopyUnattend is used.' + } +} +elseif ($DeviceNamingMode -eq 'Prefixes') { + if (-not $CopyUnattend) { + throw 'DeviceNamingMode Prefixes requires CopyUnattend. Prefix-based naming is not supported with InjectUnattend.' + } + + if ($effectiveDeviceNamePrefixes.Count -eq 0) { + throw 'DeviceNamingMode Prefixes requires at least one DeviceNamePrefixes entry or a valid DeviceNamePrefixesPath.' + } +} + # Validate that the selected Windows SKU is compatible with the chosen Windows release and ensure an ISO is provided for unsupported releases $clientSKUs = @( 'Home', @@ -4184,6 +4320,57 @@ Function New-DeploymentUSB { Import-Module "$($using:PSScriptRoot)\FFU.Common" -Force Set-CommonCoreLogPath -Path $using:LogFile + function Get-LocalUnattendSourcePath { + param( + [string]$UnattendFolder, + [string]$WindowsArch + ) + + $archSuffix = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } + return Join-Path $UnattendFolder "unattend_$archSuffix.xml" + } + + function Save-LocalStagedUnattendFile { + param( + [string]$SourcePath, + [string]$DestinationPath, + [string]$DeviceNamingMode, + [string]$DeviceNameTemplate + ) + + if ($DeviceNamingMode -in @('Legacy', 'Prefixes')) { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + [xml]$unattendXml = Get-Content -Path $SourcePath + $computerNameComponent = $null + foreach ($component in $unattendXml.unattend.settings.component) { + if ($component.ComputerName) { + $computerNameComponent = $component + break + } + } + + if ($null -eq $computerNameComponent) { + if ($DeviceNamingMode -eq 'None') { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + throw "ComputerName element not found in unattend source file: $SourcePath" + } + + if ($DeviceNamingMode -eq 'None') { + $computerNameComponent.ComputerName = '*' + } + elseif ($DeviceNamingMode -eq 'Template') { + $computerNameComponent.ComputerName = $DeviceNameTemplate + } + + $unattendXml.Save($DestinationPath) + } + $DiskNumber = $USBDrive.DeviceID.Replace("\\.\PHYSICALDRIVE", "") WriteLog "Thread $([System.Threading.Thread]::CurrentThread.ManagedThreadId) processing DiskNumber $DiskNumber ($($USBDrive.Model))" @@ -4244,15 +4431,15 @@ Function New-DeploymentUSB { $UnattendPathOnUSB = Join-Path $DeployPartitionDriveLetter "Unattend" WriteLog "Copying unattend file to $UnattendPathOnUSB" New-Item -Path $UnattendPathOnUSB -ItemType Directory -ErrorAction SilentlyContinue | Out-Null - if ($using:WindowsArch -eq 'x64') { - Copy-Item -Path (Join-Path $using:UnattendFolder 'unattend_x64.xml') -Destination (Join-Path $UnattendPathOnUSB 'Unattend.xml') -Force | Out-Null + $unattendSource = Get-LocalUnattendSourcePath -UnattendFolder $using:UnattendFolder -WindowsArch $using:WindowsArch + Save-LocalStagedUnattendFile -SourcePath $unattendSource -DestinationPath (Join-Path $UnattendPathOnUSB 'Unattend.xml') -DeviceNamingMode $using:DeviceNamingMode -DeviceNameTemplate $using:normalizedDeviceNameTemplate + if ($using:DeviceNamingMode -eq 'Prefixes') { + WriteLog "Writing prefixes.txt file to $UnattendPathOnUSB" + $using:effectiveDeviceNamePrefixes | Set-Content -Path (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Encoding UTF8 } - elseif ($using:WindowsArch -eq 'arm64') { - Copy-Item -Path (Join-Path $using:UnattendFolder 'unattend_arm64.xml') -Destination (Join-Path $UnattendPathOnUSB 'Unattend.xml') -Force | Out-Null - } - if (Test-Path (Join-Path $using:UnattendFolder 'prefixes.txt')) { + elseif (($using:DeviceNamingMode -eq 'Legacy') -and (Test-Path -Path $using:resolvedDeviceNamePrefixesPath -PathType Leaf)) { WriteLog "Copying prefixes.txt file to $UnattendPathOnUSB" - Copy-Item -Path (Join-Path $using:UnattendFolder 'prefixes.txt') -Destination (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Force | Out-Null + Copy-Item -Path $using:resolvedDeviceNamePrefixesPath -Destination (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Force | Out-Null } WriteLog 'Copy completed' } @@ -5518,9 +5705,26 @@ if ($CopyUnattend) { WriteLog "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file" throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file" } + + if ($DeviceNamingMode -eq 'Prefixes') { + $unattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch + if (-not (Test-UnattendHasComputerNameElement -Path $unattendSourcePath)) { + throw "DeviceNamingMode Prefixes requires a ComputerName element in $unattendSourcePath" + } + } + WriteLog 'Unattend validation complete' } +if ($InjectUnattend -and $DeviceNamingMode -eq 'Template') { + $injectUnattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch + if (Test-Path -Path $injectUnattendSourcePath -PathType Leaf) { + if (-not (Test-UnattendHasComputerNameElement -Path $injectUnattendSourcePath)) { + throw "DeviceNamingMode Template requires a ComputerName element in $injectUnattendSourcePath" + } + } +} + #Override $InstallApps value if using ESD to build FFU. This is due to a strange issue where building the FFU #from vhdx doesn't work (you get an older style OOBE screen and get stuck in an OOBE reboot loop when hitting next). #This behavior doesn't happen with WIM files. @@ -6418,9 +6622,7 @@ if ($InstallApps) { #Create Apps ISO # Inject Unattend.xml into Apps if requested and applicable if ($InstallApps -and $InjectUnattend) { - # Determine source unattend.xml based on architecture - $archSuffix = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } - $unattendSource = Join-Path $UnattendFolder "unattend_$archSuffix.xml" + $unattendSource = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch # Ensure target folder exists under Apps $targetFolder = Join-Path $AppsPath 'Unattend' @@ -6431,7 +6633,7 @@ if ($InstallApps) { # Copy if source exists; otherwise log and skip if (Test-Path -Path $unattendSource -PathType Leaf) { $destination = Join-Path $targetFolder 'Unattend.xml' - Copy-Item -Path $unattendSource -Destination $destination -Force | Out-Null + Save-StagedUnattendFile -SourcePath $unattendSource -DestinationPath $destination -DeviceNamingMode $DeviceNamingMode -DeviceNameTemplate $normalizedDeviceNameTemplate WriteLog "Injected unattend file into Apps: $unattendSource -> $destination" } else { diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1 index 3bf3926..90b8e32 100644 --- a/FFUDevelopment/BuildFFUVM_UI.ps1 +++ b/FFUDevelopment/BuildFFUVM_UI.ps1 @@ -432,6 +432,60 @@ $script:uiState.Controls.btnRun.Add_Click({ return } + if ($config.CopyUnattend -and $config.InjectUnattend) { + [System.Windows.MessageBox]::Show("Copy Unattend.xml and Inject Unattend.xml cannot both be selected. Choose only one unattend delivery method.", "Unattend Selection Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: choose only one unattend delivery method." + return + } + + if ($config.DeviceNamingMode -eq 'Template') { + if ([string]::IsNullOrWhiteSpace([string]$config.DeviceNameTemplate)) { + [System.Windows.MessageBox]::Show("Specify a device name before using 'Specify Device Name'.", "Device Name Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: device name required." + return + } + + if (-not ($config.CopyUnattend -or $config.InjectUnattend)) { + [System.Windows.MessageBox]::Show("Select Copy Unattend.xml or Inject Unattend.xml before using 'Specify Device Name'.", "Unattend Selection Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: unattend delivery method required for device naming." + return + } + + $templateWithoutSupportedVariables = ([string]$config.DeviceNameTemplate) -replace '(?i)%serial%', '' + if ($templateWithoutSupportedVariables -match '%') { + [System.Windows.MessageBox]::Show("Only the %serial% device name variable is supported.", "Unsupported Device Name Variable", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: unsupported device name variable." + return + } + + if ($config.InjectUnattend -and (-not $config.CopyUnattend) -and ([string]$config.DeviceNameTemplate -match '(?i)%serial%')) { + [System.Windows.MessageBox]::Show("The %serial% device name variable is only supported when Copy Unattend.xml is selected.", "Unsupported Inject Unattend Setting", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: %serial% requires Copy Unattend.xml." + return + } + } + elseif ($config.DeviceNamingMode -eq 'Prefixes') { + if (-not $config.CopyUnattend) { + [System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify a list of Prefixes'.", "Copy Unattend Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes require Copy Unattend.xml." + return + } + + $hasSavedPrefixesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNamePrefixesPath) -and (Test-Path -Path $config.DeviceNamePrefixesPath -PathType Leaf) + if ((($null -eq $config.DeviceNamePrefixes) -or ($config.DeviceNamePrefixes.Count -eq 0)) -and -not $hasSavedPrefixesPath) { + [System.Windows.MessageBox]::Show("Enter at least one prefix or choose a valid prefixes file before using 'Specify a list of Prefixes'.", "Prefixes Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes required." + return + } + } + $configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json" # Sort top-level keys alphabetically for consistent output $sortedConfig = [ordered]@{} diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml index de8f077..4d5f511 100644 --- a/FFUDevelopment/BuildFFUVM_UI.xaml +++ b/FFUDevelopment/BuildFFUVM_UI.xaml @@ -836,9 +836,11 @@ - + - + + + @@ -903,8 +905,40 @@ - - + + + + + + + + + + + + + + + + + + + + +