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 @@ - - + + + + + + + + + + + + + + + + + + + + +