diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 4309a06..250d415 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -70,7 +70,31 @@ When set to $true, enables adding WinPE drivers. By default copies drivers from When set to $true, will copy the provisioning package from the $FFUDevelopmentPath\PPKG folder to the Deployment partition of the USB drive. Default is $false. .PARAMETER CopyUnattend -When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is $false. +When set to $true, stages the selected architecture-specific unattend XML file as Unattend.xml on 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, Prompt, Template, Prefixes, and SerialComputerNames. + +.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 DeviceNameSerialComputerNames +Sets the CSV content used when DeviceNamingMode is SerialComputerNames. The CSV must include SerialNumber and ComputerName headers. + +.PARAMETER DeviceNameSerialComputerNamesPath +Path to the source CSV file used when DeviceNamingMode is SerialComputerNames and DeviceNameSerialComputerNames is not supplied. Default is $FFUDevelopmentPath\Unattend\SerialComputerNames.csv. + +.PARAMETER UnattendX64FilePath +Path to the x64 unattend XML source file. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. + +.PARAMETER UnattendArm64FilePath +Path to the arm64 unattend XML source file. Default is $FFUDevelopmentPath\Unattend\unattend_arm64.xml. .PARAMETER CreateDeploymentMedia When set to $true, this will create WinPE deployment media for use when deploying to a physical device. @@ -106,7 +130,7 @@ Prefix for the generated FFU file. Default is _FFU. Headers to use when downloading files. Not recommended to modify. .PARAMETER InjectUnattend -When set to $true and InstallApps is also $true, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend to $FFUDevelopmentPath\Apps\Unattend\Unattend.xml so sysprep can use it inside the VM. Default is $false. +When set to $true and InstallApps is also $true, stages the selected architecture-specific unattend XML file to $FFUDevelopmentPath\Apps\Unattend\Unattend.xml so sysprep can use it inside the VM. Default is $false. .PARAMETER InstallApps When set to $true, the script will create an Apps.iso file from the $FFUDevelopmentPath\Apps folder. It will also create a VM, mount the Apps.iso, install the apps, sysprep, and capture the VM. When set to $false, the FFU is created from a VHDX file, and no VM is created. @@ -407,6 +431,15 @@ param( [bool]$AllowVHDXCaching, [bool]$CopyPPKG, [bool]$CopyUnattend, + [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')] + [string]$DeviceNamingMode = 'Legacy', + [string]$DeviceNameTemplate, + [string[]]$DeviceNamePrefixes, + [string]$DeviceNamePrefixesPath, + [string[]]$DeviceNameSerialComputerNames, + [string]$DeviceNameSerialComputerNamesPath, + [string]$UnattendX64FilePath, + [string]$UnattendArm64FilePath, [bool]$CopyAutopilot, [bool]$CompactOS = $true, [bool]$CleanupDeployISO = $true, @@ -447,7 +480,7 @@ param( [switch]$Cleanup ) $ProgressPreference = 'SilentlyContinue' -$version = '2603.2' +$version = '2604.1' # Remove any existing modules to avoid conflicts if (Get-Module -Name 'FFU.Common.Core' -ErrorAction SilentlyContinue) { @@ -505,6 +538,173 @@ if ($ConfigFile -and (Test-Path -Path $ConfigFile)) { } } +function Get-UnattendSourcePath { + param( + [Parameter(Mandatory = $true)] + [string]$UnattendFolder, + [Parameter(Mandatory = $true)] + [string]$WindowsArch, + [string]$UnattendX64FilePath, + [string]$UnattendArm64FilePath + ) + + $resolvedArch = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } + $resolvedSourcePath = if ($resolvedArch -eq 'arm64') { + if ([string]::IsNullOrWhiteSpace($UnattendArm64FilePath)) { + Join-Path $UnattendFolder 'unattend_arm64.xml' + } + else { + $UnattendArm64FilePath + } + } + else { + if ([string]::IsNullOrWhiteSpace($UnattendX64FilePath)) { + Join-Path $UnattendFolder 'unattend_x64.xml' + } + else { + $UnattendX64FilePath + } + } + + WriteLog "Resolved unattend source path for ${resolvedArch}: $resolvedSourcePath" + return $resolvedSourcePath +} + +function Initialize-UnattendComputerNamePath { + param( + [Parameter(Mandatory = $true)] + [xml]$UnattendXml, + [Parameter(Mandatory = $true)] + [string]$WindowsArch + ) + + $unattendRoot = $UnattendXml.DocumentElement + if (($null -eq $unattendRoot) -or ($unattendRoot.LocalName -ne 'unattend')) { + throw 'Unattend XML is missing the unattend root element.' + } + + $unattendNamespace = $unattendRoot.NamespaceURI + if ([string]::IsNullOrWhiteSpace($unattendNamespace)) { + throw 'Unattend XML is missing the default unattend namespace.' + } + + $namespaceManager = New-Object System.Xml.XmlNamespaceManager($UnattendXml.NameTable) + $namespaceManager.AddNamespace('un', $unattendNamespace) + + $specializeSettings = $unattendRoot.SelectSingleNode("un:settings[@pass='specialize']", $namespaceManager) + $createdSpecializeSettings = $false + if ($null -eq $specializeSettings) { + $specializeSettings = $UnattendXml.CreateElement('settings', $unattendNamespace) + $null = $specializeSettings.SetAttribute('pass', 'specialize') + $firstSettingsNode = $unattendRoot.SelectSingleNode('un:settings', $namespaceManager) + if ($null -ne $firstSettingsNode) { + $null = $unattendRoot.InsertBefore($specializeSettings, $firstSettingsNode) + } + else { + $null = $unattendRoot.AppendChild($specializeSettings) + } + $createdSpecializeSettings = $true + } + + $shellSetupComponent = $specializeSettings.SelectSingleNode("un:component[@name='Microsoft-Windows-Shell-Setup']", $namespaceManager) + $createdShellSetupComponent = $false + if ($null -eq $shellSetupComponent) { + $processorArchitecture = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'amd64' } + $shellSetupComponent = $UnattendXml.CreateElement('component', $unattendNamespace) + $null = $shellSetupComponent.SetAttribute('name', 'Microsoft-Windows-Shell-Setup') + $null = $shellSetupComponent.SetAttribute('processorArchitecture', $processorArchitecture) + $null = $shellSetupComponent.SetAttribute('publicKeyToken', '31bf3856ad364e35') + $null = $shellSetupComponent.SetAttribute('language', 'neutral') + $null = $shellSetupComponent.SetAttribute('versionScope', 'nonSxS') + $null = $shellSetupComponent.SetAttribute('xmlns:wcm', 'http://www.w3.org/2000/xmlns/', 'http://schemas.microsoft.com/WMIConfig/2002/State') + $null = $shellSetupComponent.SetAttribute('xmlns:xsi', 'http://www.w3.org/2000/xmlns/', 'http://www.w3.org/2001/XMLSchema-instance') + + $firstComponentNode = $specializeSettings.SelectSingleNode('un:component', $namespaceManager) + if ($null -ne $firstComponentNode) { + $null = $specializeSettings.InsertBefore($shellSetupComponent, $firstComponentNode) + } + else { + $null = $specializeSettings.AppendChild($shellSetupComponent) + } + $createdShellSetupComponent = $true + } + + $computerNameElement = $shellSetupComponent.SelectSingleNode('un:ComputerName', $namespaceManager) + $createdComputerNameElement = $false + if ($null -eq $computerNameElement) { + $computerNameElement = $UnattendXml.CreateElement('ComputerName', $unattendNamespace) + $null = $shellSetupComponent.AppendChild($computerNameElement) + $createdComputerNameElement = $true + } + + return [PSCustomObject]@{ + ComputerNameElement = $computerNameElement + CreatedSpecializeSettings = $createdSpecializeSettings + CreatedShellSetupComponent = $createdShellSetupComponent + CreatedComputerNameElement = $createdComputerNameElement + } +} + +function Save-StagedUnattendFile { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath, + [Parameter(Mandatory = $true)] + [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')] + [string]$DeviceNamingMode, + [string]$DeviceNameTemplate, + [Parameter(Mandatory = $true)] + [string]$WindowsArch, + [bool]$LegacyPrefixesWillBeStaged = $false + ) + + if ($DeviceNamingMode -eq 'None') { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + [xml]$unattendXml = Get-Content -Path $SourcePath + $computerNamePath = Initialize-UnattendComputerNamePath -UnattendXml $unattendXml -WindowsArch $WindowsArch + + if ($computerNamePath.CreatedSpecializeSettings -or $computerNamePath.CreatedShellSetupComponent -or $computerNamePath.CreatedComputerNameElement) { + $createdParts = @() + if ($computerNamePath.CreatedSpecializeSettings) { + $createdParts += 'specialize settings' + } + if ($computerNamePath.CreatedShellSetupComponent) { + $createdParts += 'Microsoft-Windows-Shell-Setup component' + } + if ($computerNamePath.CreatedComputerNameElement) { + $createdParts += 'ComputerName element' + } + WriteLog "Created $($createdParts -join ', ') while staging unattend file $DestinationPath" + } + + if ($DeviceNamingMode -eq 'Prompt') { + $computerNamePath.ComputerNameElement.InnerText = 'MyComputer' + } + elseif ($DeviceNamingMode -eq 'Template') { + $computerNamePath.ComputerNameElement.InnerText = $DeviceNameTemplate + } + elseif ($DeviceNamingMode -eq 'Prefixes') { + if ($computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = '*' + } + } + elseif ($DeviceNamingMode -eq 'SerialComputerNames') { + if ($computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = '*' + } + } + elseif (($DeviceNamingMode -eq 'Legacy') -and $computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = if ($LegacyPrefixesWillBeStaged) { '*' } else { 'MyComputer' } + } + + $unattendXml.Save($DestinationPath) +} + $vmSwitchWasExplicitlyBound = $PSBoundParameters.ContainsKey('VMSwitchName') $enableVmNetworkingWasExplicitlyBound = $PSBoundParameters.ContainsKey('EnableVMNetworking') if (-not $EnableVMNetworking -and $vmSwitchWasExplicitlyBound -and -not $enableVmNetworkingWasExplicitlyBound) { @@ -512,6 +712,101 @@ 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 +} +$effectiveDeviceNameSerialComputerNames = @($DeviceNameSerialComputerNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +$resolvedDeviceNameSerialComputerNamesPath = if ([string]::IsNullOrWhiteSpace($DeviceNameSerialComputerNamesPath)) { + Join-Path (Join-Path $FFUDevelopmentPath 'Unattend') 'SerialComputerNames.csv' +} +else { + $DeviceNameSerialComputerNamesPath +} + +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 (($DeviceNamingMode -eq 'SerialComputerNames') -and ($effectiveDeviceNameSerialComputerNames.Count -eq 0) -and (Test-Path -Path $resolvedDeviceNameSerialComputerNamesPath -PathType Leaf)) { + $effectiveDeviceNameSerialComputerNames = @(Get-Content -Path $resolvedDeviceNameSerialComputerNamesPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + WriteLog "Loaded serial computer-name mappings from $resolvedDeviceNameSerialComputerNamesPath" +} + +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 'Prompt') { + if (-not $CopyUnattend) { + throw 'DeviceNamingMode Prompt requires CopyUnattend. Prompt-based naming is not supported with InjectUnattend.' + } +} +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.' + } +} +elseif ($DeviceNamingMode -eq 'SerialComputerNames') { + if (-not $CopyUnattend) { + throw 'DeviceNamingMode SerialComputerNames requires CopyUnattend. Serial-to-computer-name mapping is not supported with InjectUnattend.' + } + + if ($effectiveDeviceNameSerialComputerNames.Count -eq 0) { + throw 'DeviceNamingMode SerialComputerNames requires DeviceNameSerialComputerNames content or a valid DeviceNameSerialComputerNamesPath.' + } + + try { + $serialComputerNameMappings = @($effectiveDeviceNameSerialComputerNames | ConvertFrom-Csv -ErrorAction Stop) + } + catch { + throw "DeviceNamingMode SerialComputerNames requires valid CSV content with SerialNumber and ComputerName headers. $($_.Exception.Message)" + } + + if ($serialComputerNameMappings.Count -eq 0) { + throw 'DeviceNamingMode SerialComputerNames requires at least one CSV data row.' + } + + $serialComputerNameHeaders = @($serialComputerNameMappings[0].PSObject.Properties.Name) + if ((-not ($serialComputerNameHeaders -contains 'SerialNumber')) -or (-not ($serialComputerNameHeaders -contains 'ComputerName'))) { + throw 'DeviceNamingMode SerialComputerNames requires SerialNumber and ComputerName headers.' + } + + $validSerialComputerNameMappings = @($serialComputerNameMappings | Where-Object { + -not [string]::IsNullOrWhiteSpace([string]$_.SerialNumber) -and -not [string]::IsNullOrWhiteSpace([string]$_.ComputerName) + }) + if ($validSerialComputerNameMappings.Count -eq 0) { + throw 'DeviceNamingMode SerialComputerNames requires at least one row with both SerialNumber and ComputerName values.' + } +} + # Validate that the selected Windows SKU is compatible with the chosen Windows release and ensure an ISO is provided for unsupported releases $clientSKUs = @( 'Home', @@ -705,6 +1000,8 @@ if (-not $EdgePath) { $EdgePath = "$AppsPath\Edge" } if (-not $DriversFolder) { $DriversFolder = "$FFUDevelopmentPath\Drivers" } if (-not $PPKGFolder) { $PPKGFolder = "$FFUDevelopmentPath\PPKG" } if (-not $UnattendFolder) { $UnattendFolder = "$FFUDevelopmentPath\Unattend" } +if ([string]::IsNullOrWhiteSpace($UnattendX64FilePath)) { $UnattendX64FilePath = Join-Path $UnattendFolder 'unattend_x64.xml' } +if ([string]::IsNullOrWhiteSpace($UnattendArm64FilePath)) { $UnattendArm64FilePath = Join-Path $UnattendFolder 'unattend_arm64.xml' } if (-not $AutopilotFolder) { $AutopilotFolder = "$FFUDevelopmentPath\Autopilot" } if (-not $PEDriversFolder) { $PEDriversFolder = "$FFUDevelopmentPath\PEDrivers" } if (-not $VHDXCacheFolder) { $VHDXCacheFolder = "$FFUDevelopmentPath\VHDXCache" } @@ -4184,6 +4481,164 @@ Function New-DeploymentUSB { Import-Module "$($using:PSScriptRoot)\FFU.Common" -Force Set-CommonCoreLogPath -Path $using:LogFile + function Get-LocalUnattendSourcePath { + param( + [string]$UnattendFolder, + [string]$WindowsArch, + [string]$UnattendX64FilePath, + [string]$UnattendArm64FilePath + ) + + $resolvedArch = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } + $resolvedSourcePath = if ($resolvedArch -eq 'arm64') { + if ([string]::IsNullOrWhiteSpace($UnattendArm64FilePath)) { + Join-Path $UnattendFolder 'unattend_arm64.xml' + } + else { + $UnattendArm64FilePath + } + } + else { + if ([string]::IsNullOrWhiteSpace($UnattendX64FilePath)) { + Join-Path $UnattendFolder 'unattend_x64.xml' + } + else { + $UnattendX64FilePath + } + } + + WriteLog "Resolved unattend source path for ${resolvedArch}: $resolvedSourcePath" + return $resolvedSourcePath + } + + function Initialize-UnattendComputerNamePath { + param( + [xml]$UnattendXml, + [string]$WindowsArch + ) + + $unattendRoot = $UnattendXml.DocumentElement + if (($null -eq $unattendRoot) -or ($unattendRoot.LocalName -ne 'unattend')) { + throw 'Unattend XML is missing the unattend root element.' + } + + $unattendNamespace = $unattendRoot.NamespaceURI + if ([string]::IsNullOrWhiteSpace($unattendNamespace)) { + throw 'Unattend XML is missing the default unattend namespace.' + } + + $namespaceManager = New-Object System.Xml.XmlNamespaceManager($UnattendXml.NameTable) + $namespaceManager.AddNamespace('un', $unattendNamespace) + + $specializeSettings = $unattendRoot.SelectSingleNode("un:settings[@pass='specialize']", $namespaceManager) + $createdSpecializeSettings = $false + if ($null -eq $specializeSettings) { + $specializeSettings = $UnattendXml.CreateElement('settings', $unattendNamespace) + $null = $specializeSettings.SetAttribute('pass', 'specialize') + $firstSettingsNode = $unattendRoot.SelectSingleNode('un:settings', $namespaceManager) + if ($null -ne $firstSettingsNode) { + $null = $unattendRoot.InsertBefore($specializeSettings, $firstSettingsNode) + } + else { + $null = $unattendRoot.AppendChild($specializeSettings) + } + $createdSpecializeSettings = $true + } + + $shellSetupComponent = $specializeSettings.SelectSingleNode("un:component[@name='Microsoft-Windows-Shell-Setup']", $namespaceManager) + $createdShellSetupComponent = $false + if ($null -eq $shellSetupComponent) { + $processorArchitecture = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'amd64' } + $shellSetupComponent = $UnattendXml.CreateElement('component', $unattendNamespace) + $null = $shellSetupComponent.SetAttribute('name', 'Microsoft-Windows-Shell-Setup') + $null = $shellSetupComponent.SetAttribute('processorArchitecture', $processorArchitecture) + $null = $shellSetupComponent.SetAttribute('publicKeyToken', '31bf3856ad364e35') + $null = $shellSetupComponent.SetAttribute('language', 'neutral') + $null = $shellSetupComponent.SetAttribute('versionScope', 'nonSxS') + $null = $shellSetupComponent.SetAttribute('xmlns:wcm', 'http://www.w3.org/2000/xmlns/', 'http://schemas.microsoft.com/WMIConfig/2002/State') + $null = $shellSetupComponent.SetAttribute('xmlns:xsi', 'http://www.w3.org/2000/xmlns/', 'http://www.w3.org/2001/XMLSchema-instance') + + $firstComponentNode = $specializeSettings.SelectSingleNode('un:component', $namespaceManager) + if ($null -ne $firstComponentNode) { + $null = $specializeSettings.InsertBefore($shellSetupComponent, $firstComponentNode) + } + else { + $null = $specializeSettings.AppendChild($shellSetupComponent) + } + $createdShellSetupComponent = $true + } + + $computerNameElement = $shellSetupComponent.SelectSingleNode('un:ComputerName', $namespaceManager) + $createdComputerNameElement = $false + if ($null -eq $computerNameElement) { + $computerNameElement = $UnattendXml.CreateElement('ComputerName', $unattendNamespace) + $null = $shellSetupComponent.AppendChild($computerNameElement) + $createdComputerNameElement = $true + } + + return [PSCustomObject]@{ + ComputerNameElement = $computerNameElement + CreatedSpecializeSettings = $createdSpecializeSettings + CreatedShellSetupComponent = $createdShellSetupComponent + CreatedComputerNameElement = $createdComputerNameElement + } + } + + function Save-LocalStagedUnattendFile { + param( + [string]$SourcePath, + [string]$DestinationPath, + [string]$DeviceNamingMode, + [string]$DeviceNameTemplate, + [string]$WindowsArch, + [bool]$LegacyPrefixesWillBeStaged = $false + ) + + if ($DeviceNamingMode -eq 'None') { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + [xml]$unattendXml = Get-Content -Path $SourcePath + $computerNamePath = Initialize-UnattendComputerNamePath -UnattendXml $unattendXml -WindowsArch $WindowsArch + + if ($computerNamePath.CreatedSpecializeSettings -or $computerNamePath.CreatedShellSetupComponent -or $computerNamePath.CreatedComputerNameElement) { + $createdParts = @() + if ($computerNamePath.CreatedSpecializeSettings) { + $createdParts += 'specialize settings' + } + if ($computerNamePath.CreatedShellSetupComponent) { + $createdParts += 'Microsoft-Windows-Shell-Setup component' + } + if ($computerNamePath.CreatedComputerNameElement) { + $createdParts += 'ComputerName element' + } + WriteLog "Created $($createdParts -join ', ') while staging unattend file $DestinationPath" + } + + if ($DeviceNamingMode -eq 'Prompt') { + $computerNamePath.ComputerNameElement.InnerText = 'MyComputer' + } + elseif ($DeviceNamingMode -eq 'Template') { + $computerNamePath.ComputerNameElement.InnerText = $DeviceNameTemplate + } + elseif ($DeviceNamingMode -eq 'Prefixes') { + if ($computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = '*' + } + } + elseif ($DeviceNamingMode -eq 'SerialComputerNames') { + if ($computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = '*' + } + } + elseif (($DeviceNamingMode -eq 'Legacy') -and $computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = if ($LegacyPrefixesWillBeStaged) { '*' } else { 'MyComputer' } + } + + $unattendXml.Save($DestinationPath) + } + $DiskNumber = $USBDrive.DeviceID.Replace("\\.\PHYSICALDRIVE", "") WriteLog "Thread $([System.Threading.Thread]::CurrentThread.ManagedThreadId) processing DiskNumber $DiskNumber ($($USBDrive.Model))" @@ -4244,15 +4699,20 @@ 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 -UnattendX64FilePath $using:UnattendX64FilePath -UnattendArm64FilePath $using:UnattendArm64FilePath + $legacyPrefixesWillBeStaged = ($using:DeviceNamingMode -eq 'Legacy') -and (Test-Path -Path $using:resolvedDeviceNamePrefixesPath -PathType Leaf) + Save-LocalStagedUnattendFile -SourcePath $unattendSource -DestinationPath (Join-Path $UnattendPathOnUSB 'Unattend.xml') -DeviceNamingMode $using:DeviceNamingMode -DeviceNameTemplate $using:normalizedDeviceNameTemplate -WindowsArch $using:WindowsArch -LegacyPrefixesWillBeStaged $legacyPrefixesWillBeStaged + 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 + elseif ($using:DeviceNamingMode -eq 'SerialComputerNames') { + WriteLog "Writing SerialComputerNames.csv file to $UnattendPathOnUSB" + $using:effectiveDeviceNameSerialComputerNames | Set-Content -Path (Join-Path $UnattendPathOnUSB 'SerialComputerNames.csv') -Encoding UTF8 } - if (Test-Path (Join-Path $using:UnattendFolder 'prefixes.txt')) { + elseif ($legacyPrefixesWillBeStaged) { 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' } @@ -5506,18 +5966,32 @@ if ($CopyAutopilot) { WriteLog 'Autopilot validation complete' } -#Validate Unattend folder -if ($CopyUnattend) { +# Validate unattend source file +if ($CopyUnattend -or $InjectUnattend) { WriteLog 'Doing Unattend validation' - if (!(Test-Path -Path $UnattendFolder)) { - WriteLog "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing" - throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing" + $selectedUnattendMode = if ($CopyUnattend) { 'CopyUnattend' } else { 'InjectUnattend' } + $unattendSourcePath = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch -UnattendX64FilePath $UnattendX64FilePath -UnattendArm64FilePath $UnattendArm64FilePath + if (!(Test-Path -Path $unattendSourcePath -PathType Leaf)) { + WriteLog "-$selectedUnattendMode is set to `$true, but the selected unattend XML file is missing: $unattendSourcePath" + throw "-$selectedUnattendMode is set to `$true, but the selected unattend XML file is missing: $unattendSourcePath" } - #Check for .XML file - if (!(Get-ChildItem -Path $UnattendFolder -Filter unattend_*.xml)) { - 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" + + $selectedUnattendFile = Get-Item -Path $unattendSourcePath -ErrorAction SilentlyContinue + if (($null -eq $selectedUnattendFile) -or ($selectedUnattendFile.Length -le 0)) { + WriteLog "-$selectedUnattendMode is set to `$true, but the selected unattend XML file is empty: $unattendSourcePath" + throw "-$selectedUnattendMode is set to `$true, but the selected unattend XML file is empty: $unattendSourcePath" } + + if ($DeviceNamingMode -ne 'None') { + try { + [xml]$validationUnattendXml = Get-Content -Path $unattendSourcePath + $null = Initialize-UnattendComputerNamePath -UnattendXml $validationUnattendXml -WindowsArch $WindowsArch + } + catch { + throw "DeviceNamingMode $DeviceNamingMode requires a valid specialize/Microsoft-Windows-Shell-Setup/ComputerName path in $unattendSourcePath. $($_.Exception.Message)" + } + } + WriteLog 'Unattend validation complete' } @@ -6418,9 +6892,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 -UnattendX64FilePath $UnattendX64FilePath -UnattendArm64FilePath $UnattendArm64FilePath # Ensure target folder exists under Apps $targetFolder = Join-Path $AppsPath 'Unattend' @@ -6431,7 +6903,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 -WindowsArch $WindowsArch WriteLog "Injected unattend file into Apps: $unattendSource -> $destination" } else { @@ -6998,10 +7470,8 @@ try { if ($WindowsRelease -eq 2016 -and $installationType -eq "Server") { WriteLog 'WindowsRelease is 2016, adding SSU first' WriteLog "Adding SSU to $WindowsPartition" - # Add-WindowsPackage -Path $WindowsPartition -PackagePath $SSUFilePath -PreventPending | Out-Null - # Commenting out -preventpending as it causes an issue with the SSU being applied - # Seems to be because of the registry being mounted per dism.log - Add-WindowsPackage -Path $WindowsPartition -PackagePath $SSUFilePath | Out-Null + # Add-WindowsPackage -Path $WindowsPartition -PackagePath $SSUFilePath | Out-Null + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WindowsPartition /Add-Package /PackagePath:$SSUFilePath" | Out-Null WriteLog "SSU added to $WindowsPartition" # WriteLog "Removing $SSUFilePath" # Remove-Item -Path $SSUFilePath -Force | Out-Null @@ -7010,7 +7480,8 @@ try { if ($WindowsRelease -in 2016, 2019, 2021 -and $isLTSC) { WriteLog "WindowsRelease is $WindowsRelease and is $WindowsSKU, adding SSU first" WriteLog "Adding SSU to $WindowsPartition" - Add-WindowsPackage -Path $WindowsPartition -PackagePath $SSUFilePath | Out-Null + # Add-WindowsPackage -Path $WindowsPartition -PackagePath $SSUFilePath | Out-Null + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WindowsPartition /Add-Package /PackagePath:$SSUFilePath" | Out-Null WriteLog "SSU added to $WindowsPartition" # WriteLog "Removing $SSUFilePath" # Remove-Item -Path $SSUFilePath -Force | Out-Null @@ -7023,23 +7494,27 @@ try { } else { WriteLog "Adding $CUPath to $WindowsPartition" - Add-WindowsPackage -Path $WindowsPartition -PackagePath $CUPath | Out-Null + # Add-WindowsPackage -Path $WindowsPartition -PackagePath $CUPath | Out-Null + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WindowsPartition /Add-Package /PackagePath:$CUPath" | Out-Null WriteLog "$CUPath added to $WindowsPartition" } } if ($UpdatePreviewCU) { WriteLog "Adding $CUPPath to $WindowsPartition" - Add-WindowsPackage -Path $WindowsPartition -PackagePath $CUPPath | Out-Null + # Add-WindowsPackage -Path $WindowsPartition -PackagePath $CUPPath | Out-Null + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WindowsPartition /Add-Package /PackagePath:$CUPPath" | Out-Null WriteLog "$CUPPath added to $WindowsPartition" } if ($UpdateLatestNet) { WriteLog "Adding $NETPath to $WindowsPartition" - Add-WindowsPackage -Path $WindowsPartition -PackagePath $NETPath | Out-Null + # Add-WindowsPackage -Path $WindowsPartition -PackagePath $NETPath | Out-Null + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WindowsPartition /Add-Package /PackagePath:$NETPath" | Out-Null WriteLog "$NETPath added to $WindowsPartition" } if ($UpdateLatestMicrocode -and $WindowsRelease -in 2016, 2019) { WriteLog "Adding $MicrocodePath to $WindowsPartition" - Add-WindowsPackage -Path $WindowsPartition -PackagePath $MicrocodePath | Out-Null + # Add-WindowsPackage -Path $WindowsPartition -PackagePath $MicrocodePath | Out-Null + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Image:$WindowsPartition /Add-Package /PackagePath:$MicrocodePath" | Out-Null WriteLog "$MicrocodePath added to $WindowsPartition" } WriteLog "KBs added to $WindowsPartition" diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1 index 3bf3926..2dd3c1e 100644 --- a/FFUDevelopment/BuildFFUVM_UI.ps1 +++ b/FFUDevelopment/BuildFFUVM_UI.ps1 @@ -46,7 +46,8 @@ $script:uiState = [PSCustomObject]@{ logStreamReader = $null; pollTimer = $null; currentBuildProcess = $null; - lastConfigFilePath = $null + lastConfigFilePath = $null; + loadedDeviceNamingMode = $null }; Flags = @{ installAppsForcedByUpdates = $false; @@ -56,7 +57,9 @@ $script:uiState = [PSCustomObject]@{ lastSortAscending = $true; isBuilding = $false; isCleanupRunning = $false; - isFluentSupported = $false + isFluentSupported = $false; + deviceNamingModeWasExplicitlyChanged = $false; + suppressDeviceNamingChangeTracking = $false }; Defaults = @{}; LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log" @@ -432,6 +435,116 @@ $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.CopyUnattend -or $config.InjectUnattend) { + $selectedUnattendArch = if ($config.WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } + $selectedUnattendSourcePath = if ($selectedUnattendArch -eq 'arm64') { + [string]$config.UnattendArm64FilePath + } + else { + [string]$config.UnattendX64FilePath + } + + if ([string]::IsNullOrWhiteSpace($selectedUnattendSourcePath)) { + [System.Windows.MessageBox]::Show("Select a valid $selectedUnattendArch unattend XML file before using Copy Unattend.xml or Inject Unattend.xml.", "Unattend File Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file path required." + return + } + + if (-not (Test-Path -Path $selectedUnattendSourcePath -PathType Leaf)) { + [System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file was not found:`n$selectedUnattendSourcePath", "Unattend File Missing", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file missing." + return + } + + $selectedUnattendFileInfo = Get-Item -Path $selectedUnattendSourcePath -ErrorAction SilentlyContinue + if (($null -eq $selectedUnattendFileInfo) -or ($selectedUnattendFileInfo.Length -le 0)) { + [System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file is empty:`n$selectedUnattendSourcePath", "Unattend File Empty", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file empty." + return + } + } + + if ($config.DeviceNamingMode -eq 'Prompt') { + if (-not $config.CopyUnattend) { + [System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Prompt for Device Name'.", "Copy Unattend Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: prompt naming requires Copy Unattend.xml." + return + } + } + elseif ($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 + } + } + elseif ($config.DeviceNamingMode -eq 'SerialComputerNames') { + if (-not $config.CopyUnattend) { + [System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify Serial to Device Name Mapping'.", "Copy Unattend Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping requires Copy Unattend.xml." + return + } + + $hasSavedSerialComputerNamesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNameSerialComputerNamesPath) -and (Test-Path -Path $config.DeviceNameSerialComputerNamesPath -PathType Leaf) + if ((($null -eq $config.DeviceNameSerialComputerNames) -or ($config.DeviceNameSerialComputerNames.Count -eq 0)) -and -not $hasSavedSerialComputerNamesPath) { + [System.Windows.MessageBox]::Show("Enter CSV content or choose a valid SerialComputerNames.csv file before using 'Specify Serial to Device Name Mapping'.", "Serial Mapping Required", "OK", "Warning") | Out-Null + $btnRun.IsEnabled = $true + $script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping 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..7de9f6d 100644 --- a/FFUDevelopment/BuildFFUVM_UI.xaml +++ b/FFUDevelopment/BuildFFUVM_UI.xaml @@ -836,9 +836,13 @@ - + - + + + + + @@ -898,13 +902,90 @@ - - - + + + + + + + + + + + +